Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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).
|
||||
|
||||
+3
-1
@@ -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)。
|
||||
|
||||
+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.18" 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,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.18"
|
||||
},
|
||||
"paths": {
|
||||
"/api/v1/devices/{device_id}/widgets/selection": {
|
||||
@@ -15227,7 +15227,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.9"
|
||||
"default": "0.1.18"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -17490,7 +17490,7 @@
|
||||
"version": {
|
||||
"type": "string",
|
||||
"title": "Version",
|
||||
"default": "0.1.9"
|
||||
"default": "0.1.18"
|
||||
},
|
||||
"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.18",
|
||||
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.18",
|
||||
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.18"
|
||||
|
||||
|
||||
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.18"
|
||||
api_version: str = "v1"
|
||||
commit: str = "development"
|
||||
build_time: str = "unknown"
|
||||
|
||||
@@ -662,13 +662,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.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
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "pigway-frame-flow"
|
||||
version = "0.1.17"
|
||||
requires-python = ">=3.12"
|
||||
version = "0.1.18"
|
||||
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.18-" + 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,77 @@ 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_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()
|
||||
|
||||
Reference in New Issue
Block a user