11 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
mfwadejr 2e91841329 Remove obsolete legacy template
Publish container / publish (push) Failing after 10s
2026-08-29 21:26:27 -04:00
mfwadejr 1fee676176 Release clean vBoxStock 3.0.0 identifiers 2026-08-29 21:24:53 -04:00
mfwadejr 50b6cf93a1 Publish container as vboxstock 2026-08-29 21:23:50 -04:00
mfwadejr 01c25e7fc8 Rename vBoxStock test fixtures 2026-08-29 21:21:40 -04:00
mfwadejr 7daaf51e64 Rename browser keys to vBoxStock 2026-08-29 21:20:33 -04:00
14 changed files with 79 additions and 35 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
data
*.zip
test
README.md
docker-compose.yml
vboxstock-unraid.xml
+1 -1
View File
@@ -8,7 +8,7 @@ on:
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: mfwadejr/vseebox-stockroom IMAGE_NAME: mfwadejr/vboxstock
jobs: jobs:
publish: publish:
+7 -4
View File
@@ -1,13 +1,16 @@
FROM node:22-alpine FROM node:22-alpine
LABEL org.opencontainers.image.source="https://github.com/mfwadejr/vseebox-stockroom" LABEL org.opencontainers.image.source="https://github.com/mfwadejr/vboxstock"
LABEL org.opencontainers.image.description="Self-contained vSeeBox inventory and sales tracker" LABEL org.opencontainers.image.description="Self-contained vSeeBox inventory and sales tracker"
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"]
+29 -11
View File
@@ -72,17 +72,19 @@ For use outside a trusted private network, place vBoxStock behind an HTTPS rever
```sh ```sh
docker run -d \ docker run -d \
--name vseebox-stockroom \ --name vboxstock \
--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/vseebox-stockroom: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
@@ -101,25 +103,40 @@ docker compose up -d
## Unraid ## Unraid
Use the included `stockroom-unraid.xml` template or create a container with these settings: Use the included `vboxstock-unraid.xml` template or create a container with these settings:
| Setting | Value | | Setting | Value |
| --- | --- | | --- | --- |
| Repository | `ghcr.io/mfwadejr/vseebox-stockroom:latest` | | Repository | `ghcr.io/mfwadejr/vboxstock:latest` |
| WebUI port | `3000` | | WebUI port | `3000` |
| Container data path | `/data` | | Container data path | `/data` |
| Suggested Unraid host path | `/mnt/user/appdata/vseebox-stockroom` | | 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
vBoxStock uses SQLite and does not require MySQL, PostgreSQL, Redis, or another service. Persistent content is stored under `/data`: vBoxStock uses SQLite and does not require MySQL, PostgreSQL, Redis, or another service. Persistent content is stored under `/data`:
- `/data/stockroom.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.
@@ -129,20 +146,21 @@ Backups contain customer information and password hashes. Store downloaded copie
If every administrator is inaccessible, run this on the Docker host, replacing the container name, username, and temporary password if needed: If every administrator is inaccessible, run this on the Docker host, replacing the container name, username, and temporary password if needed:
```sh ```sh
docker exec -it vseebox-stockroom node server.mjs reset-admin admin NewPassword123 docker exec -it vboxstock node server.mjs reset-admin admin NewPassword123
``` ```
The account is enabled as an administrator and must change the supplied password on its next login. The reset is recorded in the audit log. Because command arguments may briefly appear in process listings, the password can instead be supplied through an environment variable: The account is enabled as an administrator and must change the supplied password on its next login. The reset is recorded in the audit log. Because command arguments may briefly appear in process listings, the password can instead be supplied through an environment variable:
```sh ```sh
docker exec -e RESET_ADMIN_PASSWORD=NewPassword123 -it vseebox-stockroom node server.mjs reset-admin admin docker exec -e RESET_ADMIN_PASSWORD=NewPassword123 -it vboxstock node server.mjs reset-admin admin
``` ```
## Container details ## Container details
- Image: `ghcr.io/mfwadejr/vseebox-stockroom: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
+5 -3
View File
@@ -1,7 +1,7 @@
services: services:
stockroom: vboxstock:
image: ghcr.io/mfwadejr/vseebox-stockroom:latest image: ghcr.io/mfwadejr/vboxstock:latest
container_name: vseebox-stockroom container_name: vboxstock
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3000"
@@ -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

