Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51d76a196e | ||
|
|
038b858eb7 | ||
|
|
4e9eccd809 |
+1
-6
@@ -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=
|
||||||
|
|||||||
@@ -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:
|
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 }}
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
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 \
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'"
|
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
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 规则;本次没有删除。
|
||||||
|
|
||||||
实际版本、镜像标识、通过 / 失败 / 未执行项见本任务验证记录。
|
实际部署回执和照片库统计应私下保管,不提交公共仓库。
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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。
|
正式发布使用 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 断连、服务器重启或压力测试。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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
@@ -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,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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user