Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3bed2edce | ||
|
|
f8fe8e8b14 | ||
|
|
50903564ad | ||
|
|
69ef87e8e9 | ||
|
|
09c6773d76 | ||
|
|
ecfa17607f | ||
|
|
a83591d2c8 | ||
|
|
beb0bb0eb8 | ||
|
|
cc3fd02eb7 | ||
|
|
3a5acaa72a | ||
|
|
8e66b6d741 | ||
|
|
acdf267e48 |
@@ -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.
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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 演示:
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -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 的部署限制继续保留。中文字体、第三方许可证和依赖元数据均保留;不以删除包清单掩盖告警。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -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)
|
||||||
|
|||||||
@@ -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="/")
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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};}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -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);});}});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 9.9 KiB |
@@ -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,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"
|
||||||
|
|||||||
@@ -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}}'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)});
|
||||||
|
|||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||