Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3bed2edce | ||
|
|
f8fe8e8b14 | ||
|
|
50903564ad | ||
|
|
69ef87e8e9 | ||
|
|
09c6773d76 | ||
|
|
ecfa17607f | ||
|
|
a83591d2c8 | ||
|
|
beb0bb0eb8 |
@@ -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.24" 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.24
|
# pigway-frame-flow v0.1.26
|
||||||
|
|
||||||
- 历史记录标明云端图片 ID 或本地图片 ID,同时显示图片名称和错误原因,避免编号遮住失败提示。
|
完善单用户账号安全与设置页面体验。
|
||||||
- 详情优先展示可用的本地预览;云端图片清理后仍可关联原上传记录。保留图片名称快照,本地文件清理后不再提供失效预览。
|
|
||||||
- 不重发设备指令,不改变图片清理策略。
|
|
||||||
|
|
||||||
验证:603 项测试、Ruff、mypy 及中英文桌面/平板/手机历史列表、错误提示和预览检查通过。
|
- 浏览器账号仅支持单用户;密码至少 8 位,强化加盐 scrypt 哈希,兼容并自动升级原有哈希。
|
||||||
|
- 登录增加账号与来源 IP 限流、跨来源请求拦截;修改密码后撤销全部会话。
|
||||||
|
- 修改密码增加“确认新密码”和一致性校验;桌面端三个紧凑输入框同排,鉴权信息区收窄,手机端纵向排列。
|
||||||
|
- 左上角保留正式 Logo,文字采用 PIGWAY / FRAME FLOW 两行排版,适配深浅主题。
|
||||||
|
|
||||||
|
现有 API Token、设备集成与控制边界保持一致。源码包包含 LICENSE、NOTICE 及第三方许可材料。公网访问仍应使用 HTTPS。
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workfl
|
|||||||
c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workflows/verify.yml
|
c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workflows/verify.yml
|
||||||
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
|
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
|
||||||
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
|
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
|
||||||
0acd6eb62044b52828dae0dbd6f34ae91fb64ea1b6fa9f67062c6125a7364e40 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
|
||||||
4eb58ccd6f4b073d6f6e2ed2ca1ce430813064831b1143d63c6ab8b0c453638f RELEASE_NOTES.md
|
1083bf4b9bfa263efa4243703d3345e546db86bd1f9eb22bda581fc509aa6cfe RELEASE_NOTES.md
|
||||||
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
|
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
|
||||||
@@ -32,6 +32,10 @@ b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATI
|
|||||||
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,9 +44,9 @@ 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
|
||||||
53aa03c15f847a1d770f2132d117f793602ecf766b6ab92973bcdf2169946eae 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
|
||||||
@@ -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
|
||||||
365cb15a6b449bd0e7048666ae9485570956bafca4a5ba2324b6248f4cb08c95 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,23 +120,27 @@ 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
|
||||||
3acad7c209e3483481c3b966b27a0609f2d12c2383955274dfd226a1fe5f41bf 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
|
||||||
|
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
|
||||||
|
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
|
||||||
cd3dc54289418eea667ad71e236cce61e9dc6add4925bdb46c8a9b7c19860c48 pigway_frame_flow/web/features.js
|
cd3dc54289418eea667ad71e236cce61e9dc6add4925bdb46c8a9b7c19860c48 pigway_frame_flow/web/features.js
|
||||||
a43bde8abb2ac9a9e0f41f83219c43c20804d76f0984407308847238554b1f3d pigway_frame_flow/web/i18n.js
|
914baa94037bbe2bffa1a48a934274644eae7b8d523af0b789360e8ca7b0de27 pigway_frame_flow/web/i18n.js
|
||||||
d2eae1d2116dc426da00b709154516ca633ee0cd41abd23a5d8f165b1f1673ef pigway_frame_flow/web/index.html
|
f36ec88d90471fe1702fb6d50bf99f9b189af1170cbac47113135f2761985933 pigway_frame_flow/web/index.html
|
||||||
0609e796b5ca8346405b1bb6e65ac9f88ee163f7a93024d96d54d4e6ab96ba41 pigway_frame_flow/web/style.css
|
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
|
||||||
7cc44dc5400afa2b12e99ff07252c26c27ec9f8ad87e779d1ab16caf77e29e50 pyproject.toml
|
bb03214a95e1f7c5889129ffecb6236b6fe9053ed8e2ff8a1090d379602bd23d pyproject.toml
|
||||||
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
|
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
|
||||||
008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf 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
|
||||||
@@ -171,9 +179,9 @@ b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify
|
|||||||
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
|
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
|
||||||
41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json
|
41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json
|
||||||
c2c398d7323e47255646e580c498a211a8f1a4fee32861954eaa29628bec8301 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
|
||||||
|
|||||||
@@ -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.24",
|
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.24",
|
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,32 +107,60 @@ 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):
|
||||||
|
with db.lock:
|
||||||
|
# Reject login CSRF before checking credentials. Never trust forwarded hosts.
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if request.headers.get("sec-fetch-site") == "cross-site" or (
|
||||||
|
origin and origin != str(request.base_url).rstrip("/")
|
||||||
|
):
|
||||||
|
raise FlowError(E.FORBIDDEN, "Cross-origin login is not allowed", 403)
|
||||||
identity = hashlib.sha256(
|
identity = hashlib.sha256(
|
||||||
(request.client.host if request.client else "unknown").encode()
|
(request.client.host if request.client else "unknown").encode()
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,))
|
# Account throttling also applies when an attacker changes source IPs.
|
||||||
|
identities = (
|
||||||
|
identity,
|
||||||
|
"account:" + hashlib.sha256(payload.username.encode()).hexdigest(),
|
||||||
|
)
|
||||||
|
attempts = [
|
||||||
|
db.one("SELECT * FROM login_attempts WHERE identity=?", (key,))
|
||||||
|
for key in identities
|
||||||
|
]
|
||||||
|
attempt = next(
|
||||||
|
(a for a in attempts if a and a["blocked_until"] > time.time()), None
|
||||||
|
)
|
||||||
if attempt and attempt["blocked_until"] > time.time():
|
if attempt and attempt["blocked_until"] > time.time():
|
||||||
raise FlowError(
|
raise FlowError(
|
||||||
E.RATE_LIMITED, "Too many login attempts; try again later", 429
|
E.RATE_LIMITED, "Too many login attempts; try again later", 429
|
||||||
)
|
)
|
||||||
row = db.one(
|
row = db.one(
|
||||||
"SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,)
|
"SELECT * FROM users WHERE username=? AND enabled=1",
|
||||||
|
(payload.username,),
|
||||||
)
|
)
|
||||||
# Perform a password calculation even for unknown usernames.
|
# Perform a password calculation even for unknown usernames.
|
||||||
fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64
|
fallback = "scrypt-v2$" + "00" * 16 + "$" + "00" * 64
|
||||||
valid = verify_password(
|
valid = verify_password(
|
||||||
payload.password, row["password_hash"] if row else fallback
|
payload.password, row["password_hash"] if row else fallback
|
||||||
)
|
)
|
||||||
if not row or not valid:
|
if not row or not valid:
|
||||||
|
for key, previous in zip(identities, attempts, strict=True):
|
||||||
failures = (
|
failures = (
|
||||||
attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0
|
previous["failures"]
|
||||||
|
if previous and previous["blocked_until"] == 0
|
||||||
|
else 0
|
||||||
) + 1
|
) + 1
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
|
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
|
||||||
(identity, failures, time.time() + 300 if failures >= 5 else 0),
|
(key, failures, time.time() + 300 if failures >= 5 else 0),
|
||||||
)
|
)
|
||||||
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
||||||
db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,))
|
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)
|
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
||||||
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
||||||
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
|
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
|
||||||
@@ -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:
|
||||||
|
connection.execute(
|
||||||
"UPDATE users SET password_hash=? WHERE username=?",
|
"UPDATE users SET password_hash=? WHERE username=?",
|
||||||
(password_hash(payload.new_password), session["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="/")
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ class SettingsView(BaseModel):
|
|||||||
|
|
||||||
class Health(BaseModel):
|
class Health(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
version: str = "0.1.24"
|
version: str = "0.1.26"
|
||||||
|
|
||||||
|
|
||||||
class Readiness(BaseModel):
|
class Readiness(BaseModel):
|
||||||
@@ -399,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.24"
|
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"
|
||||||
|
|||||||
@@ -126,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(),
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -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.24</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.24"></script><script src="/ui/features.js?v=0.1.24"></script><script src="/ui/extensions.js?v=0.1.24"></script><script src="/ui/app.js?v=0.1.24"></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.24"
|
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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||