From 1fee676176c4ee9a7c11ab594059d66d5ae19ba2 Mon Sep 17 00:00:00 2001 From: mfwadejr <125498560+mfwadejr@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:24:53 -0400 Subject: [PATCH] Release clean vBoxStock 3.0.0 identifiers --- .dockerignore | 7 +++++++ Dockerfile | 2 +- README.md | 18 +++++++++--------- docker-compose.yml | 6 +++--- package.json | 4 ++-- server.mjs | 10 +++++----- vboxstock-unraid.xml | 10 ++++++++++ 7 files changed, 37 insertions(+), 20 deletions(-) create mode 100644 .dockerignore create mode 100644 vboxstock-unraid.xml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8715194 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +data +*.zip +test +README.md +docker-compose.yml +vboxstock-unraid.xml diff --git a/Dockerfile b/Dockerfile index 1001c7d..730665d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ 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" WORKDIR /app COPY package.json server.mjs ./ diff --git a/README.md b/README.md index 4d60ac7..c6a522b 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,12 @@ For use outside a trusted private network, place vBoxStock behind an HTTPS rever ```sh docker run -d \ - --name vseebox-stockroom \ + --name vboxstock \ --restart unless-stopped \ -p 3000:3000 \ -e TZ=America/New_York \ -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. @@ -101,14 +101,14 @@ docker compose up -d ## 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 | | --- | --- | -| Repository | `ghcr.io/mfwadejr/vseebox-stockroom:latest` | +| Repository | `ghcr.io/mfwadejr/vboxstock:latest` | | WebUI port | `3000` | | Container data path | `/data` | -| Suggested Unraid host path | `/mnt/user/appdata/vseebox-stockroom` | +| Suggested Unraid host path | `/mnt/user/appdata/vboxstock` | | Network mode | `bridge` | Open the container's WebUI after installation. Updates can be applied with **Force Update** or through the CA Auto Update Applications plugin. @@ -117,7 +117,7 @@ Open the container's WebUI after installation. Updates can be applied with **For 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 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. @@ -129,18 +129,18 @@ 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: ```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: ```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 -- Image: `ghcr.io/mfwadejr/vseebox-stockroom:latest` +- Image: `ghcr.io/mfwadejr/vboxstock:latest` - Application port: `3000/tcp` - Persistent volume: `/data` - Health check: `GET /api/health` diff --git a/docker-compose.yml b/docker-compose.yml index 0851d20..65ceaa6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: - stockroom: - image: ghcr.io/mfwadejr/vseebox-stockroom:latest - container_name: vseebox-stockroom + vboxstock: + image: ghcr.io/mfwadejr/vboxstock:latest + container_name: vboxstock restart: unless-stopped ports: - "3000:3000" diff --git a/package.json b/package.json index 2297e72..5732503 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "vseebox-stockroom", - "version": "2.2.0", + "name": "vboxstock", + "version": "3.0.0", "private": true, "type": "module", "engines": { "node": ">=22.13.0" }, diff --git a/server.mjs b/server.mjs index 2ccced2..4b47497 100644 --- a/server.mjs +++ b/server.mjs @@ -6,7 +6,7 @@ import { backup, DatabaseSync } from "node:sqlite"; import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto"; 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(); mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true}); let db=new DatabaseSync(databasePath); @@ -47,13 +47,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(); 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"); -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 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 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).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 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 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} 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()}} @@ -79,7 +79,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(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==="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)} 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)}} diff --git a/vboxstock-unraid.xml b/vboxstock-unraid.xml new file mode 100644 index 0000000..65b2ddf --- /dev/null +++ b/vboxstock-unraid.xml @@ -0,0 +1,10 @@ + + + vBoxStockghcr.io/mfwadejr/vboxstock:latesthttps://github.com/mfwadejr/vboxstock/pkgs/container/vboxstock + bridgeshfalse + Self-contained vSeeBox inventory and sales tracker with an embedded SQLite database. + Tools:http://[IP]:[PORT:3000]/ + 3000 + /mnt/user/appdata/vboxstock + America/New_York +