Compare commits

...
3 Commits
Author SHA1 Message Date
渡口浪人 51d76a196e fix: honor Synology photo orientation before landscape rendering
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m25s
2026-10-18 17:43:25 +08:00
渡口浪人 038b858eb7 fix: remove private deployment metadata and retain release licenses
Community checks / verify (push) Skipped
Verify Frame Flow / verify (push) Successful in 5m8s
2026-10-18 16:14:56 +08:00
渡口浪人 4e9eccd809 fix: complete open-source delivery and isolate community CI 2026-10-18 15:56:16 +08:00
27 changed files with 278 additions and 52 deletions
+1 -6
View File
@@ -17,15 +17,10 @@ IMAGE_URL_ALLOWED_HOSTS=
BUILD_COMMIT=development BUILD_COMMIT=development
BUILD_TIME=unknown BUILD_TIME=unknown
# Optional read-only NAS photo mount; use deploy/docker-compose.photos.yml. # Filesystem/NFS photo sources are retired. Use Synology Photos API or WebDAV.
# NAS_PHOTOS_DIR=/volume2/photo
# PHOTO_ROOT=/photos
# Optional WebDAV credential reference; prefer a secret file under /config. # Optional WebDAV credential reference; prefer a secret file under /config.
# FRAME_FLOW_SOURCE_CLOUD_FILE=/config/webdav-cloud.secret # 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. # 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). # Login in the photo-source editor; only the session is saved in /config (mode 0600).
SYNOLOGY_PHOTOS_URL= SYNOLOGY_PHOTOS_URL=
+26
View File
@@ -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
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs: jobs:
verify: verify:
# The NAS Docker runner must never execute code from external forks. # 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 runs-on: ubuntu-latest
env: env:
DOCKER_PIP_INDEX_URL: ${{ vars.DOCKER_PIP_INDEX_URL }} DOCKER_PIP_INDEX_URL: ${{ vars.DOCKER_PIP_INDEX_URL }}
+3
View File
@@ -24,3 +24,6 @@ build-cache/wheels/*
# Private browser captures and platform metadata # Private browser captures and platform metadata
docs/screenshots/ docs/screenshots/
.DS_Store .DS_Store
# Private deployment receipts belong outside public source control.
docs/verification/
+19
View File
@@ -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.
+1
View File
@@ -37,6 +37,7 @@ RUN --mount=type=cache,id=frameflow-pip-py314-${TARGETARCH},target=/root/.cache/
else python /build-tools/install.py --locked; fi else python /build-tools/install.py --locked; fi
COPY pigway_frame_flow ./pigway_frame_flow COPY pigway_frame_flow ./pigway_frame_flow
COPY vendor ./vendor COPY vendor ./vendor
COPY LICENSE NOTICE.md ./
# Publish only the cleaned filesystem, without retaining build-stage layers. # Publish only the cleaned filesystem, without retaining build-stage layers.
RUN rm -rf /usr/local/lib/python*/site-packages/pip \ RUN rm -rf /usr/local/lib/python*/site-packages/pip \
/usr/local/lib/python*/site-packages/pip-*.dist-info \ /usr/local/lib/python*/site-packages/pip-*.dist-info \
+3 -1
View File
@@ -1,5 +1,7 @@
# PIGWay Frame Flow # PIGWay Frame Flow
Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md) and [security reporting](SECURITY.md).
[简体中文](README.zh-CN.md) | English [简体中文](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. 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. - 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. - 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. - 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. - 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). 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).
+3 -1
View File
@@ -1,5 +1,7 @@
# PIGWay Frame Flow # PIGWay Frame Flow
开发环境目前支持 Python 3.14.x。参见[贡献指南](CONTRIBUTING.md)和[安全问题报告](SECURITY.md)。
简体中文 | [English](README.md) 简体中文 | [English](README.md)
轻量、自托管的智能画框自动化平台,可独立运行在 NAS 或服务器上,提供自己的 Web 界面、图库、图片渲染、调度器和 REST API。KoKonna 是支持的设备集成之一;外部应用无需持有它的凭据或引入其 SDK。 轻量、自托管的智能画框自动化平台,可独立运行在 NAS 或服务器上,提供自己的 Web 界面、图库、图片渲染、调度器和 REST API。KoKonna 是支持的设备集成之一;外部应用无需持有它的凭据或引入其 SDK。
@@ -47,7 +49,7 @@ Synology Photos 可使用独立的 API 来源,无需 `/photos` 或 NFS 挂载
- 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。 - 七种模板:家庭早报、天气、日历、一句话、照片与文字、每日诗词、系统告警。
- 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。 - 拼图工作室支持 1–12 张图片的拼图与海报;可选 OpenAI 兼容接口排版,以及从数据源自动选图拼接。
- 绑定设备的定时任务、顺序/随机播放列表、带优先级的临时覆盖、恢复与持久化操作历史。 - 绑定设备的定时任务、顺序/随机播放列表、带优先级的临时覆盖、恢复与持久化操作历史。
- 只读 NAS 文件夹、Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。 - Synology Photos 人物筛选、WebDAV 和可选网盘连接器;每个数据源独立配置、独立授权。
- 可选天气、日历、监控、AI、MQTT 与 Webhook 集成。需要配置对应 Provider;尚未授权的数据源不会被当作已连接。 - 可选天气、日历、监控、AI、MQTT 与 Webhook 集成。需要配置对应 Provider;尚未授权的数据源不会被当作已连接。
模板会**生成图片**,纯文字模板也是如此。使用顺序是:选择模板 → 编辑内容 → 生成预览 → 显示到画框。生成预览和保存模板设置都不会更新画框。“照片与文字”和“家庭早报”可以包含指定的本地照片,其他模板不会自动选图;定时模板任务每次运行会重新渲染。参见[照片数据源](docs/PHOTO_SOURCES.md)、[自动拼图](docs/AUTOMATIC_COLLAGE.md)和[集成配置及限制](docs/EXTENSIONS.md)。 模板会**生成图片**,纯文字模板也是如此。使用顺序是:选择模板 → 编辑内容 → 生成预览 → 显示到画框。生成预览和保存模板设置都不会更新画框。“照片与文字”和“家庭早报”可以包含指定的本地照片,其他模板不会自动选图;定时模板任务每次运行会重新渲染。参见[照片数据源](docs/PHOTO_SOURCES.md)、[自动拼图](docs/AUTOMATIC_COLLAGE.md)和[集成配置及限制](docs/EXTENSIONS.md)。
+15
View File
@@ -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.
+2 -1
View File
@@ -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'" 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 pigway_frame_flow /app/pigway_frame_flow
COPY vendor /app/vendor COPY vendor /app/vendor
COPY LICENSE NOTICE.md /app/
# Publish only the cleaned filesystem, without retaining build-stage layers. # Publish only the cleaned filesystem, without retaining build-stage layers.
RUN rm -rf /usr/local/lib/python*/site-packages/pip \ RUN rm -rf /usr/local/lib/python*/site-packages/pip \
/usr/local/lib/python*/site-packages/pip-*.dist-info \ /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_COMMIT
ARG BUILD_TIME ARG BUILD_TIME
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.17" org.opencontainers.image.revision=$BUILD_COMMIT LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.18" org.opencontainers.image.revision=$BUILD_COMMIT
+4 -4
View File
@@ -9,8 +9,8 @@ services:
init: true init: true
user: "10333:10333" user: "10333:10333"
ports: ports:
- "${FRAME_FLOW_BIND:-192.0.2.10}:81:18787" - "${FRAME_FLOW_BIND:-127.0.0.1}:81:18787"
env_file: ${FRAME_FLOW_ENV_FILE:-/srv/pigway/apps/pigway-frame-flow/.env} env_file: ${FRAME_FLOW_ENV_FILE:?Set the private environment file path}
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
DATA_DIR: /data DATA_DIR: /data
@@ -23,12 +23,12 @@ services:
TMPDIR: /data/tmp TMPDIR: /data/tmp
volumes: volumes:
- type: bind - type: bind
source: /srv/pigway/apps/pigway-frame-flow/data source: ${FRAME_FLOW_APP_DIR:?Set the existing application directory}/data
target: /data target: /data
bind: bind:
create_host_path: false create_host_path: false
- type: bind - type: bind
source: /srv/pigway/apps/pigway-frame-flow/config source: ${FRAME_FLOW_APP_DIR:?Set the existing application directory}/config
target: /config target: /config
bind: bind:
create_host_path: false create_host_path: false
+17 -1
View File
@@ -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. 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 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 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. 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. unreachable remote Git objects and archiving/removing historical Actions logs.
A private original mirror is retained outside the repository. Existing clones must 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. 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.
+6 -9
View File
@@ -10,7 +10,7 @@
- 支持目录相对路径、递归开关、日期文件夹 / 标题、方向及现有质量筛选。`.` 表示所选空间根目录;路径以 Photos 返回的目录名为准,不是 Ubuntu 绝对路径。 - 支持目录相对路径、递归开关、日期文件夹 / 标题、方向及现有质量筛选。`.` 表示所选空间根目录;路径以 Photos 返回的目录名为准,不是 Ubuntu 绝对路径。
- 图片使用经过实测的 `Thumbnail.get` v2、`size=xl`。原始文件名不代表下载的是原图。对 NAS 当前原图下载接口的探测被拒绝,原图下载**未宣称支持**。 - 图片使用经过实测的 `Thumbnail.get` v2、`size=xl`。原始文件名不代表下载的是原图。对 NAS 当前原图下载接口的探测被拒绝,原图下载**未宣称支持**。
- 照片枚举 `Browse.Item.list` v7,人物 `Browse.Person.list` v3;每批 500 条,最多 100000 条,超过明确失败,界面目录上限仍由 `catalog_limit` 控制。不支持 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 候选配置 ## 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` 无 `/photos` 绑定。`create_host_path: false` 防止在 T7 未挂载时悄悄创建目录;但仍需在启动前检查 `findmnt -T "$FRAME_FLOW_APP_DIR"` 的实际源设备。
- `/srv/pigway/apps/pigway-frame-flow/config:/config`
无 `/photos` 绑定。`create_host_path: false` 防止在 T7 未挂载时悄悄创建目录;但仍需在启动前检查 `findmnt -T /srv/pigway` 的实际源设备。
`.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。 `.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 授权判断 ## NFS 授权判断
API 来源的共享 / 个人空间取图已经可以完全不依赖 NFS,因此这条新取图链路不需要为 `192.0.2.10` 保留 DSM `photo` NFS 授权。但目前旧来源和待迁移配置没有全部切换验收,不能据此宣称迁移完成或旧本地来源已可脱离挂载。确认没有其他已批准用途后,可以单独授权删除该特定 NFS 规则;本次没有删除。 API 来源的共享 / 个人空间取图已经可以完全不依赖 NFS,因此这条新取图链路不需要为旧客户端保留 DSM `photo` NFS 授权。但目前旧来源和待迁移配置没有全部切换验收,不能据此宣称迁移完成或旧本地来源已可脱离挂载。确认没有其他已批准用途后,可以单独授权删除该特定 NFS 规则;本次没有删除。
实际版本、镜像标识、通过 / 失败 / 未执行项见本任务验证记录。 实际部署回执和照片库统计应私下保管,不提交公共仓库。
+8 -1
View File
@@ -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 ## Audit and adoption boundary
+3 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "PIGWay Frame Flow API", "title": "PIGWay Frame Flow API",
"version": "0.1.9" "version": "0.1.18"
}, },
"paths": { "paths": {
"/api/v1/devices/{device_id}/widgets/selection": { "/api/v1/devices/{device_id}/widgets/selection": {
@@ -15227,7 +15227,7 @@
"version": { "version": {
"type": "string", "type": "string",
"title": "Version", "title": "Version",
"default": "0.1.9" "default": "0.1.18"
} }
}, },
"type": "object", "type": "object",
@@ -17490,7 +17490,7 @@
"version": { "version": {
"type": "string", "type": "string",
"title": "Version", "title": "Version",
"default": "0.1.9" "default": "0.1.18"
}, },
"api_version": { "api_version": {
"type": "string", "type": "string",
+6
View File
@@ -0,0 +1,6 @@
# v0.1.18
- Synology Photos 列表显式请求照片方向信息;按 EXIF 旋转后的宽高筛选横向、竖向和自动方向,避免存储像素为横向的竖拍照片误入横图来源。
- 渲染前复核实际取得图片的 EXIF 方向;若元数据或缓存预览与来源方向不符,则跳过该候选。显式选择方向不符的照片时返回明确错误。
- 保持既有水印和铺满布局,不修改 NAS 原照片、来源设置或现有图库;修复不自动重新生成、上传或切换已显示图片。
- 源码及镜像保留 LICENSE、NOTICE;公开验证只使用合成照片和通用示例,私有部署参数与回执保存在仓库之外。
+2 -2
View File
@@ -10,10 +10,10 @@
正式发布使用 scripts/server-release.py:仅在既有 Ubuntu Server 环境运行,从已提交源码包构建,校验依赖锁、源码哈希、T7 UUID、容器安全约束和真实写入设置;备份 SQLite、配置及 Compose 后切换镜像,失败恢复旧 Compose,不自动回写旧数据库。它不执行上传、显示、删除或恢复图片等设备操作。成功后记录正式提交号、保存源码包及审计回执,删除本次暂存目录;白名单内的 Frame 旧迁移输入归档到本次 recovery,清除无恢复引用的临时镜像别名,共享 Console 资料不动。旧镜像及历史恢复资料保留供回滚,不执行全局 prune。 正式发布使用 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 断连、服务器重启或压力测试。 局域网 HTTPS、独立加密备份及恢复演练仍是后续工作;T7 recovery 不属于独立备份。没有执行 USB 断连、服务器重启或压力测试。
+2 -2
View File
@@ -126,7 +126,7 @@ def create_app(settings=None, start_worker=True):
app = FastAPI( app = FastAPI(
title="PIGWay Frame Flow API", title="PIGWay Frame Flow API",
docs_url=None, docs_url=None,
version="0.1.17", version="0.1.18",
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.17", version="0.1.18",
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)
+2 -2
View File
@@ -366,7 +366,7 @@ class SettingsView(BaseModel):
class Health(BaseModel): class Health(BaseModel):
status: str status: str
version: str = "0.1.17" version: str = "0.1.18"
class Readiness(BaseModel): class Readiness(BaseModel):
@@ -397,7 +397,7 @@ class RestoreRequest(StrictModel):
class Version(BaseModel): class Version(BaseModel):
product: str = "PIGWay Frame Flow" product: str = "PIGWay Frame Flow"
version: str = "0.1.17" version: str = "0.1.18"
api_version: str = "v1" api_version: str = "v1"
commit: str = "development" commit: str = "development"
build_time: str = "unknown" build_time: str = "unknown"
+16 -2
View File
@@ -662,13 +662,27 @@ class PhotoSources:
data = self.assets.fetch_url(item["id"], headers=self.headers(source)) data = self.assets.fetch_url(item["id"], headers=self.headers(source))
if len(data) > self.settings.max_bytes: if len(data) > self.settings.max_bytes:
raise FlowError(E.INVALID_REQUEST, "Image exceeds size limit") 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 io import BytesIO
from PIL import Image from PIL import Image
with Image.open(BytesIO(data)) as 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( raise FlowError(
E.INVALID_REQUEST, E.INVALID_REQUEST,
"Photo is below the configured minimum resolution", "Photo is below the configured minimum resolution",
@@ -299,7 +299,9 @@ class SynologyPhotos:
rows = self.pages( rows = self.pages(
prefix + ".Browse.Item", prefix + ".Browse.Item",
7, 7,
additional=json.dumps(["folder", "thumbnail", "resolution"]), additional=json.dumps(
["folder", "thumbnail", "resolution", "orientation"]
),
**({"person_id": person} if person is not None else {}), **({"person_id": person} if person is not None else {}),
) )
groups.append( groups.append(
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head> <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="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.18</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
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "pigway-frame-flow" name = "pigway-frame-flow"
version = "0.1.17" version = "0.1.18"
requires-python = ">=3.12" 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"] 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] [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"] 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 = ["."] pythonpath = ["."]
testpaths = ["tests"] testpaths = ["tests"]
[tool.ruff] [tool.ruff]
# Preserve existing syntax/formatting independently of runtime support.
target-version = "py312"
exclude = ["vendor", ".venv"] exclude = ["vendor", ".venv"]
[build-system] [build-system]
requires = ["setuptools>=75"] requires = ["setuptools>=75"]
+13 -9
View File
@@ -13,10 +13,11 @@ import time
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
APP = Path("/srv/pigway/apps/pigway-frame-flow") ROOT = Path(os.getenv("FRAME_FLOW_ROOT", "/srv/frameflow"))
RECOVERY = Path("/srv/pigway/recovery/frame-flow") APP = ROOT / "apps/pigway-frame-flow"
RECOVERY = ROOT / "recovery/frame-flow"
CONTAINER = "pigway-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): def run(*args):
@@ -112,11 +113,14 @@ def verify_runtime(commit):
def main(): def main():
assert os.geteuid() == 0, "Run with sudo on the Ubuntu Server" 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) os.umask(0o077)
run("/usr/local/libexec/pigway-guard-mount") run("/usr/local/libexec/pigway-guard-mount")
assert run("findmnt", "-n", "-o", "UUID", "/srv/pigway") == ( assert run("findmnt", "-n", "-o", "UUID", str(ROOT)) == expected_uuid
"00000000-0000-4000-8000-000000000001"
)
with (RECOVERY / ".release.lock").open("a") as lock: with (RECOVERY / ".release.lock").open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
release(lock) release(lock)
@@ -124,7 +128,7 @@ def main():
def release(lock): def release(lock):
ctx = Path(__file__).resolve().parent.parent 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() commit = (ctx / "RELEASE_COMMIT").read_text().strip()
assert re.fullmatch("[0-9a-f]{40}", commit) assert re.fullmatch("[0-9a-f]{40}", commit)
hashes = json.loads((ctx / "RELEASE_HASHES.json").read_text()) 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" assert dst.execute("pragma quick_check").fetchone()[0] == "ok"
base = "pigway-frame-flow:release-base-" + commit[:12] base = "pigway-frame-flow:release-base-" + commit[:12]
run("docker", "image", "tag", before["Image"], base) run("docker", "image", "tag", before["Image"], base)
image = "pigway-frame-flow:0.1.17-" + commit[:12] image = "pigway-frame-flow:0.1.18-" + commit[:12]
try: try:
run( run(
"docker", "docker",
@@ -193,7 +197,7 @@ def release(lock):
n: h n: h
for n, h in hashes.items() for n, h in hashes.items()
if n.startswith(("pigway_frame_flow/", "vendor/")) 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())' 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( subprocess.run(
+19
View File
@@ -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}", r"https://github\.com/actions/(checkout|setup-python)@[0-9a-f]{40}",
action, 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
+22 -1
View File
@@ -63,7 +63,7 @@ def test_release_readiness_retries_on_actual_listener(release_tool, monkeypatch)
def listener(url, timeout): def listener(url, timeout):
calls.append(url) 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") raise urllib.error.URLError("Connection refused during restart")
return io.BytesIO(b'{"status":"ready"}') 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) monkeypatch.setattr(release_tool.time, "sleep", lambda _: None)
release_tool.wait_ready() release_tool.wait_ready()
assert len(calls) == 2 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()
+76 -2
View File
@@ -69,12 +69,12 @@ def test_people_pagination_does_not_stop_at_500(settings, monkeypatch):
return { return {
"list": [ "list": [
{"id": i} {"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) monkeypatch.setattr(p, "request", request)
assert len(p.people()) == 1334 assert len(p.people()) == 1203
assert calls == [0, 500, 1000] assert calls == [0, 500, 1000]
@@ -401,3 +401,77 @@ def test_explicit_offline_conversion_preserves_task_reference(service, settings)
and after.person_match == "all" and after.person_match == "all"
and after.location == "family" and after.location == "family"
) )
@pytest.mark.parametrize("space", ["shared", "personal"])
@pytest.mark.parametrize("exif", [5, 6, 7, 8])
def test_api_requests_orientation_before_filtering_rotated_portraits(
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)
# Model Photos' opt-in fields: stored pixels alone look landscape.
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
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()