Compare commits
12
Commits
deepseek-fix
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f2b807068 | ||
|
|
d46f9644bb | ||
|
|
f17d2f533a | ||
|
|
3a21070d62 | ||
|
|
e862827d2d | ||
|
|
a6e93564ba | ||
|
|
5020d2c288 | ||
|
|
ac759a60fe | ||
|
|
75f9686533 | ||
|
|
daee65e0d5 | ||
|
|
69b62b6af3 | ||
|
|
6d38beaf26 |
@@ -0,0 +1,14 @@
|
||||
.git/
|
||||
.gitignore
|
||||
node_modules/
|
||||
*.log
|
||||
.env*
|
||||
.env.local
|
||||
.env.*.local
|
||||
.vscode/
|
||||
.idea/
|
||||
*.md
|
||||
compose.yaml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
data/
|
||||
+54
-5
@@ -1,18 +1,67 @@
|
||||
FROM oven/bun:latest
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# STAGE 1: Dependencies
|
||||
# Install only production dependencies
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
FROM oven/bun:1.2-slim AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --production --frozen-lockfile
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# STAGE 2: Runtime
|
||||
# Minimal image with Chromium + Bun runtime only
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
LABEL org.opencontainers.image.source="https://gitea.bigoscloud.com/biggy/samsung-tv-server" \
|
||||
org.opencontainers.image.title="Samsung TV Server" \
|
||||
org.opencontainers.image.description="Samsung TV Server — Bun runtime" \
|
||||
org.opencontainers.image.description="Samsung TV Server — Bun runtime (optimized)" \
|
||||
org.opencontainers.image.licenses="MIT"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y chromium && rm -rf /var/lib/apt/lists/*
|
||||
# Install Chromium with aggressive cleanup
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
chromium \
|
||||
ca-certificates \
|
||||
fonts-liberation \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libatspi2.0-0 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
xdg-utils && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/* /var/log/apt/* /tmp/*
|
||||
|
||||
# Copy Bun binary from official image (lighter than full install)
|
||||
COPY --from=oven/bun:1.2-slim /usr/local/bin/bun /usr/local/bin/bun
|
||||
COPY --from=oven/bun:1.2-slim /usr/local/bin/bunx /usr/local/bin/bunx
|
||||
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy application code
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --production
|
||||
COPY src/ ./src/
|
||||
COPY public/ ./public/
|
||||
|
||||
COPY . .
|
||||
# Create data directory for SQLite
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
EXPOSE 3099
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"": {
|
||||
"name": "samsung-tv-server",
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^2.24.0",
|
||||
"puppeteer": "^24.43.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -12,14 +13,41 @@
|
||||
"add": "^2.0.6",
|
||||
"oxfmt": "^0.49.0",
|
||||
"oxlint": "^1.64.0",
|
||||
"typescript": "^6.0.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="],
|
||||
|
||||
"@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "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" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="],
|
||||
|
||||
"@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.8.1", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.6" } }, "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ=="],
|
||||
|
||||
"@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="],
|
||||
|
||||
"@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.2.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg=="],
|
||||
|
||||
"@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.1", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g=="],
|
||||
|
||||
"@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="],
|
||||
|
||||
"@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.4", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw=="],
|
||||
|
||||
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="],
|
||||
|
||||
"@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="],
|
||||
|
||||
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.49.0", "", { "os": "android", "cpu": "arm" }, "sha512-HbifJ84prIh9+55CTPAU35JdRQrwg47y16cGerCC+iejSKOuHXYo2WDql6l7cQlzrYVtc3f4UWY+dBj2lRmOeA=="],
|
||||
|
||||
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.49.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Ef7SKJqAaH2d7E6eXZZa2OffIShbhFMxnGK0zd93p4qiyTJr75B0qf7lrPD+qQOwcf04BrjYJ0JUxq8d5+yZwg=="],
|
||||
@@ -104,6 +132,8 @@
|
||||
|
||||
"@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"add": ["add@2.0.6", "", {}, "sha512-j5QzrmsokwWWp6kUcJQySpbG+xfOBqqKnup3OIk1pz+kB/80SLorZ9V8zHFLO92Lcd+hbvq8bT+zOGoPkmBV0Q=="],
|
||||
@@ -134,6 +164,8 @@
|
||||
|
||||
"basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||
@@ -150,18 +182,30 @@
|
||||
|
||||
"cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="],
|
||||
|
||||
"css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="],
|
||||
|
||||
"cssstyle": ["cssstyle@6.2.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.28", "css-tree": "^3.1.0", "lru-cache": "^11.2.6" } }, "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
||||
|
||||
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
|
||||
"degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="],
|
||||
|
||||
"devtools-protocol": ["devtools-protocol@0.0.1608973", "", {}, "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-VVwJidIJcp1hpg2OMXML3ZVRPYSZiq4aX7qBh83BSIpOaRDqI+qxhXjjIWnpzkOXhmp0L81lnoME1mnCc9H48A=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
|
||||
@@ -190,6 +234,8 @@
|
||||
|
||||
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
@@ -202,15 +248,23 @@
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
|
||||
|
||||
"isomorphic-dompurify": ["isomorphic-dompurify@2.36.0", "", { "dependencies": { "dompurify": "^3.3.1", "jsdom": "^28.0.0" } }, "sha512-E8YkGyPY3a/U5s0WOoc8Ok+3SWL/33yn2IHCoxCFLBUUPVy9WGa++akJZFxQCcJIhI+UvYhbrbnTIFQkHKZbgA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"jsdom": ["jsdom@28.1.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.8.1", "@bramus/specificity": "^2.4.2", "@exodus/bytes": "^1.11.0", "cssstyle": "^6.0.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.21.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
||||
"lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="],
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
@@ -232,6 +286,8 @@
|
||||
|
||||
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
|
||||
"parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
@@ -244,14 +300,20 @@
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"puppeteer": ["puppeteer@24.43.1", "", { "dependencies": { "@puppeteer/browsers": "2.13.2", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1608973", "puppeteer-core": "24.43.1", "typed-query-selector": "^2.12.2" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw=="],
|
||||
|
||||
"puppeteer-core": ["puppeteer-core@24.43.1", "", { "dependencies": { "@puppeteer/browsers": "2.13.2", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1608973", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.20.0" } }, "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
|
||||
|
||||
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||
|
||||
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||
@@ -262,12 +324,16 @@
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
|
||||
|
||||
"tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="],
|
||||
|
||||
"tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="],
|
||||
@@ -278,20 +344,44 @@
|
||||
|
||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
|
||||
|
||||
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
||||
|
||||
"tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typed-query-selector": ["typed-query-selector@2.12.2", "", {}, "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.21.0", "", {}, "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="],
|
||||
|
||||
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
|
||||
|
||||
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
|
||||
|
||||
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
@@ -301,5 +391,7 @@
|
||||
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -11,12 +11,14 @@
|
||||
"fmt:check": "oxfmt --check"
|
||||
},
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^2.24.0",
|
||||
"puppeteer": "^24.43.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.13",
|
||||
"add": "^2.0.6",
|
||||
"oxfmt": "^0.49.0",
|
||||
"oxlint": "^1.64.0"
|
||||
"oxlint": "^1.64.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
+235
-9
@@ -179,6 +179,10 @@
|
||||
background: rgba(46, 204, 113, 0.1);
|
||||
color: var(--green);
|
||||
}
|
||||
.badge-wait {
|
||||
background: rgba(255, 152, 0, 0.15);
|
||||
color: #ff9800;
|
||||
}
|
||||
.card-url-preview {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
@@ -229,10 +233,33 @@
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.modal-close:hover {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
}
|
||||
.modal h2 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 24px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
@@ -512,6 +539,11 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.device-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
font-size: 15px;
|
||||
margin-bottom: 24px;
|
||||
@@ -550,6 +582,7 @@
|
||||
<button class="btn btn-ghost btn-sm" id="debug-toggle" onclick="toggleDebug()">
|
||||
🔍 Debug
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="openImportModal()">📤 Import</button>
|
||||
<button class="btn btn-primary" onclick="openCreateModal()">+ New View</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -652,12 +685,31 @@
|
||||
<th>Requests</th>
|
||||
<th>Last Seen</th>
|
||||
<th>User Agent</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="debug-devices-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="debug-section">
|
||||
<h3>🚫 Banned IPs</h3>
|
||||
<div style="overflow-x: auto">
|
||||
<table class="debug-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>Banned At</th>
|
||||
<th>Reason</th>
|
||||
<th>Banned By</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="debug-banned-tbody"><tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -735,13 +787,15 @@
|
||||
.map((v) => {
|
||||
const viewUrl = origin + "/v/" + v.id;
|
||||
const urlPreview = v.urls.map((u, i) =>
|
||||
`${u.url} (${u.durationSec}s)${u.forceDarkMode ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}`
|
||||
`${u.url} (${u.durationSec}s${u.waitBeforeCaptureMs ? ` +${u.waitBeforeCaptureMs}ms` : ""})${u.forceDarkMode ? " 🌙" : ""}${u.bakeStyles ? " 🍞" : ""}`
|
||||
).join(" → ");
|
||||
const anyBake = v.urls.some((u) => u.bakeStyles);
|
||||
const anyWait = v.urls.some((u) => u.waitBeforeCaptureMs > 0);
|
||||
const badges = [
|
||||
`<span class="badge badge-${v.method}">${v.method.toUpperCase()}</span>`,
|
||||
v.metaRefreshEnabled ? '<span class="badge badge-refresh">META-REFRESH</span>' : "",
|
||||
anyBake ? '<span class="badge badge-bake">BAKE</span>' : "",
|
||||
anyWait ? '<span class="badge badge-wait">WAIT</span>' : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
@@ -751,6 +805,7 @@
|
||||
<div class="card-header">
|
||||
<span class="card-title">${esc(v.name)}</span>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<button class="btn btn-icon btn-ghost btn-sm" onclick="exportView('${v.id}')" title="Export">📥</button>
|
||||
<button class="btn btn-icon btn-ghost btn-sm" onclick="openEditModal('${v.id}')" title="Edit">✏️</button>
|
||||
<button class="btn btn-icon btn-danger btn-sm" onclick="confirmDelete('${v.id}','${esc(v.name)}')" title="Delete">🗑</button>
|
||||
</div>
|
||||
@@ -781,8 +836,11 @@
|
||||
|
||||
function openModal(html) {
|
||||
document.getElementById("modal-container").innerHTML = `
|
||||
<div class="modal-overlay" onclick="if(event.target === this) closeModal()">
|
||||
<div class="modal">${html}</div>
|
||||
<div class="modal-overlay">
|
||||
<div class="modal">
|
||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||
${html}
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
@@ -823,6 +881,75 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// EXPORT / IMPORT
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
async function exportView(id) {
|
||||
try {
|
||||
const response = await fetch("/api/views/" + id + "?export=true");
|
||||
if (!response.ok) throw new Error("Export failed");
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `view-${id}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
toast("View exported!");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function openImportModal() {
|
||||
openModal(`
|
||||
<h2>Import View</h2>
|
||||
<form id="import-form" onsubmit="handleImport(event)">
|
||||
<div class="field">
|
||||
<label>Paste View JSON</label>
|
||||
<textarea id="import-json" rows="12" placeholder='{"name":"My View","urls":[{"url":"https://example.com","durationSec":30}],"method":"ssr"}' required style="font-family:monospace;font-size:13px;"></textarea>
|
||||
<div class="hint">Paste the JSON content from an exported view file.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Import View</button>
|
||||
</div>
|
||||
</form>`);
|
||||
}
|
||||
|
||||
async function handleImport(e) {
|
||||
e.preventDefault();
|
||||
const jsonText = document.getElementById("import-json").value.trim();
|
||||
if (!jsonText) {
|
||||
toast("Please paste JSON content", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate JSON structure
|
||||
const data = JSON.parse(jsonText);
|
||||
if (!data.name || !data.urls || !Array.isArray(data.urls)) {
|
||||
toast("Invalid view JSON: missing required fields", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await api("POST", "/api/views/import", data);
|
||||
closeModal();
|
||||
toast("View imported successfully!");
|
||||
loadViews();
|
||||
} catch (err) {
|
||||
if (err.message.includes("JSON")) {
|
||||
toast("Invalid JSON format", "error");
|
||||
} else {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// EDITOR BUILDER
|
||||
// ═══════════════════════════════════════════════
|
||||
@@ -831,9 +958,9 @@
|
||||
const isEdit = !!existing;
|
||||
const title = isEdit ? "Edit View" : "Create New View";
|
||||
const idAttr = isEdit ? ` data-id="${existing.id}"` : "";
|
||||
const urls = existing ? existing.urls : [{ url: "", durationSec: 30 }];
|
||||
const urls = existing ? existing.urls : [{ url: "", durationSec: 30, waitBeforeCaptureMs: 0 }];
|
||||
let urlRowsHTML = urls
|
||||
.map((u, i) => buildUrlRowHTML(u.url, u.durationSec, i, urls.length, u.forceDarkMode, u.bakeStyles))
|
||||
.map((u, i) => buildUrlRowHTML(u.url, u.durationSec, u.waitBeforeCaptureMs, i, urls.length, u.forceDarkMode, u.bakeStyles))
|
||||
.join("");
|
||||
const method = existing ? existing.method : "ssr";
|
||||
const metaChecked = existing ? existing.metaRefreshEnabled : false;
|
||||
@@ -877,7 +1004,7 @@
|
||||
<div class="field">
|
||||
<label>Cache TTL (seconds)</label>
|
||||
<input type="number" id="view-cache-ttl" value="${cacheTtl}" min="0" max="86400" required>
|
||||
<div class="hint">How long a render stays in cache. 0 = always re-render. Each access extends TTL.</div>
|
||||
<div class="hint">How long a render stays in cache. 0 = always re-render.</div>
|
||||
</div>
|
||||
|
||||
<div class="field" style="display:flex;gap:12px;">
|
||||
@@ -900,16 +1027,20 @@
|
||||
</form>`;
|
||||
}
|
||||
|
||||
function buildUrlRowHTML(url, durationSec, index, total, forceDarkMode, bakeStyles) {
|
||||
function buildUrlRowHTML(url, durationSec, waitBeforeCaptureMs, index, total, forceDarkMode, bakeStyles) {
|
||||
const isSingle = total <= 1;
|
||||
const disabledAttr = isSingle ? "disabled" : "";
|
||||
const grayStyle = isSingle ? 'style="opacity:0.4;"' : "";
|
||||
const darkChecked = forceDarkMode ? "checked" : "";
|
||||
const bakeChecked = bakeStyles ? "checked" : "";
|
||||
const waitVal = waitBeforeCaptureMs ?? 0;
|
||||
return `
|
||||
<div class="url-row" data-index="${index}">
|
||||
<input type="url" placeholder="https://example.com/slideshow" value="${esc(url || "")}" class="url-input" required>
|
||||
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayStyle} required>
|
||||
<div style="display:flex;gap:8px;flex:1;">
|
||||
<input type="number" placeholder="Seconds" value="${durationSec || 30}" min="1" max="86400" class="duration-input" ${disabledAttr} ${grayStyle} required title="Display duration">
|
||||
<input type="number" placeholder="Wait ms" value="${waitVal}" min="0" max="60000" class="wait-input" title="Wait before capture (ms)">
|
||||
</div>
|
||||
<label class="dark-toggle" title="Force dark mode">
|
||||
<input type="checkbox" class="dark-mode-input" ${darkChecked}>
|
||||
🌙
|
||||
@@ -930,7 +1061,7 @@
|
||||
const container = document.getElementById("url-rows");
|
||||
const rows = container.querySelectorAll(".url-row");
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = buildUrlRowHTML("", 30, rows.length, rows.length + 1, false, false);
|
||||
div.innerHTML = buildUrlRowHTML("", 30, 0, rows.length, rows.length + 1, false, false);
|
||||
container.appendChild(div.firstElementChild);
|
||||
updateUrlRowStates();
|
||||
}
|
||||
@@ -987,9 +1118,11 @@
|
||||
if (!urlVal) continue;
|
||||
const forceDarkMode = row.querySelector(".dark-mode-input")?.checked ?? false;
|
||||
const bakeStyles = row.querySelector(".bake-styles-input")?.checked ?? false;
|
||||
const waitBeforeCaptureMs = parseInt(row.querySelector(".wait-input")?.value) || 0;
|
||||
urls.push({
|
||||
url: urlVal,
|
||||
durationSec: parseInt(row.querySelector(".duration-input").value) || 30,
|
||||
waitBeforeCaptureMs,
|
||||
forceDarkMode,
|
||||
bakeStyles,
|
||||
});
|
||||
@@ -1138,6 +1271,7 @@
|
||||
document.getElementById("debug-devices-tbody").innerHTML = data.devices
|
||||
.map((d) => {
|
||||
const ago = Math.round((Date.now() - d.lastSeen) / 1000);
|
||||
const ipEnc = encodeURIComponent(d.ip);
|
||||
return `
|
||||
<tr>
|
||||
<td>${esc(d.ip)}</td>
|
||||
@@ -1145,10 +1279,20 @@
|
||||
<td>${d.requestCount}</td>
|
||||
<td>${ago}s ago</td>
|
||||
<td class="url-cell" title="${esc(d.userAgent)}">${esc(d.userAgent.slice(0, 60))}</td>
|
||||
<td>
|
||||
<div class="device-actions">
|
||||
<button class="btn btn-xs btn-ghost" onclick="adminRefresh('${ipEnc}')" title="Force cache refresh">↺</button>
|
||||
<button class="btn btn-xs btn-ghost" onclick="adminDisconnect('${ipEnc}')" title="Disconnect">⏏</button>
|
||||
<button class="btn btn-xs btn-danger" onclick="adminBan('${ipEnc}', '${esc(d.ip)}')" title="Ban IP">🚫</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
// ── Banned IPs table ──
|
||||
fetchBannedIPs();
|
||||
|
||||
if (!debugTickInterval) {
|
||||
debugTickInterval = setInterval(tickDebugValues, 500);
|
||||
}
|
||||
@@ -1177,6 +1321,88 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// ADMIN ACTIONS
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
async function adminDisconnect(ipEnc) {
|
||||
try {
|
||||
await api("POST", "/api/admin/disconnect/" + ipEnc);
|
||||
toast("Device disconnected");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function adminRefresh(ipEnc) {
|
||||
try {
|
||||
await api("POST", "/api/admin/refresh/" + ipEnc);
|
||||
toast("Cache refreshed");
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function adminBan(ipEnc, ipDisplay) {
|
||||
openModal(`
|
||||
<h2>Ban IP</h2>
|
||||
<div class="confirm-text">Ban <strong>${esc(ipDisplay)}</strong>? Future connections will receive 403.</div>
|
||||
<div class="field">
|
||||
<label>Reason</label>
|
||||
<input type="text" id="ban-reason" placeholder="Reason (optional)" value="Banned by administrator">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-danger" onclick="doAdminBan('${ipEnc}', '${esc(ipDisplay)}')">Ban</button>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
async function doAdminBan(ipEnc, ipDisplay) {
|
||||
const reason = document.getElementById("ban-reason")?.value || "Banned by administrator";
|
||||
try {
|
||||
await api("POST", "/api/admin/ban/" + ipEnc, { reason });
|
||||
closeModal();
|
||||
toast(`Banned ${decodeURIComponent(ipEnc)}`);
|
||||
fetchBannedIPs();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function adminUnban(ipEnc) {
|
||||
try {
|
||||
await api("POST", "/api/admin/unban/" + ipEnc);
|
||||
toast("IP unbanned");
|
||||
fetchBannedIPs();
|
||||
} catch (err) {
|
||||
toast(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBannedIPs() {
|
||||
try {
|
||||
const banned = await api("GET", "/api/banned");
|
||||
const tbody = document.getElementById("debug-banned-tbody");
|
||||
if (!tbody) return;
|
||||
if (!banned.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--muted);text-align:center;">No banned IPs</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = banned.map((b) => {
|
||||
const ipEnc = encodeURIComponent(b.ip);
|
||||
const dt = new Date(b.banned_at * 1000).toLocaleString();
|
||||
return `
|
||||
<tr>
|
||||
<td>${esc(b.ip)}</td>
|
||||
<td>${esc(dt)}</td>
|
||||
<td>${esc(b.reason || "—")}</td>
|
||||
<td>${esc(b.banned_by || "—")}</td>
|
||||
<td><button class="btn btn-xs btn-ghost" onclick="adminUnban('${ipEnc}')">Unban</button></td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// KEYBOARD
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
+594
-114
@@ -1,6 +1,177 @@
|
||||
/**
|
||||
* ╔══════════════════════════════════════════════════════════════════════╗
|
||||
* ║ SAMSUNG TV PROXY — Bun + Puppeteer SSR Server ║
|
||||
* ╚══════════════════════════════════════════════════════════════════════╝
|
||||
*
|
||||
* Renders web pages to SSR HTML (with full CSS/resource inlining) or MJPEG
|
||||
* screenshots, served to Samsung TVs (Tizen browser) and other limited
|
||||
* clients. Multi-browser page-pool, render-cache, URL-rotation views,
|
||||
* WebSocket debug dashboard, IP ban management, and device tracking.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* QUICK-SEARCH INDEX — jump by section name or semantic topic:
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* SEC | ~LINE | FLAG / TOPIC | WHAT IT DOES
|
||||
* ────┼───────┼───────────────────────────────────┼───────────────────────
|
||||
* 01 | 35 | CONFIG | All tunables (pool, timeouts, intervals)
|
||||
* 02 | 57 | TYPES / interfaces | View, URLItem, CachedRender, DeviceEntry…
|
||||
* 03 | 115 | DATABASE / SQLite / schema | views + banned_ips tables, migrations
|
||||
* 04 | 158 | DB HELPERS / dbLoad / dbInsert | CRUD: dbLoadAll, dbInsert, dbUpdate, dbDelete
|
||||
* 05 | 202 | BAN SYSTEM / dbBanIP / dbIsBanned | IP ban persistence layer
|
||||
* 06 | 219 | VIEW CACHE / viewCache | In-memory mirror of DB views table
|
||||
* 07 | 230 | DEVICE TRACKING / devices | Per-IP tracking, ban gate, warmup trigger
|
||||
* 08 | 252 | DEBUG DATA / getDebugData | Aggregates server+views+devices for API/WS
|
||||
* 09 | 285 | PAGE POOL / PagePool / acquire | Multi-browser page lifecycle (acquire/release/reset)
|
||||
* 10 | 383 | WS DEBUG / debugClients | Real-time debug-over-WebSocket push
|
||||
* 11 | 393 | RENDER CACHE / renderCache | URL→HTML cache with TTL, inFlight tracking
|
||||
* 12 | 417 | WARMUP / warmupView / warmupCache | Pre-cache active-device views on interval
|
||||
* 13 | 461 | CACHE INVALIDATION / invalidate | Purge all cache entries for a view
|
||||
* 14 | 475 | RENDER QUEUE / renderQueue | FIFO queue with timeout; drains into PagePool
|
||||
* 15 | 506 | RENDER ENGINE / renderViewToCache | Core: SSR or MJPEG render → cache write
|
||||
* 16 | 549 | GET-OR-RENDER / getOrRender | Cache-hit → inFlight → queue → render decision tree
|
||||
* 17 | 590 | CACHE STATS / cacheStats | Telemetry: per-cache-entry TTL/URL info
|
||||
* 18 | 620 | ROTATION / getCurrentURLIndex | Time-based URL cycling (+ remainingSec)
|
||||
* 19 | 645 | SSR INLINING / injectMetaRefresh | Meta-refresh injection, DOMPurify sanitization
|
||||
* 20 | 663 | CSS IMPORT RESOLVER / @import | Recursive @import resolver (max 3 levels)
|
||||
* 21 | 694 | COMPUTED STYLE BAKING / bake | Inline computed styles for Tizen compat
|
||||
* 22 | 777 | RESOURCE INLINING / inlineAll | CSS/images/favicons/bg→data: URIs in-page (logs errors)
|
||||
* 23 | 887 | SSR RENDER / renderSSR | Full SSR pipeline: acquire→goto→inline→strip
|
||||
* 24 | 932 | SCREENSHOT / renderScreenshotFrame| JPEG screenshot renderer (MJPEG mode)
|
||||
* 25 | 961 | SCREENSHOT HTML / screenshotHTML | Wrap JPEG base64 in refresh-page HTML
|
||||
* 26 | 981 | ERROR PAGE / errorPage | Styled error HTML with auto-retry meta-refresh
|
||||
* 27 | 1002 | VIEW SERVING / serveView | /v/:id → rotation→render→response pipeline
|
||||
* 28 | 1032 | API / handleAPI | REST: CRUD views, ban/unban, disconnect, refresh
|
||||
* 29 | 1181 | STATIC FILES / serveStatic | Serves ../public (SPA fallback to index.html)
|
||||
* 30 | 1198 | HTTP SERVER / Bun.serve | Main fetch handler: /ws, /v/:id, /api, static
|
||||
* 31 | 1293 | INIT+SHUTDOWN / init / shutdown | Bootstrap + graceful drain + signal handlers
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* DATA-FLOW MAP (how a GET /v/:id request travels):
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Client GET /v/:id
|
||||
* │
|
||||
* ▼
|
||||
* Bun.serve.fetch() [ SEC 30 ]
|
||||
* │
|
||||
* ├─ deviceTrack(ip…) [ SEC 07 ] ← blocks banned IPs
|
||||
* │
|
||||
* ▼
|
||||
* serveView(view) [ SEC 27 ]
|
||||
* │
|
||||
* ├─ getCurrentURLIndex(view) [ SEC 18 ] ← time-based rotation
|
||||
* │
|
||||
* ▼
|
||||
* getOrRender(view, idx) [ SEC 16 ]
|
||||
* │
|
||||
* ├── cache HIT? ──▶ return cached body
|
||||
* ├── inFlight? ──▶ await existing promise
|
||||
* ├── pool free? ──▶ renderViewToCache() [ SEC 15 ]
|
||||
* │ │
|
||||
* │ ├─ renderSSR() [ SEC 23 ]
|
||||
* │ │ ├─ pagePool.acquire() [ SEC 09 ]
|
||||
* │ │ ├─ page.goto(url)
|
||||
* │ │ ├─ inlineAllResources() [ SEC 22 ]
|
||||
* │ │ │ └─ resolveCSSImports() [ SEC 20 ]
|
||||
* │ │ ├─ [opt] bakeComputedStyles() [ SEC 21 ]
|
||||
* │ │ ├─ sanitizeHTML()
|
||||
* │ │ └─ pagePool.release()
|
||||
* │ │
|
||||
* │ └─ renderScreenshotFrame() [ SEC 24 ]
|
||||
* │ └─ screenshotHTML() [ SEC 25 ]
|
||||
* │
|
||||
* └── pool full? ──▶ enqueue → drainRenderQueue() [ SEC 14 ]
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* KEY DATA STRUCTURES (grep these to find type definitions & usage):
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* View — id, name, urls(URLItem[]), method(ssr|mjpeg), cacheTtl, viewport
|
||||
* CachedRender — body(string), urlIndex, createdAt, expiresAt
|
||||
* ActiveRender — promise(Promise<string>), urlIndex (in-flight dedup)
|
||||
* QueuedRender — resolve/reject callbacks, key, queuedAt (FIFO overflow)
|
||||
* DeviceEntry — ip, viewId, userAgent, lastSeen, requestCount
|
||||
* BrowserSlot — browser, available(Page[]), inUse, max
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* HOW TO SEARCH THIS FILE (for humans & AIs):
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* There is no need to read the file line-by-line. Use grep / text-search
|
||||
* on any of the keywords below to jump directly to what you need:
|
||||
*
|
||||
* "CONFIG" → tunables (port, timeouts, pool sizing)
|
||||
* "interface View" → View type definition
|
||||
* "CREATE TABLE" → DB schema
|
||||
* "dbLoadAll|dbInsert" → DB CRUD operations
|
||||
* "dbBanIP|banned" → IP banning
|
||||
* "viewCache" → in-memory view mirror
|
||||
* "deviceTrack" → device tracking & ban gate
|
||||
* "class PagePool" → browser/page pool management
|
||||
* "acquire|release" → page lifecycle
|
||||
* "renderCache|CachedRender" → render cache
|
||||
* "getOrRender" → cache/inflight/queue decision
|
||||
* "renderQueue|drainRenderQueue" → overflow queue
|
||||
* "renderViewToCache" → actual render execution
|
||||
* "warmupView|warmupCache" → pre-caching
|
||||
* "cacheInvalidate" → cache purging
|
||||
* "getCurrentURLIndex" → URL rotation logic
|
||||
* "renderSSR" → SSR rendering
|
||||
* "renderScreenshotFrame" → MJPEG rendering
|
||||
* "inlineAllResources" → CSS/image inlining
|
||||
* "bakeComputedStyles" → style baking for Tizen
|
||||
* "resolveCSSImports" → @import recursion
|
||||
* "serveView" → /v/:id response builder
|
||||
* "handleAPI" → REST API handler
|
||||
* "Bun.serve" → HTTP server entry
|
||||
* "shutdown" → graceful shutdown
|
||||
* "getDebugData" → debug telemetry
|
||||
* "WebSocket|ws" → debug WS
|
||||
* "errorPage" → error HTML template
|
||||
* "screenshotHTML" → MJPEG wrapper HTML
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* MODIFICATION GUIDE (what to touch when adding/changing features):
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* To add a new View field:
|
||||
* 1. Add to `interface View` [ SEC 02 ]
|
||||
* 2. Add column migration [ SEC 03 ]
|
||||
* 3. Update `rowToView()` [ SEC 03 ]
|
||||
* 4. Update `dbInsert()` / `dbUpdate()` [ SEC 04 ]
|
||||
* 5. Update `CreateViewBody` [ SEC 02 ]
|
||||
* 6. Update API handler [ SEC 28 ]
|
||||
* 7. Update `getDebugData()` [ SEC 08 ] (if visible in debug)
|
||||
*
|
||||
* To add a new API endpoint:
|
||||
* 1. Add route in `handleAPI()` [ SEC 28 ]
|
||||
* 2. If real-time: add to `getDebugData()` [ SEC 08 ]
|
||||
* 3. If admin: consider ban/auth gate [ SEC 07 ]
|
||||
*
|
||||
* To change rendering behavior:
|
||||
* - SSR pipeline: `renderSSR()` [ SEC 23 ]
|
||||
* - MJPEG pipeline: `renderScreenshotFrame()` [ SEC 24 ]
|
||||
* - Inlining logic: `inlineAllResources()` [ SEC 22 ]
|
||||
* - Style baking: `bakeComputedStyles()` [ SEC 21 ]
|
||||
* - Script removal: `sanitizeHTML()` [ SEC 19 ]
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* EXTERNAL DEPENDENCIES:
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* bun:sqlite — embedded SQLite (views.db, banned_ips.db)
|
||||
* puppeteer — headless Chromium (launched via /usr/bin/chromium)
|
||||
* Bun — runtime: Bun.file(), Bun.serve(), WebSocket upgrade
|
||||
* crypto — randomUUID() for view IDs
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════════
|
||||
*/
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import type { Browser, Page } from "puppeteer";
|
||||
import puppeteer from "puppeteer";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CONFIG
|
||||
@@ -16,6 +187,7 @@ const CONFIG = {
|
||||
MAX_CONCURRENT_RENDERS: 16,
|
||||
QUEUE_TIMEOUT_MS: 30000,
|
||||
CACHE_CLEANUP_INTERVAL_MS: 30000,
|
||||
WARMUP_INTERVAL_MS: 60000,
|
||||
} as const;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -25,6 +197,7 @@ const CONFIG = {
|
||||
interface URLItem {
|
||||
url: string;
|
||||
durationSec: number;
|
||||
waitBeforeCaptureMs?: number;
|
||||
forceDarkMode?: boolean;
|
||||
bakeStyles?: boolean;
|
||||
}
|
||||
@@ -115,6 +288,15 @@ db.run(`
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS banned_ips (
|
||||
ip TEXT PRIMARY KEY,
|
||||
banned_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
reason TEXT,
|
||||
banned_by TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Migrations for upgrading from older schema
|
||||
try {
|
||||
db.run("ALTER TABLE views ADD COLUMN cache_ttl_sec INTEGER NOT NULL DEFAULT 60");
|
||||
@@ -143,6 +325,30 @@ function rowToView(row: ViewRow): View {
|
||||
};
|
||||
}
|
||||
|
||||
// Seed a default view on first run (only when table is empty)
|
||||
const viewCount = (db.query("SELECT COUNT(*) as n FROM views").get() as { n: number }).n;
|
||||
if (viewCount === 0) {
|
||||
db.run(
|
||||
`INSERT INTO views (id,name,urls,method,meta_refresh_enabled,cache_ttl_sec,viewport_width,viewport_height,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
[
|
||||
crypto.randomUUID(),
|
||||
"Demo View",
|
||||
JSON.stringify([
|
||||
{ url: "https://www.google.com", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
|
||||
{ url: "https://duck.ai", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
|
||||
{ url: "https://www.msn.com", durationSec: 30, waitBeforeCaptureMs: 0, forceDarkMode: false, bakeStyles: false },
|
||||
]),
|
||||
"ssr",
|
||||
0,
|
||||
60,
|
||||
1920,
|
||||
1080,
|
||||
Math.floor(Date.now() / 1000),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function dbLoadAll(): View[] {
|
||||
return (db.query("SELECT * FROM views ORDER BY created_at DESC").all() as ViewRow[]).map(
|
||||
rowToView,
|
||||
@@ -192,6 +398,35 @@ function dbDelete(id: string): boolean {
|
||||
return db.run("DELETE FROM views WHERE id = ?", [id]).changes > 0;
|
||||
}
|
||||
|
||||
function dbBanIP(ip: string, reason?: string): void {
|
||||
db.run(
|
||||
"INSERT OR REPLACE INTO banned_ips (ip, banned_at, reason, banned_by) VALUES (?, ?, ?, ?)",
|
||||
[ip, Math.floor(Date.now() / 1000), reason ?? "Manual ban", "admin"],
|
||||
);
|
||||
}
|
||||
|
||||
function dbUnbanIP(ip: string): boolean {
|
||||
return db.run("DELETE FROM banned_ips WHERE ip = ?", [ip]).changes > 0;
|
||||
}
|
||||
|
||||
function dbIsBanned(ip: string): boolean {
|
||||
return db.query("SELECT ip FROM banned_ips WHERE ip = ?").get(ip) != null;
|
||||
}
|
||||
|
||||
function dbGetBannedIPs(): Array<{
|
||||
ip: string;
|
||||
banned_at: number;
|
||||
reason: string;
|
||||
banned_by: string;
|
||||
}> {
|
||||
return db.query("SELECT * FROM banned_ips ORDER BY banned_at DESC").all() as Array<{
|
||||
ip: string;
|
||||
banned_at: number;
|
||||
reason: string;
|
||||
banned_by: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// IN-MEMORY VIEW CACHE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -215,9 +450,16 @@ function viewCacheAll(): View[] {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const devices = new Map<string, DeviceEntry>();
|
||||
const warmedUpViews = new Set<string>();
|
||||
const DEVICE_TTL_MS = 120_000;
|
||||
|
||||
function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): void {
|
||||
function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: string): boolean {
|
||||
// Check if IP is banned
|
||||
if (dbIsBanned(ip)) {
|
||||
console.warn(`[security] Blocked connection from banned IP: ${ip}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = devices.get(ip);
|
||||
if (existing) {
|
||||
existing.viewId = viewId;
|
||||
@@ -227,7 +469,16 @@ function deviceTrack(ip: string, viewId: string, viewName: string, userAgent: st
|
||||
existing.requestCount++;
|
||||
} else {
|
||||
devices.set(ip, { ip, viewId, viewName, userAgent, lastSeen: Date.now(), requestCount: 1 });
|
||||
|
||||
// First user connecting to this view - trigger warmup
|
||||
if (!warmedUpViews.has(viewId)) {
|
||||
warmedUpViews.add(viewId);
|
||||
warmupView(viewId).catch((err) => {
|
||||
console.error(`[warmup] Failed to warm up view ${viewId}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function devicesPrune(): void {
|
||||
@@ -247,7 +498,7 @@ function devicesSnapshot(): DeviceEntry[] {
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
function getDebugData() {
|
||||
const viewsDebug = viewCacheAll().map(v => {
|
||||
const viewsDebug = viewCacheAll().map((v) => {
|
||||
const idx = getCurrentURLIndex(v);
|
||||
return {
|
||||
id: v.id,
|
||||
@@ -262,7 +513,7 @@ function getDebugData() {
|
||||
currentUrl: v.urls[idx]?.url ?? "",
|
||||
remainingSec: getRemainingSec(v),
|
||||
totalCycleSec: v.urls.reduce((s, u) => s + u.durationSec, 0),
|
||||
cacheEntries: cacheStats().filter(c => c.viewId === v.id),
|
||||
cacheEntries: cacheStats().filter((c) => c.viewId === v.id),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -364,7 +615,9 @@ class PagePool {
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn(`[page-pool] resetPage failed, discarding page: ${err}`);
|
||||
try { await page.close(); } catch {}
|
||||
try {
|
||||
await page.close();
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -439,10 +692,7 @@ let pagePool: PagePool;
|
||||
// WEBSOCKET DEBUG CLIENTS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const debugClients = new Map<
|
||||
any,
|
||||
{ interval: number; timer: ReturnType<typeof setInterval> }
|
||||
>();
|
||||
const debugClients = new Map<any, { interval: number; timer: ReturnType<typeof setInterval> }>();
|
||||
const DEBUG_DEFAULT_INTERVAL_MS = 1000;
|
||||
const DEBUG_MIN_INTERVAL_MS = 100;
|
||||
|
||||
@@ -467,6 +717,64 @@ function cachePurgeExpired(): void {
|
||||
|
||||
setInterval(cachePurgeExpired, CONFIG.CACHE_CLEANUP_INTERVAL_MS);
|
||||
|
||||
async function warmupView(viewId: string): Promise<void> {
|
||||
const view = viewCacheGet(viewId);
|
||||
if (!view) {
|
||||
console.warn(`[warmup] View ${viewId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[warmup] Starting precache for view "${view.name}" (${view.urls.length} URLs)`);
|
||||
|
||||
for (let i = 0; i < view.urls.length; i++) {
|
||||
const key = renderCacheKey(view.id, i);
|
||||
const cached = renderCache.get(key);
|
||||
const now = Date.now();
|
||||
|
||||
if (!cached || now >= cached.expiresAt) {
|
||||
try {
|
||||
await getOrRender(view, i);
|
||||
console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[warmup] Precache complete for view "${view.name}"`);
|
||||
}
|
||||
|
||||
async function warmupCache(): Promise<void> {
|
||||
const activeDevices = devicesSnapshot();
|
||||
if (activeDevices.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[warmup] Starting periodic precache for ${activeDevices.length} active device(s)`);
|
||||
|
||||
const views = viewCacheAll();
|
||||
for (const view of views) {
|
||||
for (let i = 0; i < view.urls.length; i++) {
|
||||
const key = renderCacheKey(view.id, i);
|
||||
const cached = renderCache.get(key);
|
||||
const now = Date.now();
|
||||
|
||||
if (!cached || now >= cached.expiresAt) {
|
||||
try {
|
||||
await getOrRender(view, i);
|
||||
console.log(`[warmup] Precached ${view.name} URL ${i}: ${view.urls[i].url}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[warmup] Failed to precache ${view.name} URL ${i}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[warmup] Periodic precache complete`);
|
||||
}
|
||||
|
||||
setInterval(warmupCache, CONFIG.WARMUP_INTERVAL_MS);
|
||||
|
||||
function cacheInvalidate(viewId: string): void {
|
||||
const prefix = `${viewId}:`;
|
||||
for (const key of renderCache.keys()) {
|
||||
@@ -478,6 +786,8 @@ function cacheInvalidate(viewId: string): void {
|
||||
renderQueue.splice(i, 1);
|
||||
}
|
||||
}
|
||||
// Allow re-warming if view is recreated
|
||||
warmedUpViews.delete(viewId);
|
||||
}
|
||||
|
||||
function drainRenderQueue(): void {
|
||||
@@ -504,7 +814,6 @@ function drainRenderQueue(): void {
|
||||
|
||||
const cached = renderCache.get(next.key);
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
cached.expiresAt = Date.now() + view.cacheTtlSec * 1000;
|
||||
next.resolve(cached.body);
|
||||
continue;
|
||||
}
|
||||
@@ -531,16 +840,26 @@ async function renderViewToCache(view: View, urlIndex: number, key: string): Pro
|
||||
const urlItem = view.urls[urlIndex];
|
||||
const forceDarkMode = urlItem.forceDarkMode ?? false;
|
||||
const bakeStyles = urlItem.bakeStyles ?? false;
|
||||
const waitBeforeCaptureMs = urlItem.waitBeforeCaptureMs ?? 0;
|
||||
|
||||
let body: string;
|
||||
if (view.method === "mjpeg") {
|
||||
const jpeg = await renderScreenshotFrame(
|
||||
urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode,
|
||||
urlItem.url,
|
||||
view.viewportWidth,
|
||||
view.viewportHeight,
|
||||
forceDarkMode,
|
||||
waitBeforeCaptureMs,
|
||||
);
|
||||
body = jpeg.toString("base64");
|
||||
} else {
|
||||
body = await renderSSR(
|
||||
urlItem.url, view.viewportWidth, view.viewportHeight, forceDarkMode, bakeStyles,
|
||||
urlItem.url,
|
||||
view.viewportWidth,
|
||||
view.viewportHeight,
|
||||
forceDarkMode,
|
||||
bakeStyles,
|
||||
waitBeforeCaptureMs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -564,7 +883,6 @@ async function getOrRender(view: View, urlIndex: number): Promise<string> {
|
||||
|
||||
const cached = renderCache.get(key);
|
||||
if (cached && now < cached.expiresAt) {
|
||||
cached.expiresAt = now + view.cacheTtlSec * 1000;
|
||||
return cached.body;
|
||||
}
|
||||
|
||||
@@ -681,17 +999,69 @@ function injectMetaRefresh(html: string, intervalSec: number): string {
|
||||
return `<html><head>${tag}</head><body>${html}</body></html>`;
|
||||
}
|
||||
|
||||
function stripScriptTags(html: string): string {
|
||||
return html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
|
||||
.replace(/\s+on\w+\s*=\s*"[^"]*"/gi, "")
|
||||
.replace(/\s+on\w+\s*=\s*'[^']*'/gi, "");
|
||||
/**
|
||||
* Sanitize HTML using DOMPurify to remove scripts and event handlers.
|
||||
* More robust than regex-based stripping — prevents bypasses via
|
||||
* malformed tags, encoding tricks, and nested structures.
|
||||
*/
|
||||
function sanitizeHTML(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [
|
||||
// Document structure
|
||||
"html", "head", "body", "title", "meta", "link", "base",
|
||||
// Content sections
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "header", "footer", "main", "section", "article", "aside", "nav",
|
||||
// Text content
|
||||
"p", "div", "span", "blockquote", "pre", "code", "em", "strong", "i", "b", "u", "s", "small", "sub", "sup",
|
||||
// Lists
|
||||
"ul", "ol", "li", "dl", "dt", "dd",
|
||||
// Tables
|
||||
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
|
||||
// Forms (static display only)
|
||||
"form", "input", "button", "select", "option", "textarea", "label", "fieldset", "legend",
|
||||
// Media
|
||||
"img", "figure", "figcaption", "audio", "video", "source", "track", "canvas", "svg",
|
||||
// Embedded content
|
||||
"iframe", "embed", "object", "param",
|
||||
// Formatting
|
||||
"br", "hr", "wbr", "mark", "del", "ins", "time", "address", "cite", "q",
|
||||
// Style
|
||||
"style"
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
// Generic
|
||||
"id", "class", "style", "title", "dir", "lang", "role", "tabindex",
|
||||
// Links
|
||||
"href", "target", "rel", "download",
|
||||
// Media
|
||||
"src", "alt", "width", "height", "loading", "decoding", "crossorigin",
|
||||
"poster", "preload", "controls", "autoplay", "loop", "muted", "playsinline",
|
||||
// Forms
|
||||
"type", "name", "value", "placeholder", "required", "disabled", "readonly",
|
||||
"checked", "selected", "multiple", "min", "max", "step", "pattern", "minlength", "maxlength",
|
||||
// Tables
|
||||
"colspan", "rowspan", "headers", "scope",
|
||||
// Meta/Link
|
||||
"charset", "content", "http-equiv", "media", "sizes", "as",
|
||||
// Iframe
|
||||
"srcdoc", "sandbox", "allow", "allowfullscreen", "frameborder", "scrolling",
|
||||
// SVG
|
||||
"viewBox", "xmlns", "fill", "stroke", "stroke-width", "d", "cx", "cy", "r", "x", "y",
|
||||
// Data
|
||||
"data-*"
|
||||
],
|
||||
// Keep the HTML intact (don't wrap in body)
|
||||
KEEP_DOCUMENT_FRAGMENT: true,
|
||||
// Allow data URIs for inlined resources
|
||||
ALLOW_DATA_ATTR: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Recursive @import resolver (max 3 levels deep) ──
|
||||
async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): Promise<string> {
|
||||
if (depth > 3) return cssText;
|
||||
const importRe = /@import\s+(?:url\s*\(\s*["']?([^)"']+)["']?\s*\)|["']([^"']+)["'])\s*([^;]*);/gi;
|
||||
const importRe =
|
||||
/@import\s+(?:url\s*\(\s*["']?([^)"']+)["']?\s*\)|["']([^"']+)["'])\s*([^;]*);/gi;
|
||||
const replacements: Array<{ match: string; resolved: string }> = [];
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -707,7 +1077,10 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P
|
||||
if (!res.ok) throw new Error("failed");
|
||||
let imported = await res.text();
|
||||
imported = await resolveCSSImports(imported, importUrl, depth + 1);
|
||||
replacements.push({ match: m[0], resolved: "/* begin " + importUrl + " */\n" + imported + "\n/* end " + importUrl + " */" });
|
||||
replacements.push({
|
||||
match: m[0],
|
||||
resolved: "/* begin " + importUrl + " */\n" + imported + "\n/* end " + importUrl + " */",
|
||||
});
|
||||
} catch {
|
||||
replacements.push({ match: m[0], resolved: "/* unresolved import: " + importUrl + " */" });
|
||||
}
|
||||
@@ -720,108 +1093,57 @@ async function resolveCSSImports(cssText: string, baseUrl: string, depth = 0): P
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// COMPUTED STYLE BAKING
|
||||
// CSS COLLECTION (simplified — all CSS into single style tag)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Bake computed styles into inline style attributes.
|
||||
* This ensures styles work on browsers with limited CSS support (e.g., Samsung TV).
|
||||
* Compares each element against a same-tagname fresh element so tag-specific defaults
|
||||
* (e.g. block display on div, inline on span, bold on h1) are accounted for.
|
||||
* Collect all CSS from loaded stylesheets and inject into a single <style> tag in head.
|
||||
* Simplified alternative to per-element computed style baking.
|
||||
* Toggle via URLItem.bakeStyles.
|
||||
*/
|
||||
async function bakeComputedStyles(page: Page): Promise<void> {
|
||||
await page.evaluate((): void => {
|
||||
const computedProperties = [
|
||||
// Layout
|
||||
"display", "position", "top", "right", "bottom", "left",
|
||||
"width", "height", "min-width", "min-height", "max-width", "max-height",
|
||||
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
|
||||
"padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
|
||||
// Flex/Grid
|
||||
"flex", "flex-direction", "flex-wrap", "flex-flow", "flex-grow", "flex-shrink", "flex-basis",
|
||||
"justify-content", "align-items", "align-content", "align-self", "gap", "row-gap", "column-gap",
|
||||
"grid", "grid-template", "grid-template-columns", "grid-template-rows", "grid-area",
|
||||
// Box model
|
||||
"box-sizing", "border-top-width", "border-right-width", "border-bottom-width", "border-left-width",
|
||||
"border-top-style", "border-right-style", "border-bottom-style", "border-left-style",
|
||||
"border-top-color", "border-right-color", "border-bottom-color", "border-left-color",
|
||||
"border-top-left-radius", "border-top-right-radius", "border-bottom-right-radius", "border-bottom-left-radius",
|
||||
// Colors & Background
|
||||
"color", "background-color", "background-image", "background-size", "background-position",
|
||||
"background-repeat", "background-attachment", "background-clip", "background-origin",
|
||||
// Typography
|
||||
"font-family", "font-size", "font-weight", "font-style", "font-variant",
|
||||
"line-height", "text-align", "text-decoration-line", "text-decoration-color",
|
||||
"text-transform", "letter-spacing", "word-spacing",
|
||||
"white-space", "overflow-wrap", "word-break",
|
||||
// Visual
|
||||
"opacity", "visibility", "z-index", "overflow-x", "overflow-y",
|
||||
"box-shadow", "text-shadow", "transform", "transform-origin",
|
||||
// List/Table
|
||||
"list-style-type", "list-style-position", "border-collapse", "border-spacing",
|
||||
// Cursor/pointer
|
||||
"cursor", "pointer-events",
|
||||
];
|
||||
const cssTexts: string[] = [];
|
||||
|
||||
// Cache of default computed values keyed by tagName
|
||||
const defaultCache = new Map<string, Map<string, string>>();
|
||||
const hiddenContainer = document.createElement("div");
|
||||
hiddenContainer.style.cssText = "position:absolute;visibility:hidden;pointer-events:none;top:-9999px;";
|
||||
document.body.appendChild(hiddenContainer);
|
||||
|
||||
function getTagDefaults(tag: string): Map<string, string> {
|
||||
let cache = defaultCache.get(tag);
|
||||
if (cache) return cache;
|
||||
cache = new Map<string, string>();
|
||||
// Collect from all stylesheets (external and inline)
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
const el = document.createElement(tag);
|
||||
hiddenContainer.appendChild(el);
|
||||
const cs = window.getComputedStyle(el);
|
||||
for (const prop of computedProperties) {
|
||||
cache.set(prop, cs.getPropertyValue(prop));
|
||||
const rules = sheet.cssRules || sheet.rules;
|
||||
if (!rules) continue;
|
||||
let cssText = "";
|
||||
for (const rule of rules) {
|
||||
cssText += rule.cssText + "\n";
|
||||
}
|
||||
el.remove();
|
||||
} catch { /* unknown tag, cache will be empty */ }
|
||||
defaultCache.set(tag, cache);
|
||||
return cache;
|
||||
if (cssText.trim()) {
|
||||
cssTexts.push(`/* ${sheet.href || "inline"} */\n${cssText}`);
|
||||
}
|
||||
|
||||
function shouldBake(value: string, defaultValue: string): boolean {
|
||||
if (!value) return false;
|
||||
// Skip browser keywords that cannot be serialized as inline style values
|
||||
if (value === "initial" || value === "inherit" || value === "unset" || value === "revert") return false;
|
||||
return value !== defaultValue;
|
||||
}
|
||||
|
||||
// Walk all elements and bake computed styles
|
||||
const elements = document.querySelectorAll<HTMLElement>("*");
|
||||
for (const el of elements) {
|
||||
if (el === hiddenContainer) continue;
|
||||
// Skip non-visual tags
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (["script", "style", "meta", "link", "noscript", "template", "head", "html"].includes(tag)) continue;
|
||||
|
||||
const computed = window.getComputedStyle(el);
|
||||
const defaults = getTagDefaults(tag);
|
||||
const stylesToBake: string[] = [];
|
||||
|
||||
for (const prop of computedProperties) {
|
||||
const value = computed.getPropertyValue(prop);
|
||||
const defaultValue = defaults.get(prop) ?? "";
|
||||
if (shouldBake(value, defaultValue)) {
|
||||
stylesToBake.push(`${prop}:${value}`);
|
||||
} catch {
|
||||
// Cross-origin stylesheets — skip (already inlined by inlineAllResources)
|
||||
}
|
||||
}
|
||||
|
||||
if (stylesToBake.length > 0) {
|
||||
// Existing inline styles take precedence — put them after baked styles
|
||||
const existingInline = el.getAttribute("style") || "";
|
||||
const bakedStyles = stylesToBake.join(";");
|
||||
el.setAttribute("style", existingInline ? `${bakedStyles};${existingInline}` : bakedStyles);
|
||||
// Also grab any inline <style> tags content directly
|
||||
const styleTags = document.querySelectorAll("style");
|
||||
for (const tag of styleTags) {
|
||||
const content = tag.textContent?.trim();
|
||||
if (content) {
|
||||
cssTexts.push(`/* inline style tag */\n${content}`);
|
||||
}
|
||||
}
|
||||
|
||||
hiddenContainer.remove();
|
||||
// Create single consolidated style tag in head
|
||||
if (cssTexts.length > 0) {
|
||||
const consolidated = document.createElement("style");
|
||||
consolidated.textContent = cssTexts.join("\n\n");
|
||||
|
||||
// Insert as first element in head so it can be overridden
|
||||
const head = document.head;
|
||||
if (head.firstChild) {
|
||||
head.insertBefore(consolidated, head.firstChild);
|
||||
} else {
|
||||
head.appendChild(consolidated);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -926,7 +1248,8 @@ async function inlineAllResources(page: Page, html: string): Promise<string> {
|
||||
}
|
||||
return collect();
|
||||
});
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.warn(`[inline] Resource inlining failed: ${err}`);
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -949,7 +1272,7 @@ async function inlineAllResources(page: Page, html: string): Promise<string> {
|
||||
|
||||
html = html.replace(
|
||||
linkRegex,
|
||||
`<style${mediaAttr}>/* inlined: ${css.url} */\n${resolvedCSS}\n</style>`
|
||||
`<style${mediaAttr}>/* inlined: ${css.url} */\n${resolvedCSS}\n</style>`,
|
||||
);
|
||||
}
|
||||
for (const img of payload.images) {
|
||||
@@ -988,6 +1311,7 @@ async function renderSSR(
|
||||
viewportHeight = 1080,
|
||||
forceDarkMode = false,
|
||||
bakeStyles = false,
|
||||
waitBeforeCaptureMs = 0,
|
||||
): Promise<string> {
|
||||
const page = await pagePool.acquire();
|
||||
try {
|
||||
@@ -1007,16 +1331,24 @@ async function renderSSR(
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before capture (for animations, dynamic content, etc.)
|
||||
if (waitBeforeCaptureMs > 0) {
|
||||
await new Promise((r) => setTimeout(r, waitBeforeCaptureMs));
|
||||
}
|
||||
|
||||
let html = await page.content();
|
||||
html = await inlineAllResources(page, html);
|
||||
|
||||
// Bake computed styles into inline attributes (for Samsung TV compatibility)
|
||||
// Set inlined HTML back to page so bake can collect all CSS
|
||||
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Bake: collect all CSS into single style tag
|
||||
if (bakeStyles) {
|
||||
await bakeComputedStyles(page);
|
||||
html = await page.content();
|
||||
}
|
||||
|
||||
html = stripScriptTags(html);
|
||||
html = sanitizeHTML(html);
|
||||
return html;
|
||||
} finally {
|
||||
await pagePool.release(page);
|
||||
@@ -1032,6 +1364,7 @@ async function renderScreenshotFrame(
|
||||
viewportWidth = 1920,
|
||||
viewportHeight = 1080,
|
||||
forceDarkMode = false,
|
||||
waitBeforeCaptureMs = 0,
|
||||
): Promise<Buffer> {
|
||||
const page = await pagePool.acquire();
|
||||
try {
|
||||
@@ -1050,6 +1383,11 @@ async function renderScreenshotFrame(
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before capture (for animations, dynamic content, etc.)
|
||||
if (waitBeforeCaptureMs > 0) {
|
||||
await new Promise((r) => setTimeout(r, waitBeforeCaptureMs));
|
||||
}
|
||||
|
||||
return Buffer.from(await page.screenshot({ type: "jpeg", quality: 75, fullPage: false }));
|
||||
} finally {
|
||||
await pagePool.release(page);
|
||||
@@ -1152,6 +1490,7 @@ function json(data: unknown, status = 200): Response {
|
||||
|
||||
async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
const method = req.method;
|
||||
const url = new URL(req.url);
|
||||
|
||||
// GET /api/views
|
||||
if (path === "/api/views" && method === "GET") {
|
||||
@@ -1163,6 +1502,11 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
return json(getDebugData());
|
||||
}
|
||||
|
||||
// GET /api/banned
|
||||
if (path === "/api/banned" && method === "GET") {
|
||||
return json(dbGetBannedIPs());
|
||||
}
|
||||
|
||||
// POST /api/views
|
||||
if (path === "/api/views" && method === "POST") {
|
||||
try {
|
||||
@@ -1183,6 +1527,7 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
urls: body.urls.map((u) => ({
|
||||
url: u.url.trim(),
|
||||
durationSec: Math.max(1, Math.round(u.durationSec)),
|
||||
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
|
||||
forceDarkMode: u.forceDarkMode ?? false,
|
||||
bakeStyles: u.bakeStyles ?? false,
|
||||
})),
|
||||
@@ -1226,6 +1571,7 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
view.urls = body.urls.map((u) => ({
|
||||
url: u.url.trim(),
|
||||
durationSec: Math.max(1, Math.round(u.durationSec)),
|
||||
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
|
||||
forceDarkMode: u.forceDarkMode ?? false,
|
||||
bakeStyles: u.bakeStyles ?? false,
|
||||
}));
|
||||
@@ -1260,6 +1606,126 @@ async function handleAPI(req: Request, path: string): Promise<Response> {
|
||||
console.log(`[api] Deleted ${viewId}`);
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
// GET /api/views/:id/export - Export single view as JSON
|
||||
if (method === "GET" && url.searchParams.get("export") === "true") {
|
||||
const view = viewCacheGet(viewId);
|
||||
if (!view) return json({ error: "View not found" }, 404);
|
||||
|
||||
const exportData = {
|
||||
name: view.name,
|
||||
urls: view.urls,
|
||||
method: view.method,
|
||||
metaRefreshEnabled: view.metaRefreshEnabled,
|
||||
cacheTtlSec: view.cacheTtlSec,
|
||||
viewportWidth: view.viewportWidth,
|
||||
viewportHeight: view.viewportHeight,
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(exportData, null, 2), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="view-${viewId}.json"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/views/import - Import a view from JSON
|
||||
if (path === "/api/views/import" && method === "POST") {
|
||||
try {
|
||||
const importData = JSON.parse(await req.text());
|
||||
|
||||
if (!importData.name?.trim()) return json({ error: "Name is required" }, 400);
|
||||
if (!importData.urls?.length) return json({ error: "At least one URL is required" }, 400);
|
||||
for (const u of importData.urls) {
|
||||
if (!u.url?.trim()) return json({ error: "Each URL must have a url" }, 400);
|
||||
if (typeof u.durationSec !== "number" || u.durationSec <= 0)
|
||||
return json({ error: "Each URL must have positive durationSec" }, 400);
|
||||
}
|
||||
const method = importData.method ?? "ssr";
|
||||
if (!["mjpeg", "ssr"].includes(method))
|
||||
return json({ error: 'method must be "mjpeg" or "ssr"' }, 400);
|
||||
|
||||
const view: View = {
|
||||
id: crypto.randomUUID(),
|
||||
name: importData.name.trim(),
|
||||
urls: importData.urls.map((u: URLItem) => ({
|
||||
url: u.url.trim(),
|
||||
durationSec: Math.max(1, Math.round(u.durationSec)),
|
||||
waitBeforeCaptureMs: Math.max(0, u.waitBeforeCaptureMs ?? 0),
|
||||
forceDarkMode: u.forceDarkMode ?? false,
|
||||
bakeStyles: u.bakeStyles ?? false,
|
||||
})),
|
||||
method: method,
|
||||
metaRefreshEnabled: importData.metaRefreshEnabled ?? false,
|
||||
cacheTtlSec: Math.max(0, importData.cacheTtlSec ?? 60),
|
||||
viewportWidth: Math.max(320, Math.min(7680, importData.viewportWidth ?? 1920)),
|
||||
viewportHeight: Math.max(240, Math.min(4320, importData.viewportHeight ?? 1080)),
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
dbInsert(view);
|
||||
viewCache.set(view.id, view);
|
||||
console.log(`[api] Imported "${view.name}" (${view.id}) method=${view.method}`);
|
||||
return json(view, 201);
|
||||
} catch {
|
||||
return json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/disconnect/:ip
|
||||
const disconnectMatch = path.match(/^\/api\/admin\/disconnect\/(.+)$/);
|
||||
if (disconnectMatch && method === "POST") {
|
||||
const ip = decodeURIComponent(disconnectMatch[1]);
|
||||
devices.delete(ip);
|
||||
console.log(`[admin] Disconnected device: ${ip}`);
|
||||
return json({ ok: true, message: `Device ${ip} disconnected` });
|
||||
}
|
||||
|
||||
// POST /api/admin/ban/:ip
|
||||
const banMatch = path.match(/^\/api\/admin\/ban\/(.+)$/);
|
||||
if (banMatch && method === "POST") {
|
||||
const ip = decodeURIComponent(banMatch[1]);
|
||||
try {
|
||||
const body = JSON.parse(await req.text());
|
||||
const reason = body.reason || "Banned by administrator";
|
||||
const bannedDevice = devices.get(ip);
|
||||
dbBanIP(ip, reason);
|
||||
devices.delete(ip);
|
||||
if (bannedDevice) cacheInvalidate(bannedDevice.viewId);
|
||||
console.log(`[admin] Banned IP: ${ip}, reason: ${reason}`);
|
||||
return json({ ok: true, message: `IP ${ip} banned` });
|
||||
} catch {
|
||||
return json({ error: "Invalid JSON body" }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/unban/:ip
|
||||
const unbanMatch = path.match(/^\/api\/admin\/unban\/(.+)$/);
|
||||
if (unbanMatch && method === "POST") {
|
||||
const ip = decodeURIComponent(unbanMatch[1]);
|
||||
const success = dbUnbanIP(ip);
|
||||
if (success) {
|
||||
console.log(`[admin] Unbanned IP: ${ip}`);
|
||||
return json({ ok: true, message: `IP ${ip} unbanned` });
|
||||
} else {
|
||||
return json({ error: "IP not found in ban list" }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/refresh/:ip
|
||||
const refreshMatch = path.match(/^\/api\/admin\/refresh\/(.+)$/);
|
||||
if (refreshMatch && method === "POST") {
|
||||
const ip = decodeURIComponent(refreshMatch[1]);
|
||||
const device = devices.get(ip);
|
||||
if (device) {
|
||||
cacheInvalidate(device.viewId);
|
||||
console.log(`[admin] Forced cache refresh for view: ${device.viewId}`);
|
||||
return json({ ok: true, message: `Cache refreshed for view ${device.viewId}` });
|
||||
} else {
|
||||
return json({ error: "Device not found" }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
return json({ error: "Not found" }, 404);
|
||||
@@ -1307,7 +1773,9 @@ const server = Bun.serve<{ interval: number }>({
|
||||
}, interval);
|
||||
debugClients.set(ws, { interval, timer });
|
||||
// Send initial payload immediately
|
||||
try { ws.send(JSON.stringify(getDebugData())); } catch {}
|
||||
try {
|
||||
ws.send(JSON.stringify(getDebugData()));
|
||||
} catch {}
|
||||
},
|
||||
message(ws, msg) {
|
||||
try {
|
||||
@@ -1319,15 +1787,21 @@ const server = Bun.serve<{ interval: number }>({
|
||||
clearInterval(client.timer);
|
||||
client.interval = interval;
|
||||
client.timer = setInterval(() => {
|
||||
try { ws.send(JSON.stringify(getDebugData())); } catch {
|
||||
try {
|
||||
ws.send(JSON.stringify(getDebugData()));
|
||||
} catch {
|
||||
clearInterval(client.timer);
|
||||
debugClients.delete(ws);
|
||||
}
|
||||
}, interval);
|
||||
// Push immediately after rate change
|
||||
try { ws.send(JSON.stringify(getDebugData())); } catch {}
|
||||
try {
|
||||
ws.send(JSON.stringify(getDebugData()));
|
||||
} catch {}
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed messages */
|
||||
}
|
||||
} catch { /* ignore malformed messages */ }
|
||||
},
|
||||
close(ws) {
|
||||
const client = debugClients.get(ws);
|
||||
@@ -1373,12 +1847,18 @@ const server = Bun.serve<{ interval: number }>({
|
||||
});
|
||||
}
|
||||
|
||||
deviceTrack(
|
||||
const tracked = deviceTrack(
|
||||
ip,
|
||||
view.id,
|
||||
view.name,
|
||||
req.headers.get("User-Agent") ?? "unknown",
|
||||
);
|
||||
if (!tracked) {
|
||||
return new Response(errorPage("Access denied"), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return serveView(view);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import asyncio
|
||||
from samsung_mdc import MDC
|
||||
|
||||
|
||||
async def main(ip, display_id):
|
||||
async with MDC(ip, verbose=True) as mdc:
|
||||
# First argument of command is always display_id
|
||||
status = await mdc.status(display_id)
|
||||
print(status) # Result is always tuple
|
||||
|
||||
if status[0] != MDC.power.POWER_STATE.ON:
|
||||
# Command arguments are always Sequence (tuple, list)
|
||||
await mdc.power(display_id, [MDC.power.POWER_STATE.ON])
|
||||
await mdc.close() # Force reconnect on next command
|
||||
await asyncio.sleep(15)
|
||||
|
||||
await mdc.display_id(display_id, [MDC.display_id.DISPLAY_ID_STATE.ON])
|
||||
# You may also use names or values instead of enums
|
||||
await mdc.display_id(display_id, ['ON']) # same
|
||||
await mdc.display_id(display_id, [1]) # same
|
||||
|
||||
if True:
|
||||
await mdc.power(display_id, [MDC.power.POWER_STATE.OFF])
|
||||
await mdc.close() # Force reconnect on next command
|
||||
|
||||
|
||||
# If you see "Connected" and timeout error, try other display_id (0, 1)
|
||||
asyncio.run(main('10.184.27.132', 1))
|
||||
@@ -0,0 +1 @@
|
||||
python-samsung-mdc
|
||||
Reference in New Issue
Block a user