Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1c41e91ea | ||
|
|
f39c7e535b | ||
|
|
51d76a196e | ||
|
|
038b858eb7 | ||
|
|
4e9eccd809 |
+1
-6
@@ -17,15 +17,10 @@ IMAGE_URL_ALLOWED_HOSTS=
|
||||
BUILD_COMMIT=development
|
||||
BUILD_TIME=unknown
|
||||
|
||||
# Optional read-only NAS photo mount; use deploy/docker-compose.photos.yml.
|
||||
# NAS_PHOTOS_DIR=/volume2/photo
|
||||
# PHOTO_ROOT=/photos
|
||||
# Filesystem/NFS photo sources are retired. Use Synology Photos API or WebDAV.
|
||||
# Optional WebDAV credential reference; prefer a secret file under /config.
|
||||
# FRAME_FLOW_SOURCE_CLOUD_FILE=/config/webdav-cloud.secret
|
||||
|
||||
# Optional existing group with photo read permission; does not change NAS ACLs.
|
||||
# NAS_PHOTOS_GID=101
|
||||
|
||||
# Optional Synology Photos shared-space people filter. Configure a pinned NAS endpoint.
|
||||
# Login in the photo-source editor; only the session is saved in /config (mode 0600).
|
||||
SYNOLOGY_PHOTOS_URL=
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Community checks
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
verify:
|
||||
# GitHub-hosted disposable runner only; never run forks on the NAS runner.
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: '3.14'
|
||||
- run: python -m pip install -e '.[test]'
|
||||
- run: python -m ruff check pigway_frame_flow tests scripts
|
||||
- run: python -m ruff format --check pigway_frame_flow tests scripts
|
||||
- run: python -m mypy pigway_frame_flow
|
||||
- run: python -m pytest -q
|
||||
- run: python scripts/ci-audit.py
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
jobs:
|
||||
verify:
|
||||
# The NAS Docker runner must never execute code from external forks.
|
||||
if: 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
|
||||
env:
|
||||
DOCKER_PIP_INDEX_URL: ${{ vars.DOCKER_PIP_INDEX_URL }}
|
||||
|
||||
@@ -24,3 +24,6 @@ build-cache/wheels/*
|
||||
# Private browser captures and platform metadata
|
||||
docs/screenshots/
|
||||
.DS_Store
|
||||
|
||||
# Private deployment receipts belong outside public source control.
|
||||
docs/verification/
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Contributing
|
||||
|
||||
Use Python 3.14.x and follow the development commands in README.md. Run Ruff
|
||||
lint/format checks, mypy and pytest before submitting a pull request. Keep changes
|
||||
focused and include a regression test for behavior fixes where practical.
|
||||
|
||||
Use mock mode with device writes disabled. Do not commit `.env`, credentials,
|
||||
private configuration, databases, photos, or production receipts. Tests must not
|
||||
contact real frames or mutate production services.
|
||||
|
||||
GitHub mirrors run community checks on disposable GitHub-hosted runners, including
|
||||
fork PRs. Gitea uses the existing trusted NAS runner and rejects fork PRs; maintainers
|
||||
review contributions before moving them to a trusted branch. Docker build/smoke
|
||||
verification remains in Gitea CI. Production releases are maintainer-controlled;
|
||||
contributors do not need access to production infrastructure.
|
||||
|
||||
For security issues, follow SECURITY.md instead of posting exploit details publicly.
|
||||
Contributions are provided under the project's MIT license; preserve third-party
|
||||
licenses and provenance when introducing external code or assets.
|
||||
@@ -37,6 +37,7 @@ RUN --mount=type=cache,id=frameflow-pip-py314-${TARGETARCH},target=/root/.cache/
|
||||
else python /build-tools/install.py --locked; fi
|
||||
COPY pigway_frame_flow ./pigway_frame_flow
|
||||
COPY vendor ./vendor
|
||||
COPY LICENSE NOTICE.md ./
|
||||
# Publish only the cleaned filesystem, without retaining build-stage layers.
|
||||
RUN rm -rf /usr/local/lib/python*/site-packages/pip \
|
||||
/usr/local/lib/python*/site-packages/pip-*.dist-info \
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# PIGWay Frame Flow
|
||||
|
||||
Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md) and [security reporting](SECURITY.md).
|
||||
|
||||
[简体中文](README.zh-CN.md) | English
|
||||
|
||||
PIGWay Frame Flow is a lightweight, self-hosted platform for programmable smart-frame automation. It runs independently on a NAS or server, with its own Web UI, image library, rendering, scheduler and REST API. KoKonna is a supported device integration; external applications do not need its credentials or SDK.
|
||||
@@ -47,7 +49,7 @@ Synology Photos API sources work without `/photos` or NFS mounts, with shared/cu
|
||||
- Seven templates: Daily Dashboard, Weather, Calendar, Quote, Photo + Text, Daily Poetry and System Alert.
|
||||
- Layout Studio for collages/posters with 1–12 images; optional OpenAI-compatible layout planning and source-driven automatic collages.
|
||||
- Device-bound schedules, ordered/random playlists, temporary priority overrides, restore and persistent operation history.
|
||||
- Read-only NAS folders, Synology Photos person filtering, WebDAV and optional cloud-drive connectors. Each source has its own folder and filter settings. People filtering uses one shared Synology Photos sign-in; WebDAV and cloud-drive credentials belong to their respective sources.
|
||||
- Synology Photos person filtering, WebDAV and optional cloud-drive connectors. Each source has its own folder and filter settings. People filtering uses one shared Synology Photos sign-in; WebDAV and cloud-drive credentials belong to their respective sources.
|
||||
- Optional weather, calendar, monitoring, AI, MQTT and webhook integrations. Provider configuration is required; an unconnected provider is not a working connection.
|
||||
|
||||
Templates **render images**, including text-only templates. Choose a template, edit its data, generate a preview, then display it. Preview generation and saving template settings do not update the frame. Photo + Text and Daily Dashboard can include a selected local photo; other templates do not automatically choose one. Scheduled template jobs render afresh on each run. See [photo sources](docs/PHOTO_SOURCES.md), [automatic collages](docs/AUTOMATIC_COLLAGE.md) and [integration setup/limits](docs/EXTENSIONS.md).
|
||||
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
# PIGWay Frame Flow
|
||||
|
||||
开发环境目前支持 Python 3.14.x。参见[贡献指南](CONTRIBUTING.md)和[安全问题报告](SECURITY.md)。
|
||||
|
||||
简体中文 | [English](README.md)
|
||||
|
||||
轻量、自托管的智能画框自动化平台,可独立运行在 NAS 或服务器上,提供自己的 Web 界面、图库、图片渲染、调度器和 REST API。KoKonna 是支持的设备集成之一;外部应用无需持有它的凭据或引入其 SDK。
|
||||
@@ -47,7 +49,7 @@ Synology Photos 可使用独立的 API 来源,无需 `/photos` 或 NFS 挂载
|
||||
- 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。
|
||||
- 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。
|
||||
- 绑定设备的定时任务、顺序/随机播放列表、带优先级的临时覆盖、恢复与持久化操作历史。
|
||||
- 只读 NAS 文件夹、Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
|
||||
- Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
|
||||
- 可选天气、日历、监控、AI、MQTT 与 Webhook 集成。需要配置对应 Provider;尚未授权的数据源不会被当作已连接。
|
||||
|
||||
模板会**生成图片**,纯文字模板也是如此。使用顺序是:选择模板 → 编辑内容 → 生成预览 → 显示到画框。生成预览和保存模板设置都不会更新画框。“照片与文字”和“家庭早报”可以包含指定的本地照片,其他模板不会自动选图;定时模板任务每次运行会重新渲染。参见[照片数据源](docs/PHOTO_SOURCES.md)、[自动拼图](docs/AUTOMATIC_COLLAGE.md)和[集成配置及限制](docs/EXTENSIONS.md)。
|
||||
@@ -69,7 +71,7 @@ Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKon
|
||||
|
||||
Ticktar 集成为可选项,Frame Flow 不依赖 Ticktar 启动,也不访问其数据库。参见[外部集成契约](docs/integrations/TICKTAR.md)。
|
||||
|
||||
Console 监控接入提供独立只读 ZIP 插件,支持服务就绪、画框在线/电量及任务最近结果,见 [Console 插件安装说明](../pigway-console-adapter-frame-flow/README.md)。
|
||||
Console 监控接入提供独立只读 ZIP 插件,支持服务就绪、画框在线/电量及任务最近结果,见 [Console 插件安装说明](https://tea.pigway.com/way/pigway-console-adapter-frame-flow/src/branch/main/README.zh-CN.md)。
|
||||
|
||||
### 存储与安全
|
||||
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# Security policy
|
||||
|
||||
Security fixes target the latest release. Older releases have no guaranteed
|
||||
backport support. Upgrade before verifying whether an issue is still present.
|
||||
|
||||
Do not publish credentials, private photos, production logs, or working exploit
|
||||
payloads in public issues. Contact the repository owner privately through the
|
||||
hosting platform when available. If no private contact channel is available,
|
||||
open an issue asking for a confidential reporting channel without technical
|
||||
vulnerability details. There is currently no dedicated security mailbox or
|
||||
promised response SLA.
|
||||
|
||||
A useful private report includes the affected version, deployment mode, required
|
||||
permissions, impact, and minimal steps using mock data. Do not test on another
|
||||
person's deployment or write to real frames without explicit authorization.
|
||||
@@ -8,6 +8,7 @@ COPY requirements.lock /app/requirements.expected.lock
|
||||
RUN python -c "from pathlib import Path; assert Path('/app/requirements.lock').read_bytes() == Path('/app/requirements.expected.lock').read_bytes(), 'Dependency lock changed; use the full Dockerfile'"
|
||||
COPY pigway_frame_flow /app/pigway_frame_flow
|
||||
COPY vendor /app/vendor
|
||||
COPY LICENSE NOTICE.md /app/
|
||||
# Publish only the cleaned filesystem, without retaining build-stage layers.
|
||||
RUN rm -rf /usr/local/lib/python*/site-packages/pip \
|
||||
/usr/local/lib/python*/site-packages/pip-*.dist-info \
|
||||
@@ -29,4 +30,4 @@ CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0
|
||||
ARG BUILD_COMMIT
|
||||
ARG 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.17" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.19" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||
|
||||
@@ -9,8 +9,8 @@ services:
|
||||
init: true
|
||||
user: "10333:10333"
|
||||
ports:
|
||||
- "${FRAME_FLOW_BIND:-192.0.2.10}:81:18787"
|
||||
env_file: ${FRAME_FLOW_ENV_FILE:-/srv/pigway/apps/pigway-frame-flow/.env}
|
||||
- "${FRAME_FLOW_BIND:-127.0.0.1}:81:18787"
|
||||
env_file: ${FRAME_FLOW_ENV_FILE:?Set the private environment file path}
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
DATA_DIR: /data
|
||||
@@ -23,12 +23,12 @@ services:
|
||||
TMPDIR: /data/tmp
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /srv/pigway/apps/pigway-frame-flow/data
|
||||
source: ${FRAME_FLOW_APP_DIR:?Set the existing application directory}/data
|
||||
target: /data
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: /srv/pigway/apps/pigway-frame-flow/config
|
||||
source: ${FRAME_FLOW_APP_DIR:?Set the existing application directory}/config
|
||||
target: /config
|
||||
bind:
|
||||
create_host_path: false
|
||||
|
||||
+17
-1
@@ -4,7 +4,9 @@ Runtime keys, passwords, sessions, private photos, databases and local `.env` fi
|
||||
must remain outside Git. Examples use documentation-only addresses and identities.
|
||||
Personal deployment reports and screenshots are excluded from public history.
|
||||
|
||||
The NAS Docker runner accepts trusted repository events only. External fork PRs
|
||||
The Gitea NAS Docker runner accepts trusted repository events only. GitHub mirrors
|
||||
use the separate community workflow on GitHub-hosted disposable runners, with
|
||||
read-only repository permissions and no deployment configuration. External fork PRs
|
||||
are not executed on this runner; maintainers must review them before copying changes
|
||||
to a trusted branch. All lint, type, test, audit and isolated Docker checks remain.
|
||||
|
||||
@@ -18,3 +20,17 @@ Publishing requires scanning all refs, removing private historic files, pruning
|
||||
unreachable remote Git objects and archiving/removing historical Actions logs.
|
||||
A private original mirror is retained outside the repository. Existing clones must
|
||||
reclone after the privacy history rewrite; do not merge the original history back.
|
||||
|
||||
## Private deployment inputs
|
||||
|
||||
Do not publish host addresses, workstation paths, disk UUIDs, photo-library counts,
|
||||
or deployment receipts. Keep receipts and recovery bundles outside the repository
|
||||
in an owner-only directory. A privacy history rewrite changes existing commit IDs;
|
||||
existing clones must be replaced instead of merging old history back.
|
||||
|
||||
`server-release.py` reads `FRAME_FLOW_ROOT` (default `/srv/frameflow`), requires
|
||||
`FRAME_FLOW_MOUNT_UUID` and `FRAME_FLOW_ORIGIN`, and retains the mount-identity
|
||||
check before release actions. Supply verified values privately to the sudo process;
|
||||
existing installations must set their existing root explicitly. Do not use the
|
||||
generic default to migrate a deployment. The script still requires the existing
|
||||
mount guard and does not change host mounts, services, or storage automatically.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- 支持目录相对路径、递归开关、日期文件夹 / 标题、方向及现有质量筛选。`.` 表示所选空间根目录;路径以 Photos 返回的目录名为准,不是 Ubuntu 绝对路径。
|
||||
- 图片使用经过实测的 `Thumbnail.get` v2、`size=xl`。原始文件名不代表下载的是原图。对 NAS 当前原图下载接口的探测被拒绝,原图下载**未宣称支持**。
|
||||
- 照片枚举 `Browse.Item.list` v7,人物 `Browse.Person.list` v3;每批 500 条,最多 100000 条,超过明确失败,界面目录上限仍由 `catalog_limit` 控制。不支持 NAS 未暴露或账号无权访问的相册。
|
||||
- 旧 `local` 来源保留挂载读取方式,不自动变成 API 来源。新建 / 编辑来源时可明确选择 API 类型。
|
||||
- 文件系统 `local` 来源已停用;旧配置需显式转换为 Photos API 来源。
|
||||
|
||||
## 安全与缓存
|
||||
|
||||
@@ -22,14 +22,11 @@
|
||||
|
||||
## Ubuntu 候选配置
|
||||
|
||||
正式代码在 `/home/developer/Documents/Projects/pigway-frame-flow` 的 `codex/synology-photos-api` 分支开发,不能以迁移临时解包目录作为开发仓库。
|
||||
在正式 Git 仓库开发;不要把迁移临时解包目录作为开发仓库。
|
||||
|
||||
候选文件:`deploy/compose.ubuntu-photos-api.yml`。外部 `192.0.2.10:81`,内部 `18787`,UID/GID `10333:10333`,数据和配置固定为:
|
||||
候选文件:`deploy/compose.ubuntu-photos-api.yml`。内部端口 `18787`,UID/GID `10333:10333`。通过私有环境变量设置 `FRAME_FLOW_APP_DIR`(已有应用目录)、`FRAME_FLOW_ENV_FILE`(已有私有配置文件)和可选 `FRAME_FLOW_BIND`(默认回环地址),不在仓库保存真实主机地址或部署目录。
|
||||
|
||||
- `/srv/pigway/apps/pigway-frame-flow/data:/data`
|
||||
- `/srv/pigway/apps/pigway-frame-flow/config:/config`
|
||||
|
||||
无 `/photos` 绑定。`create_host_path: false` 防止在 T7 未挂载时悄悄创建目录;但仍需在启动前检查 `findmnt -T /srv/pigway` 的实际源设备。
|
||||
无 `/photos` 绑定。`create_host_path: false` 防止在 T7 未挂载时悄悄创建目录;但仍需在启动前检查 `findmnt -T "$FRAME_FLOW_APP_DIR"` 的实际源设备。
|
||||
|
||||
`.env` 需从已确认的配置安全迁移,保留 Photos URL、证书 pin 与已有 Frame Flow / 相框鉴权;不要把密钥写入仓库。模板默认 `KOKONNA_WRITE_ENABLED=false`。创建 `data/tmp`,属主保持 10333:10333,`TMPDIR=/data/tmp`。Docker data-root 和 containerd root 也必须在 T7,不能仅检查 bind mount。
|
||||
|
||||
@@ -60,6 +57,6 @@ FRAME_FLOW_IMAGE='<已验证的镜像标签>' docker compose -f deploy/compose.u
|
||||
|
||||
## NFS 授权判断
|
||||
|
||||
API 来源的共享 / 个人空间取图已经可以完全不依赖 NFS,因此这条新取图链路不需要为 `192.0.2.10` 保留 DSM `photo` NFS 授权。但目前旧来源和待迁移配置没有全部切换验收,不能据此宣称迁移完成或旧本地来源已可脱离挂载。确认没有其他已批准用途后,可以单独授权删除该特定 NFS 规则;本次没有删除。
|
||||
API 来源的共享 / 个人空间取图已经可以完全不依赖 NFS,因此这条新取图链路不需要为旧客户端保留 DSM `photo` NFS 授权。但目前旧来源和待迁移配置没有全部切换验收,不能据此宣称迁移完成或旧本地来源已可脱离挂载。确认没有其他已批准用途后,可以单独授权删除该特定 NFS 规则;本次没有删除。
|
||||
|
||||
实际版本、镜像标识、通过 / 失败 / 未执行项见本任务验证记录。
|
||||
实际部署回执和照片库统计应私下保管,不提交公共仓库。
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# UI 一致性与图片加载检查
|
||||
|
||||
页面使用既有真实元数据创建控件和图片区域,不使用页面骨架或虚构图片卡片。
|
||||
搜索、排序、卡片操作与写入保护在图片请求前初始化;已知图库数据可先显示,
|
||||
后台刷新元数据。首页图片独立加载,不阻塞后续页面内容。
|
||||
|
||||
图片共用局部加载函数:旋转指示器仅居中显示于所属图片容器,网络请求与解码完成后
|
||||
立即显示图片,失败后显示中英文提示。没有固定等待或动画最短展示时长。
|
||||
图库仅请求可见区域及附近图片。浏览器禁缓存不会清除服务端缩略图缓存,
|
||||
快速图片可能在第一帧绘制前完成,动画不一定肉眼可见。
|
||||
|
||||
手动拼版先创建全部真实区域与坐标/拖动交互,再并行请求图片。
|
||||
每张图片独立完成或失败,持久的区域容器保持加载前后几何位置与坐标索引稳定。
|
||||
过期布局事件不修改新的规划。
|
||||
|
||||
移动端导航单行横向滚动,当前入口自动进入可见区域;低高度横屏侧栏可滚动。
|
||||
页头在窄屏分行,并为底部安全区域留白。可见控件保留 44 像素触控高度,
|
||||
当前导航使用 aria-current,扩展表单标签关联相应控件。
|
||||
表格与手机导航允许容器内滚动,不应导致整页水平溢出。
|
||||
|
||||
## 自动检查
|
||||
|
||||
使用隔离 Mock 服务、临时数据与测试账号,禁止对生产执行可写的 Mock 脚本。
|
||||
Photos 授权使用拦截测试响应;不需要真实 NAS 凭据。
|
||||
|
||||
```sh
|
||||
FRAME_FLOW_URL=http://127.0.0.1:6005 \
|
||||
FRAME_FLOW_ENV_FILE=/path/to/mock.env \
|
||||
FRAME_FLOW_TEST_USERNAME=test-ui \
|
||||
FRAME_FLOW_TEST_PASSWORD=test-only-password \
|
||||
node scripts/ui-layout-audit.cjs
|
||||
```
|
||||
|
||||
需要已安装的 Playwright;可通过 NODE_PATH 指定运行时。
|
||||
FRAME_FLOW_VIEWPORTS 接受包含 width/height 的 JSON 数组;
|
||||
FRAME_FLOW_SCREENSHOT_DIR 可选,用于保存仅含测试数据的截图。
|
||||
|
||||
| 脚本 | 检查范围 | 本次结果 |
|
||||
| --- | --- | --- |
|
||||
| ui-layout-audit.cjs | 11 页面、本地/云端图库、6 类弹窗、展开手动拼版;溢出、控件间距、触控高度、弹窗边界 | 16 组尺寸/语言/主题组合,305 项通过 |
|
||||
| ui-image-loading.cjs | 三种宽度、明暗主题;局部居中、成功解码、错误提示与无障碍状态 | 45 项通过 |
|
||||
| ui-progressive-loading.cjs | 阻塞初始化/元数据/图片/任务筛选;首次无缓存时控件先就绪、输入保留、慢请求切换、列表失败、无骨架 | 18 项通过 |
|
||||
| ui-studio-image-loading.cjs | 桌面/手机、明暗主题;并行请求、独立完成/失败、坐标与拖动、稳定区域 | 31 项通过 |
|
||||
|
||||
上述检查无页面脚本异常。生产静态文件与构建文件哈希比对、health/ready 检查通过;
|
||||
生产浏览器交互验证使用拦截的账号、规划及素材响应,对真实服务仅 GET,
|
||||
不代表真实账号密码登录、AI 生成、iOS/Android 实机或软键盘测试。
|
||||
|
||||
连接中心内容较长,后续可按用户任务分组;模板与排版导航图标仍有重复。
|
||||
|
||||
|
||||
## 首次未缓存图库的初始化
|
||||
|
||||
目的地标签、搜索、排序、刷新和上传控件必须先于列表接口完成。
|
||||
仅已有列表的缓存分支先渲染还不够;无缓存时也应创建真实控件与空的列表容器。
|
||||
元数据返回前不推测图片数量,不显示真实空图库提示,不生成骨架卡片。
|
||||
列表失败保留页面控件;慢云端请求期间切换 NAS,迟到响应不得覆盖新页面。
|
||||
列表返回后保留之前输入的搜索词和排序。任务筛选在同一导航内复用请求,仍不阻塞图库。
|
||||
|
||||
启动使用 Settings API 中不调用厂商接口的 device_catalog;完整设备状态作为后台更新,
|
||||
迟到响应在退出账号后不应用。ui-gallery-bootstrap.cjs 同时阻塞设备状态与图片列表,
|
||||
验证真实控件仍先显示,列表返回后保留查询和排序,2 项通过。
|
||||
@@ -1,83 +0,0 @@
|
||||
# Frame Flow 项目审计 — 2026-10-05
|
||||
|
||||
审计基线:794a7e1485cc9046be3e77f6557c75740305ac47。
|
||||
范围:本地源码、配置、Mock 测试与依赖审计;不包含本次 NAS 状态验证、真实账号登录或真实设备写入。未修改业务代码。
|
||||
|
||||
## 发现
|
||||
|
||||
### P1:队列扫描上限导致后续高优先级告警饥饿
|
||||
|
||||
`display/service.py:222` 只取按创建时间排序的前 50 个 QUEUED 操作,再跳过
|
||||
被 override 阻挡的低优先级请求。如果这 50 项全被阻挡,第 51 项更高优先级
|
||||
告警每一轮都无法被扫描到;其他设备请求与本地渲染也可能受影响。
|
||||
|
||||
Mock 复现:激活 priority=50 的一小时 override,排入 50 个 priority=10
|
||||
请求,再排 priority=90 请求,调用 process_one 后后者仍为 QUEUED。
|
||||
建议先筛选可执行请求,或分页扫描直到找到可执行操作;增加跨设备与优先级回归测试。
|
||||
|
||||
### P2:会话失效未更新连接状态,并误清第一方登录
|
||||
|
||||
`integrations/kokonna_official/rpc.py:44-51` 在官方返回“请重新登陆”时抛出
|
||||
UNAUTHORIZED,但没有使官方 session 失效。`connection.py:104-113` 只依据
|
||||
本地 token 非空,继续显示 authorized=true。`web/app.js:17` 将任意 API 401
|
||||
都当作 Frame Flow 登录失效并执行 forgetLogin,包括外部官方会话过期。
|
||||
|
||||
Mock 复现:RPC 返回 HTTP 500 / error -1 / 请重新登陆后,authorized 仍 true。
|
||||
建议区分第一方认证与外部账号过期,持久化失效状态,保留 Frame Flow 登录,
|
||||
在官方区域提供重新连接提示。
|
||||
|
||||
### P2:损坏的可选官方会话文件阻止整个服务启动
|
||||
|
||||
`connection.py:44-51` 在构造函数中直接解析 JSON 并索引字段,没有异常隔离。
|
||||
DeviceRegistry 在应用启动时构建该对象。损坏 JSON、缺字段或文件不可读会
|
||||
使可选功能故障升级为全服务启动失败,影响 API Key、UI 和 Scheduler。
|
||||
|
||||
Mock 复现:官方 secret 文件内容为 `{broken`,create_app 抛 JSONDecodeError。
|
||||
建议隔离并禁用该连接,记录不含文件内容的诊断;其他核心功能继续启动。
|
||||
|
||||
### P2:持久化失败导致断开状态与重启状态不同
|
||||
|
||||
`connection.py:122-127,146-157` 先修改内存,再保存。磁盘满、权限错误等
|
||||
导致保存失败时没有回滚。尤其 disconnect 已清内存,而磁盘仍保留旧 token,
|
||||
重启后又恢复旧连接;虽然原请求返回错误,随后状态查询却会显示未连接。
|
||||
|
||||
Mock 复现:让 _save 抛 OSError;当前 authorized=false,重新构造连接后 true。
|
||||
建议先原子持久化候选状态,再提交内存状态;失败时保持一致并提供明确错误。
|
||||
|
||||
### P2:Request ID 脱敏遗漏官方会话
|
||||
|
||||
`api/app.py:148-150` 仅检查 API Key、Frame Flow Token、registry.keys,
|
||||
没有检查用于官方会话的 settings.additional_secrets。符合 Request ID 长度和
|
||||
字符限制的会话值若被客户端误用为 X-Request-ID,可被响应头和错误模型原样回显。
|
||||
本问题不表示未授权者能凭空取到 token,而是违反已有 secret 不回显约束。
|
||||
|
||||
Mock 复现:完成模拟官方连接后,将 synthetic session `mock-session` 作为
|
||||
X-Request-ID 发给 /health,响应原样返回该值。建议统一使用完整 secret 集合。
|
||||
|
||||
## 验证
|
||||
|
||||
- pytest:154 passed,0 failed,0 warning。
|
||||
- Ruff:通过(应用和 tests)。
|
||||
- mypy:36 个源码文件通过。
|
||||
- pip-audit(requirements.lock):No known vulnerabilities found。
|
||||
- 五项专项 Mock 复现均确认上述行为,不调用真实 KoKonna。
|
||||
- 没有因发现问题而启用官方写方法;严格 RPC allowlist 仍阻止未启用方法。
|
||||
|
||||
## 边界与下一步
|
||||
|
||||
测试全绿不代表无缺陷:现有套件没有覆盖以上失败场景。
|
||||
本轮没有修复或部署;建议先修复队列饥饿,再统一外部账号状态与第一方认证,
|
||||
然后补齐会话文件容错、持久化事务一致性和 Request ID 脱敏测试。
|
||||
依赖审计仅表示本次查询未发现已知漏洞,不是全部依赖安全的证明。
|
||||
|
||||
## 修复与复验
|
||||
|
||||
上述五项已修复:队列使用 keyset 分页查找可执行操作;官方账号失效使用
|
||||
独立 `OFFICIAL_AUTH_REQUIRED` 错误码并清除会话,前端保留第一方登录且提供
|
||||
重新连接入口;可选 secret 解析失败不再阻止核心启动;持久化候选状态成功后
|
||||
才提交内存变更;Request ID 检查涵盖官方会话凭据。
|
||||
|
||||
复验:164 passed、0 warning(新增 10 项回归测试);Ruff 通过;mypy 36 文件
|
||||
通过;113 项布局检查通过;官方过期/第一方登录隔离浏览器检查通过。
|
||||
已知本地凭据对照历史扫描通过(278 个 blob);该扫描不等同于发现所有未知
|
||||
secret 的通用审计。本轮不部署 NAS,不执行真实设备写接口。
|
||||
@@ -1,27 +0,0 @@
|
||||
# 界面表达审计(2026-10-16)
|
||||
|
||||
范围:11 个主页面、主要弹窗、中英文文案、README 和照片数据源说明;对照当前 API、清理与来源记录逻辑。不是厂商接口能力或线上服务状态的全面审计。
|
||||
|
||||
| 位置 | 原问题 | 修正 |
|
||||
| --- | --- | --- |
|
||||
| 图库 | 排序使用“适配方式” | 独立“排序方式”标签 |
|
||||
| 轮播 / 播放列表 | 播放顺序使用图片适配标签;间隔重复单位 | 使用“播放顺序”“切换间隔” |
|
||||
| 总览 | 天气缺译,显示原始 weather | 补齐中英文 |
|
||||
| 总览 | 图片 ID 标成图片;型号来源标成型号 | 独立标注 ID 与信息来源 |
|
||||
| 同步状态 | “已同步”容易被视为实物刷新确认 | 标明云端确认 |
|
||||
| 图片来源 | 无记录却断言非本系统生成 | 只描述未找到可用记录 |
|
||||
| 模板编辑 | 非模板图片复用不准确的来源错误 | 明确无可编辑的模板来源 |
|
||||
| 删除 | 本地当前图也提示画框切图 | 按本地、云端、当前云端副本区分确认文案 |
|
||||
| 星标 / 多选 | 自动清理保护及隐藏勾选项范围不明确 | 加入服务端保留、手动删除、当前页选择范围说明 |
|
||||
| 上传 | 固定尺寸提示可能与输出不一致 | 提示以生成预览为准 |
|
||||
| 显示选项 | 不勾选恢复时仍显示时长,容易误解 | 说明时长与优先级的临时显示用途 |
|
||||
| 数据源 | 未配置与无匹配照片共用空态 | 拆分两种空态 |
|
||||
| 数据源 | 位置标签、凭据提示仅描述 WebDAV | 补充网盘目录 ID、百度路径、OAuth 凭据 |
|
||||
| 人物筛选 | 仅描述共享空间,但实际可选个人空间 | 按所选空间说明访问权限 |
|
||||
| AI / MQTT | AI 密钥称为设备密钥;连接状态缺译 | 独立 API 密钥标签,补齐连接状态 |
|
||||
| 错误页 | 所有失败都归因设备云端 | 改成通用页面加载失败,保留具体原因 |
|
||||
| 文档 | 照片数据源旧入口及账号独立性描述过时 | 对齐独立导航与共享 Photos 登录 |
|
||||
|
||||
验证:305 项布局检查覆盖 320、390、844 横屏、1440 宽度,中英文与明暗主题,11 页及主要弹窗;8 组星标 / 反选回归;新增文案检查覆盖静态翻译完整性、排序 / 播放 / 型号标签、本地删除和星标作用范围。测试使用隔离 Mock,无真实设备写入、照片删除、AI 调用或部署。
|
||||
|
||||
此前未提交的反选、总览图片放大及共享图片查看器改动保留。此报告不代表已提交或已部署。
|
||||
@@ -1,4 +1,11 @@
|
||||
# Gitea Actions production CD v1
|
||||
# Gitea Actions production CD v1 (historical)
|
||||
|
||||
> This NAS production deployment procedure is retired. Current Gitea Actions
|
||||
> only runs isolated verification; production uses the maintainer-specific
|
||||
> Ubuntu workflow in `scripts/server-release.py`. For a new installation use
|
||||
> the Docker Compose instructions in README.md. Filesystem/NFS photo sources
|
||||
> are disabled; use Synology Photos API or WebDAV. The configuration below is
|
||||
> retained for historical reference and must not be used for new deployments.
|
||||
|
||||
## Audit and adoption boundary
|
||||
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "PIGWay Frame Flow API",
|
||||
"version": "0.1.9"
|
||||
"version": "0.1.19"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||
@@ -15227,7 +15227,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.9"
|
||||
"default": "0.1.19"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -17490,7 +17490,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.9"
|
||||
"default": "0.1.19"
|
||||
},
|
||||
"api_version": {
|
||||
"type": "string",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# v0.1.18
|
||||
|
||||
- Synology Photos 列表显式请求照片方向信息;按 EXIF 旋转后的宽高筛选横向、竖向和自动方向,避免存储像素为横向的竖拍照片误入横图来源。
|
||||
- 渲染前复核实际取得图片的 EXIF 方向;若元数据或缓存预览与来源方向不符,则跳过该候选。显式选择方向不符的照片时返回明确错误。
|
||||
- 保持既有水印和铺满布局,不修改 NAS 原照片、来源设置或现有图库;修复不自动重新生成、上传或切换已显示图片。
|
||||
- 源码及镜像保留 LICENSE、NOTICE;公开验证只使用合成照片和通用示例,私有部署参数与回执保存在仓库之外。
|
||||
@@ -10,10 +10,10 @@
|
||||
|
||||
正式发布使用 scripts/server-release.py:仅在既有 Ubuntu Server 环境运行,从已提交源码包构建,校验依赖锁、源码哈希、T7 UUID、容器安全约束和真实写入设置;备份 SQLite、配置及 Compose 后切换镜像,失败恢复旧 Compose,不自动回写旧数据库。它不执行上传、显示、删除或恢复图片等设备操作。成功后记录正式提交号、保存源码包及审计回执,删除本次暂存目录;白名单内的 Frame 旧迁移输入归档到本次 recovery,清除无恢复引用的临时镜像别名,共享 Console 资料不动。旧镜像及历史恢复资料保留供回滚,不执行全局 prune。
|
||||
|
||||
脚本要求由 git archive 生成的源码放在 /srv/pigway/migration-inbox 的独立子目录;目录内同时提供 RELEASE_COMMIT、RELEASE_HASHES.json 和原始 source.tar.gz。哈希清单涵盖源码与源码包,不含自身。执行 sudo python3 <暂存目录>/scripts/server-release.py;成功后读取 /srv/pigway/apps/pigway-frame-flow/release-verification.json 并独立检查线上接口。
|
||||
脚本要求由 git archive 生成的源码放在 <FRAME_FLOW_ROOT>/migration-inbox 的独立子目录;目录内同时提供 RELEASE_COMMIT、RELEASE_HASHES.json 和原始 source.tar.gz。哈希清单涵盖源码与源码包,不含自身。执行 sudo python3 <暂存目录>/scripts/server-release.py;成功后读取 <FRAME_FLOW_ROOT>/apps/pigway-frame-flow/release-verification.json 并独立检查线上接口。
|
||||
|
||||
## 验收范围
|
||||
|
||||
完整后端测试、静态检查与桌面/平板/手机的中英文浏览器回归纳入发布检查。线上历史补丁已核验,真实写入保持开启;本轮正式发布不重复真实设备写入测试。隔离浏览器验证与真实设备写入证据分别记录于 docs/verification/2026-10-17-final-audit.md。
|
||||
完整后端测试、静态检查与桌面/平板/手机的中英文浏览器回归纳入发布检查。线上历史补丁已核验,真实写入保持开启;本轮正式发布不重复真实设备写入测试。隔离浏览器验证与真实设备写入证据由维护者私下保管。
|
||||
|
||||
局域网 HTTPS、独立加密备份及恢复演练仍是后续工作;T7 recovery 不属于独立备份。没有执行 USB 断连、服务器重启或压力测试。
|
||||
|
||||
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
|
||||
app = FastAPI(
|
||||
title="PIGWay Frame Flow API",
|
||||
docs_url=None,
|
||||
version="0.1.17",
|
||||
version="0.1.19",
|
||||
lifespan=lifespan,
|
||||
responses=error_responses,
|
||||
)
|
||||
@@ -1040,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
|
||||
supports_upload=True,
|
||||
),
|
||||
],
|
||||
version="0.1.17",
|
||||
version="0.1.19",
|
||||
mock=settings.mock,
|
||||
write_enabled=settings.write_enabled,
|
||||
api_key_configured=bool(settings.api_key)
|
||||
|
||||
@@ -366,7 +366,7 @@ class SettingsView(BaseModel):
|
||||
|
||||
class Health(BaseModel):
|
||||
status: str
|
||||
version: str = "0.1.17"
|
||||
version: str = "0.1.19"
|
||||
|
||||
|
||||
class Readiness(BaseModel):
|
||||
@@ -397,7 +397,7 @@ class RestoreRequest(StrictModel):
|
||||
|
||||
class Version(BaseModel):
|
||||
product: str = "PIGWay Frame Flow"
|
||||
version: str = "0.1.17"
|
||||
version: str = "0.1.19"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@@ -481,9 +481,7 @@ class PhotoSources:
|
||||
continue
|
||||
size = row.get("additional", {}).get("resolution", {})
|
||||
width, height = size.get("width", 0), size.get("height", 0)
|
||||
rotation = row.get("additional", {}).get("orientation", 1)
|
||||
if rotation in (5, 6, 7, 8):
|
||||
width, height = height, width
|
||||
# Photos resolution is already oriented; EXIF must not swap it twice.
|
||||
if not self.resolution_matches(source, width, height):
|
||||
continue
|
||||
direction = orientation(width, height)
|
||||
@@ -662,13 +660,27 @@ class PhotoSources:
|
||||
data = self.assets.fetch_url(item["id"], headers=self.headers(source))
|
||||
if len(data) > self.settings.max_bytes:
|
||||
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit")
|
||||
if source.minimum_short_edge or source.minimum_long_edge:
|
||||
if source.orientation or source.minimum_short_edge or source.minimum_long_edge:
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(BytesIO(data)) as image:
|
||||
if not self.resolution_matches(source, *image.size):
|
||||
width, height = image.size
|
||||
if image.getexif().get(274, 1) in (5, 6, 7, 8):
|
||||
width, height = height, width
|
||||
wanted = (
|
||||
orientation(*target_size)
|
||||
if source.orientation == "auto"
|
||||
else source.orientation
|
||||
)
|
||||
# Validate the fetched rendition before a crop can hide a mismatch.
|
||||
if wanted and orientation(width, height) != wanted:
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photo direction does not match the source orientation",
|
||||
)
|
||||
if not self.resolution_matches(source, width, height):
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST,
|
||||
"Photo is below the configured minimum resolution",
|
||||
|
||||
@@ -299,7 +299,9 @@ class SynologyPhotos:
|
||||
rows = self.pages(
|
||||
prefix + ".Browse.Item",
|
||||
7,
|
||||
additional=json.dumps(["folder", "thumbnail", "resolution"]),
|
||||
additional=json.dumps(
|
||||
["folder", "thumbnail", "resolution", "orientation"]
|
||||
),
|
||||
**({"person_id": person} if person is not None else {}),
|
||||
)
|
||||
groups.append(
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<!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>
|
||||
<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.17</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=photo-quality-v0117"></script><script src="/ui/features.js?v=photo-quality-v0117"></script><script src="/ui/extensions.js?v=photo-quality-v0117"></script><script src="/ui/app.js?v=photo-quality-v0117"></script></body></html>
|
||||
<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.19</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=photo-quality-v0117"></script><script src="/ui/features.js?v=photo-quality-v0117"></script><script src="/ui/extensions.js?v=photo-quality-v0117"></script><script src="/ui/app.js?v=photo-quality-v0117"></script></body></html>
|
||||
|
||||
+4
-2
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.17"
|
||||
requires-python = ">=3.12"
|
||||
version = "0.1.19"
|
||||
requires-python = ">=3.14,<3.15"
|
||||
dependencies = ["paho-mqtt==2.1.0", "python-dateutil==2.9.0.post0", "fastapi==0.142.2", "uvicorn==0.54.0", "pillow==12.3.0", "requests==2.34.2", "croniter==6.2.4", "python-multipart==0.0.32", "pyyaml==6.0.3"]
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "types-requests==2.33.0.20260906", "types-PyYAML==6.0.12.20260906", "types-croniter==6.2.4.20260711", "ruff==0.16.9"]
|
||||
@@ -9,6 +9,8 @@ test = ["pytest==9.1.1", "httpx2==2.13.1", "mypy==2.3.1", "pip-audit==2.10.1", "
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
[tool.ruff]
|
||||
# Preserve existing syntax/formatting independently of runtime support.
|
||||
target-version = "py312"
|
||||
exclude = ["vendor", ".venv"]
|
||||
[build-system]
|
||||
requires = ["setuptools>=75"]
|
||||
|
||||
@@ -13,10 +13,11 @@ import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
APP = Path("/srv/pigway/apps/pigway-frame-flow")
|
||||
RECOVERY = Path("/srv/pigway/recovery/frame-flow")
|
||||
ROOT = Path(os.getenv("FRAME_FLOW_ROOT", "/srv/frameflow"))
|
||||
APP = ROOT / "apps/pigway-frame-flow"
|
||||
RECOVERY = ROOT / "recovery/frame-flow"
|
||||
CONTAINER = "pigway-frame-flow"
|
||||
ORIGIN = "http://192.0.2.10:81"
|
||||
ORIGIN = os.getenv("FRAME_FLOW_ORIGIN", "http://127.0.0.1:6005").rstrip("/")
|
||||
|
||||
|
||||
def run(*args):
|
||||
@@ -112,11 +113,14 @@ def verify_runtime(commit):
|
||||
|
||||
def main():
|
||||
assert os.geteuid() == 0, "Run with sudo on the Ubuntu Server"
|
||||
expected_uuid = os.environ.get("FRAME_FLOW_MOUNT_UUID", "")
|
||||
assert expected_uuid, "Set FRAME_FLOW_MOUNT_UUID to the verified storage UUID"
|
||||
assert os.environ.get("FRAME_FLOW_ORIGIN"), (
|
||||
"Set FRAME_FLOW_ORIGIN to the verified listener"
|
||||
)
|
||||
os.umask(0o077)
|
||||
run("/usr/local/libexec/pigway-guard-mount")
|
||||
assert run("findmnt", "-n", "-o", "UUID", "/srv/pigway") == (
|
||||
"00000000-0000-4000-8000-000000000001"
|
||||
)
|
||||
assert run("findmnt", "-n", "-o", "UUID", str(ROOT)) == expected_uuid
|
||||
with (RECOVERY / ".release.lock").open("a") as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
release(lock)
|
||||
@@ -124,7 +128,7 @@ def main():
|
||||
|
||||
def release(lock):
|
||||
ctx = Path(__file__).resolve().parent.parent
|
||||
assert ctx.parent == Path("/srv/pigway/migration-inbox")
|
||||
assert ctx.parent == ROOT / "migration-inbox"
|
||||
commit = (ctx / "RELEASE_COMMIT").read_text().strip()
|
||||
assert re.fullmatch("[0-9a-f]{40}", commit)
|
||||
hashes = json.loads((ctx / "RELEASE_HASHES.json").read_text())
|
||||
@@ -151,7 +155,7 @@ def release(lock):
|
||||
assert dst.execute("pragma quick_check").fetchone()[0] == "ok"
|
||||
base = "pigway-frame-flow:release-base-" + commit[:12]
|
||||
run("docker", "image", "tag", before["Image"], base)
|
||||
image = "pigway-frame-flow:0.1.17-" + commit[:12]
|
||||
image = "pigway-frame-flow:0.1.19-" + commit[:12]
|
||||
try:
|
||||
run(
|
||||
"docker",
|
||||
@@ -193,7 +197,7 @@ def release(lock):
|
||||
n: h
|
||||
for n, h in hashes.items()
|
||||
if n.startswith(("pigway_frame_flow/", "vendor/"))
|
||||
or n == "requirements.lock"
|
||||
or n in {"requirements.lock", "LICENSE", "NOTICE.md"}
|
||||
}
|
||||
code = 'import hashlib,json,pathlib,sys; h=json.load(sys.stdin); assert all(hashlib.sha256((pathlib.Path("/app")/n).read_bytes()).hexdigest()==v for n,v in h.items())'
|
||||
subprocess.run(
|
||||
|
||||
@@ -34,3 +34,22 @@ def test_actions_downloads_do_not_depend_on_personal_server():
|
||||
r"https://github\.com/actions/(checkout|setup-python)@[0-9a-f]{40}",
|
||||
action,
|
||||
)
|
||||
|
||||
|
||||
def test_community_checks_are_isolated_from_private_runners():
|
||||
import yaml
|
||||
|
||||
private = yaml.safe_load(Path(".github/workflows/verify.yml").read_text())
|
||||
public = yaml.safe_load(Path(".github/workflows/community.yml").read_text())
|
||||
assert (
|
||||
"github.server_url != 'https://github.com'" in private["jobs"]["verify"]["if"]
|
||||
)
|
||||
job = public["jobs"]["verify"]
|
||||
assert job["if"] == "github.server_url == 'https://github.com'"
|
||||
assert job["runs-on"] == "ubuntu-latest"
|
||||
assert public["permissions"] == {"contents": "read"}
|
||||
workflow = Path(".github/workflows/community.yml").read_text()
|
||||
assert "secrets." not in workflow
|
||||
assert "production-deploy" not in workflow
|
||||
assert "pull_request_target" not in workflow
|
||||
assert "persist-credentials: false" in workflow
|
||||
|
||||
@@ -63,7 +63,7 @@ def test_release_readiness_retries_on_actual_listener(release_tool, monkeypatch)
|
||||
|
||||
def listener(url, timeout):
|
||||
calls.append(url)
|
||||
if url != "http://192.0.2.10:81/ready" or len(calls) == 1:
|
||||
if url != release_tool.ORIGIN + "/ready" or len(calls) == 1:
|
||||
raise urllib.error.URLError("Connection refused during restart")
|
||||
return io.BytesIO(b'{"status":"ready"}')
|
||||
|
||||
@@ -71,3 +71,24 @@ def test_release_readiness_retries_on_actual_listener(release_tool, monkeypatch)
|
||||
monkeypatch.setattr(release_tool.time, "sleep", lambda _: None)
|
||||
release_tool.wait_ready()
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_release_requires_private_storage_identity_before_host_actions(
|
||||
release_tool, monkeypatch
|
||||
):
|
||||
calls = []
|
||||
monkeypatch.setattr(release_tool.os, "geteuid", lambda: 0)
|
||||
monkeypatch.setattr(release_tool, "run", lambda *args: calls.append(args))
|
||||
monkeypatch.delenv("FRAME_FLOW_MOUNT_UUID", raising=False)
|
||||
with pytest.raises(AssertionError, match="FRAME_FLOW_MOUNT_UUID"):
|
||||
release_tool.main()
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_release_rejects_wrong_storage_uuid(release_tool, monkeypatch):
|
||||
monkeypatch.setattr(release_tool.os, "geteuid", lambda: 0)
|
||||
monkeypatch.setenv("FRAME_FLOW_MOUNT_UUID", "expected-storage-uuid")
|
||||
monkeypatch.setenv("FRAME_FLOW_ORIGIN", release_tool.ORIGIN)
|
||||
monkeypatch.setattr(release_tool, "run", lambda *args: "different-storage-uuid")
|
||||
with pytest.raises(AssertionError):
|
||||
release_tool.main()
|
||||
|
||||
@@ -69,12 +69,12 @@ def test_people_pagination_does_not_stop_at_500(settings, monkeypatch):
|
||||
return {
|
||||
"list": [
|
||||
{"id": i}
|
||||
for i in range(params["offset"], min(1334, params["offset"] + 500))
|
||||
for i in range(params["offset"], min(1203, params["offset"] + 500))
|
||||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(p, "request", request)
|
||||
assert len(p.people()) == 1334
|
||||
assert len(p.people()) == 1203
|
||||
assert calls == [0, 500, 1000]
|
||||
|
||||
|
||||
@@ -401,3 +401,79 @@ def test_explicit_offline_conversion_preserves_task_reference(service, settings)
|
||||
and after.person_match == "all"
|
||||
and after.location == "family"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("space", ["shared", "personal"])
|
||||
@pytest.mark.parametrize("exif", [5, 6, 7, 8])
|
||||
def test_api_filters_already_oriented_resolution_without_rotating_twice(
|
||||
service, settings, monkeypatch, space, exif
|
||||
):
|
||||
session(settings)
|
||||
photos = service.photo_sources
|
||||
requested = []
|
||||
|
||||
def pages(api, version, **params):
|
||||
requested[:] = json.loads(params["additional"])
|
||||
item = row(1)
|
||||
# Photos returns display dimensions even when EXIF retains a rotation.
|
||||
item["additional"]["resolution"] = {"width": 16, "height": 24}
|
||||
if "orientation" in requested:
|
||||
item["additional"]["orientation"] = exif
|
||||
return [item]
|
||||
|
||||
monkeypatch.setattr(photos.synology, "pages", pages)
|
||||
photos.save(
|
||||
"direction",
|
||||
PhotoSourceInput(
|
||||
name="Fixture",
|
||||
kind="synology-photos",
|
||||
location=".",
|
||||
people_space=space,
|
||||
orientation="landscape",
|
||||
),
|
||||
)
|
||||
assert photos.catalog("direction") == []
|
||||
photos.save(
|
||||
"direction",
|
||||
photos.get("direction").model_copy(update={"orientation": "portrait"}),
|
||||
)
|
||||
items = photos.catalog("direction")
|
||||
assert "orientation" in requested
|
||||
assert [(x["width"], x["height"]) for x in items] == [(16, 24)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rotation", [1, 8])
|
||||
def test_fetched_photo_direction_is_checked_before_cropping(
|
||||
service, settings, monkeypatch, rotation
|
||||
):
|
||||
session(settings)
|
||||
photos = service.photo_sources
|
||||
monkeypatch.setattr(photos.synology, "items", lambda *a, **k: [row(1)])
|
||||
out = io.BytesIO()
|
||||
exif = Image.Exif()
|
||||
exif[274] = rotation
|
||||
size = (16, 24) if rotation == 1 else (24, 16)
|
||||
Image.new("RGB", size, "blue").save(out, "JPEG", exif=exif)
|
||||
monkeypatch.setattr(photos.synology, "download", lambda *a, **k: out.getvalue())
|
||||
photos.save(
|
||||
"direction",
|
||||
PhotoSourceInput(
|
||||
name="Fixture",
|
||||
kind="synology-photos",
|
||||
location=".",
|
||||
orientation="landscape",
|
||||
),
|
||||
)
|
||||
with pytest.raises(FlowError, match="direction"):
|
||||
photos.read("direction", "1")
|
||||
|
||||
# A correctly selected portrait remains readable, including EXIF-only rotation.
|
||||
item = row(1)
|
||||
item["additional"]["orientation"] = 8
|
||||
item["additional"]["resolution"] = {"width": 16, "height": 24}
|
||||
monkeypatch.setattr(photos.synology, "items", lambda *a, **k: [item])
|
||||
photos.save(
|
||||
"direction",
|
||||
photos.get("direction").model_copy(update={"orientation": "portrait"}),
|
||||
)
|
||||
assert photos.read("direction", "1")[0] == out.getvalue()
|
||||
|
||||
Reference in New Issue
Block a user