diff --git a/.gitignore b/.gitignore index f2258a6..f2631d4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ # Dependencies node_modules/ -# Database -server/db/*.db -server/db/*.db-wal -server/db/*.db-shm +# Databases: SQLite files, WAL/SHM sidecars, and any .db. backups +# (e.g. .db.devbak), anywhere in the tree - never commit a database. +*.db +*.db-wal +*.db-shm +*.db.* # Uploads (user content) server/uploads/ diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh new file mode 100755 index 0000000..bc55e5d --- /dev/null +++ b/scripts/bump-version.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Bump the ScreenTinker version across every source of truth in one commit + tag. +# +# scripts/bump-version.sh major|minor|patch|X.Y.Z +# +# Updates (and commits together): VERSION (root, the value the server reads at +# runtime), server/package.json + package-lock.json, android versionName +# (+versionCode by 1), tizen/config.xml widget version. Then creates an annotated +# tag vX.Y.Z. Does NOT push - prints the push command, so a release fires +# deliberately (pushing the tag is what triggers the release workflow). +set -euo pipefail +cd "$(dirname "$0")/.." + +# Require a clean tree so the version commit can't sweep up unrelated changes. +if [ -n "$(git status --porcelain)" ]; then + echo "ERROR: working tree is dirty - commit or stash before bumping." >&2 + exit 1 +fi + +CURRENT="$(cat VERSION)" +IFS=. read -r MAJ MIN PAT <<< "$CURRENT" + +case "${1:-}" in + major) NEW="$((MAJ + 1)).0.0" ;; + minor) NEW="${MAJ}.$((MIN + 1)).0" ;; + patch) NEW="${MAJ}.${MIN}.$((PAT + 1))" ;; + [0-9]*.[0-9]*.[0-9]*) NEW="$1" ;; + *) echo "usage: $0 major|minor|patch|X.Y.Z (current: $CURRENT)" >&2; exit 1 ;; +esac +echo "Bumping $CURRENT -> $NEW" + +# 1) VERSION (source of truth) +printf '%s\n' "$NEW" > VERSION + +# 2) server/package.json version + lockfile (only the top-level "version" key; +# dependency entries are "name": "^x.y.z" and won't match "version": "x.y.z") +sed -i -E "s/(\"version\"[[:space:]]*:[[:space:]]*)\"[0-9]+\.[0-9]+\.[0-9]+\"/\1\"$NEW\"/" server/package.json +( cd server && npm install --package-lock-only >/dev/null ) + +# 3) android versionName + versionCode (+1) +sed -i -E "s/(versionName[[:space:]]*=[[:space:]]*)\"[0-9.]+\"/\1\"$NEW\"/" android/app/build.gradle.kts +CODE="$(grep -oE 'versionCode[[:space:]]*=[[:space:]]*[0-9]+' android/app/build.gradle.kts | grep -oE '[0-9]+$')" +sed -i -E "s/(versionCode[[:space:]]*=[[:space:]]*)[0-9]+/\1$((CODE + 1))/" android/app/build.gradle.kts + +# 4) tizen widget version. Leading-space guard targets the widget's version="..." +# attribute and NOT tizen:application required_version="..." (no space before +# "version" there - it's "...d_version"). +sed -i -E "s/([[:space:]]version=\")[0-9][^\"]*(\")/\1${NEW}\2/" tizen/config.xml + +# 5) commit + annotated tag (no push) +git add VERSION server/package.json server/package-lock.json android/app/build.gradle.kts tizen/config.xml +git commit -q -m "chore(release): v$NEW" +git tag -a "v$NEW" -m "ScreenTinker v$NEW" + +echo +echo "Committed + tagged v$NEW (nothing pushed). To release:" +echo " git push origin main && git push origin v$NEW" diff --git a/server/config.js b/server/config.js index fa4d505..f86c162 100644 --- a/server/config.js +++ b/server/config.js @@ -1,12 +1,23 @@ const path = require('path'); +// Data locations. Everything defaults to the in-repo layout, so existing installs +// (including production) are byte-for-byte unchanged when these are unset. Set +// DATA_DIR - or the individual *_PATH / *_DIR vars - to relocate state onto a +// mounted volume (used by the Docker image). UNSET resolves to exactly the legacy +// paths: server/db/remote_display.db, server/uploads/, server/certs/. +const DATA_DIR = process.env.DATA_DIR || __dirname; +const uploadsDir = process.env.UPLOADS_DIR || path.join(DATA_DIR, 'uploads'); +const certsDir = process.env.CERTS_DIR || path.join(DATA_DIR, 'certs'); + module.exports = { port: process.env.PORT || 3001, httpsPort: process.env.HTTPS_PORT || 3443, - dbPath: path.join(__dirname, 'db', 'remote_display.db'), - uploadsDir: path.join(__dirname, 'uploads'), - contentDir: path.join(__dirname, 'uploads', 'content'), - screenshotsDir: path.join(__dirname, 'uploads', 'screenshots'), + dataDir: DATA_DIR, + dbPath: process.env.DB_PATH || path.join(DATA_DIR, 'db', 'remote_display.db'), + uploadsDir, + contentDir: path.join(uploadsDir, 'content'), + screenshotsDir: path.join(uploadsDir, 'screenshots'), + certsDir, frontendDir: path.join(__dirname, '..', 'frontend'), // App-level heartbeat. Checker runs every heartbeatInterval and marks // devices offline if last_heartbeat is older than heartbeatTimeout. @@ -29,11 +40,11 @@ module.exports = { screenshotQuality: 70, // SSL: drop your Cloudflare Origin cert + key in certs/ folder // or set env vars SSL_CERT and SSL_KEY to custom paths - sslCert: process.env.SSL_CERT || path.join(__dirname, 'certs', 'cert.pem'), - sslKey: process.env.SSL_KEY || path.join(__dirname, 'certs', 'key.pem'), + sslCert: process.env.SSL_CERT || path.join(certsDir, 'cert.pem'), + sslKey: process.env.SSL_KEY || path.join(certsDir, 'key.pem'), // Auth jwtSecret: process.env.JWT_SECRET || (() => { - const secretFile = path.join(__dirname, 'certs', '.jwt_secret'); + const secretFile = path.join(certsDir, '.jwt_secret'); const fs = require('fs'); if (fs.existsSync(secretFile)) return fs.readFileSync(secretFile, 'utf8').trim(); const secret = require('crypto').randomBytes(64).toString('hex'); diff --git a/server/package-lock.json b/server/package-lock.json index 3daccfc..479c8a8 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,11 +1,11 @@ { - "name": "remote-display-server", + "name": "screentinker", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "remote-display-server", + "name": "screentinker", "version": "1.0.0", "dependencies": { "@azure/msal-node": "^5.2.1", diff --git a/server/package.json b/server/package.json index 203d440..3ddf536 100644 --- a/server/package.json +++ b/server/package.json @@ -1,5 +1,5 @@ { - "name": "remote-display-server", + "name": "screentinker", "version": "1.0.0", "description": "ScreenTinker - Digital Signage Management Server", "main": "server.js", diff --git a/server/routes/status.js b/server/routes/status.js index 6c9db72..aa5470e 100644 --- a/server/routes/status.js +++ b/server/routes/status.js @@ -5,6 +5,7 @@ const os = require('os'); const path = require('path'); const fs = require('fs'); const config = require('../config'); +const VERSION = require('../version'); const { PLATFORM_ROLES } = require('../middleware/auth'); // Public status page @@ -16,8 +17,7 @@ router.get('/', (req, res) => { const uptime = process.uptime(); // Public status - minimal info only (no user counts, no server internals) - let version = '1.5.1'; - try { version = require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'VERSION'), 'utf8').trim(); } catch {} + const version = VERSION; res.json({ status: 'ok', diff --git a/server/server.js b/server/server.js index 4b321e3..2844105 100644 --- a/server/server.js +++ b/server/server.js @@ -6,6 +6,7 @@ const cors = require('cors'); const path = require('path'); const fs = require('fs'); const config = require('./config'); +const VERSION = require('./version'); // Ensure upload directories exist [config.contentDir, config.screenshotsDir].forEach(dir => { @@ -469,9 +470,7 @@ updateFrontendHash(); // Recheck every 30 seconds setInterval(updateFrontendHash, 30000); app.get('/api/version', (req, res) => { - let version = '1.2.0'; - try { version = fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim(); } catch {} - res.json({ hash: frontendHash, version }); + res.json({ hash: frontendHash, version: VERSION }); }); // Public status page @@ -488,13 +487,7 @@ app.get('/api/update/check', (req, res) => { const apkSize = apkExists ? fs.statSync(apkPath).size : 0; const apkModified = apkExists ? fs.statSync(apkPath).mtimeMs : 0; - // Read version from a version file, or use the APK modification time as a version indicator - const versionFile = path.join(__dirname, '..', 'VERSION'); - let latestVersion = '1.0.0'; - try { - if (fs.existsSync(versionFile)) latestVersion = fs.readFileSync(versionFile, 'utf8').trim(); - } catch {} - + const latestVersion = VERSION; const updateAvailable = currentVersion && currentVersion !== latestVersion; res.json({ diff --git a/server/test/config-paths.test.js b/server/test/config-paths.test.js new file mode 100644 index 0000000..fb1e741 --- /dev/null +++ b/server/test/config-paths.test.js @@ -0,0 +1,44 @@ +// Hard backward-compat guarantee: with DATA_DIR (and the per-path overrides) +// UNSET, config must resolve to exactly the legacy in-repo locations, so existing +// installs - including production - see zero behavior change. Also verifies the +// overrides actually relocate state (the Docker /data case). +const { test } = require('node:test'); +const assert = require('node:assert'); +const path = require('node:path'); + +const PATH_ENV = ['DATA_DIR', 'DB_PATH', 'UPLOADS_DIR', 'CERTS_DIR']; +const serverDir = path.join(__dirname, '..'); // config.js lives in server/ + +function loadConfig(overrides) { + PATH_ENV.forEach((k) => delete process.env[k]); + process.env.JWT_SECRET = 'test-secret'; // short-circuits the secret-file-writing IIFE (no FS side effects) + Object.assign(process.env, overrides || {}); + delete require.cache[require.resolve('../config')]; + return require('../config'); +} + +test('UNSET -> exactly the legacy in-repo paths (zero change for existing installs)', () => { + const c = loadConfig(); + assert.strictEqual(c.dataDir, serverDir); + assert.strictEqual(c.dbPath, path.join(serverDir, 'db', 'remote_display.db')); + assert.strictEqual(c.uploadsDir, path.join(serverDir, 'uploads')); + assert.strictEqual(c.contentDir, path.join(serverDir, 'uploads', 'content')); + assert.strictEqual(c.screenshotsDir, path.join(serverDir, 'uploads', 'screenshots')); + assert.strictEqual(c.certsDir, path.join(serverDir, 'certs')); +}); + +test('DATA_DIR relocates db / uploads / certs onto the volume', () => { + const c = loadConfig({ DATA_DIR: '/data' }); + assert.strictEqual(c.dbPath, path.join('/data', 'db', 'remote_display.db')); + assert.strictEqual(c.uploadsDir, path.join('/data', 'uploads')); + assert.strictEqual(c.contentDir, path.join('/data', 'uploads', 'content')); + assert.strictEqual(c.screenshotsDir, path.join('/data', 'uploads', 'screenshots')); + assert.strictEqual(c.certsDir, path.join('/data', 'certs')); +}); + +test('individual overrides win over DATA_DIR', () => { + const c = loadConfig({ DATA_DIR: '/data', DB_PATH: '/custom/app.db', UPLOADS_DIR: '/media' }); + assert.strictEqual(c.dbPath, '/custom/app.db'); + assert.strictEqual(c.uploadsDir, '/media'); + assert.strictEqual(c.contentDir, path.join('/media', 'content')); +}); diff --git a/server/version.js b/server/version.js new file mode 100644 index 0000000..b5cbc0b --- /dev/null +++ b/server/version.js @@ -0,0 +1,13 @@ +// Single source of truth for the running version string. Reads the root VERSION +// file once at load (the version only changes across a deploy, which restarts the +// process). Fallback '0.0.0' so a stale literal can never masquerade as a real +// release - replaces the old hardcoded '1.2.0' / '1.5.1' fallbacks. +const fs = require('fs'); +const path = require('path'); + +let version = '0.0.0'; +try { + version = fs.readFileSync(path.join(__dirname, '..', 'VERSION'), 'utf8').trim() || '0.0.0'; +} catch { /* keep the 0.0.0 fallback */ } + +module.exports = version;