6 Commits

Author SHA1 Message Date
mfwadejr 6b941865ae Show empty fresh-install screenshots
Publish container / publish (push) Failing after 10s
2026-08-30 08:09:13 -04:00
mfwadejr a3c565b6ee Require empty fresh database 2026-08-30 08:08:11 -04:00
mfwadejr ea677286f0 Remove development seed data 3.0.2 2026-08-30 08:07:16 -04:00
mfwadejr 8317118348 Document Unraid timezone variable 2026-08-30 08:00:40 -04:00
mfwadejr 0e87f5a26a Add Unraid PUID and PGID support 3.0.1
Publish container / publish (push) Failing after 9s
2026-08-30 07:46:55 -04:00
mfwadejr 60c2af2fde Add vBoxStock permissions entrypoint 2026-08-30 07:45:54 -04:00
10 changed files with 46 additions and 9 deletions
+6 -3
View File
@@ -4,10 +4,13 @@ LABEL org.opencontainers.image.description="Self-contained vSeeBox inventory and
WORKDIR /app WORKDIR /app
COPY package.json server.mjs ./ COPY package.json server.mjs ./
COPY public ./public COPY public ./public
RUN mkdir -p /data && chown -R node:node /app /data COPY vboxstock-entrypoint.sh /usr/local/bin/vboxstock-entrypoint
ENV PORT=3000 DATA_DIR=/data NODE_ENV=production RUN apk add --no-cache su-exec \
USER node && chmod +x /usr/local/bin/vboxstock-entrypoint \
&& mkdir -p /data
ENV PORT=3000 DATA_DIR=/data NODE_ENV=production PUID=99 PGID=100
EXPOSE 3000 EXPOSE 3000
VOLUME ["/data"] VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1
ENTRYPOINT ["/usr/local/bin/vboxstock-entrypoint"]
CMD ["node", "server.mjs"] CMD ["node", "server.mjs"]
+20 -2
View File
@@ -76,13 +76,15 @@ docker run -d \
--restart unless-stopped \ --restart unless-stopped \
-p 3000:3000 \ -p 3000:3000 \
-e TZ=America/New_York \ -e TZ=America/New_York \
-e PUID=99 \
-e PGID=100 \
-v /your/persistent/path:/data \ -v /your/persistent/path:/data \
ghcr.io/mfwadejr/vboxstock:latest ghcr.io/mfwadejr/vboxstock:latest
``` ```
Open `http://YOUR-SERVER-IP:3000`, sign in with the initial credentials above, and change the password when prompted. Open `http://YOUR-SERVER-IP:3000`, sign in with the initial credentials above, and change the password when prompted.
The host path mounted at `/data` is essential. Removing the container is safe when this mount remains intact; running without a persistent mount means the database can be lost when the container is replaced. The host path mounted at `/data` is essential. Removing the container is safe when this mount remains intact; running without a persistent mount means the database can be lost when the container is replaced. `PUID` and `PGID` determine which host user and group own the mounted data. The defaults are Unraid's `nobody:users` IDs, `99:100`.
## Docker Compose ## Docker Compose
@@ -110,8 +112,19 @@ Use the included `vboxstock-unraid.xml` template or create a container with thes
| Container data path | `/data` | | Container data path | `/data` |
| Suggested Unraid host path | `/mnt/user/appdata/vboxstock` | | Suggested Unraid host path | `/mnt/user/appdata/vboxstock` |
| Network mode | `bridge` | | Network mode | `bridge` |
| Timezone (`TZ`) | `America/New_York` |
| PUID | `99` |
| PGID | `100` |
Open the container's WebUI after installation. Updates can be applied with **Force Update** or through the CA Auto Update Applications plugin. Add `TZ`, `PUID`, and `PGID` as Unraid container variables:
| Name | Key | Value |
| --- | --- | --- |
| Timezone | `TZ` | `America/New_York` |
| User ID | `PUID` | `99` |
| Group ID | `PGID` | `100` |
Open the container's WebUI after installation. Updates can be applied with **Force Update** or through the CA Auto Update Applications plugin. At startup, the container creates `/data/backups`, applies the configured `PUID` and `PGID` ownership to `/data`, and then runs the application with those IDs.
## Data and backups ## Data and backups
@@ -120,6 +133,10 @@ vBoxStock uses SQLite and does not require MySQL, PostgreSQL, Redis, or another
- `/data/vboxstock.db` — active application database - `/data/vboxstock.db` — active application database
- `/data/backups/` — locally retained database snapshots - `/data/backups/` — locally retained database snapshots
`/data` is the path inside the container. When `/mnt/user/appdata/vboxstock` is mapped directly to `/data`, the Unraid host folder contains `vboxstock.db` and `backups/`; it does not contain another nested folder named `data`.
A fresh installation creates an empty inventory, sales history, and customer list. Only the initial `admin` account is created. Replacing or upgrading the container preserves existing records because `vboxstock.db` remains in the mounted host folder. Removing or changing the `/data` mapping starts a separate empty database, so keep that mapping consistent across upgrades.
The Admin page can create a transactionally consistent snapshot, download it to another device, restore a local snapshot, or upload and restore a downloaded copy. A pre-restore snapshot is created automatically before the active database is replaced. The Admin page can create a transactionally consistent snapshot, download it to another device, restore a local snapshot, or upload and restore a downloaded copy. A pre-restore snapshot is created automatically before the active database is replaced.
Backups contain customer information and password hashes. Store downloaded copies securely. Restoring a database also restores the user accounts contained in that backup and signs out every active session. An older backup without user accounts starts the first-login `admin` / `admin` setup flow. Backups contain customer information and password hashes. Store downloaded copies securely. Restoring a database also restores the user accounts contained in that backup and signs out every active session. An older backup without user accounts starts the first-login `admin` / `admin` setup flow.
@@ -143,6 +160,7 @@ docker exec -e RESET_ADMIN_PASSWORD=NewPassword123 -it vboxstock node server.mjs
- Image: `ghcr.io/mfwadejr/vboxstock:latest` - Image: `ghcr.io/mfwadejr/vboxstock:latest`
- Application port: `3000/tcp` - Application port: `3000/tcp`
- Persistent volume: `/data` - Persistent volume: `/data`
- Runtime ownership: configurable with `PUID` and `PGID` (`99:100` by default)
- Health check: `GET /api/health` - Health check: `GET /api/health`
- Runtime: Node.js 22 - Runtime: Node.js 22
- Database: SQLite - Database: SQLite
+2
View File
@@ -9,3 +9,5 @@ services:
- ./data:/data - ./data:/data
environment: environment:
- TZ=America/New_York - TZ=America/New_York
- PUID=99
- PGID=100
Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 51 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vboxstock", "name": "vboxstock",
"version": "3.0.0", "version": "3.0.2",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "node": ">=22.13.0" }, "engines": { "node": ">=22.13.0" },
-2
View File
@@ -30,8 +30,6 @@ function validPassword(value){if(String(value||"").length<8)throw new Error("Pas
function cleanUsername(value){const name=String(value||"").trim();if(!/^[a-zA-Z0-9._-]{3,40}$/.test(name))throw new Error("Username must be 340 characters using letters, numbers, periods, dashes, or underscores.");return name} function cleanUsername(value){const name=String(value||"").trim();if(!/^[a-zA-Z0-9._-]{3,40}$/.test(name))throw new Error("Username must be 340 characters using letters, numbers, periods, dashes, or underscores.");return name}
initializeDatabase(); initializeDatabase();
try{const event=JSON.parse(await readFile(restoreMarker,"utf8"));db.prepare("INSERT INTO audit_log (username,action,target,details,ip_address) VALUES (?,'database_restore',?,?,?)").run(event.username||"system",event.target||"",event.details||"Restored database",event.ipAddress||"");await unlink(restoreMarker)}catch(error){if(error.code!=="ENOENT")console.error("Unable to import restore audit event:",error.message)} try{const event=JSON.parse(await readFile(restoreMarker,"utf8"));db.prepare("INSERT INTO audit_log (username,action,target,details,ip_address) VALUES (?,'database_restore',?,?,?)").run(event.username||"system",event.target||"",event.details||"Restored database",event.ipAddress||"");await unlink(restoreMarker)}catch(error){if(error.code!=="ENOENT")console.error("Unable to import restore audit event:",error.message)}
const seed=[["Logan Crabtree","273D000000021255","A0:BB:3E:02:12:55"],["Marvin Wade","273D00000002194E","A0:BB:3E:02:19:4E"],["Logan Crabtree","273D00000002185F","A0:BB:3E:02:18:5F"],["Logan Crabtree","273D00000002193A","A0:BB:3E:02:19:3A"],["Mark Milburn","273D0000000218BD","A0:BB:3E:02:18:BD"],["Matt Avila","273D0000000219D5","A0:BB:3E:02:19:D5"],["Andy Nguyen","273D0000000219FD","A0:BB:3E:02:19:FD"],["Andy Nguyen","273D000000021181","A0:BB:3E:02:11:81"],["Daniel Wade","273D00000002125A","A0:BB:3E:02:12:5A"],["Marvin Wade","273D0000000219D4","A0:BB:3E:02:19:D4"]];
if(db.prepare("SELECT COUNT(*) count FROM products").get().count===0){const insert=db.prepare("INSERT INTO products (id,uid,sn,mac,model,condition,received_at,status,sold_at,customer_name) VALUES (?,?,?,?,?,?,?,?,?,?)");db.exec("BEGIN");try{seed.forEach(([name,sn,mac])=>insert.run(crypto.randomUUID(),"",sn,mac,"V3 Plus","New","2024-08-20","sold","2024-08-20",name));db.exec("COMMIT")}catch(e){db.exec("ROLLBACK");throw e}}
if(db.prepare("SELECT COUNT(*) count FROM users").get().count===0){db.prepare("INSERT INTO users (id,username,password_hash,role,must_change_password) VALUES (?,?,?,?,1)").run(crypto.randomUUID(),"admin",hashPassword("admin"),"admin");db.prepare("INSERT INTO audit_log (action,target,details) VALUES ('bootstrap_admin','admin','Default administrator created; password change required')").run()} if(db.prepare("SELECT COUNT(*) count FROM users").get().count===0){db.prepare("INSERT INTO users (id,username,password_hash,role,must_change_password) VALUES (?,?,?,?,1)").run(crypto.randomUUID(),"admin",hashPassword("admin"),"admin");db.prepare("INSERT INTO audit_log (action,target,details) VALUES ('bootstrap_admin','admin','Default administrator created; password change required')").run()}
const findCustomerByName=db.prepare("SELECT id FROM customers WHERE lower(name)=lower(?) ORDER BY updated_at DESC LIMIT 1"),addCustomer=db.prepare("INSERT INTO customers (id,name,phone,address1,address2,city,state,zip,shipping_notes) VALUES (?,?,?,?,?,?,?,?,?)"); const findCustomerByName=db.prepare("SELECT id FROM customers WHERE lower(name)=lower(?) ORDER BY updated_at DESC LIMIT 1"),addCustomer=db.prepare("INSERT INTO customers (id,name,phone,address1,address2,city,state,zip,shipping_notes) VALUES (?,?,?,?,?,?,?,?,?)");
for(const old of db.prepare("SELECT DISTINCT customer_name name,phone FROM products WHERE status='sold' AND customer_name IS NOT NULL AND customer_name!='' AND customer_id IS NULL").all()){let customer=findCustomerByName.get(old.name);if(!customer){const id=crypto.randomUUID();addCustomer.run(id,old.name,old.phone||"","","","","","","");customer={id}}db.prepare("UPDATE products SET customer_id=? WHERE status='sold' AND customer_id IS NULL AND lower(customer_name)=lower(?)").run(customer.id,old.name)} for(const old of db.prepare("SELECT DISTINCT customer_name name,phone FROM products WHERE status='sold' AND customer_name IS NOT NULL AND customer_name!='' AND customer_id IS NULL").all()){let customer=findCustomerByName.get(old.name);if(!customer){const id=crypto.randomUUID();addCustomer.run(id,old.name,old.phone||"","","","","","","");customer={id}}db.prepare("UPDATE products SET customer_id=? WHERE status='sold' AND customer_id IS NULL AND lower(customer_name)=lower(?)").run(customer.id,old.name)}
+1 -1
View File
@@ -23,7 +23,7 @@ test("authentication, roles, inventory, sale, and restock", async t => {
response=await request("/api/auth/change-password",{method:"POST",headers:jsonHeaders(bootstrap.cookie),body:JSON.stringify({currentPassword:"admin",newPassword:"password8",confirmPassword:"password8"})});assert.equal(response.status,200); response=await request("/api/auth/change-password",{method:"POST",headers:jsonHeaders(bootstrap.cookie),body:JSON.stringify({currentPassword:"admin",newPassword:"password8",confirmPassword:"password8"})});assert.equal(response.status,200);
const adminCookie=response.headers.get("set-cookie").split(";")[0]; const adminCookie=response.headers.get("set-cookie").split(";")[0];
response=await request("/api/products",{headers:{cookie:adminCookie}});let items=await response.json();assert.equal(items.length,10); response=await request("/api/products",{headers:{cookie:adminCookie}});let items=await response.json();assert.equal(items.length,0,"a fresh database must contain no inventory or sales");
response=await request("/api/admin/users",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({username:"viewer",password:"viewer123",role:"readonly"})});assert.equal(response.status,201); response=await request("/api/admin/users",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({username:"viewer",password:"viewer123",role:"readonly"})});assert.equal(response.status,201);
const viewerLogin=await login("viewer","viewer123"); const viewerLogin=await login("viewer","viewer123");
response=await request("/api/auth/change-password",{method:"POST",headers:jsonHeaders(viewerLogin.cookie),body:JSON.stringify({currentPassword:"viewer123",newPassword:"viewer456",confirmPassword:"viewer456"})}); response=await request("/api/auth/change-password",{method:"POST",headers:jsonHeaders(viewerLogin.cookie),body:JSON.stringify({currentPassword:"viewer123",newPassword:"viewer456",confirmPassword:"viewer456"})});
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
puid="${PUID:-99}"
pgid="${PGID:-100}"
case "$puid:$pgid" in
*[!0-9:]*|:*|*:) echo "PUID and PGID must be numeric." >&2; exit 1 ;;
esac
mkdir -p "${DATA_DIR:-/data}" "${DATA_DIR:-/data}/backups"
chown -R "$puid:$pgid" "${DATA_DIR:-/data}"
exec su-exec "$puid:$pgid" "$@"
+2
View File
@@ -6,5 +6,7 @@
<Category>Tools:</Category><WebUI>http://[IP]:[PORT:3000]/</WebUI><TemplateURL></TemplateURL><Icon></Icon> <Category>Tools:</Category><WebUI>http://[IP]:[PORT:3000]/</WebUI><TemplateURL></TemplateURL><Icon></Icon>
<Config Name="Web UI Port" Target="3000" Default="3000" Mode="tcp" Description="vBoxStock web interface" Type="Port" Display="always" Required="true" Mask="false">3000</Config> <Config Name="Web UI Port" Target="3000" Default="3000" Mode="tcp" Description="vBoxStock web interface" Type="Port" Display="always" Required="true" Mask="false">3000</Config>
<Config Name="App Data" Target="/data" Default="/mnt/user/appdata/vboxstock" Mode="rw" Description="SQLite database and durable application data" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/vboxstock</Config> <Config Name="App Data" Target="/data" Default="/mnt/user/appdata/vboxstock" Mode="rw" Description="SQLite database and durable application data" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/vboxstock</Config>
<Config Name="User ID" Target="PUID" Default="99" Mode="" Description="Host user ID used to own and write application data. Unraid default is 99 (nobody)." Type="Variable" Display="advanced" Required="true" Mask="false">99</Config>
<Config Name="Group ID" Target="PGID" Default="100" Mode="" Description="Host group ID used to own and write application data. Unraid default is 100 (users)." Type="Variable" Display="advanced" Required="true" Mask="false">100</Config>
<Config Name="Timezone" Target="TZ" Default="America/New_York" Mode="" Description="Container timezone" Type="Variable" Display="advanced" Required="false" Mask="false">America/New_York</Config> <Config Name="Timezone" Target="TZ" Default="America/New_York" Mode="" Description="Container timezone" Type="Variable" Display="advanced" Required="false" Mask="false">America/New_York</Config>
</Container> </Container>