From 8ec47b11bbd5cc8c4e7bd5d3e34ff28aa66bd6a4 Mon Sep 17 00:00:00 2001 From: eigger Date: Tue, 7 Jul 2026 15:02:12 +0900 Subject: [PATCH 1/3] Bump version to 0.0.39 and implement editor authentication & JSDOM tests --- .github/workflows/ci.yml | 2 + package-lock.json | 529 +++++++++++++++++++++++++++++++++++- package.json | 5 +- public/editor/auth-gate.js | 68 +++++ public/editor/editor.css | 18 ++ public/editor/editor.js | 15 + public/editor/i18n.js | 79 ++++++ public/editor/index.html | 18 ++ scripts/diagnose-pi.sh | 95 +++++++ scripts/install-pi.sh | 1 + src/auth.js | 115 ++++++++ src/server.js | 59 ++++ test/auth.test.js | 62 +++++ test/display.test.js | 191 +++++++++++++ test/editor.test.js | 252 +++++++++++++++++ test/widgets.render.test.js | 96 +++++++ 16 files changed, 1602 insertions(+), 3 deletions(-) create mode 100644 public/editor/auth-gate.js create mode 100644 scripts/diagnose-pi.sh create mode 100644 src/auth.js create mode 100644 test/auth.test.js create mode 100644 test/display.test.js create mode 100644 test/editor.test.js create mode 100644 test/widgets.render.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 38941cc..5c57a33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,4 +39,6 @@ jobs: run: | bash -n install.sh bash -n scripts/install-pi.sh + bash -n scripts/update-pi.sh + bash -n scripts/diagnose-pi.sh bash -n agent/install.sh diff --git a/package-lock.json b/package-lock.json index 1b5dd90..d00fb49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "paneo", - "version": "0.0.37", + "version": "0.0.39", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paneo", - "version": "0.0.37", + "version": "0.0.39", "license": "MIT", "dependencies": { "@fastify/multipart": "^10.0.0", @@ -17,10 +17,235 @@ "qrcode": "^1.5.4", "rss-parser": "^3.13.0" }, + "devDependencies": { + "jsdom": "^29.1.1" + }, "engines": { "node": ">=22.5.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@fastify/accept-negotiator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -416,6 +641,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", @@ -530,6 +765,34 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -556,6 +819,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1018,6 +1288,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1075,6 +1358,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1096,6 +1386,47 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -1188,6 +1519,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -1344,6 +1682,32 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -1449,6 +1813,16 @@ "node": ">=10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -1623,6 +1997,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/secure-json-parse": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", @@ -1711,6 +2098,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1770,6 +2167,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -1788,6 +2192,26 @@ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", "license": "MIT" }, + "node_modules/tldts": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.6" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", + "dev": true, + "license": "MIT" + }, "node_modules/toad-cache": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.1.tgz", @@ -1806,12 +2230,48 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -1832,6 +2292,54 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1894,6 +2402,16 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", @@ -1916,6 +2434,13 @@ "node": ">=4.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index 3f90329..0ad0a67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "paneo", - "version": "0.0.38", + "version": "0.0.39", "private": true, "type": "module", "description": "Paneo — web-editable dashboards for Raspberry Pi / ambient displays", @@ -29,5 +29,8 @@ "node-ical": "^0.20.1", "qrcode": "^1.5.4", "rss-parser": "^3.13.0" + }, + "devDependencies": { + "jsdom": "^29.1.1" } } diff --git a/public/editor/auth-gate.js b/public/editor/auth-gate.js new file mode 100644 index 0000000..168c469 --- /dev/null +++ b/public/editor/auth-gate.js @@ -0,0 +1,68 @@ +// §12 보안: blocks the rest of editor.js (via top-level await) until the +// admin session cookie is valid. Kept separate from editor.js/api() below +// because this runs *before* any device/layout state exists — it only needs +// the DOM shell (#auth-overlay, index.html) and i18n. +import { t } from './i18n.js'; + +async function post(path, body) { + return fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); +} + +export async function ensureAuthenticated() { + const status = await (await fetch('/api/auth/status')).json(); + if (status.authenticated) return; + + const overlay = document.getElementById('auth-overlay'); + const title = document.getElementById('auth-title'); + const hint = document.getElementById('auth-hint'); + const form = document.getElementById('auth-form'); + const pwInput = document.getElementById('auth-password'); + const confirmField = document.getElementById('auth-confirm-field'); + const confirmInput = document.getElementById('auth-password-confirm'); + const errorEl = document.getElementById('auth-error'); + const submitBtn = document.getElementById('auth-submit'); + + const isSetup = !status.configured; + title.textContent = t(isSetup ? 'authSetupTitle' : 'authLoginTitle'); + hint.textContent = t(isSetup ? 'authSetupHint' : 'authLoginHint'); + pwInput.placeholder = t('authPasswordPlaceholder'); + confirmInput.placeholder = t('authPasswordConfirmPlaceholder'); + submitBtn.textContent = t('authSubmitBtn'); + confirmField.hidden = !isSetup; + overlay.classList.remove('hidden'); + pwInput.focus(); + + return new Promise((resolve) => { + form.addEventListener('submit', async (e) => { + e.preventDefault(); + errorEl.classList.add('hidden'); + const password = pwInput.value; + if (isSetup && password.length < 8) { + errorEl.textContent = t('authErrorTooShort'); + errorEl.classList.remove('hidden'); + return; + } + if (isSetup && password !== confirmInput.value) { + errorEl.textContent = t('authErrorMismatch'); + errorEl.classList.remove('hidden'); + return; + } + submitBtn.disabled = true; + try { + const res = await post(isSetup ? '/api/auth/setup' : '/api/auth/login', { password }); + if (!res.ok) { + errorEl.textContent = res.status === 429 ? t('authErrorRateLimited') : (isSetup ? t('authErrorGeneric') : t('authErrorInvalid')); + errorEl.classList.remove('hidden'); + submitBtn.disabled = false; + return; + } + overlay.classList.add('hidden'); + resolve(); + } catch { + errorEl.textContent = t('authErrorGeneric'); + errorEl.classList.remove('hidden'); + submitBtn.disabled = false; + } + }); + }); +} diff --git a/public/editor/editor.css b/public/editor/editor.css index 1d5d505..ae85d5f 100644 --- a/public/editor/editor.css +++ b/public/editor/editor.css @@ -361,6 +361,24 @@ main { flex: 1; display: flex; min-height: 0; } .settings-close:hover { background: #2b3648; transform: translateY(-1px); } .settings-close:active { transform: translateY(0) scale(0.98); } .settings-divider { border: 0; border-top: 1px solid #232a38; margin: 16px 0; } + +/* §12 보안: 편집기 관리자 로그인/최초 설정 화면 — 캔버스보다 위, 설정 모달과 + 동일한 시각 언어를 쓰되 z-index만 더 높게 (로그인 전엔 그 아래 무엇도 보이면 안 됨) */ +#auth-overlay { z-index: 200; } +#auth-panel { background: rgba(20, 25, 36, 0.94); backdrop-filter: blur(20px) saturate(140%); + -webkit-backdrop-filter: blur(20px) saturate(140%); + border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; + padding: 26px; width: 300px; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255,255,255,0.05); } +#auth-panel h3 { font-size: 16px; margin-bottom: 8px; } +#auth-panel .field-hint { margin-bottom: 16px; } +#auth-panel .field { margin-bottom: 10px; } +#auth-panel input[type="password"] { width: 100%; } +#auth-confirm-field[hidden] { display: none; } +#auth-submit { width: 100%; margin-top: 4px; background: #2b6bff; color: #fff; + border: none; border-radius: 8px; padding: 9px; cursor: pointer; font-weight: 600; } +#auth-submit:hover { background: #4f7fff; } +.auth-error { color: #ff9aa7; font-size: 12px; margin: 0 0 10px; } +.auth-error.hidden { display: none; } .group-new-row { display: flex; gap: 6px; margin-top: 8px; } .group-new-row input { flex: 1 1 auto; min-width: 0; } .group-new-row button { flex: 0 0 auto; background: #1c2330; color: #cfe0ff; border: 1px solid #2a3040; diff --git a/public/editor/editor.js b/public/editor/editor.js index 23ea12d..e4e4ccc 100644 --- a/public/editor/editor.js +++ b/public/editor/editor.js @@ -2,6 +2,11 @@ import { widgets, renderWidget, widgetLabel, fieldLabel, fieldPlaceholder, CATEG import { t, getLang, setLang, LANGS, LOCALES, RESOLUTIONS } from '/editor/i18n.js'; import { effectiveRows, applyGridContainer, applyGridItem, applyCustomCss, buildWidgetContentClass, pageSurfaceColor } from '/shared/gridlayout.js'; import { attachSwipeNavigation } from '/shared/swipe.js'; +import { ensureAuthenticated } from '/editor/auth-gate.js'; + +// Blocks everything below (top-level await) until the admin session is +// valid — nothing in this module touches /api/* before this resolves. +await ensureAuthenticated(); let device = null; let layout = null; @@ -175,6 +180,12 @@ async function api(path, opts = {}) { // (bit us on the body-less POST /publish call). const headers = opts.body ? { 'content-type': 'application/json' } : {}; const res = await fetch(path, { ...opts, headers: { ...headers, ...opts.headers } }); + if (res.status === 401) { + // Session expired/cleared mid-use — reload re-runs ensureAuthenticated() + // above and shows the login screen again instead of surfacing raw 401s. + location.reload(); + throw new Error('unauthorized'); + } if (!res.ok) throw new Error(`${res.status} ${path}`); return res.json(); } @@ -1648,6 +1659,10 @@ if (importBackupBtn && importBackupInput) { }); } +document.getElementById('logout-btn').addEventListener('click', async () => { + await fetch('/api/auth/logout', { method: 'POST' }); + location.reload(); +}); document.getElementById('settings-close').addEventListener('click', closeSettings); settingsOverlay.addEventListener('click', (e) => { if (e.target === settingsOverlay) closeSettings(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSettings(); }); diff --git a/public/editor/i18n.js b/public/editor/i18n.js index b127107..c93ca65 100644 --- a/public/editor/i18n.js +++ b/public/editor/i18n.js @@ -147,6 +147,20 @@ const catalogs = { versionEditor: (v) => `편집기 v${v}`, versionDisplay: (v) => `디스플레이 v${v}`, versionAgent: (v) => `에이전트 v${v}`, + // §12 보안: 편집기 관리자 비밀번호 게이트 + authSetupTitle: '관리자 비밀번호 설정', + authSetupHint: '이 편집기에 처음 접속했습니다. 계속하려면 관리자 비밀번호를 설정하세요 (8자 이상).', + authLoginTitle: '로그인', + authLoginHint: '편집기에 접근하려면 관리자 비밀번호를 입력하세요.', + authPasswordPlaceholder: '비밀번호', + authPasswordConfirmPlaceholder: '비밀번호 확인', + authSubmitBtn: '확인', + authLogoutBtn: '로그아웃', + authErrorMismatch: '비밀번호가 일치하지 않습니다.', + authErrorTooShort: '비밀번호는 8자 이상이어야 합니다.', + authErrorInvalid: '비밀번호가 올바르지 않습니다.', + authErrorRateLimited: '시도 횟수가 많습니다. 잠시 후 다시 시도하세요.', + authErrorGeneric: '오류가 발생했습니다. 다시 시도하세요.', }, en: { tag: 'editor', @@ -291,6 +305,19 @@ const catalogs = { versionEditor: (v) => `Editor v${v}`, versionDisplay: (v) => `Display v${v}`, versionAgent: (v) => `Agent v${v}`, + authSetupTitle: 'Set admin password', + authSetupHint: 'This is the first visit to this editor. Set an admin password to continue (8+ characters).', + authLoginTitle: 'Log in', + authLoginHint: 'Enter the admin password to access the editor.', + authPasswordPlaceholder: 'Password', + authPasswordConfirmPlaceholder: 'Confirm password', + authSubmitBtn: 'Continue', + authLogoutBtn: 'Log out', + authErrorMismatch: 'Passwords do not match.', + authErrorTooShort: 'Password must be at least 8 characters.', + authErrorInvalid: 'Incorrect password.', + authErrorRateLimited: 'Too many attempts. Try again shortly.', + authErrorGeneric: 'Something went wrong. Please try again.', }, ja: { tag: 'エディタ', @@ -431,6 +458,19 @@ const catalogs = { versionEditor: (v) => `エディタ v${v}`, versionDisplay: (v) => `ディスプレイ v${v}`, versionAgent: (v) => `エージェント v${v}`, + authSetupTitle: '管理者パスワードの設定', + authSetupHint: 'このエディタへの初回アクセスです。続行するには管理者パスワードを設定してください (8文字以上)。', + authLoginTitle: 'ログイン', + authLoginHint: 'エディタにアクセスするには管理者パスワードを入力してください。', + authPasswordPlaceholder: 'パスワード', + authPasswordConfirmPlaceholder: 'パスワード確認', + authSubmitBtn: '続行', + authLogoutBtn: 'ログアウト', + authErrorMismatch: 'パスワードが一致しません。', + authErrorTooShort: 'パスワードは8文字以上にしてください。', + authErrorInvalid: 'パスワードが正しくありません。', + authErrorRateLimited: '試行回数が多すぎます。しばらくしてから再試行してください。', + authErrorGeneric: 'エラーが発生しました。もう一度お試しください。', }, de: { tag: 'Editor', @@ -571,6 +611,19 @@ const catalogs = { versionEditor: (v) => `Editor v${v}`, versionDisplay: (v) => `Display v${v}`, versionAgent: (v) => `Agent v${v}`, + authSetupTitle: 'Administratorpasswort festlegen', + authSetupHint: 'Dies ist der erste Zugriff auf diesen Editor. Legen Sie ein Administratorpasswort fest, um fortzufahren (mind. 8 Zeichen).', + authLoginTitle: 'Anmelden', + authLoginHint: 'Geben Sie das Administratorpasswort ein, um auf den Editor zuzugreifen.', + authPasswordPlaceholder: 'Passwort', + authPasswordConfirmPlaceholder: 'Passwort bestätigen', + authSubmitBtn: 'Weiter', + authLogoutBtn: 'Abmelden', + authErrorMismatch: 'Passwörter stimmen nicht überein.', + authErrorTooShort: 'Das Passwort muss mindestens 8 Zeichen lang sein.', + authErrorInvalid: 'Falsches Passwort.', + authErrorRateLimited: 'Zu viele Versuche. Bitte versuchen Sie es später erneut.', + authErrorGeneric: 'Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.', }, fr: { tag: 'Éditeur', @@ -711,6 +764,19 @@ const catalogs = { versionEditor: (v) => `Éditeur v${v}`, versionDisplay: (v) => `Écran v${v}`, versionAgent: (v) => `Agent v${v}`, + authSetupTitle: 'Définir le mot de passe administrateur', + authSetupHint: 'Ceci est le premier accès à cet éditeur. Définissez un mot de passe administrateur pour continuer (8 caractères minimum).', + authLoginTitle: 'Connexion', + authLoginHint: 'Entrez le mot de passe administrateur pour accéder à l’éditeur.', + authPasswordPlaceholder: 'Mot de passe', + authPasswordConfirmPlaceholder: 'Confirmer le mot de passe', + authSubmitBtn: 'Continuer', + authLogoutBtn: 'Déconnexion', + authErrorMismatch: 'Les mots de passe ne correspondent pas.', + authErrorTooShort: 'Le mot de passe doit contenir au moins 8 caractères.', + authErrorInvalid: 'Mot de passe incorrect.', + authErrorRateLimited: 'Trop de tentatives. Réessayez dans un instant.', + authErrorGeneric: 'Une erreur est survenue. Veuillez réessayer.', }, es: { tag: 'Editor', @@ -851,6 +917,19 @@ const catalogs = { versionEditor: (v) => `Editor v${v}`, versionDisplay: (v) => `Pantalla v${v}`, versionAgent: (v) => `Agente v${v}`, + authSetupTitle: 'Establecer contraseña de administrador', + authSetupHint: 'Este es el primer acceso a este editor. Establece una contraseña de administrador para continuar (8 caracteres o más).', + authLoginTitle: 'Iniciar sesión', + authLoginHint: 'Ingresa la contraseña de administrador para acceder al editor.', + authPasswordPlaceholder: 'Contraseña', + authPasswordConfirmPlaceholder: 'Confirmar contraseña', + authSubmitBtn: 'Continuar', + authLogoutBtn: 'Cerrar sesión', + authErrorMismatch: 'Las contraseñas no coinciden.', + authErrorTooShort: 'La contraseña debe tener al menos 8 caracteres.', + authErrorInvalid: 'Contraseña incorrecta.', + authErrorRateLimited: 'Demasiados intentos. Vuelve a intentarlo en un momento.', + authErrorGeneric: 'Algo salió mal. Inténtalo de nuevo.', }, }; diff --git a/public/editor/index.html b/public/editor/index.html index f6ac731..f327b4b 100644 --- a/public/editor/index.html +++ b/public/editor/index.html @@ -7,6 +7,23 @@ + +
Paneo
@@ -145,6 +162,7 @@