+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "vseebox-stockroom", "name": "vboxstock",
"version": "2.2.0", "version": "3.0.2",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "node": ">=22.13.0" }, "engines": { "node": ">=22.13.0" },
+2 -2
View File
@@ -10,7 +10,7 @@ const ids = p => [["UID",p.uid],["SN",p.sn],["MAC",p.mac]].filter(([,v]) => v);
const primaryId = p => ids(p)[0]?.[1] || "No identifier"; const primaryId = p => ids(p)[0]?.[1] || "No identifier";
function storageStatus(state,message) { const el=$("#storageStatus"); if(!el)return; el.className=`storage-status ${state}`; el.querySelector("b").textContent=message; } function storageStatus(state,message) { const el=$("#storageStatus"); if(!el)return; el.className=`storage-status ${state}`; el.querySelector("b").textContent=message; }
const systemTheme=window.matchMedia("(prefers-color-scheme: dark)"); const systemTheme=window.matchMedia("(prefers-color-scheme: dark)");
function savedTheme(){try{return localStorage.getItem("stockroom-theme")||"system"}catch{return "system"}} function savedTheme(){try{return localStorage.getItem("vboxstock-theme")||"system"}catch{return "system"}}
function applyTheme(choice=savedTheme()){const resolved=choice==="system"?(systemTheme.matches?"dark":"light"):choice;document.documentElement.dataset.theme=resolved;document.querySelector('meta[name="theme-color"]').content=resolved==="dark"?"#0d1320":"#f5f7fb";if($("#themeSelect"))$("#themeSelect").value=choice;} function applyTheme(choice=savedTheme()){const resolved=choice==="system"?(systemTheme.matches?"dark":"light"):choice;document.documentElement.dataset.theme=resolved;document.querySelector('meta[name="theme-color"]').content=resolved==="dark"?"#0d1320":"#f5f7fb";if($("#themeSelect"))$("#themeSelect").value=choice;}
function decorateResponsiveTable(){const labels=[...document.querySelectorAll("#thead th")].map(th=>th.textContent.trim()||"Actions");document.querySelectorAll("#rows tr").forEach(row=>[...row.children].forEach((cell,index)=>cell.dataset.label=labels[index]||"Details"));} function decorateResponsiveTable(){const labels=[...document.querySelectorAll("#thead th")].map(th=>th.textContent.trim()||"Actions");document.querySelectorAll("#rows tr").forEach(row=>[...row.children].forEach((cell,index)=>cell.dataset.label=labels[index]||"Details"));}
@@ -127,7 +127,7 @@ $("#pagination").onclick=e=>{if(!e.target.dataset.page)return;state.page+=e.targ
$("#rows").onclick=async e=>{const b=e.target.closest("button");if(!b)return;const id=b.dataset.sell||b.dataset.view||b.dataset.restock||b.dataset.delete||b.dataset.id,p=state.products.find(x=>x.id===id);if(!p)return;if(b.dataset.sell)sellForm(id);else if(b.dataset.view)viewSale(p);else if(b.dataset.customer!==undefined)viewCustomer(p);else if(b.dataset.restock)restockForm(p);else if(b.dataset.delete&&confirm(`Permanently delete this ${p.status==="sold"?"sale":"product"} record?`))await change(`/api/products/${encodeURIComponent(id)}`,"DELETE",null,"Record deleted.");}; $("#rows").onclick=async e=>{const b=e.target.closest("button");if(!b)return;const id=b.dataset.sell||b.dataset.view||b.dataset.restock||b.dataset.delete||b.dataset.id,p=state.products.find(x=>x.id===id);if(!p)return;if(b.dataset.sell)sellForm(id);else if(b.dataset.view)viewSale(p);else if(b.dataset.customer!==undefined)viewCustomer(p);else if(b.dataset.restock)restockForm(p);else if(b.dataset.delete&&confirm(`Permanently delete this ${p.status==="sold"?"sale":"product"} record?`))await change(`/api/products/${encodeURIComponent(id)}`,"DELETE",null,"Record deleted.");};
$("#modal .close").onclick=closeModal; $("#modal").onclick=e=>{if(e.target===$("#modal"))closeModal();}; $("#modal .close").onclick=closeModal; $("#modal").onclick=e=>{if(e.target===$("#modal"))closeModal();};
$("#logout").onclick=logout; $("#logout").onclick=logout;
$("#themeSelect").onchange=e=>{try{localStorage.setItem("stockroom-theme",e.target.value)}catch{}applyTheme(e.target.value);}; $("#themeSelect").onchange=e=>{try{localStorage.setItem("vboxstock-theme",e.target.value)}catch{}applyTheme(e.target.value);};
systemTheme.addEventListener?.("change",()=>{if(savedTheme()==="system")applyTheme("system")}); systemTheme.addEventListener?.("change",()=>{if(savedTheme()==="system")applyTheme("system")});
$("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"}); $("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"});
applyTheme(); applyTheme();
+1 -1
View File
@@ -1,4 +1,4 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f5f7fb"><meta name="application-name" content="vBoxStock"><meta name="apple-mobile-web-app-title" content="vBoxStock"><title>vBoxStock</title><script>try{const t=localStorage.getItem("stockroom-theme")||"system",d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme:dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch{}</script><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"><link rel="manifest" href="/site.webmanifest"><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"><link rel="stylesheet" href="/theme.css"></head> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f5f7fb"><meta name="application-name" content="vBoxStock"><meta name="apple-mobile-web-app-title" content="vBoxStock"><title>vBoxStock</title><script>try{const t=localStorage.getItem("vboxstock-theme")||"system",d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme:dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch{}</script><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"><link rel="manifest" href="/site.webmanifest"><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"><link rel="stylesheet" href="/theme.css"></head>
<body class="auth-pending"><section id="authScreen" class="auth-screen"><div class="auth-card"><div class="brand auth-brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><div id="authBody"></div></div></section><div id="appShell"><header><div class="brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><input id="search" type="search" aria-label="Search inventory, sales, and customers" placeholder="Search UID, SN, MAC, model, customer…"><div class="actions edit-only"><button class="secondary" data-open="receive"> Receive product</button><button class="primary" data-open="sell">Record a sale</button></div><div class="user-menu"><span id="currentUser"></span><label class="theme-control"><span>Theme</span><select id="themeSelect" aria-label="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><button class="secondary" id="logout">Log out</button></div></header> <body class="auth-pending"><section id="authScreen" class="auth-screen"><div class="auth-card"><div class="brand auth-brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><div id="authBody"></div></div></section><div id="appShell"><header><div class="brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><input id="search" type="search" aria-label="Search inventory, sales, and customers" placeholder="Search UID, SN, MAC, model, customer…"><div class="actions edit-only"><button class="secondary" data-open="receive"> Receive product</button><button class="primary" data-open="sell">Record a sale</button></div><div class="user-menu"><span id="currentUser"></span><label class="theme-control"><span>Theme</span><select id="themeSelect" aria-label="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><button class="secondary" id="logout">Log out</button></div></header>
<main><div class="intro"><h1>Inventory Overview</h1><span id="date"></span></div><section class="stats"><article><span>Available products</span><strong id="availableCount">0</strong><small>Ready to sell</small></article><article><span>Sold this month</span><strong id="soldCount">0</strong><small id="revenue">No sales recorded</small></article><article><span>Inventory value</span><strong id="value">$0</strong><small>Based on purchase cost</small></article></section> <main><div class="intro"><h1>Inventory Overview</h1><span id="date"></span></div><section class="stats"><article><span>Available products</span><strong id="availableCount">0</strong><small>Ready to sell</small></article><article><span>Sold this month</span><strong id="soldCount">0</strong><small id="revenue">No sales recorded</small></article><article><span>Inventory value</span><strong id="value">$0</strong><small>Based on purchase cost</small></article></section>
<section class="records"><nav><button class="tab active" data-tab="available">Available inventory <i id="availableBadge">0</i></button><button class="tab" data-tab="sold">Sales history <i id="soldBadge">0</i></button><button class="tab" data-tab="customers">Customers <i id="customerBadge">0</i></button><button class="tab" data-tab="admin">Admin</button><span id="storageStatus" class="storage-status connecting" role="status" aria-live="polite"><i></i><b>Connecting to database</b></span></nav><div class="table-wrap"><table><thead id="thead"></thead><tbody id="rows"></tbody></table><div id="empty" hidden></div></div><div id="adminPanel" hidden></div><footer id="pagination"></footer></section></main> <section class="records"><nav><button class="tab active" data-tab="available">Available inventory <i id="availableBadge">0</i></button><button class="tab" data-tab="sold">Sales history <i id="soldBadge">0</i></button><button class="tab" data-tab="customers">Customers <i id="customerBadge">0</i></button><button class="tab" data-tab="admin">Admin</button><span id="storageStatus" class="storage-status connecting" role="status" aria-live="polite"><i></i><b>Connecting to database</b></span></nav><div class="table-wrap"><table><thead id="thead"></thead><tbody id="rows"></tbody></table><div id="empty" hidden></div></div><div id="adminPanel" hidden></div><footer id="pagination"></footer></section></main>
+5 -7
View File
@@ -6,7 +6,7 @@ import { backup, DatabaseSync } from "node:sqlite";
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto"; import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
const port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data"; const port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data";
const databasePath=join(dataDir,"stockroom.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public"); const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public");
const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map(); const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map();
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true}); mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
let db=new DatabaseSync(databasePath); let db=new DatabaseSync(databasePath);
@@ -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)}
@@ -47,13 +45,13 @@ async function rawBody(req){const chunks=[];let size=0;for await(const chunk of
const clientIp=req=>String(req.headers["x-forwarded-for"]||req.socket.remoteAddress||"").split(",")[0].trim(); const clientIp=req=>String(req.headers["x-forwarded-for"]||req.socket.remoteAddress||"").split(",")[0].trim();
function cookieMap(req){return Object.fromEntries(String(req.headers.cookie||"").split(";").filter(Boolean).map(part=>{const i=part.indexOf("=");return[part.slice(0,i).trim(),decodeURIComponent(part.slice(i+1))]}))} function cookieMap(req){return Object.fromEntries(String(req.headers.cookie||"").split(";").filter(Boolean).map(part=>{const i=part.indexOf("=");return[part.slice(0,i).trim(),decodeURIComponent(part.slice(i+1))]}))}
const tokenKey=token=>createHash("sha256").update(token).digest("hex"); const tokenKey=token=>createHash("sha256").update(token).digest("hex");
function sessionCookie(req,token,maxAge=SESSION_IDLE_MS/1000){return `stockroom_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${req.headers["x-forwarded-proto"]==="https"?"; Secure":""}`} function sessionCookie(req,token,maxAge=SESSION_IDLE_MS/1000){return `vboxstock_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${req.headers["x-forwarded-proto"]==="https"?"; Secure":""}`}
function currentSession(req){const token=cookieMap(req).stockroom_session;if(!token)return null;const key=tokenKey(token),session=sessions.get(key);if(!session)return null;if(Date.now()-session.lastSeen>SESSION_IDLE_MS){sessions.delete(key);return null}const user=db.prepare("SELECT id,username,role,enabled,must_change_password AS mustChangePassword FROM users WHERE id=?").get(session.userId);if(!user?.enabled){sessions.delete(key);return null}session.lastSeen=Date.now();return{...session,user,tokenKey:key}} function currentSession(req){const token=cookieMap(req).vboxstock_session;if(!token)return null;const key=tokenKey(token),session=sessions.get(key);if(!session)return null;if(Date.now()-session.lastSeen>SESSION_IDLE_MS){sessions.delete(key);return null}const user=db.prepare("SELECT id,username,role,enabled,must_change_password AS mustChangePassword FROM users WHERE id=?").get(session.userId);if(!user?.enabled){sessions.delete(key);return null}session.lastSeen=Date.now();return{...session,user,tokenKey:key}}
function audit(req,user,action,target="",details=""){db.prepare("INSERT INTO audit_log (user_id,username,action,target,details,ip_address) VALUES (?,?,?,?,?,?)").run(user?.id||null,user?.username||"system",action,target,details,clientIp(req))} function audit(req,user,action,target="",details=""){db.prepare("INSERT INTO audit_log (user_id,username,action,target,details,ip_address) VALUES (?,?,?,?,?,?)").run(user?.id||null,user?.username||"system",action,target,details,clientIp(req))}
function invalidateUserSessions(id){for(const[key,value]of sessions)if(value.userId===id)sessions.delete(key)} function invalidateUserSessions(id){for(const[key,value]of sessions)if(value.userId===id)sessions.delete(key)}
function requireOrigin(req){if(["GET","HEAD","OPTIONS"].includes(req.method))return;const origin=req.headers.origin;if(origin){const expected=`${req.headers["x-forwarded-proto"]||"http"}://${req.headers.host}`;if(origin!==expected)throw Object.assign(new Error("Invalid request origin."),{status:403})}} function requireOrigin(req){if(["GET","HEAD","OPTIONS"].includes(req.method))return;const origin=req.headers.origin;if(origin){const expected=`${req.headers["x-forwarded-proto"]||"http"}://${req.headers.host}`;if(origin!==expected)throw Object.assign(new Error("Invalid request origin."),{status:403})}}
function authorize(req,url){if(url.pathname==="/api/health"||url.pathname==="/api/auth/login")return null;const session=currentSession(req);if(!session)throw Object.assign(new Error("Authentication required."),{status:401,code:"AUTH_REQUIRED"});if(session.user.mustChangePassword&&!new Set(["/api/auth/me","/api/auth/change-password","/api/auth/logout"]).has(url.pathname))throw Object.assign(new Error("Password change required."),{status:403,code:"PASSWORD_CHANGE_REQUIRED"});if(url.pathname.startsWith("/api/admin/")&&session.user.role!=="admin")throw Object.assign(new Error("Administrator access required."),{status:403});if(req.method!=="GET"&&session.user.role!=="admin"&&!url.pathname.startsWith("/api/auth/"))throw Object.assign(new Error("This account is read-only."),{status:403});return session} function authorize(req,url){if(url.pathname==="/api/health"||url.pathname==="/api/auth/login")return null;const session=currentSession(req);if(!session)throw Object.assign(new Error("Authentication required."),{status:401,code:"AUTH_REQUIRED"});if(session.user.mustChangePassword&&!new Set(["/api/auth/me","/api/auth/change-password","/api/auth/logout"]).has(url.pathname))throw Object.assign(new Error("Password change required."),{status:403,code:"PASSWORD_CHANGE_REQUIRED"});if(url.pathname.startsWith("/api/admin/")&&session.user.role!=="admin")throw Object.assign(new Error("Administrator access required."),{status:403});if(req.method!=="GET"&&session.user.role!=="admin"&&!url.pathname.startsWith("/api/auth/"))throw Object.assign(new Error("This account is read-only."),{status:403});return session}
const backupName=(prefix="stockroom")=>`${prefix}-${new Date().toISOString().replace(/[:.]/g,"-")}.db`; const backupName=(prefix="vboxstock")=>`${prefix}-${new Date().toISOString().replace(/[:.]/g,"-")}.db`;
async function createBackup(prefix){const name=backupName(prefix),path=join(backupDir,name);await backup(db,path);return name} async function createBackup(prefix){const name=backupName(prefix),path=join(backupDir,name);await backup(db,path);return name}
function safeBackup(name){if(!/^[a-zA-Z0-9._-]+\.db$/.test(name))throw new Error("Invalid backup name");return join(backupDir,name)} function safeBackup(name){if(!/^[a-zA-Z0-9._-]+\.db$/.test(name))throw new Error("Invalid backup name");return join(backupDir,name)}
function validateBackup(path){const candidate=new DatabaseSync(path,{readOnly:true});try{const tables=new Set(candidate.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(x=>x.name));if(!tables.has("products")||!tables.has("customers"))throw new Error("This is not a valid vBoxStock database.");const integrity=candidate.prepare("PRAGMA integrity_check").get();if(Object.values(integrity)[0]!=="ok")throw new Error("The backup failed its integrity check.")}finally{candidate.close()}} function validateBackup(path){const candidate=new DatabaseSync(path,{readOnly:true});try{const tables=new Set(candidate.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(x=>x.name));if(!tables.has("products")||!tables.has("customers"))throw new Error("This is not a valid vBoxStock database.");const integrity=candidate.prepare("PRAGMA integrity_check").get();if(Object.values(integrity)[0]!=="ok")throw new Error("The backup failed its integrity check.")}finally{candidate.close()}}
@@ -79,7 +77,7 @@ async function adminApi(req,res,url,session){
if(resetMatch&&req.method==="POST"){const id=decodeURIComponent(resetMatch[1]),target=db.prepare("SELECT username FROM users WHERE id=?").get(id);if(!target)return json(res,404,{error:"User not found"});const v=await body(req);db.prepare("UPDATE users SET password_hash=?,must_change_password=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(hashPassword(validPassword(v.password)),id);invalidateUserSessions(id);audit(req,user,"password_reset",target.username);return json(res,200,{ok:true})} if(resetMatch&&req.method==="POST"){const id=decodeURIComponent(resetMatch[1]),target=db.prepare("SELECT username FROM users WHERE id=?").get(id);if(!target)return json(res,404,{error:"User not found"});const v=await body(req);db.prepare("UPDATE users SET password_hash=?,must_change_password=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(hashPassword(validPassword(v.password)),id);invalidateUserSessions(id);audit(req,user,"password_reset",target.username);return json(res,200,{ok:true})}
if(url.pathname==="/api/admin/audit"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,username,action,target,details,ip_address AS ipAddress,created_at AS createdAt FROM audit_log ORDER BY id DESC LIMIT 250").all()); if(url.pathname==="/api/admin/audit"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,username,action,target,details,ip_address AS ipAddress,created_at AS createdAt FROM audit_log ORDER BY id DESC LIMIT 250").all());
if(url.pathname==="/api/admin/backups"&&req.method==="GET"){const files=await readdir(backupDir,{withFileTypes:true}),result=[];for(const file of files)if(file.isFile()&&file.name.endsWith(".db")){const info=await stat(join(backupDir,file.name));result.push({name:file.name,size:info.size,createdAt:info.mtime.toISOString()})}return json(res,200,result.sort((a,b)=>b.createdAt.localeCompare(a.createdAt)))} if(url.pathname==="/api/admin/backups"&&req.method==="GET"){const files=await readdir(backupDir,{withFileTypes:true}),result=[];for(const file of files)if(file.isFile()&&file.name.endsWith(".db")){const info=await stat(join(backupDir,file.name));result.push({name:file.name,size:info.size,createdAt:info.mtime.toISOString()})}return json(res,200,result.sort((a,b)=>b.createdAt.localeCompare(a.createdAt)))}
if(url.pathname==="/api/admin/backups"&&req.method==="POST"){const name=await createBackup("stockroom");audit(req,user,"backup_created",name);return json(res,201,{name})} if(url.pathname==="/api/admin/backups"&&req.method==="POST"){const name=await createBackup("vboxstock");audit(req,user,"backup_created",name);return json(res,201,{name})}
if(url.pathname==="/api/admin/restore-upload"&&req.method==="POST"){confirmPassword(user,req.headers["x-confirm-password"]);const path=join(backupDir,`upload-${crypto.randomUUID()}.db`);await writeFile(path,await rawBody(req));return restoreFrom(path,res,req,user)} if(url.pathname==="/api/admin/restore-upload"&&req.method==="POST"){confirmPassword(user,req.headers["x-confirm-password"]);const path=join(backupDir,`upload-${crypto.randomUUID()}.db`);await writeFile(path,await rawBody(req));return restoreFrom(path,res,req,user)}
const backupMatch=url.pathname.match(/^\/api\/admin\/backups\/([^/]+)\/(download|restore)$/); const backupMatch=url.pathname.match(/^\/api\/admin\/backups\/([^/]+)\/(download|restore)$/);
if(backupMatch){const name=decodeURIComponent(backupMatch[1]),action=backupMatch[2],path=safeBackup(name);await stat(path);if(action==="download"&&req.method==="GET"){audit(req,user,"backup_downloaded",name);const content=await readFile(path);res.writeHead(200,{"content-type":"application/vnd.sqlite3","content-disposition":`attachment; filename="${name}"`,"content-length":content.length});return res.end(content)}if(action==="restore"&&req.method==="POST"){const v=await body(req);confirmPassword(user,v.password);return restoreFrom(path,res,req,user)}} if(backupMatch){const name=decodeURIComponent(backupMatch[1]),action=backupMatch[2],path=safeBackup(name);await stat(path);if(action==="download"&&req.method==="GET"){audit(req,user,"backup_downloaded",name);const content=await readFile(path);res.writeHead(200,{"content-type":"application/vnd.sqlite3","content-disposition":`attachment; filename="${name}"`,"content-length":content.length});return res.end(content)}if(action==="restore"&&req.method==="POST"){const v=await body(req);confirmPassword(user,v.password);return restoreFrom(path,res,req,user)}}
+2 -2
View File
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
test("authentication, roles, inventory, sale, and restock", async t => { test("authentication, roles, inventory, sale, and restock", async t => {
const data=await mkdtemp(join(tmpdir(),"stockroom-")),port=31991; const data=await mkdtemp(join(tmpdir(),"vboxstock-")),port=31991;
const processHandle=spawn(process.execPath,["server.mjs"],{cwd:import.meta.dirname+"/..",env:{...process.env,DATA_DIR:data,PORT:String(port)}}); const processHandle=spawn(process.execPath,["server.mjs"],{cwd:import.meta.dirname+"/..",env:{...process.env,DATA_DIR:data,PORT:String(port)}});
t.after(()=>processHandle.kill()); t.after(()=>processHandle.kill());
await new Promise((resolve,reject)=>{processHandle.stdout.on("data",chunk=>String(chunk).includes("listening")&&resolve());processHandle.on("error",reject)}); await new Promise((resolve,reject)=>{processHandle.stdout.on("data",chunk=>String(chunk).includes("listening")&&resolve());processHandle.on("error",reject)});
@@ -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" "$@"
@@ -1,10 +1,12 @@
<?xml version="1.0"?> <?xml version="1.0"?>
<Container version="2"> <Container version="2">
<Name>vBoxStock</Name><Repository>ghcr.io/mfwadejr/vseebox-stockroom:latest</Repository><Registry>https://github.com/mfwadejr/vseebox-stockroom/pkgs/container/vseebox-stockroom</Registry> <Name>vBoxStock</Name><Repository>ghcr.io/mfwadejr/vboxstock:latest</Repository><Registry>https://github.com/mfwadejr/vboxstock/pkgs/container/vboxstock</Registry>
<Network>bridge</Network><Shell>sh</Shell><Privileged>false</Privileged> <Network>bridge</Network><Shell>sh</Shell><Privileged>false</Privileged>
<Support></Support><Project></Project><Overview>Self-contained vSeeBox inventory and sales tracker with an embedded SQLite database.</Overview> <Support></Support><Project></Project><Overview>Self-contained vSeeBox inventory and sales tracker with an embedded SQLite database.</Overview>
<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/vseebox-stockroom" Mode="rw" Description="SQLite database and durable application data" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/vseebox-stockroom</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>