Compare commits

..
12 Commits
Author SHA1 Message Date
渡口浪人 c3bed2edce release: v0.1.26 account security and settings polish
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m47s
2026-10-20 16:33:28 +08:00
渡口浪人 f8fe8e8b14 fix: retain logo beside stacked sidebar wordmark
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m31s
2026-10-20 16:26:57 +08:00
渡口浪人 50903564ad fix: use stacked sidebar brand wordmark
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 9m57s
2026-10-20 16:23:16 +08:00
渡口浪人 69ef87e8e9 fix: confirm new password and compact settings layout
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m45s
2026-10-20 16:19:50 +08:00
渡口浪人 09c6773d76 feat: harden single-user account authentication
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m47s
2026-10-20 16:06:24 +08:00
渡口浪人 ecfa17607f release: v0.1.25 official PIGWay branding
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m52s
2026-10-19 19:22:34 +08:00
渡口浪人 a83591d2c8 feat: integrate official PIGWay v1.1 brand assets
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m18s
2026-10-19 19:03:08 +08:00
渡口浪人 beb0bb0eb8 fix: vertically center every history table column
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m25s
2026-10-18 23:23:17 +08:00
渡口浪人 cc3fd02eb7 fix: identify history images and keep failure reasons visible
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 7m29s
2026-10-18 22:58:48 +08:00
渡口浪人 3a5acaa72a fix: allow a pinned CI base-image mirror on Server
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m51s
2026-10-18 21:58:10 +08:00
渡口浪人 8e66b6d741 docs: move trusted CI runner from NAS to Server
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Failing after 5m55s
2026-10-18 21:31:03 +08:00
渡口浪人 acdf267e48 fix: align deployment labels and refresh v0.1.23 audit
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m23s
2026-10-18 20:59:32 +08:00
35 changed files with 491 additions and 120 deletions
+3 -2
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
verify: verify:
# The NAS Docker runner must never execute code from external forks. # The self-hosted Docker runner must never execute code from external forks.
if: github.server_url != 'https://github.com' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) if: github.server_url != 'https://github.com' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
@@ -15,6 +15,7 @@ jobs:
DOCKER_PIP_FALLBACK_INDEX_URL: ${{ vars.DOCKER_PIP_FALLBACK_INDEX_URL }} DOCKER_PIP_FALLBACK_INDEX_URL: ${{ vars.DOCKER_PIP_FALLBACK_INDEX_URL }}
CI_PIP_INDEX_URL: ${{ vars.CI_PIP_INDEX_URL }} CI_PIP_INDEX_URL: ${{ vars.CI_PIP_INDEX_URL }}
CI_PIP_FALLBACK_INDEX_URL: ${{ vars.CI_PIP_FALLBACK_INDEX_URL }} CI_PIP_FALLBACK_INDEX_URL: ${{ vars.CI_PIP_FALLBACK_INDEX_URL }}
CI_PYTHON_BASE: ${{ vars.CI_PYTHON_BASE }}
ALPINE_MIRROR: ${{ vars.ALPINE_MIRROR }} ALPINE_MIRROR: ${{ vars.ALPINE_MIRROR }}
steps: steps:
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
@@ -41,4 +42,4 @@ jobs:
fi fi
# Production moved to Ubuntu Server. Release manually with scripts/server-release.py; # Production moved to Ubuntu Server. Release manually with scripts/server-release.py;
# the shared NAS runner performs isolated verification only. # the shared Server runner performs isolated verification only.
+1 -1
View File
@@ -43,7 +43,7 @@ WORKDIR /app
ARG BUILD_COMMIT=development ARG BUILD_COMMIT=development
ARG BUILD_TIME=unknown ARG BUILD_TIME=unknown
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.23" org.opencontainers.image.revision=$BUILD_COMMIT LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.26" org.opencontainers.image.revision=$BUILD_COMMIT
USER 10333:10333 USER 10333:10333
EXPOSE 18787 EXPOSE 18787
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
+2
View File
@@ -1,3 +1,5 @@
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
# PIGWay Frame Flow # PIGWay Frame Flow
Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md) and [security reporting](SECURITY.md). Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md) and [security reporting](SECURITY.md).
+4
View File
@@ -1,3 +1,5 @@
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
# PIGWay Frame Flow # PIGWay Frame Flow
开发环境目前支持 Python 3.14.x。参见[贡献指南](CONTRIBUTING.md)和[安全问题报告](SECURITY.md)。 开发环境目前支持 Python 3.14.x。参见[贡献指南](CONTRIBUTING.md)和[安全问题报告](SECURITY.md)。
@@ -26,6 +28,8 @@ docker compose up -d
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。 打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
网页仅支持一个用户,不提供公开注册。密码为 8–256 位,支持密码管理器;使用带随机盐的 scrypt(N=16384、r=8、p=5)保存,旧哈希登录成功后自动升级。连续 5 次失败后按账号和来源 IP 限制登录 5 分钟。修改密码需验证当前密码,成功后撤销全部会话;写入请求校验 CSRF Token,登录拒绝跨来源请求。公网访问应通过 HTTPS,HTTPS 下会话 Cookie 自动启用 Secure。
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)。不会自动修改防火墙、路由器或反向代理。 默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)。不会自动修改防火墙、路由器或反向代理。
使用 Python 3.14 运行本地 Mock 演示: 使用 Python 3.14 运行本地 Mock 演示:
+8 -5
View File
@@ -1,7 +1,10 @@
# pigway-frame-flow v0.1.23 # pigway-frame-flow v0.1.26
- 自动调度、自动重试和后台清理在同一设备已有图片等待同步时排队,避免 /upload 激活新图或删除操作更新云端计数,干扰尚未确认的目标。等待沿用现有同步超时;保留用户手动切换与优先级行为。 完善单用户账号安全与设置页面体验。
- 在操作回执中保留最多 12 条设备读回状态及受限错误字段,记录实际图片、同步、在线和心跳,供定位未确认原因;不保存原始异常、URL 或凭据。采样不延长同步超时,原始上传证据保留。
- 沿用安全基础镜像、完整构建和固定镜像 ID 的预构建验收流程;不重发不确定的设备写入。
验证:601 项测试、Ruff、格式和 mypy 通过。历史设备物理刷新状态无法从缺失的原始采样追溯,不能将此版本描述为已修复所有网络或设备故障。 - 浏览器账号仅支持单用户;密码至少 8 位,强化加盐 scrypt 哈希,兼容并自动升级原有哈希。
- 登录增加账号与来源 IP 限流、跨来源请求拦截;修改密码后撤销全部会话。
- 修改密码增加“确认新密码”和一致性校验;桌面端三个紧凑输入框同排,鉴权信息区收窄,手机端纵向排列。
- 左上角保留正式 Logo,文字采用 PIGWAY / FRAME FLOW 两行排版,适配深浅主题。
现有 API Token、设备集成与控制边界保持一致。源码包包含 LICENSE、NOTICE 及第三方许可材料。公网访问仍应使用 HTTPS。
+1 -1
View File
@@ -16,6 +16,6 @@ person's deployment or write to real frames without explicit authorization.
## 当前容器映像检查 ## 当前容器映像检查
v0.1.21 使用官方 Python Alpine 3.24 基础映像并固定摘要,构建时应用 APK 安全更新。2026-10-18 使用 Trivy 0.76.0 扫描构建的 Linux AMD64 映像,涵盖操作系统和 Python 包、全部严重级别,未检出已知漏洞;未配置漏洞忽略规则。该结果是当日漏洞库快照,不是未来无漏洞的保证,也不代表旧运行容器已升级。 v0.1.23 使用官方 Python Alpine 3.24 基础映像并固定摘要,构建时应用 APK 安全更新。2026-10-18 使用 Trivy 0.76.0 扫描与当前生产运行 ID 一致的 Linux AMD64 映像,涵盖操作系统和 Python 包、全部严重级别,未检出已知漏洞;未配置漏洞忽略规则。该结果是当日漏洞库快照,不是未来无漏洞的保证,也不代表旧运行容器已升级。
基础映像更新后须重新构建并复扫。默认非 root、只读根目录、清空 capabilities 和 no-new-privileges 的部署限制继续保留。中文字体、第三方许可证和依赖元数据均保留;不以删除包清单掩盖告警。 基础映像更新后须重新构建并复扫。默认非 root、只读根目录、清空 capabilities 和 no-new-privileges 的部署限制继续保留。中文字体、第三方许可证和依赖元数据均保留;不以删除包清单掩盖告警。
+34 -25
View File
@@ -2,16 +2,16 @@ c2792b2c75880ff09822804632b1b3b6dd6190b38841b8c3567bb1b7e7b59a8c .dockerignore
d20031a8e861a0f3e19b7750ddb53bc84c480c469c44a70ecdf8261ec05431c0 .env.example d20031a8e861a0f3e19b7750ddb53bc84c480c469c44a70ecdf8261ec05431c0 .env.example
0c7f4585d62a8d35bfc22e0bebdf7a479a179122b2b092bfe434e3038cddd3d5 .gitattributes 0c7f4585d62a8d35bfc22e0bebdf7a479a179122b2b092bfe434e3038cddd3d5 .gitattributes
b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workflows/community.yml b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workflows/community.yml
d3ad09bdc5dc60c41a774e1cad43f3e3f8ae5c851303ae6ec2aac030392a2939 .github/workflows/verify.yml c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workflows/verify.yml
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore 3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md 4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
c6fc984a1ca46cbd3e06de4591e41d21e4eb592416385d4000ade8fd570aad0c Dockerfile bd03eb87817b811ab7701b6c63470905a0c554a8ee0de6a83c67b22732b2727e Dockerfile
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE 6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md 250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
26ad0d9063c0ee843f0f532604b8fd66e8b39fd72d129595ed078677c0d6d13c README.md b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md
ee00cb267b38485f1e574d610f0a57b51d4ae575731a18e57d06a7c43dc0a8b0 README.zh-CN.md 791881a9956148e0a3aa00e1d5650983d4e397eb5f3e67b5ccacb1fb965725ec README.zh-CN.md
5ec5770a6e0202d981c5719de8cb09fefbd5d0a79b537d949b91ec67c8bce72b RELEASE_NOTES.md 1083bf4b9bfa263efa4243703d3345e546db86bd1f9eb22bda581fc509aa6cfe RELEASE_NOTES.md
6a0c85c8d52a2d4a3b779dce306a93c4efc4dfe3ab3b23e4a6a3bb7c55079910 SECURITY.md 3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
dff739157875c8a70235c4994df868afc6824e0d3861b2716ac28014bb38144f deploy/build-cn.env dff739157875c8a70235c4994df868afc6824e0d3861b2716ac28014bb38144f deploy/build-cn.env
@@ -21,17 +21,21 @@ fd53125a5359dcae1d81e95b8447aac755270f9aeb354d3e1140d7fccb57c42e deploy/docker-
4464545a9d91bbc9fff9cd9941db63f44fa61120caca426687d1ed06635cc3e6 deploy/organized-photos-source.json 4464545a9d91bbc9fff9cd9941db63f44fa61120caca426687d1ed06635cc3e6 deploy/organized-photos-source.json
f78d6c3d40e6c5246601bb99c2b46eef91e883a8f83841d85c925394708af0c0 docker-compose.yml f78d6c3d40e6c5246601bb99c2b46eef91e883a8f83841d85c925394708af0c0 docker-compose.yml
30d3e705870ebb012ce122bd7d8f95192d86664c4d2bb45a3169c86988fbaf72 docs/AUTOMATIC_COLLAGE.md 30d3e705870ebb012ce122bd7d8f95192d86664c4d2bb45a3169c86988fbaf72 docs/AUTOMATIC_COLLAGE.md
e897b531b5ec4c2411fb11c4ea38808a6305e066782fe6112953f1cf63cef78f docs/BUILDING.md 93b6e60570d9ca2ce14a454641a32885ca84d16a82db05db22cd62e488191dff docs/BUILDING.md
d9f07164c75c757e85f4ace2b55deaf82b8b904216c5222be6c0e3ab7b28cb36 docs/EXTENSIONS.md d9f07164c75c757e85f4ace2b55deaf82b8b904216c5222be6c0e3ab7b28cb36 docs/EXTENSIONS.md
1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md 1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md
0fe1896a53cef4cfd2e380a0b979eceda493733082a9b56c3d7e89e7f62a65b3 docs/NAS.md 0fe1896a53cef4cfd2e380a0b979eceda493733082a9b56c3d7e89e7f62a65b3 docs/NAS.md
3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md 3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md
edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md
4b00d08eb5a2425b205af13b87f9be04dd6870a734b42463a3e075658039dac9 docs/PUBLICATION.md b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATION.md
5fe4641e75e0746929f71256c6ff11116c777cca8d4288ccc2d7aee91bd7a6ac docs/README.md 5fe4641e75e0746929f71256c6ff11116c777cca8d4288ccc2d7aee91bd7a6ac docs/README.md
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md 5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
1794e3b09fa4eb9fb59cd18f729f49d6387885fc97a0b4f227e825cc49603554 docs/openapi.json 1794e3b09fa4eb9fb59cd18f729f49d6387885fc97a0b4f227e825cc49603554 docs/openapi.json
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md 54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
@@ -40,16 +44,16 @@ ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json 2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
f28c2277b30d583fb7702e425ca99fe229c7e7bdc45b6142fc1bfe2e989440a6 pigway_frame_flow/api/app.py 46c6e2331bda76eeac108d07ef282dbce74f482cc679ad9cf64d9cd2e29aaef3 pigway_frame_flow/api/app.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
fbadf6beaf25b9670dcbf125ec9d192188cf9e076ee4d4a0b5decdaf3834417c pigway_frame_flow/core/accounts.py 0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py 818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py 8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py 81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py 5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
6b09243081d53fb6c8a780d77e064f0685267c61e05f514262c8514ee3a36fda pigway_frame_flow/display/service.py 5f941b8b402efd6d1adab66df1dff29b1a3effda5ab23776daa300d55d5ca924 pigway_frame_flow/display/service.py
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py 0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py 9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
@@ -82,7 +86,7 @@ a518965aa4db9abeb60865c315229c3053c8e9edd26d80f24dc0af0528bad9e6 pigway_frame_f
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json 2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py 1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
c3fbe6af9d22989f1dc2fa980f64cf51672d7d45c28c5c8e88ba121a9709b578 pigway_frame_flow/models.py 0661802c0078133225c87a835c7c19313487b4b5808e5701c1a676994836f75d pigway_frame_flow/models.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py 58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
@@ -116,29 +120,33 @@ f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_f
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json 8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json 82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json
c43faa235848bec6370d7a3d8a7b3475cbd8eb4a6e494518dfaf160ca8f77bfa pigway_frame_flow/web/app.js 0d47629b1075a171f87911001760dd0d1fca79c7afc609bf18fbaa57f008dc36 pigway_frame_flow/web/app.js
f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
fe19e508ad60fab0ab4ca61f5d966f8340143e89f1572de00e78b3f1fed8384b pigway_frame_flow/web/features.js 94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
3c4e3be6321d40547d45724fc3c54af090ebbbc2a41dec717966596577c72c5d pigway_frame_flow/web/i18n.js 0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
2086605c67bb818b5cabae20ea4d9ccecb17e350c59cdb0d0f2ba1b70f7c8601 pigway_frame_flow/web/index.html cd3dc54289418eea667ad71e236cce61e9dc6add4925bdb46c8a9b7c19860c48 pigway_frame_flow/web/features.js
0609e796b5ca8346405b1bb6e65ac9f88ee163f7a93024d96d54d4e6ab96ba41 pigway_frame_flow/web/style.css 914baa94037bbe2bffa1a48a934274644eae7b8d523af0b789360e8ca7b0de27 pigway_frame_flow/web/i18n.js
f36ec88d90471fe1702fb6d50bf99f9b189af1170cbac47113135f2761985933 pigway_frame_flow/web/index.html
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
a97528940dc2edbb35d725770eeec5c10f0a996f235302a1aed048ca755afd96 pigway_frame_flow/web/style.css
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js 050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css 1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
97aaf8a332dcf472b8933b0a39aa329865133171e828892d932d29694e011ea5 pyproject.toml bb03214a95e1f7c5889129ffecb6236b6fe9053ed8e2ff8a1090d379602bd23d pyproject.toml
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
ae264ad19391bb2515def85963f39458cfa75a8611ff54e5329106c35bd8cc6e scripts/build-verify.sh 008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh
43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py 43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py
8186d4d9c2542d42d786404b096fbbdee22e4c74edae78ddebab5bd3f632764c scripts/ci-audit.py 8186d4d9c2542d42d786404b096fbbdee22e4c74edae78ddebab5bd3f632764c scripts/ci-audit.py
b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-compose-verify.py b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-compose-verify.py
6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py 6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py
a11e660a0343f2631c1a3dd0aa28471db827fb1f43d1af4217f51209aa3afe32 scripts/create-account.py eb94843315f0671ca14fac66fc0c6e7380866f28dc822cce14312fa548bb5c82 scripts/create-account.py
cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py
80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py 80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py
11627ecbd8a3be01aa079bdf6166b4368e670f5ad4a165b5971754b8187bbc2c scripts/run_local.py 11627ecbd8a3be01aa079bdf6166b4368e670f5ad4a165b5971754b8187bbc2c scripts/run_local.py
68fc95bb91e5529135a669a33450120465109222a4ce9bd7c3e2842c814626f5 scripts/server-release.py 5a0f8560d44352f2c5595875455ab1b2397d4eb8158a8160b5587a89a934f712 scripts/server-release.py
1fc16c43485a60a05012322cb1e4a219309dbd7e11f002f510602beea743ce30 scripts/smoke-test.py 1fc16c43485a60a05012322cb1e4a219309dbd7e11f002f510602beea743ce30 scripts/smoke-test.py
1236d3b2d27559cbe73a27dcf56248ef3c60404c6fb21453caa7225570d4ac45 scripts/ui-audit-regressions.cjs 1236d3b2d27559cbe73a27dcf56248ef3c60404c6fb21453caa7225570d4ac45 scripts/ui-audit-regressions.cjs
57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs 57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs
@@ -149,7 +157,7 @@ f981da8a8b4a043b4d39686feadd0e107e008c4e9e6804c45b92607777d2dbbc scripts/ui-gal
01991b94630c9b4af9f52d304b3b7e7b121c59e32d5765f778b53d238cbc6c06 scripts/ui-gallery-destinations.cjs 01991b94630c9b4af9f52d304b3b7e7b121c59e32d5765f778b53d238cbc6c06 scripts/ui-gallery-destinations.cjs
bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gallery-stars.cjs bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gallery-stars.cjs
02bae6aea8e9b6aef99afc690ad6ddf1fe796501fd83667109cedfe67d5c2586 scripts/ui-history-layout.cjs 02bae6aea8e9b6aef99afc690ad6ddf1fe796501fd83667109cedfe67d5c2586 scripts/ui-history-layout.cjs
cb0ccb04e087e2117f2a2e7ebe7f1a6b116e39e2bfc197ea996675ef628c98ae scripts/ui-history-localization.cjs 29c9b2ef02e11aa3320d63daec28185bf6e1e781d33d3ba934125c79c81ba33a scripts/ui-history-localization.cjs
1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs 1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs
742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs 742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs
66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs 66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs
@@ -170,10 +178,10 @@ e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json 41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json
c44080785bb2cca54bb17ac9e25f2e91a36839688bc70e40faecaea42d75e9f8 tests/schema-contract.json c2c398d7323e47255646e580c498a211a8f1a4fee32861954eaa29628bec8301 tests/schema-contract.json
14a7c56c40ea4b520585c20341b3ec70fa3ba52518144a10ba3494ceceb30758 tests/test_accounts.py 23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
94c01e6d8bdbfffbff72edb91823eaf39a325ad8782aba1d8e2804402e256aec tests/test_api.py a6edb781f4d081b2db45ffe2b02eed52a10f9b87a70e1e201b5d2f7c6813b93b tests/test_api.py
24fca799b0f5e33c800c6310764b18f6b39fc210be769684171388cf14b95746 tests/test_audit_regressions.py 24fca799b0f5e33c800c6310764b18f6b39fc210be769684171388cf14b95746 tests/test_audit_regressions.py
7001a2209473e6f6bf1050d33212985f63f4844defeb5df8e75d9f2a96c0d83c tests/test_auto_collage.py 7001a2209473e6f6bf1050d33212985f63f4844defeb5df8e75d9f2a96c0d83c tests/test_auto_collage.py
f267ebcdb3cf1fcb01ef9fd9ef7c5c8c4daca11d02543f84bfbe56359f35b78a tests/test_ci_audit.py f267ebcdb3cf1fcb01ef9fd9ef7c5c8c4daca11d02543f84bfbe56359f35b78a tests/test_ci_audit.py
@@ -186,6 +194,7 @@ fc7a3b37e43ae6716c3afeeeddc0631c932175d527bd8adfea6e5e0acb166311 tests/test_con
f5d737520934371fa8291727458285ccbc6285d8612f286e424c0f0ea6c34666 tests/test_extensions.py f5d737520934371fa8291727458285ccbc6285d8612f286e424c0f0ea6c34666 tests/test_extensions.py
f695398aabf0ef7a5c86a23bef44421a09eed8b24c655987d87cd793df6a8a32 tests/test_external_photo_sources.py f695398aabf0ef7a5c86a23bef44421a09eed8b24c655987d87cd793df6a8a32 tests/test_external_photo_sources.py
2ab265986f89df625b8e9c8e057ffa2e421e1c6e9bb02884b355b1b71cb918f7 tests/test_gallery_thumbnail.py 2ab265986f89df625b8e9c8e057ffa2e421e1c6e9bb02884b355b1b71cb918f7 tests/test_gallery_thumbnail.py
5dc4027bcdad63c4a3e02c7735e6b8445c69edb43f2a3c0cd8902ef6aa1c6342 tests/test_history_images.py
cbc44ca3bd0eab93b39f0aabfe5c9d923105f4556df7fd7b86593c06daddcae5 tests/test_ical.py cbc44ca3bd0eab93b39f0aabfe5c9d923105f4556df7fd7b86593c06daddcae5 tests/test_ical.py
787bb5ca8a03b8c94496b00e5a21957dea756b9ba1a4daf354b9e27a5f56d7ef tests/test_images.py 787bb5ca8a03b8c94496b00e5a21957dea756b9ba1a4daf354b9e27a5f56d7ef tests/test_images.py
95892c597f80aed1e5217c81ce187c3eae09a18542093c02c97fd2d950cc76c6 tests/test_interval_schedule.py 95892c597f80aed1e5217c81ce187c3eae09a18542093c02c97fd2d950cc76c6 tests/test_interval_schedule.py
+16 -1
View File
@@ -7,6 +7,10 @@ locked Python dependencies, and `font-noto-cjk` for Chinese rendering. APK
repository signatures and offline wheel SHA256 checks remain enabled. repository signatures and offline wheel SHA256 checks remain enabled.
Production uses `scripts/build-verify.sh`; CI uses `scripts/ci-compose-verify.py`. Production uses `scripts/build-verify.sh`; CI uses `scripts/ci-compose-verify.py`.
Both build the same Dockerfile with BuildKit and `docker compose build --pull`. Both build the same Dockerfile with BuildKit and `docker compose build --pull`.
For a CI host that cannot reach Docker Hub, the optional `CI_PYTHON_BASE`
repository variable passes a registry mirror reference through the build script.
Keep the same official Python image digest; an unset variable preserves the
Dockerfile default. This setting does not change the shared Docker daemon.
`ALPINE_MIRROR` accepts a mirror host (without scheme or path), defaulting to `ALPINE_MIRROR` accepts a mirror host (without scheme or path), defaulting to
`dl-cdn.alpinelinux.org`. For mainland-China builds: `dl-cdn.alpinelinux.org`. For mainland-China builds:
@@ -29,6 +33,17 @@ resources and are not runtime mounts or dependencies.
### CI with a host Docker socket ### CI with a host Docker socket
The managed CI runner runs on Ubuntu Server; the NAS no longer executes jobs.
It retains `ubuntu-latest` compatibility and fixed runner/job image digests,
accepts one job at a time, and limits each task container to 4 CPUs and 8 GiB.
The runner's private state resides on the guarded business disk. Its systemd
service checks the mount identity before starting. Registration credentials are
supplied through a temporary file and removed after registration; the daemon
container does not retain the reusable registration token in its environment.
Keep the current runner and job images even when no task is executing: they are
active CI dependencies. Only trusted repository events may reach this runner's
Docker socket; CI does not automatically deploy production or write to frames.
Run `python scripts/ci-compose-verify.py` after loading the build mirror profile. Run `python scripts/ci-compose-verify.py` after loading the build mirror profile.
The script derives a temporary Compose document from production settings, with The script derives a temporary Compose document from production settings, with
an absolute client-side build context, a UUID project name, a unique CI image an absolute client-side build context, a UUID project name, a unique CI image
@@ -40,7 +55,7 @@ owned `/data` and `/config` directories; the service remains UID/GID 10333.
Build contexts are sent by the Docker client, so the job workspace can remain Build contexts are sent by the Docker client, so the job workspace can remain
inside the job container. Runtime storage is managed by the daemon; no matching inside the job container. Runtime storage is managed by the daemon; no matching
NAS host `/workspace` directory is needed. HTTP readiness and the unmodified Docker host `/workspace` directory is needed. HTTP readiness and the unmodified
nine-endpoint smoke script run inside the service with `compose exec -T`. nine-endpoint smoke script run inside the service with `compose exec -T`.
Restart is followed by another readiness check and the same smoke test. Restart is followed by another readiness check and the same smoke test.
+1 -1
View File
@@ -4,7 +4,7 @@ Runtime keys, passwords, sessions, private photos, databases and local `.env` fi
must remain outside Git. Examples use documentation-only addresses and identities. must remain outside Git. Examples use documentation-only addresses and identities.
Personal deployment reports and screenshots are excluded from public history. Personal deployment reports and screenshots are excluded from public history.
The Gitea NAS Docker runner accepts trusted repository events only. GitHub mirrors The Gitea Server Docker runner accepts trusted repository events only. GitHub mirrors
use the separate community workflow on GitHub-hosted disposable runners, with use the separate community workflow on GitHub-hosted disposable runners, with
read-only repository permissions and no deployment configuration. External fork PRs read-only repository permissions and no deployment configuration. External fork PRs
are not executed on this runner; maintainers must review them before copying changes are not executed on this runner; maintainers must review them before copying changes
+15
View File
@@ -0,0 +1,15 @@
# PIGWAY 品牌资源 / Brand assets
来源:PIGWAY Brand Production v1.1 正式交付包。图形、颜色与比例保持原文件,不使用重绘、滤镜或替代字标。
- `pigway-logo.svg`:正式 Web 图形标。
- `pigway-logo.png`:正式 256px 透明图形标,用于项目介绍页。
- `pigway-avatar.png`:正式 512px 平台图标,用于 Tea 项目头像。
本项目的产品名称保持独立。品牌物料版本不跟随软件版本变化;网页小图标使用同一正式包的 Favicon 和 Apple Touch Icon。
## SHA-256
- `0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210` `pigway-logo.svg`
- `da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7` `pigway-logo.png`
- `f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3` `pigway-avatar.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

+6 -2
View File
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
app = FastAPI( app = FastAPI(
title="PIGWay Frame Flow API", title="PIGWay Frame Flow API",
docs_url=None, docs_url=None,
version="0.1.23", version="0.1.26",
lifespan=lifespan, lifespan=lifespan,
responses=error_responses, responses=error_responses,
) )
@@ -1040,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
supports_upload=True, supports_upload=True,
), ),
], ],
version="0.1.23", version="0.1.26",
mock=settings.mock, mock=settings.mock,
write_enabled=settings.write_enabled, write_enabled=settings.write_enabled,
api_key_configured=bool(settings.api_key) api_key_configured=bool(settings.api_key)
@@ -1313,6 +1313,10 @@ def create_app(settings=None, start_worker=True):
"i18n.js", "i18n.js",
"swagger-ui-bundle.js", "swagger-ui-bundle.js",
"swagger-ui.css", "swagger-ui.css",
"favicon.svg",
"favicon.ico",
"apple-touch-icon.png",
"logo-symbol.svg",
]: ]:
raise FlowError(E.NOT_FOUND, "Resource not found", 404) raise FlowError(E.NOT_FOUND, "Resource not found", 404)
return FileResponse(root / filename) return FileResponse(root / filename)
+105 -61
View File
@@ -21,16 +21,22 @@ cookie = APIKeyCookie(name=COOKIE, auto_error=False, scheme_name="AccountSession
def password_hash(password: str) -> str: def password_hash(password: str) -> str:
salt = secrets.token_hex(16) salt = secrets.token_hex(16)
digest = hashlib.scrypt( digest = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1 password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5
).hex() ).hex()
return f"scrypt${salt}${digest}" return f"scrypt-v2${salt}${digest}"
def verify_password(password: str, encoded: str) -> bool: def verify_password(password: str, encoded: str) -> bool:
try: try:
_, salt, expected = encoded.split("$") algorithm, salt, expected = encoded.split("$")
if algorithm not in {"scrypt", "scrypt-v2"}:
return False
actual = hashlib.scrypt( actual = hashlib.scrypt(
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1 password.encode(),
salt=bytes.fromhex(salt),
n=16384,
r=8,
p=5 if algorithm == "scrypt-v2" else 1,
).hex() ).hex()
return hmac.compare_digest(actual, expected) return hmac.compare_digest(actual, expected)
except (ValueError, TypeError): except (ValueError, TypeError):
@@ -57,6 +63,7 @@ def seed_accounts(db, config_dir: Path):
db.execute( db.execute(
"CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)" "CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)"
) )
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS single_browser_account ON users((1))")
db.execute( db.execute(
"CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)" "CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)"
) )
@@ -65,7 +72,13 @@ def seed_accounts(db, config_dir: Path):
) )
path = config_dir / "accounts.json" path = config_dir / "accounts.json"
if path.exists(): if path.exists():
for user in json.loads(path.read_text()).get("users", []): users = json.loads(path.read_text()).get("users", [])
if len(users) > 1:
raise ValueError("Only one browser account is supported")
for user in users:
existing = db.one("SELECT username FROM users LIMIT 1")
if existing and existing["username"] != user["username"]:
raise ValueError("A browser account already exists")
db.execute( db.execute(
"INSERT OR IGNORE INTO users VALUES(?,?,1)", "INSERT OR IGNORE INTO users VALUES(?,?,1)",
(user["username"], user["password_hash"]), (user["username"], user["password_hash"]),
@@ -83,7 +96,7 @@ def session_user(request: Request):
) )
if not row or not row["enabled"] or row["expires_at"] <= time.time(): if not row or not row["enabled"] or row["expires_at"] <= time.time():
return None return None
if request.method in {"POST", "PUT", "DELETE"}: if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
if not hmac.compare_digest( if not hmac.compare_digest(
request.headers.get("X-CSRF-Token", ""), row["csrf"] request.headers.get("X-CSRF-Token", ""), row["csrf"]
): ):
@@ -94,60 +107,88 @@ def session_user(request: Request):
def install_accounts(app, db): def install_accounts(app, db):
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"]) @app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
def login(payload: Login, request: Request, response: Response): def login(payload: Login, request: Request, response: Response):
identity = hashlib.sha256( with db.lock:
(request.client.host if request.client else "unknown").encode() # Reject login CSRF before checking credentials. Never trust forwarded hosts.
).hexdigest() origin = request.headers.get("origin")
attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,)) if request.headers.get("sec-fetch-site") == "cross-site" or (
if attempt and attempt["blocked_until"] > time.time(): origin and origin != str(request.base_url).rstrip("/")
raise FlowError( ):
E.RATE_LIMITED, "Too many login attempts; try again later", 429 raise FlowError(E.FORBIDDEN, "Cross-origin login is not allowed", 403)
identity = hashlib.sha256(
(request.client.host if request.client else "unknown").encode()
).hexdigest()
# Account throttling also applies when an attacker changes source IPs.
identities = (
identity,
"account:" + hashlib.sha256(payload.username.encode()).hexdigest(),
) )
row = db.one( attempts = [
"SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,) db.one("SELECT * FROM login_attempts WHERE identity=?", (key,))
) for key in identities
# Perform a password calculation even for unknown usernames. ]
fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64 attempt = next(
valid = verify_password( (a for a in attempts if a and a["blocked_until"] > time.time()), None
payload.password, row["password_hash"] if row else fallback )
) if attempt and attempt["blocked_until"] > time.time():
if not row or not valid: raise FlowError(
failures = ( E.RATE_LIMITED, "Too many login attempts; try again later", 429
attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0 )
) + 1 row = db.one(
"SELECT * FROM users WHERE username=? AND enabled=1",
(payload.username,),
)
# Perform a password calculation even for unknown usernames.
fallback = "scrypt-v2$" + "00" * 16 + "$" + "00" * 64
valid = verify_password(
payload.password, row["password_hash"] if row else fallback
)
if not row or not valid:
for key, previous in zip(identities, attempts, strict=True):
failures = (
previous["failures"]
if previous and previous["blocked_until"] == 0
else 0
) + 1
db.execute(
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
(key, failures, time.time() + 300 if failures >= 5 else 0),
)
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
for key in identities:
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
if row["password_hash"].startswith("scrypt$"):
db.execute(
"UPDATE users SET password_hash=? WHERE username=?",
(password_hash(payload.password), row["username"]),
)
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
lifetime = 30 * 86400 if payload.remember else 12 * 3600
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
old = request.cookies.get(COOKIE)
if old:
db.execute(
"DELETE FROM sessions WHERE token_hash=?",
(hashlib.sha256(old.encode()).hexdigest(),),
)
db.execute( db.execute(
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)", "INSERT INTO sessions VALUES(?,?,?,?)",
(identity, failures, time.time() + 300 if failures >= 5 else 0), (
hashlib.sha256(raw.encode()).hexdigest(),
row["username"],
csrf,
time.time() + lifetime,
),
) )
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401) response.set_cookie(
db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,)) COOKIE,
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32) raw,
lifetime = 30 * 86400 if payload.remember else 12 * 3600 max_age=lifetime if payload.remember else None,
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),)) httponly=True,
old = request.cookies.get(COOKIE) samesite="strict",
if old: secure=request.url.scheme == "https",
db.execute( path="/",
"DELETE FROM sessions WHERE token_hash=?",
(hashlib.sha256(old.encode()).hexdigest(),),
) )
db.execute( return AccountView(username=row["username"], csrf_token=csrf)
"INSERT INTO sessions VALUES(?,?,?,?)",
(
hashlib.sha256(raw.encode()).hexdigest(),
row["username"],
csrf,
time.time() + lifetime,
),
)
response.set_cookie(
COOKIE,
raw,
max_age=lifetime if payload.remember else None,
httponly=True,
samesite="strict",
secure=request.url.scheme == "https",
path="/",
)
return AccountView(username=row["username"], csrf_token=csrf)
def current(request): def current(request):
row = session_user(request) row = session_user(request)
@@ -187,9 +228,12 @@ def install_accounts(app, db):
user = db.one("SELECT * FROM users WHERE username=?", (session["username"],)) user = db.one("SELECT * FROM users WHERE username=?", (session["username"],))
if not verify_password(payload.current_password, user["password_hash"]): if not verify_password(payload.current_password, user["password_hash"]):
raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401) raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401)
db.execute( with db.transaction() as connection:
"UPDATE users SET password_hash=? WHERE username=?", connection.execute(
(password_hash(payload.new_password), session["username"]), "UPDATE users SET password_hash=? WHERE username=?",
) (password_hash(payload.new_password), session["username"]),
db.execute("DELETE FROM sessions WHERE username=?", (session["username"],)) )
connection.execute(
"DELETE FROM sessions WHERE username=?", (session["username"],)
)
response.delete_cookie(COOKIE, path="/") response.delete_cookie(COOKIE, path="/")
+31 -1
View File
@@ -152,6 +152,31 @@ class DisplayService:
) )
return id return id
def operation_image(self, device_id, result):
result = dict(result or {})
asset_id = result.get("rendered_asset_id") or result.get("asset_id")
if not asset_id and result.get("image_id") is not None:
# Cloud cleanup removes image_sources; the original upload receipt survives.
row = self.db.one(
"SELECT result FROM operations WHERE device_id=? "
"AND json_extract(result,'$.image_id')=? "
"AND json_extract(result,'$.rendered_asset_id') IS NOT NULL "
"ORDER BY created_at DESC LIMIT 1",
(device_id, result["image_id"]),
)
if row:
original = json.loads(row["result"])
asset_id = original["rendered_asset_id"]
result.setdefault("image_name", original.get("image_name"))
if asset_id:
result.setdefault("asset_id", asset_id)
asset = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
if asset:
result["image_name"] = result.get("image_name") or asset["name"]
if self.assets.path(asset_id).is_file():
result["preview_url"] = f"/api/v1/assets/{asset_id}"
return result or None
def operation(self, id): def operation(self, id):
op = self.db.one("SELECT * FROM operations WHERE id=?", (id,)) op = self.db.one("SELECT * FROM operations WHERE id=?", (id,))
if not op: if not op:
@@ -162,7 +187,9 @@ class DisplayService:
device_id=op["device_id"], device_id=op["device_id"],
created_at=op["created_at"], created_at=op["created_at"],
updated_at=op["updated_at"], updated_at=op["updated_at"],
result=json.loads(op["result"]) if op["result"] else None, result=self.operation_image(
op["device_id"], json.loads(op["result"]) if op["result"] else None
),
error=json.loads(op["error"]) if op["error"] else None, error=json.loads(op["error"]) if op["error"] else None,
request_id=op["request_id"], request_id=op["request_id"],
client_id=op["client_id"], client_id=op["client_id"],
@@ -172,6 +199,9 @@ class DisplayService:
def update(self, id, status, result=None, error=None): def update(self, id, status, result=None, error=None):
if result: if result:
op = self.db.one("SELECT device_id FROM operations WHERE id=?", (id,))
result = self.operation_image(op["device_id"], result)
result.pop("preview_url", None)
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,)) row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
previous = json.loads(row["result"]) if row and row["result"] else {} previous = json.loads(row["result"]) if row and row["result"] else {}
for key in ("photo_attempts", "upload_intent", "sync_observations"): for key in ("photo_attempts", "upload_intent", "sync_observations"):
+4 -2
View File
@@ -168,6 +168,8 @@ class ErrorResponse(BaseModel):
class OperationResult(BaseModel): class OperationResult(BaseModel):
image_name: str | None = None
preview_url: str | None = None
cover_id: int | None = None cover_id: int | None = None
photo_attempts: list[dict[str, str]] = Field(default_factory=list) photo_attempts: list[dict[str, str]] = Field(default_factory=list)
image_id: int | None = None image_id: int | None = None
@@ -366,7 +368,7 @@ class SettingsView(BaseModel):
class Health(BaseModel): class Health(BaseModel):
status: str status: str
version: str = "0.1.23" version: str = "0.1.26"
class Readiness(BaseModel): class Readiness(BaseModel):
@@ -397,7 +399,7 @@ class RestoreRequest(StrictModel):
class Version(BaseModel): class Version(BaseModel):
product: str = "PIGWay Frame Flow" product: str = "PIGWay Frame Flow"
version: str = "0.1.23" version: str = "0.1.26"
api_version: str = "v1" api_version: str = "v1"
commit: str = "development" commit: str = "development"
build_time: str = "unknown" build_time: str = "unknown"
+10 -2
View File
@@ -38,6 +38,14 @@ window.addEventListener('resize',revealCurrentNavigation);
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});} function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');} function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;} function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;}
function operationResultHTML(o){
const r=o.result||{},parts=[];
if(r.image_name)parts.push(`<div>${esc(r.image_name)}</div>`);
if(r.image_id!=null)parts.push(`<small>${t('cloudImageId')}: ${esc(r.image_id)}</small>`);
else if(r.asset_id||r.rendered_asset_id)parts.push(`<small>${t('localAssetId')}: ${esc(r.asset_id||r.rendered_asset_id)}</small>`);
if(o.error)parts.push(`<div class="history-status bad">${esc(operationErrorMessage(o.error))}</div>`);
return parts.join('')||'—';
}
function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;} function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;}
function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;} function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('');revealCurrentNavigation();} function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('');revealCurrentNavigation();}
@@ -118,7 +126,7 @@ async function templateEditor(selected){
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();})); const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);}; const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
} }
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});} function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><div class="field"><label for="confirm-password">${t('confirmPassword')}</label><input id="confirm-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);const confirm=$('#confirm-password');const validate=()=>confirm.setCustomValidity(confirm.value!==$('#new-password').value?t('passwordMismatch'):'');confirm.oninput=validate;$('#new-password').oninput=validate;form.onsubmit=busy(async e=>{e.preventDefault();validate();if(!form.reportValidity())return;await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
const views={ const views={
studio:()=>layoutStudioPage(), studio:()=>layoutStudioPage(),
connections:()=>connectionsPage(), connections:()=>connectionsPage(),
@@ -143,7 +151,7 @@ const views={
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));}, async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}}, async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));}, async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));}, async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();} async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
}; };
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};} function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

+1 -1
View File
@@ -45,7 +45,7 @@ async function weatherSettings(){
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));}); result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
} }
async function enhanceHistory(){ async function enhanceHistory(){
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];const ops=await api('/api/v1/operations');rows.forEach((row,i)=>{const op=ops[i];if(!op)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+op.operation_id);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p><p>${esc(operationErrorLabel(current.error)||current.error?.message||'')}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${current.result?.url?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});} const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p>${operationResultHTML(current)}<pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${(current.result?.preview_url||current.result?.url)?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||current.result.url);});});}
async function enhanceAutomation(){ async function enhanceAutomation(){
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}}); const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
} }
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,3 +1,3 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head> <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head>
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.23</small></div></aside><div class="workspace"><header><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.23"></script><script src="/ui/features.js?v=0.1.23"></script><script src="/ui/extensions.js?v=0.1.23"></script><script src="/ui/app.js?v=0.1.23"></script></body></html> <body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.26</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.26"></script><script src="/ui/features.js?v=0.1.26"></script><script src="/ui/extensions.js?v=0.1.26"></script><script src="/ui/app.js?v=0.1.26"></script></body></html>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.9 KiB

+14 -3
View File
@@ -49,7 +49,7 @@ input[type="file"]::file-selector-button{margin-inline-end:12px}
.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px} .official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px}
.history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{vertical-align:top;white-space:nowrap}.history-actions button{white-space:nowrap} .history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{white-space:nowrap}.history-actions button{white-space:nowrap}
.photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px} .photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px}
@@ -187,7 +187,10 @@ body.login-mode header,body.login-mode footer{flex-shrink:0}
.history-status{color:var(--accent)} .history-status{color:var(--accent)}
.history-status.bad{color:var(--bad)} .history-status.bad{color:var(--bad)}
.settings-columns{align-items:stretch} .settings-columns{align-items:stretch;grid-template-columns:minmax(0,2fr) minmax(240px,1fr)}
.password-fields{grid-template-columns:repeat(3,minmax(0,1fr));max-width:780px;align-items:end}
@media(max-width:1050px){.settings-columns{grid-template-columns:minmax(0,1fr)}}
@media(max-width:720px){.password-fields{grid-template-columns:minmax(0,1fr)}}
.settings-preferences .field,.password-fields .field,#weather-form .field{min-width:0;margin-bottom:0} .settings-preferences .field,.password-fields .field,#weather-form .field{min-width:0;margin-bottom:0}
#password-form>button{align-self:flex-start} #password-form>button{align-self:flex-start}
form#weather-form{display:grid;grid-template-columns:minmax(160px,1fr) minmax(220px,2fr) repeat(2,minmax(100px,1fr)) auto;gap:18px;align-items:end} form#weather-form{display:grid;grid-template-columns:minmax(160px,1fr) minmax(220px,2fr) repeat(2,minmax(100px,1fr)) auto;gap:18px;align-items:end}
@@ -224,6 +227,14 @@ form#mqtt-config{grid-template-columns:minmax(160px,1fr) 110px minmax(180px,1.5f
.automation-table{min-width:1200px} .automation-table{min-width:1200px}
.automation-table td:is(:nth-child(2),:nth-child(5),:nth-child(6)){white-space:nowrap} .automation-table td:is(:nth-child(2),:nth-child(5),:nth-child(6)){white-space:nowrap}
.automation-table .row-actions{flex-wrap:nowrap} .automation-table .row-actions{flex-wrap:nowrap}
.history-table td:is(:nth-child(3),:nth-child(4),:nth-child(5)),.history-table .history-actions{vertical-align:middle} .history-table td{vertical-align:middle}
.image-card h3.source-path{white-space:normal;overflow-wrap:anywhere} .image-card h3.source-path{white-space:normal;overflow-wrap:anywhere}
.pigway-logo{display:block;width:56px;height:56px;padding:6px;flex:0 0 56px;object-fit:contain}
.brand>span{min-width:0}.workspace-heading{display:flex;align-items:center;gap:12px}.workspace-heading>div{min-width:0}.mobile-brand-logo{display:none}
@media(max-width:720px){.mobile-brand-logo{display:block}}
/* Stacked wordmark keeps the product name readable within the sidebar. */
.sidebar .brand{font-size:26px;font-weight:800;line-height:1.15;letter-spacing:1px}
.sidebar .brand small{margin-top:6px;font-size:12px;font-weight:700;letter-spacing:4px;line-height:1.4;white-space:nowrap}
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "pigway-frame-flow" name = "pigway-frame-flow"
version = "0.1.23" version = "0.1.26"
readme = "README.md" readme = "README.md"
license = {file = "LICENSE"} license = {file = "LICENSE"}
requires-python = ">=3.14,<3.15" requires-python = ">=3.14,<3.15"
+5 -1
View File
@@ -6,5 +6,9 @@ cd "$(dirname "$0")/.."
DOCKER_BIN=${DOCKER_BIN:-docker} DOCKER_BIN=${DOCKER_BIN:-docker}
"$DOCKER_BIN" info >/dev/null "$DOCKER_BIN" info >/dev/null
"$DOCKER_BIN" compose config --quiet "$DOCKER_BIN" compose config --quiet
"$DOCKER_BIN" compose build --pull set -- --pull
if [ -n "${CI_PYTHON_BASE:-}" ]; then
set -- "$@" --build-arg "PYTHON_BASE=$CI_PYTHON_BASE"
fi
"$DOCKER_BIN" compose build "$@"
"$DOCKER_BIN" image inspect "${FRAME_FLOW_IMAGE:-pigway-frame-flow:latest}" --format 'image={{.Id}} size_bytes={{.Size}} architecture={{.Architecture}}' "$DOCKER_BIN" image inspect "${FRAME_FLOW_IMAGE:-pigway-frame-flow:latest}" --format 'image={{.Id}} size_bytes={{.Size}} architecture={{.Architecture}}'
+2 -2
View File
@@ -19,8 +19,8 @@ if len(password) < 8 or password != getpass.getpass("Confirm password: "):
args.config_dir.mkdir(parents=True, exist_ok=True) args.config_dir.mkdir(parents=True, exist_ok=True)
path = args.config_dir / "accounts.json" path = args.config_dir / "accounts.json"
users = json.loads(path.read_text()).get("users", []) if path.exists() else [] users = json.loads(path.read_text()).get("users", []) if path.exists() else []
if any(u["username"] == args.username for u in users): if users:
sys.exit("Bootstrap user already exists. Change the password in Settings.") sys.exit("Single-user account already exists. Change the password in Settings.")
users.append({"username": args.username, "password_hash": password_hash(password)}) users.append({"username": args.username, "password_hash": password_hash(password)})
path.write_text(json.dumps({"users": users}) + "\n") path.write_text(json.dumps({"users": users}) + "\n")
path.chmod(0o600) path.chmod(0o600)
+8
View File
@@ -219,6 +219,14 @@ def release(lock):
service["image"] = image service["image"] = image
service["environment"]["BUILD_COMMIT"] = commit service["environment"]["BUILD_COMMIT"] = commit
service["environment"]["BUILD_TIME"] = stamp service["environment"]["BUILD_TIME"] = stamp
labels = service.get("labels") or {}
if isinstance(labels, list):
labels = {item.partition("=")[0]: item.partition("=")[2] for item in labels}
service["labels"] = {
**labels,
"org.opencontainers.image.version": version,
"org.opencontainers.image.revision": commit,
}
changed = False changed = False
try: try:
changed = True changed = True
+3 -2
View File
@@ -4,6 +4,7 @@ const root=path.join(__dirname,'../pigway_frame_flow/web');
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved; const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang); await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){ await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
if(u.pathname==='/api/v1/assets/fixture')return r.fulfill({body:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jBz8AAAAASUVORK5CYII=','base64'),contentType:'image/png'});
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})} if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]}); if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]}); if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
@@ -12,6 +13,6 @@ const root=path.join(__dirname,'../pigway_frame_flow/web');
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});} return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});}); const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor(); await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
await p.evaluate(async()=>{const previous=api;api=async()=>[{operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',error:{code:'WRITE_DISABLED'},request_id:'fixture'}];state.devices=[{id:'living-room',name:'living-room'}];try{await views.history(state.epoch);}finally{api=previous;}}); await p.evaluate(async()=>{const op={operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',result:{image_id:999,image_name:'example <photo>.jpg',preview_url:'/api/v1/assets/fixture'},error:{code:'WRITE_DISABLED'},request_id:'fixture'};api=async(path,options)=>options?.binary?(await fetch(path)).blob():path.endsWith('/fixture')?op:[op];state.devices=[{id:'living-room',name:'living-room'}];document.body.classList.remove('login-mode');shell();await views.history(state.epoch);await enhanceHistory();});
const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close(); const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes('example <photo>.jpg'));assert(text.includes('999'));assert(text.includes(lang==='zh'?'云端图片 ID':'Cloud image ID'));assert(await p.locator('td div.bad').count()===1);assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));await p.locator('.history-actions button').click();await p.locator('#operation-preview').waitFor();assert(await p.locator('#operation-preview').evaluate(img=>img.complete&&img.naturalWidth>0));assert(await p.locator('#modal-content').innerText().then(x=>x.includes('example <photo>.jpg')));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)}); }}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
+22
View File
@@ -690,6 +690,28 @@
}, },
"OperationResult": { "OperationResult": {
"properties": { "properties": {
"image_name": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Image Name"
},
"preview_url": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Preview Url"
},
"cover_id": { "cover_id": {
"anyOf": [ "anyOf": [
{ {
+101
View File
@@ -141,3 +141,104 @@ def test_browser_admin_can_read_other_clients_operation(client, app, service):
assert operation_id in [ assert operation_id in [
op["operation_id"] for op in client.get("/api/v1/operations").json() op["operation_id"] for op in client.get("/api/v1/operations").json()
] ]
def test_password_minimum_and_https_cookie(client, app):
response = login(client, app)
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
assert (
client.put(
"/api/v1/auth/password",
json={"current_password": "fixture-password", "new_password": "1234567"},
).status_code
== 422
)
assert (
client.put(
"/api/v1/auth/password",
json={"current_password": "fixture-password", "new_password": "12345678"},
).status_code
== 204
)
response = client.post(
"https://testserver/api/v1/auth/login",
json={"username": "test-user", "password": "12345678"},
)
assert response.status_code == 200
assert "Secure" in response.headers["set-cookie"]
assert response.headers["cache-control"] == "no-store"
def test_login_rejects_cross_origin(client, app):
account(app)
assert (
client.post(
"/api/v1/auth/login",
headers={"Origin": "https://evil.example"},
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 403
)
def test_account_throttle_across_source_addresses(client, app):
account(app)
for _ in range(5):
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "wrong"},
).status_code
== 401
)
app.state.service.db.execute(
"DELETE FROM login_attempts WHERE identity NOT LIKE 'account:%'"
)
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 429
)
def test_legacy_hash_upgrade(client, app):
import hmac
from pigway_frame_flow.core.accounts import verify_password
salt = "12" * 16
digest = hashlib.scrypt(
b"fixture-password", salt=bytes.fromhex(salt), n=16384, r=8, p=1
).hex()
legacy = f"scrypt${salt}${digest}"
app.state.service.db.execute(
"INSERT INTO users VALUES(?,?,1)", ("test-user", legacy)
)
assert verify_password("fixture-password", legacy)
assert (
client.post(
"/api/v1/auth/login",
json={"username": "test-user", "password": "fixture-password"},
).status_code
== 200
)
stored = app.state.service.db.one("SELECT password_hash FROM users")[
"password_hash"
]
assert stored.startswith("scrypt-v2$") and not hmac.compare_digest(stored, legacy)
assert verify_password("fixture-password", stored)
def test_only_one_browser_account(app):
import sqlite3
import pytest
account(app)
with pytest.raises(sqlite3.IntegrityError):
app.state.service.db.execute(
"INSERT INTO users VALUES(?,?,1)",
("other", password_hash("another-password")),
)
+22
View File
@@ -291,6 +291,28 @@ def test_core_has_no_client_special_case():
) )
def test_official_brand_assets(client):
html = client.get("/").text
assert "/ui/favicon.svg" in html
assert "/ui/apple-touch-icon.png" in html
assert 'class="mobile-brand-logo pigway-logo"' in html
for filename, content_type in [
("logo-symbol.svg", "image/svg+xml"),
("favicon.svg", "image/svg+xml"),
("favicon.ico", "image/vnd.microsoft.icon"),
("apple-touch-icon.png", "image/png"),
]:
response = client.get("/ui/" + filename)
assert response.status_code == 200
assert response.headers["content-type"].split(";")[0] in (
content_type,
"image/x-icon",
)
assert (
response.content == (Path("pigway_frame_flow/web") / filename).read_bytes()
)
def test_ui_languages(client): def test_ui_languages(client):
assert client.get("/ui/app.js").status_code == 200 assert client.get("/ui/app.js").status_code == 200
i18n = client.get("/ui/i18n.js").text i18n = client.get("/ui/i18n.js").text
+62
View File
@@ -0,0 +1,62 @@
from io import BytesIO
from PIL import Image
from pigway_frame_flow.models import DisplayRequest
def test_history_image_survives_cloud_cleanup(client, service):
data = BytesIO()
Image.new("RGB", (40, 60), "blue").save(data, "JPEG")
asset = service.assets.put(data.getvalue(), "example.jpg", "images")
request = DisplayRequest(content={"type": "image", "asset_id": asset["asset_id"]})
op = service.submit(
"display", request.model_dump(mode="json"), "living-room", "admin", "fixture"
)
service.process_one()
service.sync()
result = service.operation(op)["result"]
image_id = result["image_id"]
# Simulate an older receipt without the new name snapshot, then cloud cleanup.
service.db.execute(
"UPDATE operations SET result=? WHERE id=?",
(
'{"image_id":%d,"rendered_asset_id":"%s"}' % (image_id, asset["asset_id"]),
op,
),
)
service.db.execute("DELETE FROM image_sources WHERE device_id=?", ("living-room",))
deletion = service.submit(
"delete",
{"image_id": image_id, "confirm_current": True},
"living-room",
"admin",
"delete-fixture",
)
service.update(deletion, "COMPLETED", {"image_id": image_id})
receipt = client.get("/api/v1/operations/" + deletion).json()["result"]
assert receipt["image_name"] == "example.jpg"
assert client.get(receipt["preview_url"]).status_code == 200
service.assets.path(asset["asset_id"]).unlink()
service.db.execute("DELETE FROM assets WHERE id=?", (asset["asset_id"],))
receipt = client.get("/api/v1/operations/" + deletion).json()["result"]
assert receipt["image_name"] == "example.jpg"
assert receipt["preview_url"] is None
def test_history_missing_local_image_does_not_break_list(client, service):
op = service.submit("delete", {"image_id": 999}, "living-room", "admin", "fixture")
service.update(
op,
"FAILED",
{"image_id": 999},
{
"code": "DEVICE_OFFLINE",
"message": "Device offline",
"request_id": "fixture",
},
)
receipt = client.get("/api/v1/operations/" + op).json()
assert receipt["result"]["image_id"] == 999
assert receipt["result"]["preview_url"] is None
assert receipt["error"]["code"] == "DEVICE_OFFLINE"