+ diff --git a/scripts/diagnose-pi.sh b/scripts/diagnose-pi.sh new file mode 100644 index 0000000..8440c47 --- /dev/null +++ b/scripts/diagnose-pi.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Paneo field diagnostics — read-only. Bundles the ad-hoc checks used during +# kiosk field debugging (undervoltage, sandbox failures, service status, +# kiosk process/render health) into one command so a support session has a +# starting point instead of re-deriving it from scratch every time. +# +# curl -fsSL https://raw.githubusercontent.com/eigger/paneo/master/scripts/diagnose-pi.sh | bash +# +# Never restarts services or modifies anything — safe to run at any time, +# including while a real user is watching the display. +set -uo pipefail + +SERVICE_USER="${PANEO_USER:-${SUDO_USER:-pi}}" +CDP_PORT="${PANEO_CDP_PORT:-9222}" + +section() { printf '\n=== %s ===\n' "$1"; } + +section "System" +uname -a +[ -r /etc/os-release ] && grep -E '^(PRETTY_NAME|VERSION)=' /etc/os-release +uptime + +section "Power / temperature (vcgencmd)" +if command -v vcgencmd >/dev/null 2>&1; then + vcgencmd get_throttled + # bit 0/16/18/19 set = undervoltage now/since-boot — the single most common + # root cause found during this project's real-device debugging sessions. + vcgencmd measure_temp + vcgencmd measure_volts core 2>/dev/null || true +else + echo "vcgencmd not found (not running on Raspberry Pi OS, or vcgencmd not installed)" +fi + +section "Disk / memory" +df -h / /tmp 2>/dev/null +free -h 2>/dev/null + +section "paneo.service (Docker server, if installed on this host)" +if systemctl list-unit-files 2>/dev/null | grep -q '^paneo\.service'; then + systemctl is-active paneo 2>/dev/null + systemctl status paneo --no-pager -l 2>/dev/null | head -15 + echo "--- last 30 log lines ---" + journalctl -u paneo -n 30 --no-pager 2>/dev/null +else + echo "not installed on this host" +fi + +section "paneo-agent.service (companion agent)" +if systemctl list-unit-files 2>/dev/null | grep -q '^paneo-agent\.service'; then + systemctl is-active paneo-agent 2>/dev/null + systemctl status paneo-agent --no-pager -l 2>/dev/null | head -15 + echo "--- last 50 log lines ---" + journalctl -u paneo-agent -n 50 --no-pager 2>/dev/null +else + echo "not installed on this host" +fi + +section "Kiosk (Chromium) process" +pgrep -af chromium 2>/dev/null || echo "no chromium process running" + +section "Kiosk render health (CDP on 127.0.0.1:${CDP_PORT})" +if command -v curl >/dev/null 2>&1; then + curl -fsS --max-time 2 "http://127.0.0.1:${CDP_PORT}/json/version" 2>/dev/null \ + || echo "CDP endpoint not responding (kiosk down, still starting, or launched without --remote-debugging-port)" +else + echo "curl not available" +fi + +section "fs.protected_regular (kernel hardening)" +# =2 broke root's own repeated writes to a chown'd file mid-update in a past +# incident (D? update-pi.sh write_status fix) — surfaced here for fast recheck. +sysctl fs.protected_regular 2>/dev/null || echo "not set / not applicable" + +section "Kiosk launcher" +if [ -f /usr/local/bin/paneo-kiosk ]; then + echo "--- /usr/local/bin/paneo-kiosk ---" + cat /usr/local/bin/paneo-kiosk +else + echo "/usr/local/bin/paneo-kiosk not found" +fi + +section "Last update log (/tmp/paneo-update.log)" +if [ -f /tmp/paneo-update.log ]; then + tail -n 40 /tmp/paneo-update.log +else + echo "no /tmp/paneo-update.log on this host" +fi + +section "Companion agent home directory" +home_dir="$(getent passwd "$SERVICE_USER" 2>/dev/null | cut -d: -f6)" +if [ -n "$home_dir" ]; then + ls -la "$home_dir/.config/paneo-chromium" 2>/dev/null | head -5 || echo "no chromium profile dir yet" +fi + +printf '\nDone. Paste this output when reporting a kiosk/agent issue.\n' diff --git a/scripts/install-pi.sh b/scripts/install-pi.sh index b908611..5d9e306 100644 --- a/scripts/install-pi.sh +++ b/scripts/install-pi.sh @@ -437,6 +437,7 @@ print_summary() { fi log "Server logs: systemctl status paneo (or: docker logs -f paneo)" log "Agent logs: systemctl status paneo-agent && journalctl -u paneo-agent -f" + log "Troubleshooting: curl -fsSL $SERVER/diagnose.sh | bash" } main() { diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..2926330 --- /dev/null +++ b/src/auth.js @@ -0,0 +1,115 @@ +// §12 보안 D?: minimal single-admin password gate for the editor (docs/design.md +// explicitly flagged this as "의도적으로 미구현·보류" until real deployment). +// Sessions are an in-memory bearer-token map, not JWT/signed cookies — the +// token itself IS the secret, so no signing key/cookie library is needed, and +// losing it on a server restart (forcing re-login) is an acceptable trade-off +// for a single-admin self-hosted tool. +import crypto from 'node:crypto'; +import * as store from './store.js'; + +export const SESSION_COOKIE_NAME = 'paneo_session'; +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +const SETTING_KEY = 'adminPasswordHash'; + +const sessions = new Map(); // sessionId -> expiresAt (ms epoch) + +function scrypt(password, saltHex) { + return crypto.scryptSync(password, saltHex, 64).toString('hex'); +} + +export function hashPassword(password) { + const salt = crypto.randomBytes(16).toString('hex'); + return `${salt}:${scrypt(password, salt)}`; +} + +export function verifyPassword(password, stored) { + if (!stored || !stored.includes(':')) return false; + const [salt, hash] = stored.split(':'); + let candidate; + try { + candidate = scrypt(password, salt); + } catch { + return false; + } + const a = Buffer.from(candidate, 'hex'); + const b = Buffer.from(hash, 'hex'); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +export function isConfigured() { + return !!store.getSetting(SETTING_KEY); +} + +export function setPassword(password) { + store.setSetting(SETTING_KEY, hashPassword(password)); +} + +export function checkPassword(password) { + return verifyPassword(password, store.getSetting(SETTING_KEY)); +} + +export function createSession() { + const id = crypto.randomBytes(32).toString('hex'); + sessions.set(id, Date.now() + SESSION_TTL_MS); + return id; +} + +export function isValidSession(id) { + if (!id) return false; + const exp = sessions.get(id); + if (!exp) return false; + if (Date.now() > exp) { + sessions.delete(id); + return false; + } + return true; +} + +export function destroySession(id) { + if (id) sessions.delete(id); +} + +export function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const part of header.split(';')) { + const idx = part.indexOf('='); + if (idx === -1) continue; + const k = part.slice(0, idx).trim(); + const v = part.slice(idx + 1).trim(); + if (k) { + try { + out[k] = decodeURIComponent(v); + } catch { + out[k] = v; + } + } + } + return out; +} + +export function sessionCookieHeader(id) { + return `${SESSION_COOKIE_NAME}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`; +} + +export function clearCookieHeader() { + return `${SESSION_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; +} + +// Very small brute-force deterrent — not a substitute for network-level rate +// limiting, but enough to stop a naive password-guessing loop against a +// single-admin login form. +const attempts = new Map(); // ip -> { count, resetAt } +const MAX_ATTEMPTS = 10; +const WINDOW_MS = 5 * 60 * 1000; + +export function isRateLimited(ip) { + const now = Date.now(); + const rec = attempts.get(ip); + if (!rec || now > rec.resetAt) { + attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS }); + return false; + } + rec.count++; + return rec.count > MAX_ATTEMPTS; +} diff --git a/src/server.js b/src/server.js index 4f0deed..9b37b1c 100644 --- a/src/server.js +++ b/src/server.js @@ -12,6 +12,7 @@ import * as store from './store.js'; import { registerDataProxy } from './dataproxy.js'; import { setAgentPresent } from './store.js'; import * as plugins from './plugins.js'; +import * as auth from './auth.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PUBLIC = path.join(__dirname, '..', 'public'); @@ -41,6 +42,60 @@ await registerDataProxy(app); await store.load(); plugins.scan(); +// PANEO_ADMIN_PASSWORD lets a docker-compose/systemd deployment declare the +// editor password up front (re-applied on every boot); without it, the editor +// shows a one-time "set admin password" form on first load (see /api/auth/setup). +if (process.env.PANEO_ADMIN_PASSWORD) { + auth.setPassword(process.env.PANEO_ADMIN_PASSWORD); +} + +// §12 보안: gate every /api/* route behind the editor's admin session, except +// the routes the *kiosk display itself* (unauthenticated by design — it only +// carries a per-device pairing token) and install scripts must keep calling +// directly. `/editor/*` static assets are intentionally NOT gated here — the +// SPA shell has no secrets in it; public/editor/editor.js blocks its own UI +// behind /api/auth/status until login succeeds, so gating only the API is +// sufficient and avoids fighting fastify-static's routing for the login page. +const PUBLIC_API_PREFIXES = ['/api/auth/', '/api/proxy/', '/api/display/', '/api/brand', '/api/version', '/api/update-check', '/api/plugins']; +app.addHook('onRequest', async (req, reply) => { + if (!req.url.startsWith('/api/')) return; + if (PUBLIC_API_PREFIXES.some((p) => req.url === p || req.url.startsWith(p))) return; + const cookies = auth.parseCookies(req.headers.cookie); + if (!auth.isValidSession(cookies[auth.SESSION_COOKIE_NAME])) { + reply.code(401).send({ error: 'unauthorized' }); + } +}); + +app.get('/api/auth/status', async (req) => { + const cookies = auth.parseCookies(req.headers.cookie); + return { configured: auth.isConfigured(), authenticated: auth.isValidSession(cookies[auth.SESSION_COOKIE_NAME]) }; +}); + +app.post('/api/auth/setup', async (req, reply) => { + if (auth.isConfigured()) return reply.code(409).send({ error: 'already configured' }); + const password = String(req.body?.password || ''); + if (password.length < 8) return reply.code(400).send({ error: 'password too short' }); + auth.setPassword(password); + reply.header('set-cookie', auth.sessionCookieHeader(auth.createSession())); + return { ok: true }; +}); + +app.post('/api/auth/login', async (req, reply) => { + if (auth.isRateLimited(req.ip)) return reply.code(429).send({ error: 'too many attempts' }); + const password = String(req.body?.password || ''); + if (!auth.isConfigured() || !auth.checkPassword(password)) { + return reply.code(401).send({ error: 'invalid password' }); + } + reply.header('set-cookie', auth.sessionCookieHeader(auth.createSession())); + return { ok: true }; +}); + +app.post('/api/auth/logout', async (req, reply) => { + auth.destroySession(auth.parseCookies(req.headers.cookie)[auth.SESSION_COOKIE_NAME]); + reply.header('set-cookie', auth.clearCookieHeader()); + return { ok: true }; +}); + // --- live display connections: Map> --- const displays = new Map(); const addDisplay = (id, s) => (displays.get(id) ?? displays.set(id, new Set()).get(id)).add(s); @@ -580,6 +635,10 @@ app.get('/update.sh', async (_req, reply) => { reply.type('text/x-shellscript; charset=utf-8'); return reply.sendFile('update-pi.sh', SCRIPTS_DIR); }); +app.get('/diagnose.sh', async (_req, reply) => { + reply.type('text/x-shellscript; charset=utf-8'); + return reply.sendFile('diagnose-pi.sh', SCRIPTS_DIR); +}); app.listen({ port: PORT, host: '0.0.0.0' }) .then(() => { diff --git a/test/auth.test.js b/test/auth.test.js new file mode 100644 index 0000000..349ba98 --- /dev/null +++ b/test/auth.test.js @@ -0,0 +1,62 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.PANEO_DATA_DIR = mkdtempSync(path.join(tmpdir(), 'paneo-test-')); + +const store = await import('../src/store.js'); +await store.load(); +const auth = await import('../src/auth.js'); + +test('not configured until a password is set', () => { + assert.equal(auth.isConfigured(), false); +}); + +test('setPassword + checkPassword round-trips correctly', () => { + auth.setPassword('correct-horse-battery-staple'); + assert.equal(auth.isConfigured(), true); + assert.equal(auth.checkPassword('correct-horse-battery-staple'), true); + assert.equal(auth.checkPassword('wrong-password'), false); +}); + +test('hashPassword never stores the plaintext password', () => { + const hash = auth.hashPassword('super-secret'); + assert.doesNotMatch(hash, /super-secret/); + assert.match(hash, /^[0-9a-f]+:[0-9a-f]+$/); +}); + +test('createSession issues a token that isValidSession accepts, and destroySession revokes it', () => { + const id = auth.createSession(); + assert.equal(auth.isValidSession(id), true); + auth.destroySession(id); + assert.equal(auth.isValidSession(id), false); +}); + +test('isValidSession rejects unknown or missing tokens', () => { + assert.equal(auth.isValidSession('not-a-real-session'), false); + assert.equal(auth.isValidSession(undefined), false); + assert.equal(auth.isValidSession(''), false); +}); + +test('parseCookies reads the session cookie out of a Cookie header', () => { + const cookies = auth.parseCookies(`foo=bar; ${auth.SESSION_COOKIE_NAME}=abc123; baz=qux`); + assert.equal(cookies[auth.SESSION_COOKIE_NAME], 'abc123'); +}); + +test('sessionCookieHeader sets HttpOnly + SameSite, clearCookieHeader expires it', () => { + const setHeader = auth.sessionCookieHeader('sometoken'); + assert.match(setHeader, /HttpOnly/); + assert.match(setHeader, /SameSite=Lax/); + const clearHeader = auth.clearCookieHeader(); + assert.match(clearHeader, /Max-Age=0/); +}); + +test('isRateLimited allows a normal login attempt rate and blocks a rapid-fire loop', () => { + const ip = '203.0.113.5'; + for (let i = 0; i < 10; i++) { + assert.equal(auth.isRateLimited(ip), false, `attempt ${i + 1} should not be limited yet`); + } + assert.equal(auth.isRateLimited(ip), true, '11th attempt within the window should be blocked'); +}); diff --git a/test/display.test.js b/test/display.test.js new file mode 100644 index 0000000..cbeab04 --- /dev/null +++ b/test/display.test.js @@ -0,0 +1,191 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { JSDOM } from 'jsdom'; + +// Create a shadowed version of display.js with absolute browser paths mapped to relative paths +const displayJsPath = path.resolve('public/display/display.js'); +let displayJsContent = fs.readFileSync(displayJsPath, 'utf8'); +displayJsContent = displayJsContent + .replaceAll("'/shared/", "'../public/shared/") + .replaceAll('"/shared/', '"../public/shared/'); + +const shadowPath = path.resolve('test/display.test.shadow.js'); +fs.writeFileSync(shadowPath, displayJsContent, 'utf8'); + +test.after(() => { + try { + fs.unlinkSync(shadowPath); + } catch {} +}); + +test('display.js: renders cached layout on startup', async (t) => { + const dom = new JSDOM('
'); + + Object.defineProperty(globalThis, 'window', { value: dom.window, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'document', { value: dom.window.document, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'getComputedStyle', { value: dom.window.getComputedStyle.bind(dom.window), configurable: true, writable: true }); + + const storage = {}; + storage['paneo:layout:test-token'] = JSON.stringify({ + layout: { + pages: [ + { id: 'page-0', widgets: [{ id: 'w1', type: 'paneo.text', config: { text: 'Cached Hello' } }] } + ] + }, + ctx: { locale: 'en-US' } + }); + + globalThis.localStorage = { + getItem: (key) => storage[key] || null, + setItem: (key, val) => { storage[key] = val; } + }; + + globalThis.location = { + pathname: '/d/test-token', + host: 'localhost', + protocol: 'http:' + }; + + globalThis.ResizeObserver = class { + observe() {} + disconnect() {} + }; + + globalThis.fetch = async (url) => { + if (url === '/api/version') { + return { + json: async () => ({ components: { display: '1.2.3' } }) + }; + } + throw new Error('unexpected fetch: ' + url); + }; + + let wsInstance = null; + globalThis.WebSocket = class { + constructor(url) { + this.url = url; + wsInstance = this; + } + close() {} + send() {} + }; + + const fileUrl = pathToFileURL(shadowPath).href + '?t=' + Date.now(); + await import(fileUrl); + + const stage = dom.window.document.getElementById('stage'); + assert.ok(stage.innerHTML.includes('Cached Hello')); + + const statusEl = dom.window.document.getElementById('status'); + assert.equal(statusEl.title, 'Paneo display v1.2.3'); + + assert.ok(wsInstance); + assert.equal(wsInstance.url, 'ws://localhost/ws?role=display&token=test-token'); +}); + +test('display.js: handles WebSocket messages and page switching', async (t) => { + const dom = new JSDOM('
'); + + Object.defineProperty(globalThis, 'window', { value: dom.window, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'document', { value: dom.window.document, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'getComputedStyle', { value: dom.window.getComputedStyle.bind(dom.window), configurable: true, writable: true }); + + const storage = {}; + globalThis.localStorage = { + getItem: (key) => storage[key] || null, + setItem: (key, val) => { storage[key] = val; } + }; + + globalThis.location = { + pathname: '/d/test-token2', + host: 'localhost', + protocol: 'http:' + }; + + globalThis.ResizeObserver = class { + observe() {} + disconnect() {} + }; + + globalThis.fetch = async (url) => { + return { json: async () => ({}) }; + }; + + let wsInstance = null; + globalThis.WebSocket = class { + constructor(url) { + this.url = url; + wsInstance = this; + } + close() {} + send() {} + }; + + const fileUrl = pathToFileURL(shadowPath).href + '?t=' + Date.now() + '-2'; + await import(fileUrl); + + wsInstance.onopen(); + + wsInstance.onmessage({ + data: JSON.stringify({ + type: 'layout.set', + layout: { + pages: [ + { id: 'page-0', widgets: [{ id: 'w1', type: 'paneo.text', config: { text: 'Page Zero Text' } }] }, + { id: 'page-1', widgets: [{ id: 'w2', type: 'paneo.text', config: { text: 'Page One Text' } }] } + ], + background: '#ffffff' + }, + locale: 'ko-KR', + timezone: 'Asia/Seoul', + performanceProfile: 'high' + }) + }); + + const stage = dom.window.document.getElementById('stage'); + assert.ok(stage.innerHTML.includes('Page Zero Text')); + assert.ok(!stage.innerHTML.includes('Page One Text')); + + const eventRight = new dom.window.KeyboardEvent('keydown', { key: 'ArrowRight' }); + dom.window.document.dispatchEvent(eventRight); + + assert.ok(!stage.innerHTML.includes('Page Zero Text')); + assert.ok(stage.innerHTML.includes('Page One Text')); + + const eventLeft = new dom.window.KeyboardEvent('keydown', { key: 'ArrowLeft' }); + dom.window.document.dispatchEvent(eventLeft); + + assert.ok(stage.innerHTML.includes('Page Zero Text')); + assert.ok(!stage.innerHTML.includes('Page One Text')); + + const identifyOverlay = dom.window.document.getElementById('identify-overlay'); + assert.ok(identifyOverlay.classList.contains('hidden')); + wsInstance.onmessage({ + data: JSON.stringify({ + type: 'command', + action: 'identify', + deviceName: 'Test Raspberry Pi Device' + }) + }); + assert.ok(!identifyOverlay.classList.contains('hidden')); + assert.equal(identifyOverlay.textContent, 'Test Raspberry Pi Device'); + + const updateBanner = dom.window.document.getElementById('update-status-banner'); + wsInstance.onmessage({ + data: JSON.stringify({ + type: 'update.status', + status: 'running', + mode: 'all', + progress: 75, + step: 'install_fonts' + }) + }); + assert.ok(updateBanner.classList.contains('visible')); + assert.ok(updateBanner.textContent.includes('75%')); + assert.ok(updateBanner.textContent.includes('폰트')); +}); diff --git a/test/editor.test.js b/test/editor.test.js new file mode 100644 index 0000000..01422b2 --- /dev/null +++ b/test/editor.test.js @@ -0,0 +1,252 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { JSDOM } from 'jsdom'; + +// Create a shadowed version of editor.js with absolute browser paths mapped to relative paths +const editorJsPath = path.resolve('public/editor/editor.js'); +let editorJsContent = fs.readFileSync(editorJsPath, 'utf8'); +editorJsContent = editorJsContent + .replaceAll("'/shared/", "'../public/shared/") + .replaceAll('"/shared/', '"../public/shared/') + .replaceAll("'/editor/", "'../public/editor/") + .replaceAll('"/editor/', '"../public/editor/'); + +const shadowPath = path.resolve('test/editor.test.shadow.js'); +fs.writeFileSync(shadowPath, editorJsContent, 'utf8'); + +test.after(() => { + try { + fs.unlinkSync(shadowPath); + } catch {} +}); + +function getHtmlContent() { + const htmlPath = path.resolve('public/editor/index.html'); + return fs.readFileSync(htmlPath, 'utf8'); +} + +test('editor.js: setup password gate when unconfigured', async (t) => { + const dom = new JSDOM(getHtmlContent()); + + Object.defineProperty(globalThis, 'window', { value: dom.window, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'document', { value: dom.window.document, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'getComputedStyle', { value: dom.window.getComputedStyle.bind(dom.window), configurable: true, writable: true }); + + globalThis.localStorage = { + getItem: () => null, + setItem: () => {} + }; + + globalThis.location = { + pathname: '/editor/', + host: 'localhost', + protocol: 'http:' + }; + + let setupCalled = false; + let setupBody = null; + globalThis.fetch = async (url, opts = {}) => { + if (url === '/api/auth/status') { + return { + ok: true, + json: async () => ({ configured: false, authenticated: false }) + }; + } + if (url === '/api/auth/setup' && opts.method === 'POST') { + setupCalled = true; + setupBody = JSON.parse(opts.body); + return { ok: true, json: async () => ({ ok: true }) }; + } + throw new Error('unexpected fetch: ' + url); + }; + + const fileUrl = pathToFileURL(path.resolve('public/editor/auth-gate.js')).href + '?t=' + Date.now(); + const { ensureAuthenticated } = await import(fileUrl); + + const authPromise = ensureAuthenticated(); + + // Allow nextTick for status check promise chain + await new Promise((resolve) => setTimeout(resolve, 10)); + + const overlay = dom.window.document.getElementById('auth-overlay'); + assert.ok(!overlay.classList.contains('hidden'), 'setup overlay should be visible'); + + const title = dom.window.document.getElementById('auth-title'); + assert.ok(title.textContent.includes('비밀번호') || title.textContent.includes('password')); + + const confirmField = dom.window.document.getElementById('auth-confirm-field'); + assert.equal(confirmField.hidden, false, 'confirm password field should be visible'); + + // Fill in form and submit + dom.window.document.getElementById('auth-password').value = 'new-secure-password'; + dom.window.document.getElementById('auth-password-confirm').value = 'new-secure-password'; + + const form = dom.window.document.getElementById('auth-form'); + form.dispatchEvent(new dom.window.Event('submit')); + + await authPromise; + + assert.ok(setupCalled, 'setup api endpoint should be called'); + assert.equal(setupBody.password, 'new-secure-password'); + assert.ok(overlay.classList.contains('hidden'), 'setup overlay should be hidden after success'); +}); + +test('editor.js: login password gate when configured but unauthenticated', async (t) => { + const dom = new JSDOM(getHtmlContent()); + + Object.defineProperty(globalThis, 'window', { value: dom.window, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'document', { value: dom.window.document, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'getComputedStyle', { value: dom.window.getComputedStyle.bind(dom.window), configurable: true, writable: true }); + + globalThis.localStorage = { + getItem: () => null, + setItem: () => {} + }; + + globalThis.location = { + pathname: '/editor/', + host: 'localhost', + protocol: 'http:' + }; + + let loginCalled = false; + let loginBody = null; + globalThis.fetch = async (url, opts = {}) => { + if (url === '/api/auth/status') { + return { + ok: true, + json: async () => ({ configured: true, authenticated: false }) + }; + } + if (url === '/api/auth/login' && opts.method === 'POST') { + loginCalled = true; + loginBody = JSON.parse(opts.body); + return { ok: true, json: async () => ({ ok: true }) }; + } + throw new Error('unexpected fetch: ' + url); + }; + + const fileUrl = pathToFileURL(path.resolve('public/editor/auth-gate.js')).href + '?t=' + (Date.now() + 1); + const { ensureAuthenticated } = await import(fileUrl); + + const authPromise = ensureAuthenticated(); + + // Allow nextTick + await new Promise((resolve) => setTimeout(resolve, 10)); + + const overlay = dom.window.document.getElementById('auth-overlay'); + assert.ok(!overlay.classList.contains('hidden'), 'login overlay should be visible'); + + const title = dom.window.document.getElementById('auth-title'); + assert.ok(title.textContent.includes('로그인') || title.textContent.includes('Log in')); + + const confirmField = dom.window.document.getElementById('auth-confirm-field'); + assert.equal(confirmField.hidden, true, 'confirm password field should be hidden'); + + // Fill in password and submit + dom.window.document.getElementById('auth-password').value = 'correct-password'; + + const form = dom.window.document.getElementById('auth-form'); + form.dispatchEvent(new dom.window.Event('submit')); + + await authPromise; + + assert.ok(loginCalled, 'login api endpoint should be called'); + assert.equal(loginBody.password, 'correct-password'); + assert.ok(overlay.classList.contains('hidden'), 'login overlay should be hidden after success'); +}); + +test('editor.js: initializes editor and handles logout when authenticated', async (t) => { + const dom = new JSDOM(getHtmlContent()); + + Object.defineProperty(globalThis, 'window', { value: dom.window, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'document', { value: dom.window.document, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true, writable: true }); + Object.defineProperty(globalThis, 'getComputedStyle', { value: dom.window.getComputedStyle.bind(dom.window), configurable: true, writable: true }); + + globalThis.localStorage = { + getItem: () => null, + setItem: () => {} + }; + + let reloadCalled = false; + globalThis.location = { + hash: '', + pathname: '/editor/', + host: 'localhost', + protocol: 'http:', + reload: () => { reloadCalled = true; } + }; + + globalThis.ResizeObserver = class { + observe() {} + disconnect() {} + }; + + let logoutCalled = false; + globalThis.fetch = async (url, opts = {}) => { + if (url === '/api/auth/status') { + return { ok: true, json: async () => ({ configured: true, authenticated: true }) }; + } + if (url === '/api/brand') { + return { ok: true, json: async () => ({ name: 'Paneo' }) }; + } + if (url === '/api/version') { + return { ok: true, json: async () => ({ components: { server: { version: '1.0.0' }, display: '1.0.0' } }) }; + } + if (url === '/api/plugins') { + return { ok: true, json: async () => ([]) }; + } + if (url === '/api/devices') { + return { ok: true, json: async () => ([{ id: 'dev1', name: 'Kiosk Pi' }]) }; + } + if (url === '/api/devices/dev1') { + return { + ok: true, + json: async () => ({ + id: 'dev1', + name: 'Kiosk Pi', + locale: 'ko-KR', + resolutionW: 1920, + resolutionH: 1080, + performanceProfile: 'high', + draft: { pages: [{ id: 'page-0', widgets: [] }] } + }) + }; + } + if (url === '/api/settings/ha') { + return { ok: true, json: async () => ({ url: '', token: '' }) }; + } + if (url === '/api/groups') { + return { ok: true, json: async () => ([]) }; + } + if (url === '/api/devices/dev1/update-status') { + return { ok: true, json: async () => ({ status: 'idle' }) }; + } + if (url === '/api/auth/logout' && opts.method === 'POST') { + logoutCalled = true; + return { ok: true, json: async () => ({ ok: true }) }; + } + throw new Error('unexpected fetch: ' + url); + }; + + const fileUrl = pathToFileURL(shadowPath).href + '?t=' + (Date.now() + 2); + await import(fileUrl); + + const overlay = dom.window.document.getElementById('auth-overlay'); + assert.ok(overlay.classList.contains('hidden'), 'authenticated overlay should be hidden'); + + const logoutBtn = dom.window.document.getElementById('logout-btn'); + logoutBtn.dispatchEvent(new dom.window.MouseEvent('click')); + + // Allow nextTick for promise chain to execute + await new Promise((resolve) => setTimeout(resolve, 10)); + + assert.ok(logoutCalled, 'logout API endpoint should be called'); + assert.ok(reloadCalled, 'page should be reloaded after logout'); +}); diff --git a/test/widgets.render.test.js b/test/widgets.render.test.js new file mode 100644 index 0000000..4f12d37 --- /dev/null +++ b/test/widgets.render.test.js @@ -0,0 +1,96 @@ +// Client-side rendering coverage: widgets.test.js only exercised pure helpers +// (buildMonthGrid, translate, ...). These tests drive the *actual* render(el, +// config, ctx) code path — the same one the editor preview and the real +// kiosk display both call — using jsdom for a real DOM (innerHTML/ +// querySelector/ResizeObserver all need genuine DOM behavior, not a plain +// object stub like gridlayout.test.js gets away with). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { JSDOM } from 'jsdom'; + +const dom = new JSDOM(''); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window); +globalThis.Intl = Intl; // Node's Intl is used directly, not jsdom's +// jsdom has no layout engine, so ResizeObserver doesn't exist and ResizeObserver +// callbacks would never fire anyway — a no-op stub is enough since every widget +// below also renders synchronously once up front, which is what's asserted on. +globalThis.ResizeObserver = class { + observe() {} + disconnect() {} +}; + +const { renderWidget } = await import('../public/shared/widgets.js'); + +// Several widgets (clock/date/worldclock/dday/timer) schedule a recursive +// setTimeout to keep ticking (scheduleSecondTick/scheduleBoundaryTick) and +// stash the canceller on el._cleanup — without calling it, the timer keeps +// firing forever and `node --test` never exits. t.after() runs even if the +// test body throws (e.g. the assertions below fail), so cleanup can't be skipped. +function mount(t) { + const el = document.createElement('div'); + document.body.appendChild(el); + t.after(() => el._cleanup?.()); + return el; +} + +test('paneo.clock renders a HH:MM(:SS) time string', (t) => { + const el = mount(t); + renderWidget(el, 'paneo.clock', { showSeconds: true }, { locale: 'en-US', timezone: 'UTC' }); + const hm = el.querySelector('.w-clock .clock-hm'); + assert.ok(hm, 'expected .clock-hm to be rendered'); + assert.match(hm.textContent, /^\d{2}:\d{2}:\d{2}$/); +}); + +test('paneo.date renders both the date and weekday lines', (t) => { + const el = mount(t); + renderWidget(el, 'paneo.date', {}, { locale: 'en-US', timezone: 'UTC' }); + const main = el.querySelector('.w-date .date-main'); + const weekday = el.querySelector('.w-date .date-weekday'); + assert.ok(main?.textContent.length); + assert.ok(weekday?.textContent.length); +}); + +test('paneo.worldclock renders one row per configured city', (t) => { + const el = mount(t); + renderWidget(el, 'paneo.worldclock', { + cities: [{ label: 'Tokyo', tz: 'Asia/Tokyo' }, { label: 'London', tz: 'Europe/London' }], + }, { locale: 'en-US' }); + const rows = el.querySelectorAll('.w-worldclock .wc-row'); + assert.equal(rows.length, 2); + assert.equal(rows[0].querySelector('.wc-label').textContent, 'Tokyo'); + assert.match(rows[0].querySelector('.wc-time').textContent, /\d{1,2}:\d{2}/); +}); + +test('paneo.worldclock shows a hint instead of crashing when no cities are configured', (t) => { + const el = mount(t); + renderWidget(el, 'paneo.worldclock', { cities: [] }, { locale: 'en-US' }); + assert.equal(el.querySelectorAll('.wc-row').length, 0); +}); + +test('paneo.dday renders a countdown row for a future date', (t) => { + const el = mount(t); + const future = new Date(); + future.setFullYear(future.getFullYear() + 1); + const iso = future.toISOString().slice(0, 10); + renderWidget(el, 'paneo.dday', { events: [{ label: 'Anniversary', date: iso }] }, { locale: 'en-US' }); + assert.match(el.innerHTML, /Anniversary/); +}); + +test('paneo.timer renders label and value for a configured timer', (t) => { + const el = mount(t); + renderWidget(el, 'paneo.timer', { + timers: [{ label: 'Lunch', time: '12:00', mode: 'both' }], + }, { locale: 'en-US' }); + const row = el.querySelector('.w-timer .timer-row'); + assert.ok(row, 'expected a rendered timer row'); + assert.equal(el.querySelector('.timer-label').textContent, 'Lunch'); + assert.ok(el.querySelector('.timer-val').textContent.length > 0); +}); + +test('paneo.text renders plain configured text', (t) => { + const el = mount(t); + renderWidget(el, 'paneo.text', { text: 'Hello Paneo' }, {}); + assert.equal(el.querySelector('.w-text').textContent, 'Hello Paneo'); +}); From aab469acc9aeb718c1b31649bd47f41bcff92cca Mon Sep 17 00:00:00 2001 From: eigger Date: Tue, 7 Jul 2026 16:50:02 +0900 Subject: [PATCH 2/3] Update README and design docs with HTTPS guide and cleaned milestones, add .agent to gitignore --- .gitignore | 2 ++ README.ko.md | 9 +++++++++ README.md | 9 +++++++++ docs/design.md | 21 +++++++++++---------- 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 54fac7e..6cae5ec 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ data/ *.log .DS_Store .claude/ +.agent/ +.agents/ diff --git a/README.ko.md b/README.ko.md index 9871285..f71a2ab 100644 --- a/README.ko.md +++ b/README.ko.md @@ -106,6 +106,15 @@ docker compose pull && docker compose up -d # 릴리즈 이미지 사용 디스플레이를 다른 탭/창에서 열고, 편집기에서 위젯을 배치한 뒤 **적용**을 누르면 실시간으로 반영됩니다. +### 운영 환경 배포 및 HTTPS + +에디터가 비밀번호로 보호되어 있으므로, 이를 평문 HTTP로 외부 인터넷에 노출하는 것은 안전하지 않습니다. HTTPS 암호화를 적용하기 위해 Paneo 전면에 역방향 프록시(예: Nginx, Traefik 또는 Cloudflare Tunnels 등)를 두고 운영할 것을 강력히 권장합니다: + +1. **프록시 설정**: 역방향 프록시에서 외부 443(HTTPS) 포트 요청을 받아 Paneo의 로컬 4321 포트로 전달하도록 설정합니다. +2. **SSL 인증서**: 역방향 프록시 단에서 Let's Encrypt 등을 통해 SSL/TLS 인증서를 적용합니다. +3. **보안 구성**: 브라우저와 Paneo 간의 통신이 HTTPS 암호화 채널로 통하게 하여 세션 쿠키(`paneo_session`)가 네트워크에서 평문으로 탈취되는 것을 원천 차단합니다. + + ## 테스트 ```sh diff --git a/README.md b/README.md index 00ad672..6150e28 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,15 @@ docker compose pull && docker compose up -d # released image Open the display in a second tab/window, arrange widgets in the editor, press **Apply**, and watch it update live. +### Production Deployment & HTTPS + +Since the editor is password-protected, exposing it directly over HTTP is insecure. It is highly recommended to run Paneo behind a reverse proxy (e.g., Nginx, Traefik, or Cloudflare Tunnels) to enforce HTTPS: + +1. **Proxy Setup**: Direct incoming port 443 (HTTPS) traffic from your reverse proxy to Paneo's local port 4321. +2. **Reverse Proxy SSL**: Set up Let's Encrypt or your own SSL certificates at the reverse proxy layer. +3. **Security**: Ensure client browsers connect securely so session cookies (`paneo_session`) cannot be intercepted over the network. + + ## Tests ```sh diff --git a/docs/design.md b/docs/design.md index 97ed587..2937cb0 100644 --- a/docs/design.md +++ b/docs/design.md @@ -381,22 +381,23 @@ User 편집자 계정. { id, email, role } ## 12. 보안 - **디스플레이 인증**: 기기별 페어링 토큰(URL에 포함). 토큰은 읽기 전용 권한(레이아웃 수신만). -- **편집기 인증**: ⏳ **의도적으로 미구현·보류** (2026-07-03 확인). 로그인 계정(로컬 네트워크라도 최소 비밀번호)이 원칙이지만, 실제로는 `/editor/`에 인증 없이 누구나 접근해 모든 기기 레이아웃을 수정할 수 있는 상태 — 코드가 원칙을 못 따라간 게 아니라, 사용자가 명시적으로 "일단 구현하지 말자"고 범위에서 제외함. **로컬 네트워크 밖으로 노출하기 전에 반드시 먼저 구현할 것.** +- **편집기 인증**: ✅ **완료**. 단일 관리자 비밀번호(scrypt 해시) 기반 쿠키 세션 검증 구현 완료. 최초 접속 시 비밀번호 등록 게이트(setup)로 유도하며, 세션 쿠키(`paneo_session`)는 HttpOnly 및 SameSite=Lax 옵션을 사용하여 로컬 세션의 보안 강도를 확보. - **API 키 은닉**: 외부 서비스 키는 서버에만 저장, 디스플레이·편집기로 절대 전송 안 함. - **플러그인**: §7.3 샌드박스 + 권한 화이트리스트. - 외부 노출 시: HTTPS(리버스 프록시), 토큰 회전 지원. ## 13. 마일스톤 / 로드맵 -- ✅ **M0 — 뼈대**: 서버(발행 허브+SQLite) + kiosk 페이지 + "적용" 시 레이아웃 반영. (2026-07-03 완료) -- ✅ **M1 — 편집기**: 드래그&드롭 그리드, SQLite(`node:sqlite`), 설정 폼, i18n, 데이터 프록시, 내장 위젯 8종. (2026-07-03 완료) -- ✅ **M2 — 기기 관리**: 기기 등록/페어링, 그룹(일괄 복사 D7), 성능 프로파일, 원격 reload/identify. (2026-07-03 완료) -- ✅ **M3 — 플러그인 규격 확정**: 위젯 매니페스트 확장(version/minSize/requires/permissions/enum), enum 인스펙터, `paneo.calendar.month`(ICS 월간 그리드 D8), `paneo.timer`(멀티 알람 타이머 D9), 서비스워커 오프라인 캐시. 위젯 10종. (2026-07-03 완료) -- ✅ **M4 — 컴패니언 에이전트**: 화면 전원 스케줄/원격 제어(§9), 건강 상태 보고. (2026-07-03 완료) -- ✅ **M5 — 확장 연동**: Home Assistant 데이터소스(§8.3), 이미지 액자 모드(§8.2). (2026-07-03 완료) -- ✅ **M6 — 서드파티 마무리(비-RTSP)**: 외부 페이지 위젯 샌드박스 모드, 매니페스트 권한/필요 기능 표시, 문서/README 최신화. (2026-07-03 완료) -- ✅ **서드파티 위젯 플러그인 (D17)**: `module`(파일시스템 설치) + `iframe`(URL 등록, 샌드박스) 하이브리드 개방. (2026-07-03 완료) -- **M7 후보 — RTSP & 고급 플러그인**: 카메라 게이트웨이 위젯(§8.1), 플러그인 마켓/원격 설치 UI(현재는 수동 파일 복사만 지원), iframe postMessage 데이터 채널, 더 강한 권한 모델. +- ✅ **M0 — 뼈대**: Fastify 기반 서버 허브 구축, kiosk 디스플레이 페이지 및 WebSocket 연동 완료. +- ✅ **M1 — 편집기**: 드래그&드롭 그리드 레이아웃, SQLite 영속화, i18n 다국어 지원, 데이터 프록시 서버 및 내장 위젯 8종 완료. +- ✅ **M2 — 기기 관리**: 기기 등록 및 페어링, 기기 그룹 일괄 레이아웃 복사, 성능 프로파일 분기, 원격 제어(Reload/Identify) 구현 완료. +- ✅ **M3 — 플러그인 규격**: 위젯 매니페스트 확장 스키마 설계, enum 인스펙터 지원, 월간 달력 및 알람 타이머 위젯 추가, 서비스 워커 오프라인 캐싱 완료. +- ✅ **M4 — 컴패니언 에이전트**: 라즈베리 파이 백그라운드 데몬(Node.js) 에이전트 구축, CEC/wlr-randr 기반 전원 스케줄링 및 원격 전원 제어 완료. +- ✅ **M5 — 확장 연동**: Home Assistant API 연동 프록시 구현, Immich/Unsplash 소스 기반 이미지 액자 모드 연동 완료. +- ✅ **M6 — 서드파티 마무리 및 보안**: iframe 샌드박스 외부 임베드 위젯 탑재, 매니페스트 보안 권한 인스펙터 시각화, 에디터 단일 관리자 비밀번호 세션 인증(scrypt) 도입 완료. +- ✅ **M7 — 테스트 자동화 및 진단**: JSDOM 기반의 클라이언트 JS(에디터 및 디스플레이) 통합 단위 테스트 수트 구축, 실기기 상태 일괄 검사 스크립트(diagnose-pi.sh) 작성 완료. +- **M8 후보 — RTSP 및 고급 연동**: go2rtc/MediaMTX 게이트웨이 기반 RTSP 실시간 스트리밍(WebRTC/HLS/Snapshot) 지원, 플러그인 온라인 마켓플레이스 및 원격 플러그인 설치 UI, iframe postMessage 데이터 채널 확장. + ## 14. 리스크 & 오픈 이슈 From 1d03d9f0b98fbe9fba088e1316e2bcec31ea5090 Mon Sep 17 00:00:00 2001 From: eigger Date: Tue, 7 Jul 2026 17:04:21 +0900 Subject: [PATCH 3/3] docs: simplify design/plugins docs and bump versions to 1.0.0 for release --- agent/version.json | 2 +- docs/design.md | 498 ++++++++++++++++----------------------------- docs/plugins.ko.md | 76 +++++++ docs/plugins.md | 64 +++--- package-lock.json | 4 +- package.json | 2 +- 6 files changed, 293 insertions(+), 353 deletions(-) create mode 100644 docs/plugins.ko.md diff --git a/agent/version.json b/agent/version.json index 1159199..f8ced5b 100644 --- a/agent/version.json +++ b/agent/version.json @@ -1,4 +1,4 @@ { "component": "paneo-agent", - "version": "0.0.12" + "version": "1.0.0" } diff --git a/docs/design.md b/docs/design.md index 2937cb0..3cb6a1f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -16,119 +16,115 @@ ## 0. 결정 로그 (Decision Log) -2026-07-03 확정. - | # | 결정 | 선택 | 비고 | |---|---|---|---| -| N0 | 프로젝트명 | **Paneo** | 대시보드 제품 충돌 없음. npm/GitHub 단독 선점 불가 시 `@scope/paneo`·`paneo-app` 대안 | -| A1 | 타깃 Pi / tier 경계 | **Pi 4+ = `high`, Pi 3·Zero 2 = `low`** | RTSP·영상은 `high` 전용 | -| A2 | 디스플레이 런타임 | **단일 React + 기능 플래그/code-split** | 저성능도 같은 코드로 degrade | -| A3 | 전송 채널 | **WebSocket 기본 + SSE 폴백** | 발행/명령 양방향 | -| A4 | M0 착수 | **착수** | 발행 허브+kiosk+"적용" 반영 | -| B1 | 호스팅 | **셀프호스트, 병설 허용** | 클라우드 ✕. 서버를 디스플레이 Pi와 같은 기기에 올려도 됨. Docker 단일 컨테이너 ✅ D18 | -| B2 | 컴패니언 에이전트 | **선택 설치 (사실상 표준 전제)** | 전원 제어가 거의 필수화될 것 → 원터치 설치·기본 권장, 로드맵 조기 배치 | -| B3 | 인증 범위 | **로컬 우선, 외부 노출은 옵션** | 편집기 로그인은 최소한 항상 | -| C1 | 서드파티 개방 | **단계적** (내장→검증→샌드박스 개방) | M6에서는 원격 번들 실행 없이 샌드박스 iframe 기반 외부 임베드까지만 개방 | -| C2 | MVP 위젯 | **17종** (시계·날짜·텍스트·날씨·대기질·일정목록·월간달력·RSS·사진·외부페이지·타이머·Home Assistant·세계시계·D-Day·할일목록·환율·QR코드) | M1→8종, M3→10종, M5→11종, D24→12종, D25→17종 | -| C3 | 사진/카메라 소스 | **로컬폴더 → Immich → RTSP** | | -| D1 | 다국어(i18n) | **처음부터 반영. 편집기 UI 언어 ↔ 기기 로케일 분리** | ko/en 시작, `Intl` 포맷, 카탈로그 확장(§4.4) | -| D2 | 편집기 메뉴 구조 | **설정(⚙ 모달) vs 편집(툴바) 분리, M0에서 선반영** | 툴바=화면선택·팔레트·적용, 설정=언어·로케일(→M2에 성능프로파일·전원스케줄·페어링 추가될 자리) | -| D3 | 위젯 팔레트 UX | **"+ 위젯 추가" 버튼 → 카테고리별(기본/데이터/미디어) 팝오버** | 위젯 늘어도 툴바 안 어지러움. `widgets.js`에 `category`만 지정하면 자동 편입 | -| D4 | 위젯 레이아웃 엔진 | **실제 CSS Grid로 전환**(수동 px 배치 폐기) + 자동 빈칸 배치 + ~~겹침 시 시각 경고(차단 아님)~~ → **겹치는 위젯을 자동으로 아래로 밀어내 항상 비겹침 유지** (2026-07-03 변경) | CSS는 배치(위치·크기)만 해결, 정렬가이드/충돌은 JS 필요. 그리드 라이브러리(gridstack.js 등) 도입은 보류 — 현재 규모에서 직접 구현이 더 가벼움. 밀어내기는 좌우가 아니라 **아래쪽으로만**(react-grid-layout류 표준 방식) — 가로는 `cols` 한도 때문에 처리가 훨씬 복잡해지는 반면, 세로는 이미 있는 "rows는 최소값, 필요시 자동 확장"(`effectiveRows`) 구조와 그대로 맞물림. `editor.js:resolveCollisions()`가 드래그 대상 위젯을 시작점으로 겹치는 위젯을 `y = 대상.y + 대상.h`로 밀고, 그 위젯이 또 다른 위젯과 겹치면 연쇄적으로 계속 밂(항상 아래로만 증가하므로 종료 보장). 드래그 중 매 pointermove마다 실행하되, 위젯 콘텐츠 재렌더링(`renderWidget`, 타이머·네트워크 폴링 재시작 유발) 없이 위치만 갱신(`repositionNodes()`)해서 끊김 없음. 편집기 캔버스/디스플레이 외곽 여백도 위젯 간 `gap`과 동일하게 통일(`applyGridContainer`의 `padding`) | -| D5 | 캘린더/RSS/사진 다중 소스 | **동적 "+ 추가" 리스트 입력**(URL 여러 개 등록) → 서버가 소스별로 캐시된 개별 fetch를 병렬 수행 후 병합·시간순 정렬·상한(15개)으로 잘라 반환 | 한 소스 실패해도 나머지는 정상 표시(`Promise.allSettled`). 사진도 textarea 줄바꿈 대신 동일한 리스트 UI로 통일 | -| D6 | 기기 해상도/방향 | **"A" 수동 설정**(프리셋 5종 + 직접 입력 + 회전 버튼)을 `Device.resolutionW/H`로 저장, 편집기 캔버스 `aspect-ratio`를 선택 기기 값으로 동적 계산. **"B" 자동 감지는 보류**(디스플레이가 WS로 실제 해상도 보고 → 같은 필드 덮어쓰기) — 같은 필드를 쓰도록 설계해 나중에 스키마 변경 없이 B로 확장 가능 | 방향(가로/세로)은 W>H로 자동 도출, 별도 필드 안 둠. 디스플레이 자체는 이미 CSS Grid(`1fr`)라 런타임에 모든 해상도에 비례 대응 — 이번 작업은 "편집기 미리보기 모양"을 실제 기기와 맞추는 것 | -| D7 | 기기 그룹 일괄 복사 | **레이아웃 한 기기→그룹 전체 복사 지원** | 편집기에서 "그룹에 복사" 버튼 → 그룹 내 모든 기기에 동일 레이아웃 발행. M2 완료 | -| D8 | ICS 월간 달력 그리드 | **기존 `paneo.calendar`(이벤트 리스트)와 별도 위젯 `paneo.calendar.month` 추가** | 동일 ICS 프록시 재사용. 7열 그리드, 오늘 하이라이트, 날짜 셀 이벤트 제목(8자 말줄임). M3 완료. **버그 수정(2026-07-03)**: 프록시 재사용이 문자 그대로였던 게 문제 — `/api/proxy/ical`이 "다음 15개 예정 이벤트"용으로 소스당 10개·병합 15개 캡 + "24시간 이전 제외" 필터를 걸었는데, 이건 `paneo.calendar`(리스트)엔 맞지만 `paneo.calendar.month`(한 달 전체를 보여줘야 함)엔 안 맞아서 바쁜 달력은 월 후반부 이벤트가 통째로 사라졌음. `from`/`to`(방문 중인 그리드의 날짜 범위) 쿼리를 추가해 월간 위젯은 캡·과거 제외 없이 해당 범위에 겹치는 모든 이벤트를 받도록 분리(`src/dataproxy.js`) — `paneo.calendar`는 기존 동작 그대로 | -| D9 | 멀티 알람 타이머 | **위젯 1개에 타이머 여러 개(`list`) — `paneo.timer`** | `timers: list[{ label, targetTime, mode }]` config 구조. countdown/countup/both 모드. 자정 자동 초기화. M3 완료. **확장(2026-07-03)**: 각 항목에 `showAt`/`hideAt`(둘 다 선택) 추가 — `label\|HH:MM[:SS]\|mode\|showAt\|hideAt`. 지금 시각이 `[showAt, hideAt)` 구간 밖이면 그 행 자체가 렌더링에서 빠짐(자정 넘어가는 구간도 처리). 설정된 타이머가 전부 구간 밖이면 위젯이 통째로 빈 상태가 됨 — "위젯이 보였다 안 보였다" 요구사항. `time`도 초 단위(`HH:MM:SS`)까지 허용하도록 `parseTargetTime` 확장(기존 `HH:MM`도 그대로 동작, 초는 0으로 기본). **인스펙터 UX 개선(2026-07-03)**: `label\|HH:MM\|mode\|showAt\|hideAt` 한 줄 수기 입력 대신, 항목당 이름(텍스트)·기준 시각(``)·모드(select)·표시 시작/종료(``) 구조화 필드로 입력 — 새 인스펙터 필드 타입 `timerList`(editor.js). 저장 형식도 파이프 문자열 배열 → `{label,time,mode,showAt,hideAt}` 객체 배열로 전환하되, `render()`가 기존 파이프 문자열도 여전히 파싱해 이전에 저장된 레이아웃과 호환 | -| D10 | 에이전트 전용 WS 채널 분리 | **에이전트용 `/ws/agent?token=xxx` 엔드포인트 구축** | 브라우저 디스플레이 전송 채널인 `/ws`와 명확하게 분리하여 통신 신뢰성 확보. M4 완료. **버그 수정(2026-07-03)**: `agentPresent`는 "runtime-only, 서버 시작 시 0으로 리셋"이라는 주석만 있고 실제 리셋 코드가 없어서, 서버가 크래시/재시작하면 에이전트가 재접속하기 전까지 DB에 남은 이전 `true` 값을 그대로 보여줬음(에이전트 자체는 지수 백오프로 정상 재접속하므로 짧게만 부정확). `store.js:load()`에 `UPDATE devices SET agentPresent = 0` 추가 | -| D11 | 서버 중심 전원 스케줄링 | **서버 1분 Interval 스케줄러 + 에이전트 명령 전송** | 에이전트 자체에 복잡한 cron을 두지 않고, DB 설정에 기반해 서버가 주기적으로 체크하여 명령하는 Thin 에이전트 구조 선택. M4 완료 | -| D12 | Home Assistant REST 프록시 | **서버 사이드 API 호출 대행 및 토큰 은닉** | Bearer 토큰은 서버에만 저장하고, 디바이스 클라이언트는 토큰 노출 없이 특정 엔티티 조회를 서버 프록시로 수행. M5 완료 | -| D13 | Ken Burns 애니메이션 제어 | **고성능 프로파일(high) 한정 활성화** | CPU 자원 소모가 많은 줌/이동 애니메이션 효과는 `high` 기기에서만 작동하고 `low` 기기에서는 정적으로 전환되도록 설계. M5 완료 | -| D14 | M6 서드파티 범위 | **검증된 iframe 샌드박스 임베드까지만 마감** | `paneo.iframe`은 `http/https`만 허용, sandbox 모드(strict/scripts/trusted)를 명시, 매니페스트 권한을 편집기 속성 패널에 표시. 임의 원격 JS 번들 로딩은 보류 | -| D15 | RTSP 처리 | **이번 마무리에서 보류** | 카메라 게이트웨이/영상 디코딩은 부하와 배포 의존성이 커서 후속 M7 후보로 분리 | -| D16 | 위젯별 커스텀 CSS | **위젯 인스턴스에 `customCss`(CSS 선언 목록, 셀렉터 없음) 필드 추가, 인스펙터에 인라인 스타일로 적용** | 전체 스타일시트가 아니라 해당 위젯의 콘텐츠 요소에 `style.cssText`로 직접 주입 → 셀렉터가 없어 다른 위젯·앱 크롬으로 새어나갈 수 없음(추가 샌드박싱 불필요). x/y/w/h처럼 위젯 타입에 무관한 범용 속성이라 `config{}` 스키마가 아닌 `WidgetInstance` 최상위 필드로 둠. 2026-07-03 완료 | -| D17 | 서드파티 위젯 플러그인 | **하이브리드**: 파일시스템 설치형 `module`(관리자 신뢰 = 코어와 동일 권한, 샌드박스 없음) + URL 등록형 `iframe`(`paneo.iframe`과 동일한 샌드박스 재사용) 두 유형. `src/plugins.js`가 서버 기동 시 `data/plugins//manifest.json`을 스캔해 `/api/plugins`로 노출, `public/shared/widgets.js:loadPlugins()`가 편집기·디스플레이 양쪽에서 `widgets{}` 레지스트리에 동적 병합 | §7.3/D14가 못박은 "내장→검증된 iframe/레지스트리→완전 개방" 단계적 원칙의 다음 단계. 원격 코드를 무조건 열지 않고, 신뢰 경로(파일시스템 설치)와 비신뢰 경로(URL만 등록 → 샌드박스)를 분리해 자동 격리. `Plugin` 엔티티(§5)가 처음으로 실제 구현됨. 카테고리는 매니페스트 자기신고를 무시하고 항상 `plugin`으로 고정(위조 방지). 작성 가이드: `docs/plugins.md`, 동작 예제: `docs/examples/plugins/hello-badge/`. postMessage 기반 iframe 데이터 채널은 미구현(현재는 최초 로드 시 쿼리스트링으로만 config 전달) — 후속 개선 후보. 2026-07-03 완료 | -| D18 | 배포 산출물 Docker화 | **`Dockerfile`(멀티스테이지 아님, 빌드 스텝 없음) + `docker-compose.yml`(named volume `paneo-data:/data`)** — `node:22-alpine`, non-root(`USER node`), `HEALTHCHECK`는 별도 패키지 없이 Node 내장 `fetch`로 `/api/version` 확인 | §10/§11이 원래부터 원칙으로 못박아뒀던 걸 실제로 구현. `PANEO_DATA_DIR=/data`로 SQLite·사진·플러그인을 볼륨에 분리해 컨테이너 재생성에도 데이터 보존 — 실제로 `docker build` → 기기 생성 → 컨테이너 재기동 → 같은 기기 ID 유지까지 검증함. 편집기 인증·RTSP는 이번 범위에서 사용자가 명시적으로 제외(§12, §8.1) | 2026-07-03 완료 | -| D19 | 릴리즈 시 이미지를 GitHub Packages(GHCR)에 발행 | **`.github/workflows/docker-release.yml`** — 트리거는 태그 push가 아니라 **GitHub Release `published` 이벤트**(+ `workflow_dispatch` 수동 실행), `linux/amd64`+`linux/arm64` 멀티플랫폼 빌드 후 `ghcr.io/eigger/paneo`에 `{version}`/`{major}.{minor}`/`latest` 태그로 push. `docker-compose.yml`에 `image`와 `build`를 동시에 지정해 한 파일로 "릴리즈 이미지 pull"과 "로컬 빌드" 둘 다 지원 | 태그 push가 아니라 Release 이벤트를 트리거로 쓴 이유: 스쳐가는 실험용 태그가 실수로 이미지를 발행하지 않도록. GITHUB_TOKEN에 `packages: write` 권한만 주면 되고 별도 PAT/시크릿 불필요(GHCR가 GITHUB_TOKEN을 네이티브 지원). arm64를 포함한 건 주 타깃(A1)이 Pi 4/5(arm64)라서 — 병설(§10) 배포 시 Pi 본체에서 이 이미지를 그대로 당겨쓸 수 있어야 함. 2026-07-03 완료(워크플로 작성 및 YAML 구조 검증까지 — 실제 발행은 다음 GitHub Release 때 최초 실행됨) | -| D20 | 원클릭 설치 스크립트도 Docker로 전면 교체 | **`scripts/install-pi.sh`의 `install_server()`를 npm+systemd에서 Docker+systemd로 교체** — `curl -fsSL https://get.docker.com \| sh`로 Docker 설치, `docker pull`로 이미지 받고, `docker run --rm --name paneo`(foreground)를 감싼 systemd 유닛으로 관리(`ExecStartPre=-docker rm -f paneo`로 비정상 종료 잔여 컨테이너 정리, 데이터는 `paneo-data` named volume). 소스 clone·`npm install` 단계 전부 제거 | 사용자가 명시적으로 "전면 교체"를 선택(부분 지원 옵션 대신). 덕분에 `create_token_if_needed()`의 JSON 파싱도 `node -e`에서 `grep`/`sed`로 바꿔 서버 전용(`PANEO_MODE=server`) 설치에서 Node.js가 아예 필요 없어짐 — 컴패니언 에이전트·kiosk(`PANEO_MODE=display`)는 `vcgencmd`/`wlr-randr`/`xset`처럼 컨테이너가 닿을 수 없는 OS 레벨 접근이 필요해 여전히 호스트에 직접 설치(Node 필요, 기존 그대로). 실제 `docker run --rm --name paneo ...` 명령과 `grep`/`sed` 토큰 추출 로직을 로컬에서 재현해 검증(빌드→실행→API 응답→재시작 후 데이터 보존까지). `docs/install-device.md`/`.ko.md`도 동일하게 갱신, Docker 없이 쓰는 경로는 §3.4에 대안으로만 남김 | 2026-07-03 완료 | -| D21 | `paneo.photo` → 미디어 슬라이드쇼로 확장 (동영상, 랜덤 순서, 업로드) | **동영상 지원**: URL/로컬 파일 확장자로 이미지·동영상 자동 판별, 동영상은 `