Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e87f5a26a | |||
| 60c2af2fde | |||
| 2e91841329 | |||
| 1fee676176 | |||
| 50b6cf93a1 | |||
| 01c25e7fc8 | |||
| 7daaf51e64 |
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
data
|
||||
*.zip
|
||||
test
|
||||
README.md
|
||||
docker-compose.yml
|
||||
vboxstock-unraid.xml
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: mfwadejr/vseebox-stockroom
|
||||
IMAGE_NAME: mfwadejr/vboxstock
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
|
||||
+7
-4
@@ -1,13 +1,16 @@
|
||||
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 ./
|
||||
COPY public ./public
|
||||
RUN mkdir -p /data && chown -R node:node /app /data
|
||||
ENV PORT=3000 DATA_DIR=/data NODE_ENV=production
|
||||
USER node
|
||||
COPY vboxstock-entrypoint.sh /usr/local/bin/vboxstock-entrypoint
|
||||
RUN apk add --no-cache su-exec \
|
||||
&& 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
|
||||
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
|
||||
ENTRYPOINT ["/usr/local/bin/vboxstock-entrypoint"]
|
||||
CMD ["node", "server.mjs"]
|
||||
|
||||
@@ -72,17 +72,19 @@ 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 \
|
||||
-e PUID=99 \
|
||||
-e PGID=100 \
|
||||
-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.
|
||||
|
||||
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
|
||||
|
||||
@@ -101,25 +103,29 @@ 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` |
|
||||
| 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.
|
||||
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
|
||||
|
||||
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` 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`.
|
||||
|
||||
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.
|
||||
@@ -129,20 +135,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:
|
||||
|
||||
```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`
|
||||
- Runtime ownership: configurable with `PUID` and `PGID` (`99:100` by default)
|
||||
- Health check: `GET /api/health`
|
||||
- Runtime: Node.js 22
|
||||
- Database: SQLite
|
||||
|
||||
+5
-3
@@ -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"
|
||||
@@ -9,3 +9,5 @@ services:
|
||||
- ./data:/data
|
||||
environment:
|
||||
- TZ=America/New_York
|
||||
- PUID=99
|
||||
- PGID=100
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vseebox-stockroom",
|
||||
"version": "2.2.0",
|
||||
"name": "vboxstock",
|
||||
"version": "3.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": { "node": ">=22.13.0" },
|
||||
|
||||
+2
-2
@@ -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";
|
||||
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)");
|
||||
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 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.");};
|
||||
$("#modal .close").onclick=closeModal; $("#modal").onclick=e=>{if(e.target===$("#modal"))closeModal();};
|
||||
$("#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")});
|
||||
$("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"});
|
||||
applyTheme();
|
||||
|
||||
+1
-1
@@ -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>
|
||||
<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>
|
||||
|
||||
+5
-5
@@ -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)}}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
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)}});
|
||||
t.after(()=>processHandle.kill());
|
||||
await new Promise((resolve,reject)=>{processHandle.stdout.on("data",chunk=>String(chunk).includes("listening")&&resolve());processHandle.on("error",reject)});
|
||||
|
||||
@@ -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"?>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</Container>
|
||||
Reference in New Issue
Block a user