Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3bed2edce | ||
|
|
f8fe8e8b14 | ||
|
|
50903564ad | ||
|
|
69ef87e8e9 | ||
|
|
09c6773d76 | ||
|
|
ecfa17607f | ||
|
|
a83591d2c8 | ||
|
|
beb0bb0eb8 | ||
|
|
cc3fd02eb7 | ||
|
|
3a5acaa72a | ||
|
|
8e66b6d741 | ||
|
|
acdf267e48 | ||
|
|
28e971c0ff | ||
|
|
a2e8a204e8 | ||
|
|
50bb41899d | ||
|
|
cb8ac32b74 | ||
|
|
dec17aeb1d | ||
|
|
0d691826b6 | ||
|
|
5b2763910e | ||
|
|
e92a7a51fa | ||
|
|
b1c41e91ea | ||
|
|
f39c7e535b |
@@ -28,5 +28,4 @@ SYNOLOGY_PHOTOS_CERT_SHA256=
|
|||||||
|
|
||||||
# Build-only mirror hosts; official defaults work outside mainland China.
|
# Build-only mirror hosts; official defaults work outside mainland China.
|
||||||
# For mainland NAS builds: source ./deploy/build-cn.env before build-verify.sh.
|
# For mainland NAS builds: source ./deploy/build-cn.env before build-verify.sh.
|
||||||
DEBIAN_MIRROR=deb.debian.org
|
ALPINE_MIRROR=dl-cdn.alpinelinux.org
|
||||||
DEBIAN_SECURITY_MIRROR=deb.debian.org
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
verify:
|
verify:
|
||||||
# The NAS Docker runner must never execute code from external forks.
|
# The self-hosted Docker runner must never execute code from external forks.
|
||||||
if: github.server_url != 'https://github.com' && (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:
|
||||||
@@ -15,8 +15,8 @@ jobs:
|
|||||||
DOCKER_PIP_FALLBACK_INDEX_URL: ${{ vars.DOCKER_PIP_FALLBACK_INDEX_URL }}
|
DOCKER_PIP_FALLBACK_INDEX_URL: ${{ vars.DOCKER_PIP_FALLBACK_INDEX_URL }}
|
||||||
CI_PIP_INDEX_URL: ${{ vars.CI_PIP_INDEX_URL }}
|
CI_PIP_INDEX_URL: ${{ vars.CI_PIP_INDEX_URL }}
|
||||||
CI_PIP_FALLBACK_INDEX_URL: ${{ vars.CI_PIP_FALLBACK_INDEX_URL }}
|
CI_PIP_FALLBACK_INDEX_URL: ${{ vars.CI_PIP_FALLBACK_INDEX_URL }}
|
||||||
DEBIAN_MIRROR: ${{ vars.DEBIAN_MIRROR }}
|
CI_PYTHON_BASE: ${{ vars.CI_PYTHON_BASE }}
|
||||||
DEBIAN_SECURITY_MIRROR: ${{ vars.DEBIAN_SECURITY_MIRROR }}
|
ALPINE_MIRROR: ${{ vars.ALPINE_MIRROR }}
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
- uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
- uses: https://github.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
@@ -42,4 +42,4 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Production moved to Ubuntu Server. Release manually with scripts/server-release.py;
|
# Production moved to Ubuntu Server. Release manually with scripts/server-release.py;
|
||||||
# the shared NAS runner performs isolated verification only.
|
# the shared Server runner performs isolated verification only.
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ dist/
|
|||||||
|
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
|
||||||
build-cache/debs/*
|
|
||||||
!build-cache/debs/.gitkeep
|
|
||||||
build-cache/wheels/*
|
build-cache/wheels/*
|
||||||
!build-cache/wheels/.gitkeep
|
!build-cache/wheels/.gitkeep
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,24 @@
|
|||||||
ARG PYTHON_BASE=python:3.14.5-slim-bookworm@sha256:ec58d916f9e24a6035cab2bdf07f6206c4cc092a16613c60597534711332d9d6
|
ARG PYTHON_BASE=python:3.14.8-alpine3.24@sha256:f6a589d43c42b9e7f7dc67a12d37132491f362859a5d750607710cc56da3bc72
|
||||||
FROM ${PYTHON_BASE} AS prepared
|
FROM ${PYTHON_BASE} AS prepared
|
||||||
ARG DEBIAN_MIRROR=deb.debian.org
|
ARG ALPINE_MIRROR=dl-cdn.alpinelinux.org
|
||||||
ARG DEBIAN_SECURITY_MIRROR=deb.debian.org
|
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=Asia/Shanghai \
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=Asia/Shanghai \
|
||||||
DATA_DIR=/data CONFIG_DIR=/config
|
DATA_DIR=/data CONFIG_DIR=/config
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# CJK fonts intentionally retained for readable Chinese rendering.
|
# Preserve the existing FONT_PATH contract for deployments and Chinese rendering.
|
||||||
RUN --mount=type=cache,id=frameflow-bookworm-apt-${TARGETARCH},target=/var/cache/apt,sharing=locked \
|
RUN sed -i "s|https://dl-cdn.alpinelinux.org/alpine|https://${ALPINE_MIRROR}/alpine|g" /etc/apk/repositories \
|
||||||
--mount=type=cache,id=frameflow-bookworm-lists-${TARGETARCH},target=/var/lib/apt/lists,sharing=locked \
|
&& apk upgrade --no-cache \
|
||||||
--mount=type=bind,source=build-cache/debs,target=/build-debs,readonly \
|
&& apk add --no-cache font-noto-cjk tzdata \
|
||||||
rm -f /etc/apt/apt.conf.d/docker-clean \
|
&& mkdir -p /usr/share/fonts/opentype/noto \
|
||||||
&& printf 'Binary::apt::APT::Keep-Downloaded-Packages "true";\n' > /etc/apt/apt.conf.d/keep-cache \
|
&& ln -s /usr/share/fonts/noto/NotoSansCJK-Regular.ttc /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc \
|
||||||
&& sed -i "s|http://deb.debian.org/debian-security|https://${DEBIAN_SECURITY_MIRROR}/debian-security|g; s|http://deb.debian.org/debian|https://${DEBIAN_MIRROR}/debian|g" /etc/apt/sources.list.d/debian.sources \
|
&& addgroup -g 10333 frameflow && adduser -D -H -u 10333 -G frameflow frameflow \
|
||||||
&& apt-get -o Acquire::Retries=3 -o Acquire::https::Timeout=60 update \
|
|
||||||
&& if [ -f /build-debs/fonts-noto-cjk.deb ]; then \
|
|
||||||
font_version=$(apt-cache policy fonts-noto-cjk | awk '$1=="Candidate:" {print $2}'); \
|
|
||||||
font_sha=$(apt-cache show "fonts-noto-cjk=$font_version" | awk '$1=="SHA256:" {print $2;exit}'); \
|
|
||||||
test -n "$font_sha"; \
|
|
||||||
echo "$font_sha /build-debs/fonts-noto-cjk.deb" | sha256sum -c -; \
|
|
||||||
apt-get install -y --no-install-recommends tzdata; \
|
|
||||||
dpkg -i /build-debs/fonts-noto-cjk.deb; \
|
|
||||||
else apt-get install -y --no-install-recommends fonts-noto-cjk tzdata; fi \
|
|
||||||
&& groupadd --gid 10333 frameflow && useradd --uid 10333 --gid 10333 --no-create-home frameflow \
|
|
||||||
&& mkdir -p /data/images /data/generated /data/cache /config \
|
&& mkdir -p /data/images /data/generated /data/cache /config \
|
||||||
&& chown -R 10333:10333 /data /config
|
&& chown -R 10333:10333 /data /config
|
||||||
COPY requirements.lock ./
|
COPY requirements.lock ./
|
||||||
COPY scripts/ci-install.py /build-tools/install.py
|
COPY scripts/ci-install.py /build-tools/install.py
|
||||||
ARG DOCKER_PIP_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
ARG DOCKER_PIP_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||||
ARG DOCKER_PIP_FALLBACK_INDEX_URL=https://pypi.org/simple
|
ARG DOCKER_PIP_FALLBACK_INDEX_URL=https://pypi.org/simple
|
||||||
RUN --mount=type=cache,id=frameflow-pip-py314-${TARGETARCH},target=/root/.cache/pip,sharing=locked \
|
RUN --mount=type=cache,id=frameflow-pip-py314-musl-${TARGETARCH},target=/root/.cache/pip,sharing=locked \
|
||||||
--mount=type=bind,source=build-cache/wheels,target=/build-wheels,readonly \
|
--mount=type=bind,source=build-cache/wheels,target=/build-wheels,readonly \
|
||||||
if [ -f /build-wheels/SHA256SUMS ]; then \
|
if [ -f /build-wheels/SHA256SUMS ]; then \
|
||||||
(cd /build-wheels && sha256sum -c SHA256SUMS) \
|
(cd /build-wheels && sha256sum -c SHA256SUMS) \
|
||||||
@@ -54,6 +43,7 @@ WORKDIR /app
|
|||||||
ARG BUILD_COMMIT=development
|
ARG BUILD_COMMIT=development
|
||||||
ARG BUILD_TIME=unknown
|
ARG BUILD_TIME=unknown
|
||||||
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
|
ENV BUILD_COMMIT=$BUILD_COMMIT BUILD_TIME=$BUILD_TIME
|
||||||
|
LABEL org.opencontainers.image.title="PIGWay Frame Flow" org.opencontainers.image.version="0.1.26" org.opencontainers.image.revision=$BUILD_COMMIT
|
||||||
USER 10333:10333
|
USER 10333:10333
|
||||||
EXPOSE 18787
|
EXPOSE 18787
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ for endpoint coverage. Their source code was not incorporated into runtime.
|
|||||||
|
|
||||||
FastAPI, Starlette, Uvicorn, Requests, croniter, PyYAML, Pydantic and their transitive
|
FastAPI, Starlette, Uvicorn, Requests, croniter, PyYAML, Pydantic and their transitive
|
||||||
dependencies retain their respective upstream licenses. Pillow uses its upstream
|
dependencies retain their respective upstream licenses. Pillow uses its upstream
|
||||||
HPND license. Debian Noto CJK fonts retain their font package license.
|
HPND license. Noto CJK fonts use SIL OFL 1.1; the license is included in
|
||||||
|
`vendor/noto-cjk-OFL-1.1.txt`, with copyright notices retained in font metadata.
|
||||||
Development dependencies (pytest, Ruff, mypy, pip-audit, HTTPX2, Playwright) retain
|
Development dependencies (pytest, Ruff, mypy, pip-audit, HTTPX2, Playwright) retain
|
||||||
their upstream licenses and are not shipped in the runtime image.
|
their upstream licenses and are not shipped in the runtime image.
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
|
||||||
|
|
||||||
# PIGWay Frame Flow
|
# PIGWay Frame Flow
|
||||||
|
|
||||||
Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md) and [security reporting](SECURITY.md).
|
Development currently supports Python 3.14.x. See [contributing](CONTRIBUTING.md) and [security reporting](SECURITY.md).
|
||||||
@@ -26,7 +28,7 @@ docker compose up -d
|
|||||||
|
|
||||||
Open `http://127.0.0.1:6005` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
|
Open `http://127.0.0.1:6005` and sign in with the account you created. The browser uses an HttpOnly session cookie; it does not require an API token. Remembered sessions last 30 days. Change the password or sign out in Settings. `FRAME_FLOW_TOKEN` authenticates external API clients and is separate from the KoKonna API key.
|
||||||
|
|
||||||
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md), and [Gitea deployment/rollback](docs/deployment/GITEA_CD.md). No firewall, router or reverse-proxy settings are changed automatically.
|
The published interface defaults to loopback. For LAN access, explicitly set `WEB_BIND` to your NAS LAN address and choose an available `WEB_PORT`. See [Synology deployment](docs/NAS.md), [build mirrors and caching](docs/BUILDING.md). No firewall, router or reverse-proxy settings are changed automatically.
|
||||||
|
|
||||||
For a local mock demo, use Python 3.14:
|
For a local mock demo, use Python 3.14:
|
||||||
|
|
||||||
@@ -95,3 +97,5 @@ Tests mock cloud/device calls. CI also verifies an isolated Docker build, readin
|
|||||||
Cloud synchronization does not prove the physical pixels refreshed. KoKonna cloud uploads activate immediately, so upload-only uses local staging. Native slideshow membership editing is unavailable; avoid running native and Frame Flow playback simultaneously. Smart cropping is a saliency heuristic, not guaranteed face detection. The service uses one worker and is not designed for multiple replicas. Browser OAuth, AI daily-art scheduling, additional calendar recurrence features and more device drivers remain roadmap items.
|
Cloud synchronization does not prove the physical pixels refreshed. KoKonna cloud uploads activate immediately, so upload-only uses local staging. Native slideshow membership editing is unavailable; avoid running native and Frame Flow playback simultaneously. Smart cropping is a saliency heuristic, not guaranteed face detection. The service uses one worker and is not designed for multiple replicas. Browser OAuth, AI daily-art scheduling, additional calendar recurrence features and more device drivers remain roadmap items.
|
||||||
|
|
||||||
Related projects: [PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
Related projects: [PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
||||||
|
|
||||||
|
[Documentation](docs/README.md)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<p><img src="docs/brand/pigway-logo.png" alt="PIGWay" width="96" height="96"></p>
|
||||||
|
|
||||||
# PIGWay Frame Flow
|
# PIGWay Frame Flow
|
||||||
|
|
||||||
开发环境目前支持 Python 3.14.x。参见[贡献指南](CONTRIBUTING.md)和[安全问题报告](SECURITY.md)。
|
开发环境目前支持 Python 3.14.x。参见[贡献指南](CONTRIBUTING.md)和[安全问题报告](SECURITY.md)。
|
||||||
@@ -26,7 +28,9 @@ docker compose up -d
|
|||||||
|
|
||||||
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
|
打开 `http://127.0.0.1:6005`,使用刚创建的账号登录。网页使用 HttpOnly 会话 Cookie,无需输入 API Token;勾选记住登录后会话有效期为 30 天,可在设置中修改密码或退出。`FRAME_FLOW_TOKEN` 用于外部 API 客户端鉴权,与 KoKonna API Key 分开。
|
||||||
|
|
||||||
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)和 [Gitea 部署与回滚](docs/deployment/GITEA_CD.md)。不会自动修改防火墙、路由器或反向代理。
|
网页仅支持一个用户,不提供公开注册。密码为 8–256 位,支持密码管理器;使用带随机盐的 scrypt(N=16384、r=8、p=5)保存,旧哈希登录成功后自动升级。连续 5 次失败后按账号和来源 IP 限制登录 5 分钟。修改密码需验证当前密码,成功后撤销全部会话;写入请求校验 CSRF Token,登录拒绝跨来源请求。公网访问应通过 HTTPS,HTTPS 下会话 Cookie 自动启用 Secure。
|
||||||
|
|
||||||
|
默认端口只绑定本机回环地址。需要局域网访问时,显式将 `WEB_BIND` 设置为 NAS 的局域网地址,并通过 `WEB_PORT` 选择空闲端口。参见[群晖部署](docs/NAS.md)、[构建镜像源与缓存](docs/BUILDING.md)。不会自动修改防火墙、路由器或反向代理。
|
||||||
|
|
||||||
使用 Python 3.14 运行本地 Mock 演示:
|
使用 Python 3.14 运行本地 Mock 演示:
|
||||||
|
|
||||||
@@ -71,7 +75,7 @@ Web UI / Scheduler / REST API → DisplayService → FrameDeviceDriver → KoKon
|
|||||||
|
|
||||||
Ticktar 集成为可选项,Frame Flow 不依赖 Ticktar 启动,也不访问其数据库。参见[外部集成契约](docs/integrations/TICKTAR.md)。
|
Ticktar 集成为可选项,Frame Flow 不依赖 Ticktar 启动,也不访问其数据库。参见[外部集成契约](docs/integrations/TICKTAR.md)。
|
||||||
|
|
||||||
Console 监控接入提供独立只读 ZIP 插件,支持服务就绪、画框在线/电量及任务最近结果,见 [Console 插件安装说明](../pigway-console-adapter-frame-flow/README.md)。
|
Console 监控接入提供独立只读 ZIP 插件,支持服务就绪、画框在线/电量及任务最近结果,见 [Console 插件安装说明](https://tea.pigway.com/way/pigway-console-adapter-frame-flow/src/branch/main/README.zh-CN.md)。
|
||||||
|
|
||||||
### 存储与安全
|
### 存储与安全
|
||||||
|
|
||||||
@@ -97,3 +101,5 @@ KoKonna 是支持的设备集成。本项目与 KoKonna 无隶属关系,也不
|
|||||||
云端同步不等于已确认物理屏幕刷新。KoKonna 云端上传会立即切换,因此“仅上传”使用本地暂存。官方接口不支持编辑原生轮播成员,应避免原生轮播与 Frame Flow 播放同时运行。smart 裁切采用显著性启发式,不保证识别人脸。当前服务使用单 Worker,不面向多副本部署。浏览器 OAuth、AI 每日艺术调度、更多日历重复规则及设备驱动仍属于后续计划。
|
云端同步不等于已确认物理屏幕刷新。KoKonna 云端上传会立即切换,因此“仅上传”使用本地暂存。官方接口不支持编辑原生轮播成员,应避免原生轮播与 Frame Flow 播放同时运行。smart 裁切采用显著性启发式,不保证识别人脸。当前服务使用单 Worker,不面向多副本部署。浏览器 OAuth、AI 每日艺术调度、更多日历重复规则及设备驱动仍属于后续计划。
|
||||||
|
|
||||||
相关项目:[PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
相关项目:[PIGWay Raspberry Pi Agent](https://tea.pigway.com/way/pigway-raspberry-pi-agent) · [PIGWay Device Console](https://tea.pigway.com/way/pigway-device-console) · [PIGWay Yahboom Cooling HAT](https://tea.pigway.com/way/pigway-yahboom-cooling-hat)
|
||||||
|
|
||||||
|
[文档目录](docs/README.md)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# pigway-frame-flow v0.1.26
|
||||||
|
|
||||||
|
完善单用户账号安全与设置页面体验。
|
||||||
|
|
||||||
|
- 浏览器账号仅支持单用户;密码至少 8 位,强化加盐 scrypt 哈希,兼容并自动升级原有哈希。
|
||||||
|
- 登录增加账号与来源 IP 限流、跨来源请求拦截;修改密码后撤销全部会话。
|
||||||
|
- 修改密码增加“确认新密码”和一致性校验;桌面端三个紧凑输入框同排,鉴权信息区收窄,手机端纵向排列。
|
||||||
|
- 左上角保留正式 Logo,文字采用 PIGWAY / FRAME FLOW 两行排版,适配深浅主题。
|
||||||
|
|
||||||
|
现有 API Token、设备集成与控制边界保持一致。源码包包含 LICENSE、NOTICE 及第三方许可材料。公网访问仍应使用 HTTPS。
|
||||||
@@ -13,3 +13,9 @@ promised response SLA.
|
|||||||
A useful private report includes the affected version, deployment mode, required
|
A useful private report includes the affected version, deployment mode, required
|
||||||
permissions, impact, and minimal steps using mock data. Do not test on another
|
permissions, impact, and minimal steps using mock data. Do not test on another
|
||||||
person's deployment or write to real frames without explicit authorization.
|
person's deployment or write to real frames without explicit authorization.
|
||||||
|
|
||||||
|
## 当前容器映像检查
|
||||||
|
|
||||||
|
v0.1.23 使用官方 Python Alpine 3.24 基础映像并固定摘要,构建时应用 APK 安全更新。2026-10-18 使用 Trivy 0.76.0 扫描与当前生产运行 ID 一致的 Linux AMD64 映像,涵盖操作系统和 Python 包、全部严重级别,未检出已知漏洞;未配置漏洞忽略规则。该结果是当日漏洞库快照,不是未来无漏洞的保证,也不代表旧运行容器已升级。
|
||||||
|
|
||||||
|
基础映像更新后须重新构建并复扫。默认非 root、只读根目录、清空 capabilities 和 no-new-privileges 的部署限制继续保留。中文字体、第三方许可证和依赖元数据均保留;不以删除包清单掩盖告警。
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
c2792b2c75880ff09822804632b1b3b6dd6190b38841b8c3567bb1b7e7b59a8c .dockerignore
|
||||||
|
d20031a8e861a0f3e19b7750ddb53bc84c480c469c44a70ecdf8261ec05431c0 .env.example
|
||||||
|
0c7f4585d62a8d35bfc22e0bebdf7a479a179122b2b092bfe434e3038cddd3d5 .gitattributes
|
||||||
|
b45d35d737f119a70bf40cb1f9626ff80772ed7913cb35f6e9b870d6ada01a15 .github/workflows/community.yml
|
||||||
|
c8c545718e9b9880555c950a078cac160c82e0cd6e593afcf20bc13727539ec5 .github/workflows/verify.yml
|
||||||
|
3931cc314019760a582e0914e2d9c0f3b862a004d0a30d374900edcad5f599e5 .gitignore
|
||||||
|
4b4ed4926a350cbeb71436541b4566e4ba7d2494ac95236b88827a9f5dd171c2 CONTRIBUTING.md
|
||||||
|
bd03eb87817b811ab7701b6c63470905a0c554a8ee0de6a83c67b22732b2727e Dockerfile
|
||||||
|
6eb7728e6e73f9568ca6fd5ad6383d13ffc638f2870fefa2d86f72f39685c0fc LICENSE
|
||||||
|
250a2bcec248b074786d90e0329874628d70d5a0fe3b4fba47c8c57d4f84dcce NOTICE.md
|
||||||
|
b7a41a2c00df1a0dbe3869d059d65c3bcaee8c50e51217c358527c4f1d3d6c98 README.md
|
||||||
|
791881a9956148e0a3aa00e1d5650983d4e397eb5f3e67b5ccacb1fb965725ec README.zh-CN.md
|
||||||
|
1083bf4b9bfa263efa4243703d3345e546db86bd1f9eb22bda581fc509aa6cfe RELEASE_NOTES.md
|
||||||
|
3dd364e47fdbd2ce9ce2df83342c523adc21583f093a5439d501b3424878ce2f SECURITY.md
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 build-cache/wheels/.gitkeep
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 config/.gitkeep
|
||||||
|
dff739157875c8a70235c4994df868afc6824e0d3861b2716ac28014bb38144f deploy/build-cn.env
|
||||||
|
3683a3cfb63f08cc9e6144162117ab68c607d38bdeaae0e82cb418e812a450f2 deploy/compose.ubuntu-photos-api.yml
|
||||||
|
d68d727e01c592c098fde71a900bcdd234a9ca6610d0799019a6f585983b4494 deploy/devices.yml.example
|
||||||
|
fd53125a5359dcae1d81e95b8447aac755270f9aeb354d3e1140d7fccb57c42e deploy/docker-compose.photos.yml
|
||||||
|
4464545a9d91bbc9fff9cd9941db63f44fa61120caca426687d1ed06635cc3e6 deploy/organized-photos-source.json
|
||||||
|
f78d6c3d40e6c5246601bb99c2b46eef91e883a8f83841d85c925394708af0c0 docker-compose.yml
|
||||||
|
30d3e705870ebb012ce122bd7d8f95192d86664c4d2bb45a3169c86988fbaf72 docs/AUTOMATIC_COLLAGE.md
|
||||||
|
93b6e60570d9ca2ce14a454641a32885ca84d16a82db05db22cd62e488191dff docs/BUILDING.md
|
||||||
|
d9f07164c75c757e85f4ace2b55deaf82b8b904216c5222be6c0e3ab7b28cb36 docs/EXTENSIONS.md
|
||||||
|
1d8c5f79eff3115b1cd13d8f63fff1332b80d1fe1bec6b0f2713b5f80b0cbf67 docs/KOKONNA_RECOVERY.md
|
||||||
|
0fe1896a53cef4cfd2e380a0b979eceda493733082a9b56c3d7e89e7f62a65b3 docs/NAS.md
|
||||||
|
3280bddcbe2d2721bb7efeb0eb2ec03d9d5ef867c69f3e70066b69f1ad5926e2 docs/PHOTO_SOURCES.md
|
||||||
|
edf44436fd044561539c48480c481e66c1a38164f51ba5226cde04c756f7bacb docs/PHOTO_SOURCE_CONNECTIONS.md
|
||||||
|
b48f3ab6f28404bb34c188067a832384f4ba290e56912461ef3565528aa157d8 docs/PUBLICATION.md
|
||||||
|
5fe4641e75e0746929f71256c6ff11116c777cca8d4288ccc2d7aee91bd7a6ac docs/README.md
|
||||||
|
5eb19e7e4ed1ceddbdf388fd55697e40f1b7ecd9a4fbdf8ab485132cc684dcb2 docs/REAL_DEVICE_TEST.md
|
||||||
|
bfc38b889fa966031e195c3131db1d4a713e0dc35c644e3047c7ed94f2354700 docs/RESEARCH.md
|
||||||
|
fac36f986e1a7a331d3669aa8e2013e6c0ea7a99c16a61ba3ff573e836aa87e1 docs/SYNOLOGY_PHOTOS_API.md
|
||||||
|
f8ef7749c58d4396781bbc21371048f6561e791efbd489570224dc51173bffff docs/brand/README.md
|
||||||
|
f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3 docs/brand/pigway-avatar.png
|
||||||
|
da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7 docs/brand/pigway-logo.png
|
||||||
|
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 docs/brand/pigway-logo.svg
|
||||||
|
ac0481196e593ff9cca2e5133ff41fb3614268e6a10728a0deca3ad3bfb80b34 docs/integrations/TICKTAR.md
|
||||||
|
1794e3b09fa4eb9fb59cd18f729f49d6387885fc97a0b4f227e825cc49603554 docs/openapi.json
|
||||||
|
54bf7061e0626900ce35e19e81efc5c32c8fd6a0438bdad68f494e1ce81616d4 docs/research/KOKONNA_ACCOUNT_CAPABILITIES.md
|
||||||
|
a07cbe71571c6e2d5a1b94897bd63321c74675c05d15438c4964eef39db0fd76 docs/research/KOKONNA_CONTENT_SUBSCRIPTIONS.md
|
||||||
|
ec53d587b855be84283c50f8fb45be62a4c1caba78b28d436a8260fd171943a2 docs/research/KOKONNA_LOCAL_CONTROL.md
|
||||||
|
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 docs/research/KOKONNA_WIDGET_CATALOG.json
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/__init__.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/api/__init__.py
|
||||||
|
46c6e2331bda76eeac108d07ef282dbce74f482cc679ad9cf64d9cd2e29aaef3 pigway_frame_flow/api/app.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/core/__init__.py
|
||||||
|
0b8402603c2f397f4f42491873b27bf363959194b05a0c67c7b1d49596288b95 pigway_frame_flow/core/accounts.py
|
||||||
|
818e8fb29d22c13159a6cb22bcbc6b9e211a8a1301588126eb192ac4ed71441b pigway_frame_flow/core/auth.py
|
||||||
|
8f2220fa90b0dc2e45df2023dc5ac695efe55361644ef266829553c99151949b pigway_frame_flow/core/registry.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/display/__init__.py
|
||||||
|
81effa34d6c452579cfa65781909f7c5a9e8ddad136e4c47ee0704939851879a pigway_frame_flow/display/cloud_library.py
|
||||||
|
5e7e272e671a6d01d9b677416c77967ca488b4e2aa559f4211cf0de4ba1e88bc pigway_frame_flow/display/recovery.py
|
||||||
|
e1e4ae3ba321ae7282c5a4e061c52f24d2a6a6e3d4637e69e4b1fbd05004f5f6 pigway_frame_flow/display/retention.py
|
||||||
|
5f941b8b402efd6d1adab66df1dff29b1a3effda5ab23776daa300d55d5ca924 pigway_frame_flow/display/service.py
|
||||||
|
0975b24488be19f24e768c4c76d9f3c0348417c1351c4e410897da00f7be6656 pigway_frame_flow/display/upload_reconciliation.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/__init__.py
|
||||||
|
9a8386ef368a6df7fd8cb23bcc442c28e784729a5ce4a78911ad86245d5c5794 pigway_frame_flow/drivers/base.py
|
||||||
|
54cff8782e21e2ee4b8540259a4868380c58a0aa656c9d23796a049ab96a1b4f pigway_frame_flow/drivers/generic_http.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/drivers/kokonna/__init__.py
|
||||||
|
32d221f49ad28a309941856a8b1e2ba1806ec90b80e48ff51aefd2b5466cb8b5 pigway_frame_flow/drivers/kokonna/client.py
|
||||||
|
74306c44ef49223d5b2011fb667d4e19a5c8974742076df75d8a6196a96ac7f4 pigway_frame_flow/drivers/kokonna/driver.py
|
||||||
|
58c7daeaea8eb942d9e5d4f4d484e624b99c6c986a530f6af22cf05cf40cceb7 pigway_frame_flow/drivers/kokonna/mock.py
|
||||||
|
1a139e6f5169c23ce8339fe35fb39bb104a8f0a45f8c1222eda9996df459dd5d pigway_frame_flow/drivers/kokonna/widgets.py
|
||||||
|
f548a5a2b01d298eb3543cf6a1ac41177357bc8eb81b5578293347a15310b700 pigway_frame_flow/errors.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/extensions/__init__.py
|
||||||
|
4655fb6cdf9550efd90d33b8de64511d8d6ff5a96d19782fc52d99498fe2a6c0 pigway_frame_flow/extensions/ai_images.py
|
||||||
|
394459895a060f43d0ace27a4be0ea06ba1590df5de026b5661b06d42f2b31f9 pigway_frame_flow/extensions/api.py
|
||||||
|
cf05d8bf20ad1ddb399ba0fc244fe1f36c895133579660f5ab7fc6716f9e66fc pigway_frame_flow/extensions/auto_collage.py
|
||||||
|
46dab9596ba605da4d306ea941b7f19c4f3cde1f714fe1889c9f15bd8c087e42 pigway_frame_flow/extensions/device_events.py
|
||||||
|
d463465ca3aea449e67386bc84a5f25b58535f74d89d7858c5b9bca0d93189d3 pigway_frame_flow/extensions/layout.py
|
||||||
|
13bea510a6b5ae1958b3e095936429516c3e3bee6b06c3676a7eb31f311766e0 pigway_frame_flow/extensions/mqtt.py
|
||||||
|
95da4664d2ceb47ce8f45184f9924d8e61c63bb54b09e1844c55dfe35beb83db pigway_frame_flow/extensions/official.py
|
||||||
|
6d70e410d3559f370e4489aa92b54eea91d11407e8039c9adf31612de0456df1 pigway_frame_flow/extensions/providers.py
|
||||||
|
ab3081bd1afd2a4cd501355f10771cd92f3432439d9bc240d0790c2e76c2a508 pigway_frame_flow/extensions/quality.py
|
||||||
|
8e7d7da457d3b82811dc98cf6c3a93fcd7cbe8a4addb5682f48b1acd4983de82 pigway_frame_flow/extensions/secrets.py
|
||||||
|
49f2d6f7a35675fedaf03ca648f046bde6e49aebb21d6e7afb39f1b8a523fbb2 pigway_frame_flow/extensions/storage.py
|
||||||
|
1f2db3a58e7ebe5a4531f4550356fc9bcb331a2701f0bc25edf0ea8b13a57d97 pigway_frame_flow/extensions/webhooks.py
|
||||||
|
b0855f8d22665fb208dfe47856f374b76a3861c15efb312d4ada81e84fee64e5 pigway_frame_flow/integrations/__init__.py
|
||||||
|
d3025e294466d72c9332a7c577c3262816c6a12d12c7c2f0c241a7e423034eed pigway_frame_flow/integrations/kokonna_official/README.md
|
||||||
|
21536166e5fe9e15bfd4ea4865c85bcbdf79838aedc002e6c3148b1667bc8519 pigway_frame_flow/integrations/kokonna_official/__init__.py
|
||||||
|
a22723f461a9b420d1dc11ea0b794a41bb4621771b52465db21cdc0f199d51e8 pigway_frame_flow/integrations/kokonna_official/capabilities.py
|
||||||
|
c2ac7edcc5112131f093a9a33b31d632186d5ca7517ca6b26407c8099bdcba03 pigway_frame_flow/integrations/kokonna_official/connection.py
|
||||||
|
a518965aa4db9abeb60865c315229c3053c8e9edd26d80f24dc0af0528bad9e6 pigway_frame_flow/integrations/kokonna_official/rpc.py
|
||||||
|
2a68fe95ba4afc24aec6abd3e6ab24a360eb6bc3005ab0fefd696190915d76c1 pigway_frame_flow/integrations/kokonna_official/widget_catalog.json
|
||||||
|
aea594fb4e4c9a6e2094d4d7973eb627ac02fb632811b2d1a558a0ee1cd12e77 pigway_frame_flow/integrations/kokonna_official/widgets.py
|
||||||
|
1fe53f4bfb407b8db774fe730131ecf1213e16d752a3506c4c56b46365ea14e8 pigway_frame_flow/main.py
|
||||||
|
0661802c0078133225c87a835c7c19313487b4b5808e5701c1a676994836f75d pigway_frame_flow/models.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/providers/__init__.py
|
||||||
|
b7a6cb8eb40d8dedfbce18777f8fbd433a174f14bcdadf498bd834132aa568fd pigway_frame_flow/providers/cloud_drives.py
|
||||||
|
58b070f0c814d639a4a912d079479d751d459816d59f2199d0be03e4d573ec3b pigway_frame_flow/providers/external.py
|
||||||
|
e90b6becc3e19baa83fab763cb156eb8882a96594c04040305dcb315f68e85d0 pigway_frame_flow/providers/ical.py
|
||||||
|
69e7419e67e8416bf73f37e257a2586d2a1aa4d2706da8f7b42c5cb8ca7f1eb3 pigway_frame_flow/providers/oauth.py
|
||||||
|
abff8ee4b4eb1575451bcd5addf54451056af665d7aaa90173b5ed69a75a4d8e pigway_frame_flow/providers/photo_names.py
|
||||||
|
7d6fd8ff3ae99798b26d7a82644353e2c9ce5439e18c64393dc261c395ac6f4c pigway_frame_flow/providers/photo_orientation.py
|
||||||
|
322cf299b1811b61636771b925aacfd57aa1cab0b0d84a245b70a2989eda66c0 pigway_frame_flow/providers/photo_sources.py
|
||||||
|
114adfb3fcd1ab050c1dd2f1fff9d91fdde192b8eced907dee3bc8086037e13d pigway_frame_flow/providers/photos_cache.py
|
||||||
|
119427bc435ec8d4c91114713f37d07f22067eaeec391261d1441a1436baf5e0 pigway_frame_flow/providers/poetry.py
|
||||||
|
c44bf2b4a3776bebe3689b251ae3e8c352a43088829ae8c3f6fd379b64dea05e pigway_frame_flow/providers/synology_photos.py
|
||||||
|
7dfc5eab241122c4758d8b0c027369049231451dc8e0e1b49f7da56532a8e87d pigway_frame_flow/providers/weather.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/renderers/__init__.py
|
||||||
|
ce1e3d9dd7d8dc8d76d6206ab9cd05974bd2e81e1bbf28711f4b461332bf9236 pigway_frame_flow/renderers/frame_geometry.py
|
||||||
|
34a22709b300e7e36813d37ce449cebe5a5051bf6711c2c28f59a649b5e0bdc7 pigway_frame_flow/renderers/images.py
|
||||||
|
b70197edc25101b7ee73434b253ae947cddc8a9d3e55545369ac7189e117b2a2 pigway_frame_flow/renderers/photo_caption.py
|
||||||
|
241f7d80997a6b6c7d421723d5375d4890f64eea8c70680abefb0fa5d15b9502 pigway_frame_flow/renderers/photo_layout.py
|
||||||
|
d36258296d04b6575d78dd9831f8d3f66cf8b1e9182e2e90a589d10fa0067a04 pigway_frame_flow/renderers/templates.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/scheduler/__init__.py
|
||||||
|
ee80352e10eb871293bd8700837fab8d5f2e0da777d22e32811e935cf7572edc pigway_frame_flow/scheduler/engine.py
|
||||||
|
d54facc006da1345a150780c414830efaa2e0ab75fa0c9673cf5d39e912472b1 pigway_frame_flow/scheduler/upload_retries.py
|
||||||
|
77464a08816507a2423f08327c7767a9c019f55ea8f232856fa647e72e1e0246 pigway_frame_flow/settings.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 pigway_frame_flow/storage/__init__.py
|
||||||
|
36cfc19b9583809f59a2276da3ba0f023a747013b6903d3a11fbb5941931681a pigway_frame_flow/storage/database.py
|
||||||
|
44c0c47d852ffd80cb05969965ee216479c1047daffabf9c3bdc8fe2b036db12 pigway_frame_flow/templates/calendar.json
|
||||||
|
1b01fcda4b1cba11c3b152dfc2223d4119f1879ddd0b7eaf6a2e2731a6f6bf6d pigway_frame_flow/templates/collage.json
|
||||||
|
7bfe623e0569beae25b44261d949679c95d42c8bd095ef6b5cca25cf1b0d4c40 pigway_frame_flow/templates/daily-dashboard.json
|
||||||
|
f9e777980ea5bb3ea8fb2357f23530802811ea2674d25100af9b728cbc9f83a9 pigway_frame_flow/templates/daily-poetry.json
|
||||||
|
990181518d89d8a7c5021e477849eea387906b45fe2e2db3735b85349974d0c8 pigway_frame_flow/templates/photo-text.json
|
||||||
|
2fcc77fce9d50475b822ea27345f350525adae9bce76613f11fc097797eaf7d6 pigway_frame_flow/templates/poster.json
|
||||||
|
8f37296f52a88c11a000a5a0767310354550da34e1d4c0c7260393ec00cd0d9b pigway_frame_flow/templates/quote.json
|
||||||
|
82c17f4e7eaac95483592add181f549186dea9d7c0375fa9cad3554e3ccd1794 pigway_frame_flow/templates/system-alert.json
|
||||||
|
d6f705b9d183b57d50759e283db2161f8493c4e3969459e74da635252014c925 pigway_frame_flow/templates/weather.json
|
||||||
|
0d47629b1075a171f87911001760dd0d1fca79c7afc609bf18fbaa57f008dc36 pigway_frame_flow/web/app.js
|
||||||
|
f332fac63f611572597812a06ae0cc266a59e5677efda0d6e9b6925acf9f9309 pigway_frame_flow/web/apple-touch-icon.png
|
||||||
|
d7d55b06ead173b4c23a277fa39800cadfc18f1163c500e8617cfc1f4ee0dea9 pigway_frame_flow/web/extensions.js
|
||||||
|
94558dbf524a1068e83283eb814c1b1501512d4b7e75e092b52e9aabfea720e7 pigway_frame_flow/web/favicon.ico
|
||||||
|
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/favicon.svg
|
||||||
|
cd3dc54289418eea667ad71e236cce61e9dc6add4925bdb46c8a9b7c19860c48 pigway_frame_flow/web/features.js
|
||||||
|
914baa94037bbe2bffa1a48a934274644eae7b8d523af0b789360e8ca7b0de27 pigway_frame_flow/web/i18n.js
|
||||||
|
f36ec88d90471fe1702fb6d50bf99f9b189af1170cbac47113135f2761985933 pigway_frame_flow/web/index.html
|
||||||
|
0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210 pigway_frame_flow/web/logo-symbol.svg
|
||||||
|
a97528940dc2edbb35d725770eeec5c10f0a996f235302a1aed048ca755afd96 pigway_frame_flow/web/style.css
|
||||||
|
cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30 pigway_frame_flow/web/swagger-LICENSE
|
||||||
|
050bc415ee7048dcd881682678f720264e7da5e373f7461d7c58c755305255f7 pigway_frame_flow/web/swagger-ui-bundle.js
|
||||||
|
1ac324f7dcd27e4b9386b4bd6421271ec147e922a22c05ba24b11515e9aa6321 pigway_frame_flow/web/swagger-ui.css
|
||||||
|
bb03214a95e1f7c5889129ffecb6236b6fe9053ed8e2ff8a1090d379602bd23d pyproject.toml
|
||||||
|
d34ac82e4c3feacd38546aa2f0a4491ab17ddbf3ad8f489375783166d78923f0 requirements.lock
|
||||||
|
008af5f4adf616c86e805febc016faad60be0008abf5dddad6e955f1d03606cf scripts/build-verify.sh
|
||||||
|
43e8345ac5f29c2a5d1fa751c0e7c963b00b5391817393541e72c52919f0f2b4 scripts/check-compose-remote.py
|
||||||
|
8186d4d9c2542d42d786404b096fbbdee22e4c74edae78ddebab5bd3f632764c scripts/ci-audit.py
|
||||||
|
b0ec44fba0adfceca3655628021683030469be77c9e303c3f5cf830e8fd0cee3 scripts/ci-compose-verify.py
|
||||||
|
6ebe82488d7eb2f10f69cf8bf4ed4e1ee272a216413fc5be1329f05889ed49a7 scripts/ci-install.py
|
||||||
|
eb94843315f0671ca14fac66fc0c6e7380866f28dc822cce14312fa548bb5c82 scripts/create-account.py
|
||||||
|
cdabf30144ea089ae8e64228f6762951f53d4fe8c191673fb5676331d249473d scripts/migrate-photos-api.py
|
||||||
|
80813607835c19d321085fac4cabb17d865d98ed62a09b7f45195ac48bec94a8 scripts/package-nas.py
|
||||||
|
b0b877cfbb2bdf830e2e41097c2057538bb9d0b61a3c39952fe505d910cd5c29 scripts/persistence-check.py
|
||||||
|
b7247c5590fe17ae461311f2fbdbeda0daa8a34c0804cef22a314c7ff8f4c389 scripts/production-deploy.py
|
||||||
|
11627ecbd8a3be01aa079bdf6166b4368e670f5ad4a165b5971754b8187bbc2c scripts/run_local.py
|
||||||
|
5a0f8560d44352f2c5595875455ab1b2397d4eb8158a8160b5587a89a934f712 scripts/server-release.py
|
||||||
|
1fc16c43485a60a05012322cb1e4a219309dbd7e11f002f510602beea743ce30 scripts/smoke-test.py
|
||||||
|
1236d3b2d27559cbe73a27dcf56248ef3c60404c6fb21453caa7225570d4ac45 scripts/ui-audit-regressions.cjs
|
||||||
|
57a79a534a71442f5e3a70ed9ff96f426007d9f6dd0bb9b1f5d79eac773d2254 scripts/ui-auto-collage.cjs
|
||||||
|
5b61836a89db45cbd22274b9e0b035283d29d4b4926c05e203bd5da85a3bb270 scripts/ui-copy-audit.cjs
|
||||||
|
6b602f617ba845fb64335ececc90ca3fac46073f13555707652f2f080be73b2e scripts/ui-dashboard-preview.cjs
|
||||||
|
67b87d9155b74e2591ab459cc785aa4f2ff6e30c0ad6acef297593261f34ed00 scripts/ui-gallery-bootstrap.cjs
|
||||||
|
f981da8a8b4a043b4d39686feadd0e107e008c4e9e6804c45b92607777d2dbbc scripts/ui-gallery-current.cjs
|
||||||
|
01991b94630c9b4af9f52d304b3b7e7b121c59e32d5765f778b53d238cbc6c06 scripts/ui-gallery-destinations.cjs
|
||||||
|
bc47ba7a196d84a155d9df5f0e2e23e892256979faa435e01b0a63816562fa01 scripts/ui-gallery-stars.cjs
|
||||||
|
02bae6aea8e9b6aef99afc690ad6ddf1fe796501fd83667109cedfe67d5c2586 scripts/ui-history-layout.cjs
|
||||||
|
29c9b2ef02e11aa3320d63daec28185bf6e1e781d33d3ba934125c79c81ba33a scripts/ui-history-localization.cjs
|
||||||
|
1f87c8b0c6d403c96bf91ca62b6679d1ccdfa3d8cca3f13a006d73f669fe7b09 scripts/ui-image-loading.cjs
|
||||||
|
742197504264a547bcf5f121ed7fae447d8d31f51672d41278b4b984e94b5129 scripts/ui-integrations.cjs
|
||||||
|
66a12251ea36f372072923ee0b8c7bfebac222fe1dbb702b22242a3859512a9a scripts/ui-interval-schedule.cjs
|
||||||
|
70837acf16586f2c97e4e0ba30c1de00484b12113caeedda54ddb226ccaad510 scripts/ui-layout-audit.cjs
|
||||||
|
39969a70bc473ff3f7496490a2096a0de4aaf98e389953b2be4a9bd1fef3f006 scripts/ui-layout.cjs
|
||||||
|
b8b6c94cce58efbcb97dd33fc9ce3f3525be1349679f0cf543a6c61af4bd7e6f scripts/ui-login-layout.cjs
|
||||||
|
8574025d0b22f653c58846bdbb81cc10b4a7a7ca515d8e6b462083f6ca6aa39a scripts/ui-official-connection.cjs
|
||||||
|
98a03f5fb7aeead34a50ad6c17ee4cd5f2cc61eeb6ca7460652835dc31255447 scripts/ui-photo-people.cjs
|
||||||
|
19ec17efbcef3dd162d3698e0b30d55f4182ae44d2058eb94552d2ef44562e04 scripts/ui-photo-retention.cjs
|
||||||
|
f721682b12291d232bf727b1c999a7231fb1f4fc5030303baea04ad3b3bfdf2a scripts/ui-photo-sources.cjs
|
||||||
|
a4ba9a6d5fbfff7aa05d77ce7eab74a6468c1e929a90305a4afa8abbc6c891a0 scripts/ui-photos-api-form.cjs
|
||||||
|
7047f9f49886abe3c2f08b6b2bfaea35bf9682d4e3aaee773e90c5f3885375fc scripts/ui-photos-api.cjs
|
||||||
|
d390c1c9a76ddc167a17bfd7afb0bd1a7169809c8a0b30a26a5be4d18c3032de scripts/ui-progressive-loading.cjs
|
||||||
|
8eee6d087ae3d65208c423d40623052a9bfbf3db4907299b832e81fbe835fb71 scripts/ui-smoke.cjs
|
||||||
|
4d1c13a81d28f681aa1401953636d702cecfaed81274b347876c39e09931bb53 scripts/ui-studio-image-loading.cjs
|
||||||
|
8a1a3ea2b4deb25ac656a0089e085e9837330ce9a386672f302326ac0cb71306 scripts/ui-widgets.cjs
|
||||||
|
e474740bdb7dbe0617f7c6b170ada918e51f5eed8edbb9c2c47c4590a5dc2359 scripts/verify-photos-api-container.py
|
||||||
|
b7816947e33a77997a4dd7d595ce385081fc578b2821aedb859277c3b490bf07 scripts/verify-photos-api.py
|
||||||
|
d4f04e4c8f51c56aa21389d60115ecd1dda384da444dfd4d40237314fb51e3ea tests/conftest.py
|
||||||
|
41f128b9bf09fd160b8e99f3c811b73443b765b019c71306ada3dc18b83a31ca tests/contract.json
|
||||||
|
c2c398d7323e47255646e580c498a211a8f1a4fee32861954eaa29628bec8301 tests/schema-contract.json
|
||||||
|
23ffc7413f8388648c9d52d3c8f56e2966b73ca1bdc5e209e6c15469c790edba tests/test_accounts.py
|
||||||
|
e87ce325c8260f96e586b20697ab0bac71058918ba73f7412eb482364f83f73e tests/test_ai_images.py
|
||||||
|
a6edb781f4d081b2db45ffe2b02eed52a10f9b87a70e1e201b5d2f7c6813b93b tests/test_api.py
|
||||||
|
24fca799b0f5e33c800c6310764b18f6b39fc210be769684171388cf14b95746 tests/test_audit_regressions.py
|
||||||
|
7001a2209473e6f6bf1050d33212985f63f4844defeb5df8e75d9f2a96c0d83c tests/test_auto_collage.py
|
||||||
|
f267ebcdb3cf1fcb01ef9fd9ef7c5c8c4daca11d02543f84bfbe56359f35b78a tests/test_ci_audit.py
|
||||||
|
ad0f05ee4b1a9bb384869cc7f95294b23fc2fb1968ceb125ca306a87d9930207 tests/test_ci_compose.py
|
||||||
|
f256706ccd7ded02fd2c1cc4460600080eef4263b13ef986d46484e7d6dc3c03 tests/test_ci_install.py
|
||||||
|
d4469052fc3a6213c5353312e9826655491ff9c1cbd8cd2151e3a1f4ef67b7af tests/test_client.py
|
||||||
|
fc7a3b37e43ae6716c3afeeeddc0631c932175d527bd8adfea6e5e0acb166311 tests/test_content_provenance.py
|
||||||
|
40d0962edd395f57342e88e6da5467bce6cad0661ba11a5aeae51351cee72ab4 tests/test_device_events.py
|
||||||
|
764a1fcb0cc024c69416d170fcc62b3f01ef54c573981be5b9a611b3137bc3bf tests/test_display.py
|
||||||
|
f5d737520934371fa8291727458285ccbc6285d8612f286e424c0f0ea6c34666 tests/test_extensions.py
|
||||||
|
f695398aabf0ef7a5c86a23bef44421a09eed8b24c655987d87cd793df6a8a32 tests/test_external_photo_sources.py
|
||||||
|
2ab265986f89df625b8e9c8e057ffa2e421e1c6e9bb02884b355b1b71cb918f7 tests/test_gallery_thumbnail.py
|
||||||
|
5dc4027bcdad63c4a3e02c7735e6b8445c69edb43f2a3c0cd8902ef6aa1c6342 tests/test_history_images.py
|
||||||
|
cbc44ca3bd0eab93b39f0aabfe5c9d923105f4556df7fd7b86593c06daddcae5 tests/test_ical.py
|
||||||
|
787bb5ca8a03b8c94496b00e5a21957dea756b9ba1a4daf354b9e27a5f56d7ef tests/test_images.py
|
||||||
|
95892c597f80aed1e5217c81ce187c3eae09a18542093c02c97fd2d950cc76c6 tests/test_interval_schedule.py
|
||||||
|
1f2a28f4f9fc336fe843ced3ac14e1c4442bd6b4dcdb6460cd20451e6cf7fa57 tests/test_local_cloud_library.py
|
||||||
|
15fd0eab1474aae281729b66b703236625d832adc66e8bbfa791febb7d700eb8 tests/test_mqtt.py
|
||||||
|
9f005ec78c2309098cdb10c03d4167aa0e020afcc28af17a23b7afa056ad2d1e tests/test_oauth.py
|
||||||
|
69de025192b4d481d29c38271736293e8b3c4de8bd061f80521548e299cfba10 tests/test_official_connection.py
|
||||||
|
5f308c16e716cd47364b031e62509ad6bc430855f2d2d4d7f46db5f743f4e0aa tests/test_official_module.py
|
||||||
|
239444c6d6fae4cf5212d077cfc064832dde8223222b10d3679e8df2e4b30979 tests/test_photo_canvas.py
|
||||||
|
5e45cfda1fdc9f1580a1082721e64c27cc6b4e28f4e73e0afb7d875c96b46391 tests/test_photo_captions.py
|
||||||
|
cad91937e8429647319cd01fd1925132b7afe057d5420be9586c5fe63f79e498 tests/test_photo_failures.py
|
||||||
|
2bfc64fe11c882dd7de0ad0d9cc5d73a3f5a3394771adc84e7b010243a452a9c tests/test_photo_orientation.py
|
||||||
|
f14bad45534041ff0b7dead21d2952ec9d6c60e9f718ab5393bdf5a47b2a6d3e tests/test_photo_paths.py
|
||||||
|
64117766bf85dbd2a5fac77a55ba24303c1067d5e7d92571385210816dd3e85c tests/test_photo_resolution.py
|
||||||
|
8226d48f02aadcc60b7d951dc60808d559891d64bf7de7f434731dcf0a90cd44 tests/test_photo_sources.py
|
||||||
|
1e5f544dd4abb4e360efa7d7057f9ccafef7c2e15d19d1eb68db059f5bf55a90 tests/test_photo_watermark.py
|
||||||
|
530050aa636b6ddb6c2ed989bf84bbe6c9c5849a1efb3da68c4b8e8e9aa8cc3b tests/test_photos_api_boundary.py
|
||||||
|
9ab51167f18ae5c75efbf8bec2c479557e38a0e7d55cf43665d2e01d4233d816 tests/test_poetry.py
|
||||||
|
ab773ca6868ec007786e7142d5425663a5c8da35b9aa90837ac8e46b51e51507 tests/test_production_bootstrap.py
|
||||||
|
fee152c5f6eaeda712d38cf88a79213f5e885ba323eb15dfa5a12cf4c88028c3 tests/test_production_deploy.py
|
||||||
|
f0601f5ed89e8a14a4584a9683611aa7510a964f50a642348b8ab6c62df72278 tests/test_publication_security.py
|
||||||
|
915992a2ef9aeb8ae5c688d5dd50c6acd37ba726eb5aff1a0df991ffe374d94e tests/test_recovery.py
|
||||||
|
e0a426eed6f1b6f59207b4de7d8286b6852550bfa28ad30e20f7e911ad9865ee tests/test_retention.py
|
||||||
|
ab2cd30163453fe571b55c30cb26dc047a104a536ec01d2c64aa2bc91996ba11 tests/test_scheduler.py
|
||||||
|
0cccc08adf35a06dbf704eb5c35966bc1c3de0ce39d3014efd7ca751253ce79f tests/test_server_release.py
|
||||||
|
a7ae1b4a64bfcbcf2b7ff795b30ba91db24ee2ad7544073dca2bf43d021cd266 tests/test_smoke_dependencies.py
|
||||||
|
2624fb6ca01f32d93499d7d771144dcaa2850e5f77b2ac3b7b566891b5e9d283 tests/test_synology_photos.py
|
||||||
|
ca98469f6a6b8b49c36a00657ea1a81775c3496e1fbdd5e38eb4d5298b5936b4 tests/test_synology_photos_api.py
|
||||||
|
33f05bc6279cf3573493335ae5bb1c91130754b927b52aa5f50e47f2d951d2ae tests/test_upload_reconciliation.py
|
||||||
|
bdc3142f9c2291a91a5091b4b98b1addf9569cfb141810024ce6f85309704b9b tests/test_upload_retries.py
|
||||||
|
8ec55ce5706d791a155f13445fe7e0828278e8ee1fa1b527591fdf24cc5b2e86 tests/test_weather.py
|
||||||
|
e95eaa1f8a7416c5035ae6deae640b8d5defb2db36c0b8dac6d65bcceedc6750 tests/test_widgets.py
|
||||||
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 vendor/__init__.py
|
||||||
|
4cae8606769fdb590c427bc1716a871017bf22d91aabf8a52513bf126cfb847f vendor/guooscar_kokonna.py
|
||||||
|
63c7a2f1a6adf98e7fb83a83ae2d6a2dddcecd7bce4d7de25dd6f26bd496019f vendor/guooscar_protocol.py
|
||||||
|
e5f5b7a5955f2438da131859a08c9d78cda3718ada8a9365a74cbbb9ac5e7853 vendor/metadata.json
|
||||||
|
6a73f9541c2de74158c0e7cf6b0a58ef774f5a780bf191f2d7ec9cc53efe2bf2 vendor/noto-cjk-OFL-1.1.txt
|
||||||
|
e8cf7d54ea46c19aba793983889b7f7425e1ebfcaaccec764a7db091646e203c vendor/paho-mqtt-EDL-1.0.txt
|
||||||
|
57938f47d5f05f7de119b17fcecd4509c5d13644c8d5f43db30e45684d09d07e vendor/swagger-metadata.json
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Reuse a reviewed image only when the locked Python dependencies are identical.
|
|
||||||
# For a fresh installation use the repository's main Dockerfile instead.
|
|
||||||
ARG BASE_IMAGE
|
|
||||||
FROM ${BASE_IMAGE} AS prepared
|
|
||||||
# Cleanup runs only in the disposable preparation stage.
|
|
||||||
USER 0
|
|
||||||
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 \
|
|
||||||
/usr/local/lib/python*/ensurepip \
|
|
||||||
&& rm -f /usr/local/bin/pip /usr/local/bin/pip[0-9]* \
|
|
||||||
&& find / -name .DS_Store -type f -delete
|
|
||||||
|
|
||||||
FROM scratch
|
|
||||||
COPY --from=prepared / /
|
|
||||||
ENV PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 TZ=Asia/Shanghai \
|
|
||||||
DATA_DIR=/data CONFIG_DIR=/config
|
|
||||||
WORKDIR /app
|
|
||||||
USER 10333:10333
|
|
||||||
EXPOSE 18787
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:18787/health',timeout=3)"
|
|
||||||
CMD ["python", "-m", "uvicorn", "pigway_frame_flow.main:app", "--host", "0.0.0.0", "--port", "18787", "--workers", "1", "--no-access-log"]
|
|
||||||
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.18" org.opencontainers.image.revision=$BUILD_COMMIT
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
# Shared mainland-China build profile; source this file before Compose builds.
|
# Shared mainland-China build profile; source before Compose builds.
|
||||||
# Nonempty environment / Gitea repository variables override these defaults.
|
ALPINE_MIRROR=${ALPINE_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
|
||||||
DEBIAN_MIRROR=${DEBIAN_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
|
export ALPINE_MIRROR
|
||||||
DEBIAN_SECURITY_MIRROR=${DEBIAN_SECURITY_MIRROR:-mirrors.tuna.tsinghua.edu.cn}
|
|
||||||
export DEBIAN_MIRROR DEBIAN_SECURITY_MIRROR
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
args:
|
args:
|
||||||
DEBIAN_MIRROR: ${DEBIAN_MIRROR:-deb.debian.org}
|
ALPINE_MIRROR: ${ALPINE_MIRROR:-dl-cdn.alpinelinux.org}
|
||||||
DEBIAN_SECURITY_MIRROR: ${DEBIAN_SECURITY_MIRROR:-deb.debian.org}
|
|
||||||
DOCKER_PIP_INDEX_URL: ${DOCKER_PIP_INDEX_URL:-https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple}
|
DOCKER_PIP_INDEX_URL: ${DOCKER_PIP_INDEX_URL:-https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple}
|
||||||
DOCKER_PIP_FALLBACK_INDEX_URL: ${DOCKER_PIP_FALLBACK_INDEX_URL:-https://pypi.org/simple}
|
DOCKER_PIP_FALLBACK_INDEX_URL: ${DOCKER_PIP_FALLBACK_INDEX_URL:-https://pypi.org/simple}
|
||||||
BUILD_COMMIT: ${BUILD_COMMIT:-development}
|
BUILD_COMMIT: ${BUILD_COMMIT:-development}
|
||||||
|
|||||||
@@ -2,53 +2,48 @@
|
|||||||
|
|
||||||
### Build networking and cache
|
### Build networking and cache
|
||||||
|
|
||||||
The Dockerfile retains the pinned Python base, locked Python dependencies,
|
The runtime uses an official Python 3.14 Alpine 3.24 image pinned by digest,
|
||||||
`fonts-noto-cjk`, Debian repository signature checks, and offline artifact
|
locked Python dependencies, and `font-noto-cjk` for Chinese rendering. APK
|
||||||
SHA256 verification. Production uses `scripts/build-verify.sh`; CI uses `scripts/ci-compose-verify.py`.
|
repository signatures and offline wheel SHA256 checks remain enabled.
|
||||||
Both build the same Dockerfile with BuildKit and retain `docker compose build --pull`.
|
Production uses `scripts/build-verify.sh`; CI uses `scripts/ci-compose-verify.py`.
|
||||||
|
Both build the same Dockerfile with BuildKit and `docker compose build --pull`.
|
||||||
|
For a CI host that cannot reach Docker Hub, the optional `CI_PYTHON_BASE`
|
||||||
|
repository variable passes a registry mirror reference through the build script.
|
||||||
|
Keep the same official Python image digest; an unset variable preserves the
|
||||||
|
Dockerfile default. This setting does not change the shared Docker daemon.
|
||||||
|
|
||||||
APT mirror hosts are configurable through `DEBIAN_MIRROR` and
|
`ALPINE_MIRROR` accepts a mirror host (without scheme or path), defaulting to
|
||||||
`DEBIAN_SECURITY_MIRROR` (hosts only, without scheme or path). Defaults are
|
`dl-cdn.alpinelinux.org`. For mainland-China builds:
|
||||||
`deb.debian.org`; HTTPS is used for both Debian and security repositories.
|
|
||||||
For mainland-China NAS builds, use the shared profile:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
. ./deploy/build-cn.env
|
. ./deploy/build-cn.env
|
||||||
./scripts/build-verify.sh
|
./scripts/build-verify.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
CI loads this same profile, using Tsinghua TUNA for both repositories unless
|
CI uses this profile; the Gitea `ALPINE_MIRROR` repository variable can override
|
||||||
Gitea repository variables `DEBIAN_MIRROR` / `DEBIAN_SECURITY_MIRROR` override
|
it. APK upgrades available packages during a fresh dependency-layer build.
|
||||||
it. Production may also set the two hosts directly in `.env` when calling the
|
Use `docker compose build --pull --no-cache` to refresh OS packages explicitly.
|
||||||
build script without the profile. An alternate mainland mirror is
|
|
||||||
`mirrors.ustc.edu.cn`; availability and throughput depend on the Runner network.
|
|
||||||
No fallback disables signature verification or removes packages.
|
|
||||||
|
|
||||||
Layer order stays: pinned base → APT/font dependencies → `requirements.lock`
|
Layer order is pinned base → APK/fonts → requirements → Python dependencies
|
||||||
→ pip dependencies → application/vendor source → build metadata.
|
→ application source → metadata. BuildKit caches pip downloads separately for
|
||||||
A source-only change therefore reuses the APT and pip layers, provided the
|
musl and architecture. Optional `build-cache/wheels/` must contain compatible
|
||||||
same builder, base digest, mirror arguments, and optional offline artifacts
|
musllinux wheels and their SHA256 manifest; empty-cache builds work normally.
|
||||||
are used. Changing the lockfile rebuilds pip and later layers, not APT.
|
Do not reuse Debian/manylinux wheels on Alpine. Caches are disposable builder
|
||||||
Changing the mirror or APT instructions rebuilds APT and later layers.
|
resources and are not runtime mounts or dependencies.
|
||||||
|
|
||||||
Previously an APT layer miss redownloaded indexes/packages, and pip used
|
|
||||||
`--no-cache-dir`. Now locked BuildKit mounts retain APT indexes, downloaded
|
|
||||||
`.deb` files, and pip downloads/wheels outside the final image. Debian's
|
|
||||||
`docker-clean` hook is disabled so it cannot discard cached packages.
|
|
||||||
Re-executed APT steps still run `apt-get update` and validate repository
|
|
||||||
signatures; cached packages and pip artifacts can avoid repeat large downloads.
|
|
||||||
The initial build after this Dockerfile change will miss dependency layers;
|
|
||||||
subsequent builds benefit. No measured speedup is claimed before CI completes.
|
|
||||||
|
|
||||||
Caches belong to the Docker builder, not the ephemeral CI workspace. Keep the
|
|
||||||
NAS Runner's builder storage across jobs; removing the builder, pruning caches,
|
|
||||||
or using a fresh Docker daemon loses the benefit. Cache mounts are disposable:
|
|
||||||
an empty cache still builds correctly. `--pull` remains enabled; a pinned base
|
|
||||||
digest check alone does not invalidate dependency layers. To deliberately
|
|
||||||
refresh installed APT versions, use `docker compose build --pull --no-cache`.
|
|
||||||
|
|
||||||
### CI with a host Docker socket
|
### CI with a host Docker socket
|
||||||
|
|
||||||
|
The managed CI runner runs on Ubuntu Server; the NAS no longer executes jobs.
|
||||||
|
It retains `ubuntu-latest` compatibility and fixed runner/job image digests,
|
||||||
|
accepts one job at a time, and limits each task container to 4 CPUs and 8 GiB.
|
||||||
|
The runner's private state resides on the guarded business disk. Its systemd
|
||||||
|
service checks the mount identity before starting. Registration credentials are
|
||||||
|
supplied through a temporary file and removed after registration; the daemon
|
||||||
|
container does not retain the reusable registration token in its environment.
|
||||||
|
Keep the current runner and job images even when no task is executing: they are
|
||||||
|
active CI dependencies. Only trusted repository events may reach this runner's
|
||||||
|
Docker socket; CI does not automatically deploy production or write to frames.
|
||||||
|
|
||||||
Run `python scripts/ci-compose-verify.py` after loading the build mirror profile.
|
Run `python scripts/ci-compose-verify.py` after loading the build mirror profile.
|
||||||
The script derives a temporary Compose document from production settings, with
|
The script derives a temporary Compose document from production settings, with
|
||||||
an absolute client-side build context, a UUID project name, a unique CI image
|
an absolute client-side build context, a UUID project name, a unique CI image
|
||||||
@@ -60,7 +55,7 @@ owned `/data` and `/config` directories; the service remains UID/GID 10333.
|
|||||||
|
|
||||||
Build contexts are sent by the Docker client, so the job workspace can remain
|
Build contexts are sent by the Docker client, so the job workspace can remain
|
||||||
inside the job container. Runtime storage is managed by the daemon; no matching
|
inside the job container. Runtime storage is managed by the daemon; no matching
|
||||||
NAS host `/workspace` directory is needed. HTTP readiness and the unmodified
|
Docker host `/workspace` directory is needed. HTTP readiness and the unmodified
|
||||||
nine-endpoint smoke script run inside the service with `compose exec -T`.
|
nine-endpoint smoke script run inside the service with `compose exec -T`.
|
||||||
Restart is followed by another readiness check and the same smoke test.
|
Restart is followed by another readiness check and the same smoke test.
|
||||||
|
|
||||||
@@ -94,10 +89,14 @@ certificate environment settings, and proxy settings remain in effect.
|
|||||||
These `CI_PIP_*` settings apply only to the Job's pip install. They are not
|
These `CI_PIP_*` settings apply only to the Job's pip install. They are not
|
||||||
passed into Docker build arguments or persisted into the production image.
|
passed into Docker build arguments or persisted into the production image.
|
||||||
The Dockerfile keeps its own cached/offline dependency installation and logs
|
The Dockerfile keeps its own cached/offline dependency installation and logs
|
||||||
the Docker pip index host for online installs. Debian mirror variables affect
|
the Docker pip index host for online installs. The Alpine mirror variable affects
|
||||||
APT only and are loaded later in the Docker verification step.
|
APK only and are loaded later in the Docker verification step.
|
||||||
|
|
||||||
If dependency installation fails, the always() cleanup step still executes.
|
If dependency installation fails, the always() cleanup step still executes.
|
||||||
It skips the Python/PyYAML cleanup script when no CI Compose manifest exists,
|
It skips the Python/PyYAML cleanup script when no CI Compose manifest exists,
|
||||||
as no Docker verification resources have been created at that point.
|
as no Docker verification resources have been created at that point.
|
||||||
|
|
||||||
|
|
||||||
|
### Verified prebuilt production image
|
||||||
|
|
||||||
|
When the production host cannot reach the registry, build the complete Dockerfile in a trusted build environment and transfer it with `docker image save` / `docker image load`. Supply the immutable `sha256:` image ID privately as `FRAME_FLOW_PREBUILT_IMAGE_ID` to `scripts/server-release.py`. The expected version/commit tag must already exist. The script checks the ID, version and revision labels, then verifies runtime source and license hashes in an isolated container without production mounts before switching the service. Existing storage checks, backups, post-deployment verification and rollback remain in effect.
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ A failed mirror installation retries the same locked requirements against the
|
|||||||
fallback, without extra indexes, version downgrades, or disabling build isolation.
|
fallback, without extra indexes, version downgrades, or disabling build isolation.
|
||||||
Logs show index hosts and redact URL credentials/query parameters.
|
Logs show index hosts and redact URL credentials/query parameters.
|
||||||
|
|
||||||
APT/font layers remain cached when Python dependencies change. The Python layer
|
APK/font layers remain cached when Python dependencies change. The Python layer
|
||||||
changes only when the lock, installer, base image, or mirror arguments change;
|
changes only when the lock, installer, base image, or mirror arguments change;
|
||||||
application source changes reuse it. BuildKit pip cache survives layer misses.
|
application source changes reuse it. BuildKit pip cache survives layer misses.
|
||||||
An explicitly supplied, checksum-verified offline wheel directory still takes
|
An explicitly supplied, checksum-verified offline wheel directory still takes
|
||||||
|
|||||||
@@ -128,23 +128,19 @@ all uncompressed rootfs diff IDs before loading. With the verified local tag loa
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo env DOCKER_BUILDKIT=1 /usr/local/bin/docker compose build \
|
sudo env DOCKER_BUILDKIT=1 /usr/local/bin/docker compose build \
|
||||||
--build-arg PYTHON_BASE=python:3.14.5-slim-bookworm
|
--build-arg PYTHON_BASE=python:3.14.8-alpine3.24
|
||||||
```
|
```
|
||||||
|
|
||||||
This per-build override does not change global registry or network configuration.
|
This per-build override does not change global registry or network configuration.
|
||||||
|
|
||||||
A slow Debian CDN can be overridden only for this build, retaining APT GPG
|
A slow Alpine CDN can be overridden for this build, retaining APK signature
|
||||||
verification and changing no NAS system settings:
|
verification and changing no NAS system settings:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo /usr/local/bin/docker compose build --build-arg DEBIAN_MIRROR=mirrors.aliyun.com
|
sudo /usr/local/bin/docker compose build --build-arg ALPINE_MIRROR=mirrors.tuna.tsinghua.edu.cn
|
||||||
```
|
```
|
||||||
|
|
||||||
Optional `build-cache/debs/fonts-noto-cjk.deb` avoids a slow build-network download.
|
Optional `build-cache/wheels/` can hold all locked Linux AMD64/Python 3.14 musllinux wheels
|
||||||
Dockerfile verifies it against SHA-256 from the GPG-authenticated APT index before use.
|
|
||||||
The cache is not a runtime mount or committed dependency, and empty-cache builds work.
|
|
||||||
|
|
||||||
Optional `build-cache/wheels/` can hold all 24 Linux AMD64/Python 3.14 wheels
|
|
||||||
and `SHA256SUMS`, verified against official PyPI release metadata. Builds verify
|
and `SHA256SUMS`, verified against official PyPI release metadata. Builds verify
|
||||||
the manifest and use pip `--no-index`; incomplete caches fail explicitly.
|
the manifest and use pip `--no-index`; incomplete caches fail explicitly.
|
||||||
Build commit/time are set after dependency installation to preserve build cache.
|
Build commit/time are set after dependency installation to preserve build cache.
|
||||||
|
|||||||
@@ -4,29 +4,26 @@ 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 Gitea NAS Docker runner accepts trusted repository events only. GitHub mirrors
|
The Gitea Server Docker runner accepts trusted repository events only. GitHub mirrors
|
||||||
use the separate community workflow on GitHub-hosted disposable runners, with
|
use the separate community workflow on GitHub-hosted disposable runners, with
|
||||||
read-only repository permissions and no deployment configuration. External fork PRs
|
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.
|
||||||
|
|
||||||
Private deployment configuration is stored in Gitea Actions Secrets:
|
Public CI verifies source only. Runtime configuration belongs in private environment
|
||||||
`PROD_DATA_DIR`, `PROD_CONFIG_DIR`, `PROD_PHOTOS_DIR`, `PROD_WEB_BIND`,
|
files or the deployment system's secret store. Never print complete Docker inspect
|
||||||
`SYNOLOGY_PHOTOS_URL`, `SYNOLOGY_PHOTOS_CERT_SHA256`, and `PRODUCTION_ENV`.
|
environment or secret values.
|
||||||
Project/image names, ports, enable flags and mirror settings remain Variables.
|
|
||||||
Never print complete Docker inspect environment or secret values.
|
|
||||||
|
|
||||||
Publishing requires scanning all refs, removing private historic files, pruning
|
Before publishing, scan the current source and distribution files for secrets and
|
||||||
unreachable remote Git objects and archiving/removing historical Actions logs.
|
private receipts. Keep Git history and existing remote releases. If a real historic
|
||||||
A private original mirror is retained outside the repository. Existing clones must
|
secret is found, revoke it and handle history remediation as a separate operation.
|
||||||
reclone after the privacy history rewrite; do not merge the original history back.
|
|
||||||
|
|
||||||
## Private deployment inputs
|
## Private deployment inputs
|
||||||
|
|
||||||
Do not publish host addresses, workstation paths, disk UUIDs, photo-library counts,
|
Do not publish host addresses, workstation paths, disk UUIDs, photo-library counts,
|
||||||
or deployment receipts. Keep receipts and recovery bundles outside the repository
|
or deployment receipts. Do not include deployment receipts or recovery bundles in published archives. Local
|
||||||
in an owner-only directory. A privacy history rewrite changes existing commit IDs;
|
obsolete artifacts may be removed after verification; active runtime configuration,
|
||||||
existing clones must be replaced instead of merging old history back.
|
databases and images must be preserved.
|
||||||
|
|
||||||
`server-release.py` reads `FRAME_FLOW_ROOT` (default `/srv/frameflow`), requires
|
`server-release.py` reads `FRAME_FLOW_ROOT` (default `/srv/frameflow`), requires
|
||||||
`FRAME_FLOW_MOUNT_UUID` and `FRAME_FLOW_ORIGIN`, and retains the mount-identity
|
`FRAME_FLOW_MOUNT_UUID` and `FRAME_FLOW_ORIGIN`, and retains the mount-identity
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 文档目录
|
||||||
|
|
||||||
|
安装、连接及日常使用从[中文首页](../README.zh-CN.md)或[英文首页](../README.md)开始。本目录收录仍适用的功能、配置与开发说明;版本变化见[版本记录](../RELEASE_NOTES.md)。
|
||||||
|
|
||||||
|
- [自动拼图](AUTOMATIC_COLLAGE.md)
|
||||||
|
- [构建、缓存与 CI](BUILDING.md)
|
||||||
|
- [可选连接与扩展](EXTENSIONS.md)
|
||||||
|
- [厂商请求失败与未知写入处理](KOKONNA_RECOVERY.md)
|
||||||
|
- [Docker 与群晖安装](NAS.md)
|
||||||
|
- [照片来源配置](PHOTO_SOURCES.md)
|
||||||
|
- [照片来源与共享连接](PHOTO_SOURCE_CONNECTIONS.md)
|
||||||
|
- [公开源码与隐私边界](PUBLICATION.md)
|
||||||
|
- [真实画框测试步骤](REAL_DEVICE_TEST.md)
|
||||||
|
- [厂商接口研究概览](RESEARCH.md)
|
||||||
|
- [Synology Photos API 与来源迁移](SYNOLOGY_PHOTOS_API.md)
|
||||||
|
- [Ticktar 外部集成契约](integrations/TICKTAR.md)
|
||||||
|
- [厂商账号能力研究](research/KOKONNA_ACCOUNT_CAPABILITIES.md)
|
||||||
|
- [厂商内容订阅研究](research/KOKONNA_CONTENT_SUBSCRIPTIONS.md)
|
||||||
|
- [厂商本地控制研究](research/KOKONNA_LOCAL_CONTROL.md)
|
||||||
|
- [开发与贡献](../CONTRIBUTING.md)
|
||||||
|
- [安全问题反馈](../SECURITY.md)
|
||||||
|
- [第三方声明](../NOTICE.md)
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
FRAME_FLOW_IMAGE='<已验证的镜像标签>' docker compose -f deploy/compose.ubuntu-photos-api.yml config --quiet
|
FRAME_FLOW_IMAGE='<已验证的镜像标签>' docker compose -f deploy/compose.ubuntu-photos-api.yml config --quiet
|
||||||
```
|
```
|
||||||
|
|
||||||
完整构建使用根目录 Dockerfile。若使用 `deploy/Dockerfile.runtime-update`,必须指定已审核的基础镜像,构建步骤会检查依赖锁与基础镜像相同;不相同时必须完整构建。本次依赖未新增,已比对现有 0.1.8 基础镜像的锁文件。
|
发布统一使用根目录 Dockerfile 完整构建,包含固定基础映像、系统安全更新和锁定依赖,避免继承旧映像中的系统漏洞。
|
||||||
|
|
||||||
## 旧来源转换与恢复
|
## 旧来源转换与恢复
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
# UI 一致性与图片加载检查
|
|
||||||
|
|
||||||
页面使用既有真实元数据创建控件和图片区域,不使用页面骨架或虚构图片卡片。
|
|
||||||
搜索、排序、卡片操作与写入保护在图片请求前初始化;已知图库数据可先显示,
|
|
||||||
后台刷新元数据。首页图片独立加载,不阻塞后续页面内容。
|
|
||||||
|
|
||||||
图片共用局部加载函数:旋转指示器仅居中显示于所属图片容器,网络请求与解码完成后
|
|
||||||
立即显示图片,失败后显示中英文提示。没有固定等待或动画最短展示时长。
|
|
||||||
图库仅请求可见区域及附近图片。浏览器禁缓存不会清除服务端缩略图缓存,
|
|
||||||
快速图片可能在第一帧绘制前完成,动画不一定肉眼可见。
|
|
||||||
|
|
||||||
手动拼版先创建全部真实区域与坐标/拖动交互,再并行请求图片。
|
|
||||||
每张图片独立完成或失败,持久的区域容器保持加载前后几何位置与坐标索引稳定。
|
|
||||||
过期布局事件不修改新的规划。
|
|
||||||
|
|
||||||
移动端导航单行横向滚动,当前入口自动进入可见区域;低高度横屏侧栏可滚动。
|
|
||||||
页头在窄屏分行,并为底部安全区域留白。可见控件保留 44 像素触控高度,
|
|
||||||
当前导航使用 aria-current,扩展表单标签关联相应控件。
|
|
||||||
表格与手机导航允许容器内滚动,不应导致整页水平溢出。
|
|
||||||
|
|
||||||
## 自动检查
|
|
||||||
|
|
||||||
使用隔离 Mock 服务、临时数据与测试账号,禁止对生产执行可写的 Mock 脚本。
|
|
||||||
Photos 授权使用拦截测试响应;不需要真实 NAS 凭据。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
FRAME_FLOW_URL=http://127.0.0.1:6005 \
|
|
||||||
FRAME_FLOW_ENV_FILE=/path/to/mock.env \
|
|
||||||
FRAME_FLOW_TEST_USERNAME=test-ui \
|
|
||||||
FRAME_FLOW_TEST_PASSWORD=test-only-password \
|
|
||||||
node scripts/ui-layout-audit.cjs
|
|
||||||
```
|
|
||||||
|
|
||||||
需要已安装的 Playwright;可通过 NODE_PATH 指定运行时。
|
|
||||||
FRAME_FLOW_VIEWPORTS 接受包含 width/height 的 JSON 数组;
|
|
||||||
FRAME_FLOW_SCREENSHOT_DIR 可选,用于保存仅含测试数据的截图。
|
|
||||||
|
|
||||||
| 脚本 | 检查范围 | 本次结果 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| ui-layout-audit.cjs | 11 页面、本地/云端图库、6 类弹窗、展开手动拼版;溢出、控件间距、触控高度、弹窗边界 | 16 组尺寸/语言/主题组合,305 项通过 |
|
|
||||||
| ui-image-loading.cjs | 三种宽度、明暗主题;局部居中、成功解码、错误提示与无障碍状态 | 45 项通过 |
|
|
||||||
| ui-progressive-loading.cjs | 阻塞初始化/元数据/图片/任务筛选;首次无缓存时控件先就绪、输入保留、慢请求切换、列表失败、无骨架 | 18 项通过 |
|
|
||||||
| ui-studio-image-loading.cjs | 桌面/手机、明暗主题;并行请求、独立完成/失败、坐标与拖动、稳定区域 | 31 项通过 |
|
|
||||||
|
|
||||||
上述检查无页面脚本异常。生产静态文件与构建文件哈希比对、health/ready 检查通过;
|
|
||||||
生产浏览器交互验证使用拦截的账号、规划及素材响应,对真实服务仅 GET,
|
|
||||||
不代表真实账号密码登录、AI 生成、iOS/Android 实机或软键盘测试。
|
|
||||||
|
|
||||||
连接中心内容较长,后续可按用户任务分组;模板与排版导航图标仍有重复。
|
|
||||||
|
|
||||||
|
|
||||||
## 首次未缓存图库的初始化
|
|
||||||
|
|
||||||
目的地标签、搜索、排序、刷新和上传控件必须先于列表接口完成。
|
|
||||||
仅已有列表的缓存分支先渲染还不够;无缓存时也应创建真实控件与空的列表容器。
|
|
||||||
元数据返回前不推测图片数量,不显示真实空图库提示,不生成骨架卡片。
|
|
||||||
列表失败保留页面控件;慢云端请求期间切换 NAS,迟到响应不得覆盖新页面。
|
|
||||||
列表返回后保留之前输入的搜索词和排序。任务筛选在同一导航内复用请求,仍不阻塞图库。
|
|
||||||
|
|
||||||
启动使用 Settings API 中不调用厂商接口的 device_catalog;完整设备状态作为后台更新,
|
|
||||||
迟到响应在退出账号后不应用。ui-gallery-bootstrap.cjs 同时阻塞设备状态与图片列表,
|
|
||||||
验证真实控件仍先显示,列表返回后保留查询和排序,2 项通过。
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# Frame Flow 项目审计 — 2026-10-05
|
|
||||||
|
|
||||||
审计基线:794a7e1485cc9046be3e77f6557c75740305ac47。
|
|
||||||
范围:本地源码、配置、Mock 测试与依赖审计;不包含本次 NAS 状态验证、真实账号登录或真实设备写入。未修改业务代码。
|
|
||||||
|
|
||||||
## 发现
|
|
||||||
|
|
||||||
### P1:队列扫描上限导致后续高优先级告警饥饿
|
|
||||||
|
|
||||||
`display/service.py:222` 只取按创建时间排序的前 50 个 QUEUED 操作,再跳过
|
|
||||||
被 override 阻挡的低优先级请求。如果这 50 项全被阻挡,第 51 项更高优先级
|
|
||||||
告警每一轮都无法被扫描到;其他设备请求与本地渲染也可能受影响。
|
|
||||||
|
|
||||||
Mock 复现:激活 priority=50 的一小时 override,排入 50 个 priority=10
|
|
||||||
请求,再排 priority=90 请求,调用 process_one 后后者仍为 QUEUED。
|
|
||||||
建议先筛选可执行请求,或分页扫描直到找到可执行操作;增加跨设备与优先级回归测试。
|
|
||||||
|
|
||||||
### P2:会话失效未更新连接状态,并误清第一方登录
|
|
||||||
|
|
||||||
`integrations/kokonna_official/rpc.py:44-51` 在官方返回“请重新登陆”时抛出
|
|
||||||
UNAUTHORIZED,但没有使官方 session 失效。`connection.py:104-113` 只依据
|
|
||||||
本地 token 非空,继续显示 authorized=true。`web/app.js:17` 将任意 API 401
|
|
||||||
都当作 Frame Flow 登录失效并执行 forgetLogin,包括外部官方会话过期。
|
|
||||||
|
|
||||||
Mock 复现:RPC 返回 HTTP 500 / error -1 / 请重新登陆后,authorized 仍 true。
|
|
||||||
建议区分第一方认证与外部账号过期,持久化失效状态,保留 Frame Flow 登录,
|
|
||||||
在官方区域提供重新连接提示。
|
|
||||||
|
|
||||||
### P2:损坏的可选官方会话文件阻止整个服务启动
|
|
||||||
|
|
||||||
`connection.py:44-51` 在构造函数中直接解析 JSON 并索引字段,没有异常隔离。
|
|
||||||
DeviceRegistry 在应用启动时构建该对象。损坏 JSON、缺字段或文件不可读会
|
|
||||||
使可选功能故障升级为全服务启动失败,影响 API Key、UI 和 Scheduler。
|
|
||||||
|
|
||||||
Mock 复现:官方 secret 文件内容为 `{broken`,create_app 抛 JSONDecodeError。
|
|
||||||
建议隔离并禁用该连接,记录不含文件内容的诊断;其他核心功能继续启动。
|
|
||||||
|
|
||||||
### P2:持久化失败导致断开状态与重启状态不同
|
|
||||||
|
|
||||||
`connection.py:122-127,146-157` 先修改内存,再保存。磁盘满、权限错误等
|
|
||||||
导致保存失败时没有回滚。尤其 disconnect 已清内存,而磁盘仍保留旧 token,
|
|
||||||
重启后又恢复旧连接;虽然原请求返回错误,随后状态查询却会显示未连接。
|
|
||||||
|
|
||||||
Mock 复现:让 _save 抛 OSError;当前 authorized=false,重新构造连接后 true。
|
|
||||||
建议先原子持久化候选状态,再提交内存状态;失败时保持一致并提供明确错误。
|
|
||||||
|
|
||||||
### P2:Request ID 脱敏遗漏官方会话
|
|
||||||
|
|
||||||
`api/app.py:148-150` 仅检查 API Key、Frame Flow Token、registry.keys,
|
|
||||||
没有检查用于官方会话的 settings.additional_secrets。符合 Request ID 长度和
|
|
||||||
字符限制的会话值若被客户端误用为 X-Request-ID,可被响应头和错误模型原样回显。
|
|
||||||
本问题不表示未授权者能凭空取到 token,而是违反已有 secret 不回显约束。
|
|
||||||
|
|
||||||
Mock 复现:完成模拟官方连接后,将 synthetic session `mock-session` 作为
|
|
||||||
X-Request-ID 发给 /health,响应原样返回该值。建议统一使用完整 secret 集合。
|
|
||||||
|
|
||||||
## 验证
|
|
||||||
|
|
||||||
- pytest:154 passed,0 failed,0 warning。
|
|
||||||
- Ruff:通过(应用和 tests)。
|
|
||||||
- mypy:36 个源码文件通过。
|
|
||||||
- pip-audit(requirements.lock):No known vulnerabilities found。
|
|
||||||
- 五项专项 Mock 复现均确认上述行为,不调用真实 KoKonna。
|
|
||||||
- 没有因发现问题而启用官方写方法;严格 RPC allowlist 仍阻止未启用方法。
|
|
||||||
|
|
||||||
## 边界与下一步
|
|
||||||
|
|
||||||
测试全绿不代表无缺陷:现有套件没有覆盖以上失败场景。
|
|
||||||
本轮没有修复或部署;建议先修复队列饥饿,再统一外部账号状态与第一方认证,
|
|
||||||
然后补齐会话文件容错、持久化事务一致性和 Request ID 脱敏测试。
|
|
||||||
依赖审计仅表示本次查询未发现已知漏洞,不是全部依赖安全的证明。
|
|
||||||
|
|
||||||
## 修复与复验
|
|
||||||
|
|
||||||
上述五项已修复:队列使用 keyset 分页查找可执行操作;官方账号失效使用
|
|
||||||
独立 `OFFICIAL_AUTH_REQUIRED` 错误码并清除会话,前端保留第一方登录且提供
|
|
||||||
重新连接入口;可选 secret 解析失败不再阻止核心启动;持久化候选状态成功后
|
|
||||||
才提交内存变更;Request ID 检查涵盖官方会话凭据。
|
|
||||||
|
|
||||||
复验:164 passed、0 warning(新增 10 项回归测试);Ruff 通过;mypy 36 文件
|
|
||||||
通过;113 项布局检查通过;官方过期/第一方登录隔离浏览器检查通过。
|
|
||||||
已知本地凭据对照历史扫描通过(278 个 blob);该扫描不等同于发现所有未知
|
|
||||||
secret 的通用审计。本轮不部署 NAS,不执行真实设备写接口。
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 界面表达审计(2026-10-16)
|
|
||||||
|
|
||||||
范围:11 个主页面、主要弹窗、中英文文案、README 和照片数据源说明;对照当前 API、清理与来源记录逻辑。不是厂商接口能力或线上服务状态的全面审计。
|
|
||||||
|
|
||||||
| 位置 | 原问题 | 修正 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 图库 | 排序使用“适配方式” | 独立“排序方式”标签 |
|
|
||||||
| 轮播 / 播放列表 | 播放顺序使用图片适配标签;间隔重复单位 | 使用“播放顺序”“切换间隔” |
|
|
||||||
| 总览 | 天气缺译,显示原始 weather | 补齐中英文 |
|
|
||||||
| 总览 | 图片 ID 标成图片;型号来源标成型号 | 独立标注 ID 与信息来源 |
|
|
||||||
| 同步状态 | “已同步”容易被视为实物刷新确认 | 标明云端确认 |
|
|
||||||
| 图片来源 | 无记录却断言非本系统生成 | 只描述未找到可用记录 |
|
|
||||||
| 模板编辑 | 非模板图片复用不准确的来源错误 | 明确无可编辑的模板来源 |
|
|
||||||
| 删除 | 本地当前图也提示画框切图 | 按本地、云端、当前云端副本区分确认文案 |
|
|
||||||
| 星标 / 多选 | 自动清理保护及隐藏勾选项范围不明确 | 加入服务端保留、手动删除、当前页选择范围说明 |
|
|
||||||
| 上传 | 固定尺寸提示可能与输出不一致 | 提示以生成预览为准 |
|
|
||||||
| 显示选项 | 不勾选恢复时仍显示时长,容易误解 | 说明时长与优先级的临时显示用途 |
|
|
||||||
| 数据源 | 未配置与无匹配照片共用空态 | 拆分两种空态 |
|
|
||||||
| 数据源 | 位置标签、凭据提示仅描述 WebDAV | 补充网盘目录 ID、百度路径、OAuth 凭据 |
|
|
||||||
| 人物筛选 | 仅描述共享空间,但实际可选个人空间 | 按所选空间说明访问权限 |
|
|
||||||
| AI / MQTT | AI 密钥称为设备密钥;连接状态缺译 | 独立 API 密钥标签,补齐连接状态 |
|
|
||||||
| 错误页 | 所有失败都归因设备云端 | 改成通用页面加载失败,保留具体原因 |
|
|
||||||
| 文档 | 照片数据源旧入口及账号独立性描述过时 | 对齐独立导航与共享 Photos 登录 |
|
|
||||||
|
|
||||||
验证:305 项布局检查覆盖 320、390、844 横屏、1440 宽度,中英文与明暗主题,11 页及主要弹窗;8 组星标 / 反选回归;新增文案检查覆盖静态翻译完整性、排序 / 播放 / 型号标签、本地删除和星标作用范围。测试使用隔离 Mock,无真实设备写入、照片删除、AI 调用或部署。
|
|
||||||
|
|
||||||
此前未提交的反选、总览图片放大及共享图片查看器改动保留。此报告不代表已提交或已部署。
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# PIGWAY 品牌资源 / Brand assets
|
||||||
|
|
||||||
|
来源:PIGWAY Brand Production v1.1 正式交付包。图形、颜色与比例保持原文件,不使用重绘、滤镜或替代字标。
|
||||||
|
|
||||||
|
- `pigway-logo.svg`:正式 Web 图形标。
|
||||||
|
- `pigway-logo.png`:正式 256px 透明图形标,用于项目介绍页。
|
||||||
|
- `pigway-avatar.png`:正式 512px 平台图标,用于 Tea 项目头像。
|
||||||
|
|
||||||
|
本项目的产品名称保持独立。品牌物料版本不跟随软件版本变化;网页小图标使用同一正式包的 Favicon 和 Apple Touch Icon。
|
||||||
|
|
||||||
|
## SHA-256
|
||||||
|
|
||||||
|
- `0fa494a3fbd456c754bbea3f7f050be139e32e3b42b09a1ed953094087437210` `pigway-logo.svg`
|
||||||
|
- `da6734d8a23305f6254c0936dadff4ec34977b4c934f5067186423c8cfce3fc7` `pigway-logo.png`
|
||||||
|
- `f709156217c798f6b6230dae1f341373e0814a67ab90e881401da577f52ec3d3` `pigway-avatar.png`
|
||||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -1,388 +0,0 @@
|
|||||||
# 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
|
|
||||||
|
|
||||||
Production `docker-compose.yml` is unchanged: AMD64 image, UID/GID 10333,
|
|
||||||
`unless-stopped`, read-only rootfs, `/tmp` tmpfs, dropped capabilities, limited
|
|
||||||
logs, `/data` and `/config` bind mounts, default private bridge, and configurable
|
|
||||||
host bind/port. SQLite/images/generated/cache live under `/data`; accounts,
|
|
||||||
vendor sessions and configuration live under `/config`. Neither is disposable.
|
|
||||||
The optional photos mount is read-only and has supplementary group access.
|
|
||||||
|
|
||||||
Historical repository receipts mention `/volume1/docker/pigway-frame-flow/data`,
|
|
||||||
`/volume1/docker/pigway-frame-flow/config`, `/volume1/photo`, port 18787, and
|
|
||||||
container/project `pigway-frame-flow`. These are **confirmation candidates**, not
|
|
||||||
script defaults. The deploy action accepts only explicitly configured paths
|
|
||||||
that match the existing running container on the Runner's Docker daemon.
|
|
||||||
It rejects different projects, ports, unknown mounts, host/custom networking,
|
|
||||||
missing photos confirmation, or different user/restart policy. The deploy action does not create NAS host directories or migrate/adopt an unrelated
|
|
||||||
container. First-time directory creation is limited to the manual bootstrap below.
|
|
||||||
|
|
||||||
CI uses `ffci-*` projects, per-run image tags and named volumes, no host ports.
|
|
||||||
Its cleanup does not refer to any production resource. Production uses fixed
|
|
||||||
configured names and existing bind mounts, with `create_host_path: false`.
|
|
||||||
|
|
||||||
## Gitea configuration
|
|
||||||
|
|
||||||
Repository Settings → Actions configuration (private paths/addresses use Secrets):
|
|
||||||
|
|
||||||
| Variable or Secret | Value to explicitly confirm |
|
|
||||||
| --- | --- |
|
|
||||||
| `PROD_DEPLOY_ENABLED` | Initially `false`; change to `true` only after plan acceptance |
|
|
||||||
| `PROD_PROJECT` | Existing Compose project label; candidate `pigway-frame-flow` |
|
|
||||||
| `PROD_CONTAINER` | Existing container name; candidate `pigway-frame-flow` |
|
|
||||||
| `PROD_IMAGE_REPO` | Fixed local image repository; suggested `pigway-frame-flow` |
|
|
||||||
| `PROD_DATA_DIR` (Secret) | Existing absolute NAS host data path; no default |
|
|
||||||
| `PROD_CONFIG_DIR` (Secret) | Existing absolute NAS host config path; no default |
|
|
||||||
| `PROD_PHOTOS_DIR` (Secret) | Existing read-only photos source if present; otherwise empty |
|
|
||||||
| `PROD_WEB_BIND` (Secret) | Exact existing Docker HostIp (`0.0.0.0` or NAS LAN IP etc.) |
|
|
||||||
| `PROD_WEB_PORT` | Exact existing mapped port |
|
|
||||||
| `FRAME_FLOW_STORAGE_NAME` | Optional NAS display name for the local gallery; defaults to NAS |
|
|
||||||
| `PROD_STATE_VOLUME` | Dedicated new deployment journal volume, e.g. `pigway-frame-flow-cd-state` |
|
|
||||||
| `PROD_MOCK` | Explicit `true`/`false`, consistent with intended production mode |
|
|
||||||
| `PROD_WRITE_ENABLED` | Explicit `true`/`false`; preserve owner's existing write policy |
|
|
||||||
|
|
||||||
The state volume must be separate from application/CI storage. An existing
|
|
||||||
volume without the matching `frameflow.deploy.project` label is rejected.
|
|
||||||
Supplementary groups are read from the existing container; no host ACL changes.
|
|
||||||
Keep existing `DEBIAN_MIRROR`, `DEBIAN_SECURITY_MIRROR`, `CI_PIP_INDEX_URL`, and
|
|
||||||
`CI_PIP_FALLBACK_INDEX_URL` settings from Verify; CI/production builds share the
|
|
||||||
Dockerfile, pinned base, lockfile, and BuildKit caches.
|
|
||||||
|
|
||||||
Repository Settings → Actions → Secrets:
|
|
||||||
|
|
||||||
`PRODUCTION_ENV`: multiline **unquoted** `KEY=value` entries, without shell
|
|
||||||
expansion. At least:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
FRAME_FLOW_TOKEN=<existing Frame Flow administrator token>
|
|
||||||
KOKONNA_API_KEY=<existing KoKonna key, or empty when mock>
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional additional runtime secret variables can be included. No key values are
|
|
||||||
committed, passed to image builds, or printed. Nonsecret mock/write flags use
|
|
||||||
Variables. Other existing runtime environment values are retained. The script
|
|
||||||
sets commit/build time and internal data/config paths explicitly. Dollar signs
|
|
||||||
in secret values are escaped before Compose interpolation. Snapshots containing
|
|
||||||
old runtime secrets are stored only in a private journal file (0600, directory
|
|
||||||
0700) on the dedicated Docker volume; Docker administrators can access it.
|
|
||||||
Never upload that journal or a full Docker inspect as a public CI artifact.
|
|
||||||
|
|
||||||
## First safe trial
|
|
||||||
|
|
||||||
1. On NAS, inspect only the necessary fields (use existing Docker/sudo access):
|
|
||||||
```sh
|
|
||||||
docker inspect pigway-frame-flow --format '{{index .Config.Labels "com.docker.compose.project"}}'
|
|
||||||
docker inspect pigway-frame-flow --format '{{json .Mounts}}'
|
|
||||||
docker inspect pigway-frame-flow --format '{{json .HostConfig.PortBindings}} {{.HostConfig.NetworkMode}}'
|
|
||||||
```
|
|
||||||
Confirm the Variables/Secrets against these actual values, including photos.
|
|
||||||
2. Configure the Secret privately. Keep `PROD_DEPLOY_ENABLED=false`.
|
|
||||||
3. Manually run **Verify Frame Flow** on `main`, `deploy_action=plan`.
|
|
||||||
Verify must pass first. Plan checks resource identity, paths/port, required
|
|
||||||
Secret keys, current `/ready`, smoke endpoints and commit. It performs no
|
|
||||||
build, stop, restart, tagging, lock creation, volume creation, or data writes.
|
|
||||||
If no production Variables exist, it reports configuration missing and does
|
|
||||||
nothing; that is an unconfigured plan, not production acceptance.
|
|
||||||
4. Review `PLAN ONLY` output and arrange a maintenance window for the first real
|
|
||||||
deploy/restart. Set `PROD_DEPLOY_ENABLED=true`, manually run `deploy_action=deploy`.
|
|
||||||
5. After `DEPLOY VERIFIED`, subsequent pushes to `main` run Verify → deploy.
|
|
||||||
Pushes to other branches and pull requests never deploy. A manual run defaults
|
|
||||||
to plan, even when automatic deployment is enabled.
|
|
||||||
|
|
||||||
The deploy action supports updates to an existing healthy deployment. For first-time
|
|
||||||
creation use the explicit manual bootstrap flow below. No real production deployment or rollback drill has
|
|
||||||
been performed during implementation; those are first-run acceptance steps.
|
|
||||||
|
|
||||||
## Deployment transaction and rollback
|
|
||||||
|
|
||||||
1. Verify job must succeed completely, including isolated Docker tests/cleanup.
|
|
||||||
2. Deploy job checks out the same revision and prepares tools. An atomic
|
|
||||||
`docker create` reservation named `<PROD_CONTAINER>-deploy-lock` serializes
|
|
||||||
production changes on the daemon. Job `concurrency` is also declared, but
|
|
||||||
correctness does not depend on Gitea implementing it. A competing deploy
|
|
||||||
fails safely instead of changing production. No other job may remove a lock
|
|
||||||
whose owner label differs from its own.
|
|
||||||
3. Re-read the current baseline under the lock; confirm ready/smoke. Record exact
|
|
||||||
image ID, runtime Compose/environment, version/commit. Pin the old image with
|
|
||||||
an immutable rollback tag so normal dangling-image cleanup cannot remove it.
|
|
||||||
4. Refuse a stale checked-out `main` revision that no longer equals remote main.
|
|
||||||
Build an immutable `release-<commit>-<owner>` tag locally, without a registry
|
|
||||||
then point `latest` at the candidate; CI image tags are never changed.
|
|
||||||
5. Persist a `prepared` journal with old/new runtime configs. Create an additive
|
|
||||||
SQLite online backup under `/data/deploy-backups/<owner>.db`. No data/config
|
|
||||||
directory or file is removed, replaced or recursively chowned.
|
|
||||||
6. `compose up -d --no-build --pull never` replaces only the configured service.
|
|
||||||
Check `/ready`, the unchanged nine-endpoint read-only smoke test, and exact
|
|
||||||
`/api/v1/version` commit. Only all checks passing produce `DEPLOY VERIFIED`.
|
|
||||||
7. Any activation/verification failure runs `up` again with the saved exact old
|
|
||||||
image ID and old runtime environment/mounts, then repeats ready/smoke/commit
|
|
||||||
verification. A failed deploy remains a failed workflow even if rollback
|
|
||||||
succeeds. If rollback fails, the journal/lock are retained, failure is explicit,
|
|
||||||
and redacted scoped diagnostics are emitted.
|
|
||||||
8. An `always()` step uses the recorded owner to recover an interrupted prepared
|
|
||||||
transaction and release only its own lock. Abrupt host loss or termination of
|
|
||||||
the entire runner can prevent both handlers: inspect the journal/lock before
|
|
||||||
manually recovering; never automatically force-unlock an unknown owner.
|
|
||||||
|
|
||||||
Rollback is an **image/config rollback**, not an automatic database rewind.
|
|
||||||
User data accumulated during deployment remains. The SQLite backup is for
|
|
||||||
operator-reviewed recovery; destructive or backward-incompatible future schema
|
|
||||||
changes must not be auto-deployed without a separate migration/backup plan.
|
|
||||||
Baseline artifacts and additive backups are retained for the operator; there is
|
|
||||||
no automated pruning of production rollback history or data.
|
|
||||||
|
|
||||||
After enabling CD, use its workflow/script as the production lifecycle owner.
|
|
||||||
The old NAS checkout's Compose/.env files are not rewritten remotely by CD and
|
|
||||||
may describe an older deployment. Do not concurrently recreate production from
|
|
||||||
that old checkout. The repository's standalone Compose remains usable for an
|
|
||||||
independent deployment; the private journal records the CD runtime configuration.
|
|
||||||
|
|
||||||
## Actual commit and manual rollback
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -fsS http://NAS_LAN_IP:PORT/api/v1/version
|
|
||||||
# Check `commit`, `version`, and `build_time` against successful deployment output.
|
|
||||||
docker inspect PRODUCTION_CONTAINER --format '{{.Image}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
For a manual rollback: keep CD enabled and run **Verify Frame Flow** on `main`
|
|
||||||
with `deploy_action=rollback`. After Verify passes, the script locks production,
|
|
||||||
loads the last journal's `previous` image/config and verifies recovery. This is
|
|
||||||
last-baseline rollback, not an arbitrary image selector. Never use `down -v`,
|
|
||||||
volume pruning, or delete data/config to roll back.
|
|
||||||
|
|
||||||
If a failed run retained a lock, inspect only its owner label:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker inspect PRODUCTION_CONTAINER-deploy-lock --format '{{index .Config.Labels "frameflow.deploy.owner"}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
With the documented Variables configured in a trusted Docker-enabled shell:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
FRAME_FLOW_DEPLOY_OWNER=<confirmed failed owner> CD_ACTION=recover python scripts/production-deploy.py
|
|
||||||
```
|
|
||||||
|
|
||||||
`recover` retries that owner's journaled rollback and retains the lock again if
|
|
||||||
recovery fails. If the very first attempt stopped before a journal was written,
|
|
||||||
confirm that the original image is still running and healthy before an operator
|
|
||||||
removes the stale lock. Do not delete a live lock or any persistence volume.
|
|
||||||
|
|
||||||
## First-time production bootstrap (explicit manual action)
|
|
||||||
|
|
||||||
The confirmed NAS residue is a `created`, never-started container with dead
|
|
||||||
`/workspace/way/...` bind paths. It is not an adoption/rollback baseline.
|
|
||||||
No migration from those paths is performed. Production Compose remains independently usable.
|
|
||||||
|
|
||||||
Set the ordinary repository Actions Variables below; set paths and addresses as Secrets (see docs/PUBLICATION.md):
|
|
||||||
|
|
||||||
```text
|
|
||||||
PROD_DEPLOY_ENABLED=true
|
|
||||||
PROD_PROJECT=pigway-frame-flow
|
|
||||||
PROD_CONTAINER=pigway-frame-flow
|
|
||||||
PROD_IMAGE_REPO=pigway-frame-flow
|
|
||||||
PROD_DATA_DIR=/volume1/docker/pigway-frame-flow/data
|
|
||||||
PROD_CONFIG_DIR=/volume1/docker/pigway-frame-flow/config
|
|
||||||
PROD_WEB_BIND=192.0.2.10
|
|
||||||
PROD_WEB_PORT=6005
|
|
||||||
PROD_STATE_VOLUME=pigway-frame-flow-cd-state
|
|
||||||
PROD_MOCK=true
|
|
||||||
PROD_WRITE_ENABLED=false
|
|
||||||
```
|
|
||||||
|
|
||||||
Leave `PROD_PHOTOS_DIR` unset/empty. Existing mirror Variables still apply.
|
|
||||||
Enabling CD does not enable real frame writes. Before the first successful bootstrap,
|
|
||||||
ordinary main pushes report `production not bootstrapped / bootstrap required`.
|
|
||||||
They cannot create production. `bootstrap` additionally requires the Actions event
|
|
||||||
`workflow_dispatch`; the default action remains `plan`. Every action is gated by
|
|
||||||
successful complete Verify on main.
|
|
||||||
|
|
||||||
Create repository Actions **Secret** `PRODUCTION_ENV` as multiline bare `KEY=value`:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
FRAME_FLOW_TOKEN=replace-with-a-long-random-private-token
|
|
||||||
KOKONNA_API_KEY=
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not add shell quotes, `export`, interpolation or spaces around `=`. An existing
|
|
||||||
KoKonna key may be supplied but first bootstrap forcibly uses Mock with writes off.
|
|
||||||
Optional application secrets/settings may be added in the same format; bootstrap
|
|
||||||
always overrides Mock/write flags, data/config paths, timezone and build identity.
|
|
||||||
This token remains useful for API/smoke checks even if Web UI uses account login.
|
|
||||||
Do not paste the real Secret into logs or repository files.
|
|
||||||
|
|
||||||
### Safety and cleanup
|
|
||||||
|
|
||||||
Bootstrap builds the verified main revision first, then acquires the same atomic
|
|
||||||
Docker daemon deployment lock used by deploy. Host path checks run in a short-lived,
|
|
||||||
network-disabled helper with **read-only** NAS root mounted, not CI workspace paths.
|
|
||||||
Synology does not automatically create missing bind sources with `-v`. A temporary
|
|
||||||
network-disabled helper mounts the explicitly configured existing parent directories
|
|
||||||
and creates only their data/config leaves, owned `10333:10333`, mode `0750`. It never
|
|
||||||
mounts NAS root writable, recursively changes parent permissions, or deletes contents.
|
|
||||||
Parents must already exist; missing parents fail closed. Nonempty directories, symlinks
|
|
||||||
(including ancestor symlinks), overlapping paths and unknown journal resources fail closed.
|
|
||||||
|
|
||||||
A same-name residue can be removed **without force** only after checking all of:
|
|
||||||
Compose project/service labels; `Status=created`; `Running=false`;
|
|
||||||
`StartedAt=0001-01-01T00:00:00Z`; exactly two bind mounts `/data` and `/config`;
|
|
||||||
and both old host sources absent. A running, previously started, unknown or data-bearing
|
|
||||||
container is refused. An existing default network must be owned by this Compose
|
|
||||||
project and contain no endpoints other than the validated residue.
|
|
||||||
|
|
||||||
The production service uses `create_host_path=false`, the explicit NAS LAN bind,
|
|
||||||
non-root UID, `unless-stopped`, normal bridge network and existing resource/security
|
|
||||||
limits. Acceptance runs ready, all read-only smoke endpoints, version/commit,
|
|
||||||
preflight mount/port/project/user/restart checks, one restart, repeated checks,
|
|
||||||
Docker health and exact built-image ID before `BOOTSTRAP VERIFIED`.
|
|
||||||
|
|
||||||
On failure after transaction preparation, cleanup removes only this transaction's
|
|
||||||
owner-labelled new container and its newly created, empty, project-labelled network.
|
|
||||||
The previously validated never-started residue has no rollback value and is not recreated.
|
|
||||||
**Data/config files, images/build cache and the private CD journal volume are retained.**
|
|
||||||
No `down -v`, volume prune or removal of unrelated/CI resources is used.
|
|
||||||
Cleanup failure retains the lock so the always recovery step can retry using the journal.
|
|
||||||
Only an explicitly dispatched bootstrap may retry a failed first-install journal, and
|
|
||||||
only when its project/container/mounts/port match, its volume ownership matches, and
|
|
||||||
target directories remain empty or absent. Prepared/success/foreign journals are refused.
|
|
||||||
On a new transaction, the prior journal is archived as `deploy.<owner>.json` inside
|
|
||||||
the private state volume before replacing the active journal; generated data is never
|
|
||||||
automatically emptied and journals are never deleted.
|
|
||||||
Abrupt host/runner loss still requires operator review of the lock/journal.
|
|
||||||
The journal is private (0700/0600) and may contain prior/candidate secrets; never export it
|
|
||||||
as an Actions artifact. First bootstrap has no prior healthy version to roll back to.
|
|
||||||
|
|
||||||
### First run and acceptance
|
|
||||||
|
|
||||||
1. In Gitea repository **Actions → Verify Frame Flow → Run workflow**, choose branch
|
|
||||||
`main`, action **plan**. This is read-only and reports bootstrap required for the
|
|
||||||
absent/created instance; it is not a bootstrap safety acceptance. Detailed host
|
|
||||||
residue/directory checks happen under the lock in bootstrap.
|
|
||||||
2. Run the same workflow on `main` with action **bootstrap**. Verify runs in full first.
|
|
||||||
No NAS bootstrap is executed merely by committing this implementation.
|
|
||||||
3. Require `BOOTSTRAP VERIFIED` in the deploy log. In Container Manager confirm
|
|
||||||
container `pigway-frame-flow` is running/healthy, user 10333, restart unless-stopped,
|
|
||||||
exact `/volume1/docker/pigway-frame-flow/{data,config}` mounts and
|
|
||||||
`192.0.2.10:6005` binding (not loopback).
|
|
||||||
4. Open `http://192.0.2.10:6005/api/v1/version`; its `commit` must equal the full
|
|
||||||
workflow checkout commit. Check `/ready` returns HTTP 200. The immutable image tag
|
|
||||||
is `pigway-frame-flow:latest`; compare its image ID in
|
|
||||||
Container Manager with the running container.
|
|
||||||
5. Keep `PROD_DEPLOY_ENABLED=true`. Subsequent main pushes Verify then deploy using
|
|
||||||
this same runtime and journal mechanism. The first update records bootstrap as
|
|
||||||
its rollback baseline. Repeating bootstrap against this instance is refused.
|
|
||||||
Enable real-device read-only access and write access only through separate explicit
|
|
||||||
operator decisions; no automatic change is made by bootstrap.
|
|
||||||
|
|
||||||
Docker tests are mocked locally. The first manual NAS Runner bootstrap, including
|
|
||||||
real host mounts and restart, remains the required live acceptance.
|
|
||||||
|
|
||||||
## Actions download transport
|
|
||||||
|
|
||||||
Verify and deploy reference official GitHub Actions directly, pinned to immutable
|
|
||||||
commits rather than moving tags:
|
|
||||||
|
|
||||||
| Action | Official upstream commit |
|
|
||||||
| --- | --- |
|
|
||||||
| actions/checkout v4 | 11d5960a326750d5838078e36cf38b85af677262 |
|
|
||||||
| actions/setup-python v5 | a26af69be951a213d495a4c3e4e4022e16d87065 |
|
|
||||||
|
|
||||||
The workflow uses explicit `https://github.com/actions/...` URLs so Gitea's
|
|
||||||
configured default Actions host cannot redirect downloads to the project owner's
|
|
||||||
server. Forks download Actions through their own Runner/network; no public action
|
|
||||||
mirror is served by this project. The former Tea auxiliary repository is private
|
|
||||||
and is no longer required by current workflows.
|
|
||||||
|
|
||||||
Keep the Runner's action cache and Python toolcache persistent. For Gitea Runner
|
|
||||||
versions supporting `cache.offline_mode`, use a private Runner configuration:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
runner:
|
|
||||||
file: /data/.runner
|
|
||||||
cache:
|
|
||||||
offline_mode: true
|
|
||||||
```
|
|
||||||
|
|
||||||
In the Runner's own Compose service (not the application), set `HOME=/data` and
|
|
||||||
`CONFIG_FILE=/data/config.yaml`, with `/data` already persisted. Before recreating
|
|
||||||
an idle Runner, back up its configuration and copy the existing action cache from
|
|
||||||
`/root/.cache/act` to `/data/.cache/act`. Preserve registration, labels, image,
|
|
||||||
mounts and networks; do not interrupt active jobs or recreate other services.
|
|
||||||
Verify cached actions against the pinned upstream commit and tree before reuse.
|
|
||||||
This is administrator configuration, not a public workflow dependency.
|
|
||||||
|
|
||||||
Offline reuse avoids refreshing cached actions from GitHub on each job. Moving
|
|
||||||
tags/branches may remain at the cached version; this workflow deliberately pins
|
|
||||||
immutable SHAs. A new action URL or commit requires an initial download or verified
|
|
||||||
administrator prewarming; an old mirror subtree is not automatically equivalent
|
|
||||||
to an upstream Git repository. Do not prune Runner caches between jobs. Python
|
|
||||||
toolcache misses still download Python from its upstream release source. Network
|
|
||||||
failures remain failures, never reasons to skip validation or silently fall back
|
|
||||||
to the owner's Tea server.
|
|
||||||
|
|
||||||
Administrators needing a mainland-specific download route can configure their
|
|
||||||
own Runner/network cache privately. Such settings must not introduce public
|
|
||||||
workflow references to a personal mirror. This change does not modify NAS VPN,
|
|
||||||
proxy, firewall or other applications. All validation and deploy gates remain.
|
|
||||||
|
|
||||||
### Synology Photos dependencies
|
|
||||||
|
|
||||||
For a production photo source using Synology Photos person filtering, configure
|
|
||||||
`SYNOLOGY_PHOTOS_URL` and `SYNOLOGY_PHOTOS_CERT_SHA256` as Gitea Secrets. The
|
|
||||||
certificate fingerprint must match the configured HTTPS endpoint. Keep the Photos
|
|
||||||
session in the persistent `/config` directory; never put session credentials in
|
|
||||||
Variables or repository files.
|
|
||||||
|
|
||||||
`PROD_PHOTOS_DIR` must identify the actual NAS photo directory mounted read-only
|
|
||||||
at `/photos`. Adoption requires that mount to exist on the current container;
|
|
||||||
changing this Variable alone does not create a new mount. Existing supplementary
|
|
||||||
permission groups are preserved by deployment.
|
|
||||||
|
|
||||||
Restoring SQLite and session files does not restore these runtime dependencies.
|
|
||||||
Production smoke checks now reject an enabled people-filtered source if its
|
|
||||||
Photos configuration or stored connection is missing. These checks do not prove
|
|
||||||
session validity or scan the complete catalog: after restoring a source, also
|
|
||||||
verify its read-only `/api/v1/photo-sources/{id}/images?limit=1` endpoint before
|
|
||||||
running an automation task.
|
|
||||||
|
|
||||||
### Advisory-service network resilience
|
|
||||||
|
|
||||||
CI dependency installation uses the domestic `CI_PIP_INDEX_URL` first and the
|
|
||||||
`CI_PIP_FALLBACK_INDEX_URL` sequentially on failure. These package indexes do not
|
|
||||||
replace PyPI's vulnerability JSON API.
|
|
||||||
|
|
||||||
`python scripts/ci-audit.py` retains the pinned `requirements.lock`, PyPI advisory
|
|
||||||
service and `--no-deps --disable-pip` checks. It uses a 60-second socket timeout,
|
|
||||||
at most three attempts, and 5/10-second backoff only for network failures. Known
|
|
||||||
vulnerabilities, malformed/incomplete reports and skipped dependencies fail
|
|
||||||
immediately. Every attempt has a five-minute process limit. A timeout or failed
|
|
||||||
final attempt still blocks deployment.
|
|
||||||
|
|
||||||
The advisory HTTP cache is reused within the job (including retries) and follows
|
|
||||||
the audit client's normal HTTP freshness rules. It is not a permanent offline
|
|
||||||
snapshot or a cross-job cache. Raw network exceptions are omitted because proxy
|
|
||||||
URLs may contain credentials. Logs identify the advisory host and whether the
|
|
||||||
complete audit succeeded.
|
|
||||||
|
|
||||||
### Production image retention
|
|
||||||
|
|
||||||
Production uses `pigway-frame-flow:latest`. Builds first create a unique release
|
|
||||||
image, then tag it as latest before activation. The old image is pinned during
|
|
||||||
an update so a failed deployment can restore it. Only after the new version
|
|
||||||
passes verification are old release/rollback images and duplicate tags removed.
|
|
||||||
After successful deployment, only the current production image remains; there
|
|
||||||
is no retained historical image for manual rollback. Rebuild a historical
|
|
||||||
revision before manually reverting a successful deployment.
|
|
||||||
|
|
||||||
Images referenced by other containers (including stopped containers), images
|
|
||||||
with unrelated tags, and CI resources remain protected. Cleanup never prunes
|
|
||||||
volumes, data, or shared build cache. Cleanup failure is a warning and does not
|
|
||||||
roll back a verified deployment. Failed deployments do not run cleanup.
|
|
||||||
@@ -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.18"
|
"version": "0.1.23"
|
||||||
},
|
},
|
||||||
"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.18"
|
"default": "0.1.23"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -17490,7 +17490,7 @@
|
|||||||
"version": {
|
"version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"title": "Version",
|
"title": "Version",
|
||||||
"default": "0.1.18"
|
"default": "0.1.23"
|
||||||
},
|
},
|
||||||
"api_version": {
|
"api_version": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
# v0.1.11
|
|
||||||
|
|
||||||
照片底部单行按“日期 · 已命名人物 · 主题 · 地点 · 文件名”排列,空项省略,人物之间也使用 ` · `。主题、日期和地点沿用最近的日期文件夹解析;没有结构化日期目录时仍保留文件名。人物来自 Synology Photos 已命名人物相册的实际图片归属,按账号会话和空间缓存 15 分钟,不显示未命名人物编号。人物查询失败时保留图片及其他描述。
|
|
||||||
|
|
||||||
新建照片源默认开启底部文字,既有来源保留明确设置。横向来源继续同时约束选图和生成画布,不拉伸图片。更新来源表单说明与浏览描述顺序。
|
|
||||||
|
|
||||||
使用既有 Ubuntu Server 发布流程,备份配置、数据库与 Compose,从提交源码构建并核验部署哈希;失败恢复旧 Compose。发布过程不主动切换真实画框,不重绘历史图库图片。
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# v0.1.12
|
|
||||||
|
|
||||||
修复临时展示到期或手动恢复后,“上一张图片”仍指向当前图片,导致已确认删除仍被恢复保护拦截的问题。只有设备已确认同步到受管理的当前图片、且没有基础恢复目标、临时展示或待确认写入时,才清除这条自身引用;不同图片的有效恢复引用继续受保护。既有残留引用在读取设备状态或删除校验时自动修正,不需要强制删除图片或重置数据库。
|
|
||||||
|
|
||||||
有效恢复引用仍拒绝删除,并通过结构化原因显示中英文提示。保留当前图片删除确认,不主动删除照片或切换画框。
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# v0.1.13
|
|
||||||
|
|
||||||
照片底部同时显示所有已识别且已命名的人物,人物之间使用 ` & `;日期、人物、主题、地点及文件名之间继续使用 ` · `。例如 `2026-10-18 · Alice & Bob · Family · one.jpg`。人物仍来自 Photos 实际相册归属,未命名人物不显示;历史生成图片不自动重绘。
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# v0.1.14
|
|
||||||
|
|
||||||
底部文字仅显示日期、已命名人物(使用 & 连接)、文件夹主题及地点,不显示原始文件名。原始文件名仍用于图库和诊断记录。
|
|
||||||
|
|
||||||
上传传输区分明确的 DNS/连接建立失败与已建立连接中断:前者尚未发送 HTTP 写入,最多重试三次,失败后结束该次操作,不进入阻塞后续写入的 UNKNOWN;读超时或连接中断等不确定结果继续保留核对保护,不盲目重放上传。既有未知操作通过只读设备/图库核验后使用原有 acknowledge 流程关闭,保留原始错误和审计记录。
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
# v0.1.15
|
|
||||||
|
|
||||||
自动照片、拼版、模板及数据模板任务在上传失败后,持久保存同一张暂存图片,并在下一次计划之前安排最多 5 次额外重试。间隔按 1、2、4、8、16 权重递增,随剩余窗口缩放,计划时间集中在窗口前半段,为上传与设备同步留出时间。重试不移动原定计划,也不复用旧操作 ID;每次独立记录操作及事件。成功后取消剩余重试,重启后沿用已有计数与截止时间;停用任务不再追加重试,删除任务取消其重试计划。
|
|
||||||
|
|
||||||
若额外重试均失败,下一次计划仍先上传同一张图;若这一次仍失败,只删除该任务未入库、未星标的生成缓存,再按照片源生成并上传新图。NAS 原照片、用户上传文件和成功图库图片不由此流程删除。新图仍失败时,进入它自己的下一轮恢复窗口。
|
|
||||||
|
|
||||||
上传结果未知不再阻塞后续独立上传。每次重试前先只读核对设备及云图库,精确确认已成功的上传不再重传;需继续尝试时关闭旧尝试并保留错误和核对审计。删除、恢复及自动清理仍保留各自的保护条件。重试窗口到期后不再启动额外上传,待同步尝试经核对结束后交给原计划执行。
|
|
||||||
|
|
||||||
任务生成图片先保存在缓存,确认任务成功后才进入本地图库;失败不会产生看似成功的本地图库记录。额外重试仍归属原任务的云端保留规则,任务列表显示最近尝试结果。
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# v0.1.16
|
|
||||||
|
|
||||||
统一 PIGWay 品牌写法,优化图库、自动化、历史记录、设置、连接中心和拼图页面布局。
|
|
||||||
|
|
||||||
- 图库直接点击图片查看,移除重复的查看按钮;缩略图底框为正方形,卡片宽度适配单行操作按钮。
|
|
||||||
- 全站共用更宽的内容区域,宽屏图库可一行显示三张卡片;搜索、排序、来源和任务筛选合并排列。
|
|
||||||
- 自动化与历史记录状态改为仅保留颜色的文字,优化列宽与操作对齐。
|
|
||||||
- 设置与鉴权信息保持左右并排,鉴权卡片内部信息改为单列;相关设置控件紧凑排列。
|
|
||||||
- 连接中心和自动拼图短字段合并排列,按钮按内容宽度显示,空结果提示不再占据额外空间。
|
|
||||||
- 窄屏自动换行,保留图片预览、长文本和表格所需空间。
|
|
||||||
|
|
||||||
本轮界面检查覆盖 11 个主页面、本地图库及弹窗,中英文、明暗主题和四种屏幕宽度共 289 项布局检查通过;隔离环境拼图流程 8 项检查通过。生产部署后的界面复查使用隔离数据,没有执行真实画框写入。
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.17
|
|
||||||
|
|
||||||
- 总览预览改用原图,避免放大低分辨率缩略图。
|
|
||||||
- 云端图库缩略图升级为最大 860×860,等比缩放、JPEG 质量 88;原位替换旧缓存,仍只保留一档。点击放大继续读取原图。
|
|
||||||
- 照片源新增最小短边、最小长边参数,同时检查源照片和实际取得的预览尺寸,默认 0 不限制。
|
|
||||||
- 本地图库显示 NAS 原照片完整路径并支持换行,保留原文件名大小写和后缀;历史四张图片已按照片 ID 核实并补齐来源。
|
|
||||||
- 自动化新增“目标相框”列;历史记录的状态、结果、调用方及操作按钮垂直居中。
|
|
||||||
- 左上角统一显示 PIGWay Frame Flow,移除图标和重复品牌前缀。
|
|
||||||
- 设置与鉴权保持左右并排、卡片等高,鉴权内部单列。图片预览弹窗保留原有布局。
|
|
||||||
|
|
||||||
不修改 NAS 原照片,不主动切换真实画框。清理仅针对项目临时构建物及过期镜像,保留持久数据和近期回滚资源。
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# v0.1.18
|
|
||||||
|
|
||||||
- Synology Photos 列表显式请求照片方向信息;按 EXIF 旋转后的宽高筛选横向、竖向和自动方向,避免存储像素为横向的竖拍照片误入横图来源。
|
|
||||||
- 渲染前复核实际取得图片的 EXIF 方向;若元数据或缓存预览与来源方向不符,则跳过该候选。显式选择方向不符的照片时返回明确错误。
|
|
||||||
- 保持既有水印和铺满布局,不修改 NAS 原照片、来源设置或现有图库;修复不自动重新生成、上传或切换已显示图片。
|
|
||||||
- 源码及镜像保留 LICENSE、NOTICE;公开验证只使用合成照片和通用示例,私有部署参数与回执保存在仓库之外。
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# PIGWay Frame Flow v0.1.5
|
|
||||||
|
|
||||||
## 简体中文
|
|
||||||
|
|
||||||
- 图片仅在真实图片区域内居中显示加载动画,无页面骨架、虚构图片卡片或人为展示延时;请求及解码完成后立即显示,失败时显示局部提示。
|
|
||||||
- 图库搜索、排序、卡片操作及写入保护先于图片请求初始化,复用真实列表元数据;本地任务筛选异步填充,不阻塞其他控件。
|
|
||||||
- 手动拼版改为并行加载,各图独立完成或失败;固定区域容器保留坐标和拖动交互,加载前后位置一致。
|
|
||||||
- 修复手机底栏文字裁切、窄屏页头拥挤和横屏导航可达性,支持底部安全区域,补齐表单标签和当前导航无障碍状态。
|
|
||||||
- 默认对外访问端口改为 **6005**。本地启动、Compose、测试地址及文档同步;容器内部仍监听 18787。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
本地 505 项 Python 测试、Ruff、格式检查、mypy、JavaScript 语法和差异检查通过。
|
|
||||||
浏览器回归:布局 305 项、图片加载 45 项、图库加载顺序 14 项、手动拼版 31 项通过。
|
|
||||||
先行 UI 部署的生产静态文件哈希、health/ready 和拦截测试数据下的浏览器交互已验证;
|
|
||||||
不代表真实 AI 生成、画框写入、手机实机或软键盘测试。
|
|
||||||
|
|
||||||
### 升级说明
|
|
||||||
|
|
||||||
已有安装须更新私有环境配置中的 `WEB_PORT=6005`,并重建服务后生效。
|
|
||||||
自动部署的 `PROD_WEB_PORT` 必须与现有实例的映射一致;仅修改该变量不能迁移端口。
|
|
||||||
NAS 附件是源码部署包,需自行构建 Docker 镜像,不含密钥、运行配置、数据库或私人照片。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
Image loading indicators stay inside real image regions, without page skeletons or artificial delays.
|
|
||||||
Gallery controls initialize before image requests. Manual collage images load independently in parallel,
|
|
||||||
with stable regions and working coordinate/drag controls. Mobile navigation, safe-area spacing and form
|
|
||||||
accessibility are improved. The default published port is now **6005**, mapped to container port 18787.
|
|
||||||
Existing installations must update their private port configuration and recreate the service.
|
|
||||||
NAS source package and SHA256 checksum are included; this is not a prebuilt Docker image.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# PIGWay Frame Flow v0.1.6
|
|
||||||
|
|
||||||
## 简体中文
|
|
||||||
|
|
||||||
- 图库使用具体目的地标签:`KoKonna 云端图库` 与 `<NAS 名称> 本地图库`,NAS 名称通过 `FRAME_FLOW_STORAGE_NAME` 配置。
|
|
||||||
- Settings API 增加目的地元数据,标签仅展示连接已配置、明确支持保存且存在适配器的目的地;全局写入保护保持独立。
|
|
||||||
- 后续网盘须实现读取/保存适配器并完成连接后才能加入标签栏,显示具体服务商/实例名称。当前只读照片来源不会作为保存目的地展示。
|
|
||||||
- 长标签在手机上换行,避免整页横向溢出;中英文保持相同目的地身份。
|
|
||||||
- Compose 和自动部署透传 NAS 展示名称;分支 CI 完整验证后发布标签,避免相同提交的标签推送重复占用构建队列。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
本地 507 项 Python 测试、Ruff、格式检查、mypy、JavaScript 语法检查通过。
|
|
||||||
图库目的地标签与能力过滤 18 项浏览器检查通过;覆盖 320–1440 像素、中英文、明暗主题和长 NAS 名称。
|
|
||||||
本次不增加任何网盘写入功能,不改变画框写入策略。
|
|
||||||
|
|
||||||
### 升级
|
|
||||||
|
|
||||||
Compose 设置 `FRAME_FLOW_STORAGE_NAME`;Actions 设置同名仓库 Variable。
|
|
||||||
名称只描述实例,不作为路径或授权凭据。未设置时回退为 NAS。
|
|
||||||
默认访问端口仍为 6005,容器内部端口仍为 18787。
|
|
||||||
NAS 源码包与 SHA256 附件不含运行配置、密钥、数据库或私人照片,需自行构建镜像。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
Gallery tabs identify the concrete destination: KoKonna cloud gallery and the configured NAS name plus Local gallery.
|
|
||||||
Destination metadata includes connection and upload capabilities; only implemented writable adapters are shown.
|
|
||||||
Existing read-only cloud photo sources are not presented as save destinations. Long labels wrap on mobile.
|
|
||||||
Set FRAME_FLOW_STORAGE_NAME in Compose or the Actions repository Variables. No cloud-drive write support or device write policy is changed.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# PIGWay Frame Flow v0.1.7
|
|
||||||
|
|
||||||
## 简体中文
|
|
||||||
|
|
||||||
修复首次打开图库时内容区空白、必须等待云端图片列表返回才出现控件的问题。
|
|
||||||
|
|
||||||
- 目的地标签、搜索、排序、刷新和上传按钮立即初始化,图片列表独立请求。
|
|
||||||
- 仅在工具栏提示正在读取列表;未知数量时不生成图片卡片,不提前显示“图库为空”。实际图片仍在自己的区域内独立加载。
|
|
||||||
- 启动使用轻量设备身份目录,完整设备状态在后台更新,避免厂商状态请求阻塞首屏。
|
|
||||||
- 慢请求期间可以切换到 NAS 图库;过期响应不会覆盖当前页面。
|
|
||||||
- 列表失败时保留控件并在工具栏显示错误,仍可刷新或切换目的地。
|
|
||||||
- 保留等待期间输入的搜索词和排序选择;本地任务筛选请求在同一次导航内复用。
|
|
||||||
|
|
||||||
### 验证
|
|
||||||
|
|
||||||
本地 508 项 Python 测试、Ruff、格式检查、mypy 和 JavaScript 语法检查通过。
|
|
||||||
新增首次未缓存列表的控件就绪、输入保留、慢云端切换和列表失败回归。
|
|
||||||
不增加图库列表的服务端缓存,不修改厂商请求重试、照片、生成内容或设备写入策略。
|
|
||||||
|
|
||||||
## English
|
|
||||||
|
|
||||||
Fix the blank gallery on its first uncached visit. Destination tabs and gallery controls initialize before metadata requests complete.
|
|
||||||
Slow or failed cloud listing does not prevent switching to NAS storage. Search and sort choices survive list arrival, and stale responses cannot replace the active destination.
|
|
||||||
Only a small toolbar status indicates metadata loading; real images keep independent loading indicators.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# PIGWay Frame Flow v0.1.8
|
|
||||||
|
|
||||||
增加 NAS 本地图库反选、放大总览图片,并统一图片查看器与界面表达。
|
|
||||||
|
|
||||||
- 反选只切换当前页可见图片的勾选状态;隐藏勾选项的批量操作范围明确提示。
|
|
||||||
- 总览当前图片预览增大,点击或键盘打开与图库相同的查看器,读取原图并支持适应屏幕、原始尺寸和 Esc 关闭。
|
|
||||||
- 修正图库排序、播放顺序、型号信息来源、云端确认状态等标签,补齐天气与连接状态翻译。
|
|
||||||
- 区分本地与云端删除影响,解释星标保留规则,避免对未知来源作无依据判断。
|
|
||||||
- 修正数据源空态、路径与凭据说明;同步 README 和操作文档中的共享 Photos 登录机制与入口。
|
|
||||||
|
|
||||||
验证:305 项布局检查、8 组星标与反选检查、12 组总览查看器检查、45 项图片加载检查及新增中英文文案检查通过。全套后端与静态检查由本次发布流水线再次验证。检查不涉及真实照片删除、设备写入或收费 AI 调用。
|
|
||||||
|
|
||||||
Improve NAS gallery selection, dashboard image viewing and UI wording. Invert selection affects visible items on the current page. Dashboard previews open the same full-image viewer as the gallery. Storage destinations, deletion scope, server-side stars, cloud confirmation and shared Photos credentials are clarified in both languages.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# v0.1.9
|
|
||||||
|
|
||||||
照片来源通过 Synology Photos API 按账号权限读取,共享/个人空间、人物 any/all 筛选和有界 XL 预览缓存可用。生产拒绝文件系统照片来源及 /photos 挂载;本地上传、生成图片和 Server 本地图库继续保留。NAS 原图下载接口曾被拒绝,本版不宣称支持原图下载。
|
|
||||||
|
|
||||||
自动化新增 1~10080 分钟的真实间隔,跨整点与跨天不按 Cron 字段重置,既有 Cron 任务保持原样。显示时长明确为到期恢复期限。统一来源快捷创建任务编辑器,改善登录及来源表单响应式布局、设备显示名与历史记录中英文;诊断详情保留原始标识。
|
|
||||||
|
|
||||||
## 生产部署
|
|
||||||
|
|
||||||
生产已迁至 Ubuntu Server,由 systemd 管理 Docker;业务、Docker 数据和恢复资料位于 T7,并有 UUID 与挂载依赖保护。NAS 旧 Frame 专属资源已清理且独立复核 PASS。CI 保留隔离验证,移除 NAS 自动生产部署任务,不再向 NAS 发布。
|
|
||||||
|
|
||||||
正式发布使用 scripts/server-release.py:仅在既有 Ubuntu Server 环境运行,从已提交源码包构建,校验依赖锁、源码哈希、T7 UUID、容器安全约束和真实写入设置;备份 SQLite、配置及 Compose 后切换镜像,失败恢复旧 Compose,不自动回写旧数据库。它不执行上传、显示、删除或恢复图片等设备操作。成功后记录正式提交号、保存源码包及审计回执,删除本次暂存目录;白名单内的 Frame 旧迁移输入归档到本次 recovery,清除无恢复引用的临时镜像别名,共享 Console 资料不动。旧镜像及历史恢复资料保留供回滚,不执行全局 prune。
|
|
||||||
|
|
||||||
脚本要求由 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 并独立检查线上接口。
|
|
||||||
|
|
||||||
## 验收范围
|
|
||||||
|
|
||||||
完整后端测试、静态检查与桌面/平板/手机的中英文浏览器回归纳入发布检查。线上历史补丁已核验,真实写入保持开启;本轮正式发布不重复真实设备写入测试。隔离浏览器验证与真实设备写入证据由维护者私下保管。
|
|
||||||
|
|
||||||
局域网 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.18",
|
version="0.1.26",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
responses=error_responses,
|
responses=error_responses,
|
||||||
)
|
)
|
||||||
@@ -1040,7 +1040,7 @@ def create_app(settings=None, start_worker=True):
|
|||||||
supports_upload=True,
|
supports_upload=True,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
version="0.1.18",
|
version="0.1.26",
|
||||||
mock=settings.mock,
|
mock=settings.mock,
|
||||||
write_enabled=settings.write_enabled,
|
write_enabled=settings.write_enabled,
|
||||||
api_key_configured=bool(settings.api_key)
|
api_key_configured=bool(settings.api_key)
|
||||||
@@ -1313,6 +1313,10 @@ def create_app(settings=None, start_worker=True):
|
|||||||
"i18n.js",
|
"i18n.js",
|
||||||
"swagger-ui-bundle.js",
|
"swagger-ui-bundle.js",
|
||||||
"swagger-ui.css",
|
"swagger-ui.css",
|
||||||
|
"favicon.svg",
|
||||||
|
"favicon.ico",
|
||||||
|
"apple-touch-icon.png",
|
||||||
|
"logo-symbol.svg",
|
||||||
]:
|
]:
|
||||||
raise FlowError(E.NOT_FOUND, "Resource not found", 404)
|
raise FlowError(E.NOT_FOUND, "Resource not found", 404)
|
||||||
return FileResponse(root / filename)
|
return FileResponse(root / filename)
|
||||||
|
|||||||
@@ -21,16 +21,22 @@ cookie = APIKeyCookie(name=COOKIE, auto_error=False, scheme_name="AccountSession
|
|||||||
def password_hash(password: str) -> str:
|
def password_hash(password: str) -> str:
|
||||||
salt = secrets.token_hex(16)
|
salt = secrets.token_hex(16)
|
||||||
digest = hashlib.scrypt(
|
digest = hashlib.scrypt(
|
||||||
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1
|
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=5
|
||||||
).hex()
|
).hex()
|
||||||
return f"scrypt${salt}${digest}"
|
return f"scrypt-v2${salt}${digest}"
|
||||||
|
|
||||||
|
|
||||||
def verify_password(password: str, encoded: str) -> bool:
|
def verify_password(password: str, encoded: str) -> bool:
|
||||||
try:
|
try:
|
||||||
_, salt, expected = encoded.split("$")
|
algorithm, salt, expected = encoded.split("$")
|
||||||
|
if algorithm not in {"scrypt", "scrypt-v2"}:
|
||||||
|
return False
|
||||||
actual = hashlib.scrypt(
|
actual = hashlib.scrypt(
|
||||||
password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1
|
password.encode(),
|
||||||
|
salt=bytes.fromhex(salt),
|
||||||
|
n=16384,
|
||||||
|
r=8,
|
||||||
|
p=5 if algorithm == "scrypt-v2" else 1,
|
||||||
).hex()
|
).hex()
|
||||||
return hmac.compare_digest(actual, expected)
|
return hmac.compare_digest(actual, expected)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
@@ -57,6 +63,7 @@ def seed_accounts(db, config_dir: Path):
|
|||||||
db.execute(
|
db.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)"
|
"CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)"
|
||||||
)
|
)
|
||||||
|
db.execute("CREATE UNIQUE INDEX IF NOT EXISTS single_browser_account ON users((1))")
|
||||||
db.execute(
|
db.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)"
|
"CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)"
|
||||||
)
|
)
|
||||||
@@ -65,7 +72,13 @@ def seed_accounts(db, config_dir: Path):
|
|||||||
)
|
)
|
||||||
path = config_dir / "accounts.json"
|
path = config_dir / "accounts.json"
|
||||||
if path.exists():
|
if path.exists():
|
||||||
for user in json.loads(path.read_text()).get("users", []):
|
users = json.loads(path.read_text()).get("users", [])
|
||||||
|
if len(users) > 1:
|
||||||
|
raise ValueError("Only one browser account is supported")
|
||||||
|
for user in users:
|
||||||
|
existing = db.one("SELECT username FROM users LIMIT 1")
|
||||||
|
if existing and existing["username"] != user["username"]:
|
||||||
|
raise ValueError("A browser account already exists")
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT OR IGNORE INTO users VALUES(?,?,1)",
|
"INSERT OR IGNORE INTO users VALUES(?,?,1)",
|
||||||
(user["username"], user["password_hash"]),
|
(user["username"], user["password_hash"]),
|
||||||
@@ -83,7 +96,7 @@ def session_user(request: Request):
|
|||||||
)
|
)
|
||||||
if not row or not row["enabled"] or row["expires_at"] <= time.time():
|
if not row or not row["enabled"] or row["expires_at"] <= time.time():
|
||||||
return None
|
return None
|
||||||
if request.method in {"POST", "PUT", "DELETE"}:
|
if request.method in {"POST", "PUT", "PATCH", "DELETE"}:
|
||||||
if not hmac.compare_digest(
|
if not hmac.compare_digest(
|
||||||
request.headers.get("X-CSRF-Token", ""), row["csrf"]
|
request.headers.get("X-CSRF-Token", ""), row["csrf"]
|
||||||
):
|
):
|
||||||
@@ -94,32 +107,60 @@ def session_user(request: Request):
|
|||||||
def install_accounts(app, db):
|
def install_accounts(app, db):
|
||||||
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
|
@app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"])
|
||||||
def login(payload: Login, request: Request, response: Response):
|
def login(payload: Login, request: Request, response: Response):
|
||||||
|
with db.lock:
|
||||||
|
# Reject login CSRF before checking credentials. Never trust forwarded hosts.
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
if request.headers.get("sec-fetch-site") == "cross-site" or (
|
||||||
|
origin and origin != str(request.base_url).rstrip("/")
|
||||||
|
):
|
||||||
|
raise FlowError(E.FORBIDDEN, "Cross-origin login is not allowed", 403)
|
||||||
identity = hashlib.sha256(
|
identity = hashlib.sha256(
|
||||||
(request.client.host if request.client else "unknown").encode()
|
(request.client.host if request.client else "unknown").encode()
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,))
|
# Account throttling also applies when an attacker changes source IPs.
|
||||||
|
identities = (
|
||||||
|
identity,
|
||||||
|
"account:" + hashlib.sha256(payload.username.encode()).hexdigest(),
|
||||||
|
)
|
||||||
|
attempts = [
|
||||||
|
db.one("SELECT * FROM login_attempts WHERE identity=?", (key,))
|
||||||
|
for key in identities
|
||||||
|
]
|
||||||
|
attempt = next(
|
||||||
|
(a for a in attempts if a and a["blocked_until"] > time.time()), None
|
||||||
|
)
|
||||||
if attempt and attempt["blocked_until"] > time.time():
|
if attempt and attempt["blocked_until"] > time.time():
|
||||||
raise FlowError(
|
raise FlowError(
|
||||||
E.RATE_LIMITED, "Too many login attempts; try again later", 429
|
E.RATE_LIMITED, "Too many login attempts; try again later", 429
|
||||||
)
|
)
|
||||||
row = db.one(
|
row = db.one(
|
||||||
"SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,)
|
"SELECT * FROM users WHERE username=? AND enabled=1",
|
||||||
|
(payload.username,),
|
||||||
)
|
)
|
||||||
# Perform a password calculation even for unknown usernames.
|
# Perform a password calculation even for unknown usernames.
|
||||||
fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64
|
fallback = "scrypt-v2$" + "00" * 16 + "$" + "00" * 64
|
||||||
valid = verify_password(
|
valid = verify_password(
|
||||||
payload.password, row["password_hash"] if row else fallback
|
payload.password, row["password_hash"] if row else fallback
|
||||||
)
|
)
|
||||||
if not row or not valid:
|
if not row or not valid:
|
||||||
|
for key, previous in zip(identities, attempts, strict=True):
|
||||||
failures = (
|
failures = (
|
||||||
attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0
|
previous["failures"]
|
||||||
|
if previous and previous["blocked_until"] == 0
|
||||||
|
else 0
|
||||||
) + 1
|
) + 1
|
||||||
db.execute(
|
db.execute(
|
||||||
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
|
"INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)",
|
||||||
(identity, failures, time.time() + 300 if failures >= 5 else 0),
|
(key, failures, time.time() + 300 if failures >= 5 else 0),
|
||||||
)
|
)
|
||||||
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401)
|
||||||
db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,))
|
for key in identities:
|
||||||
|
db.execute("DELETE FROM login_attempts WHERE identity=?", (key,))
|
||||||
|
if row["password_hash"].startswith("scrypt$"):
|
||||||
|
db.execute(
|
||||||
|
"UPDATE users SET password_hash=? WHERE username=?",
|
||||||
|
(password_hash(payload.password), row["username"]),
|
||||||
|
)
|
||||||
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32)
|
||||||
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
lifetime = 30 * 86400 if payload.remember else 12 * 3600
|
||||||
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
|
db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),))
|
||||||
@@ -187,9 +228,12 @@ def install_accounts(app, db):
|
|||||||
user = db.one("SELECT * FROM users WHERE username=?", (session["username"],))
|
user = db.one("SELECT * FROM users WHERE username=?", (session["username"],))
|
||||||
if not verify_password(payload.current_password, user["password_hash"]):
|
if not verify_password(payload.current_password, user["password_hash"]):
|
||||||
raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401)
|
raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401)
|
||||||
db.execute(
|
with db.transaction() as connection:
|
||||||
|
connection.execute(
|
||||||
"UPDATE users SET password_hash=? WHERE username=?",
|
"UPDATE users SET password_hash=? WHERE username=?",
|
||||||
(password_hash(payload.new_password), session["username"]),
|
(password_hash(payload.new_password), session["username"]),
|
||||||
)
|
)
|
||||||
db.execute("DELETE FROM sessions WHERE username=?", (session["username"],))
|
connection.execute(
|
||||||
|
"DELETE FROM sessions WHERE username=?", (session["username"],)
|
||||||
|
)
|
||||||
response.delete_cookie(COOKIE, path="/")
|
response.delete_cookie(COOKIE, path="/")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Durable, read-only recovery progress for writes with uncertain outcomes."""
|
"""Durable, read-only recovery progress for writes with uncertain outcomes."""
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from pigway_frame_flow.errors import ErrorCode
|
||||||
from pigway_frame_flow.storage.database import encode, now_iso
|
from pigway_frame_flow.storage.database import encode, now_iso
|
||||||
|
|
||||||
|
|
||||||
@@ -71,3 +73,92 @@ class RecoveryMonitor:
|
|||||||
if row:
|
if row:
|
||||||
row["requires_manual_review"] = row["state"] == "manual_review"
|
row["requires_manual_review"] = row["state"] == "manual_review"
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
def observe_device(self, op, *, device=None, error=None):
|
||||||
|
row = self.db.one("SELECT result FROM operations WHERE id=?", (op["id"],))
|
||||||
|
result = json.loads(row["result"] or "{}")
|
||||||
|
observation = {"checked_at": now_iso()}
|
||||||
|
if device is not None:
|
||||||
|
observation.update(
|
||||||
|
image_id=device.current_content.id,
|
||||||
|
synced=device.current_content.synced,
|
||||||
|
online=device.status == "online",
|
||||||
|
heartbeat=device.last_seen_at,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
observation["read_error"] = {
|
||||||
|
"code": error.code,
|
||||||
|
**{
|
||||||
|
k: v
|
||||||
|
for k, v in error.details.items()
|
||||||
|
if k
|
||||||
|
in ("transport_error", "http_status", "attempts", "retry_exhausted")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
samples = result.get("sync_observations", [])
|
||||||
|
if samples:
|
||||||
|
old = samples[-1]
|
||||||
|
changed = {k: v for k, v in old.items() if k != "checked_at"} != {
|
||||||
|
k: v for k, v in observation.items() if k != "checked_at"
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
not changed
|
||||||
|
and (
|
||||||
|
datetime.now(UTC) - datetime.fromisoformat(old["checked_at"])
|
||||||
|
).total_seconds()
|
||||||
|
< 60
|
||||||
|
):
|
||||||
|
return observation
|
||||||
|
samples.append(observation)
|
||||||
|
result["sync_observations"] = (
|
||||||
|
[samples[0], *samples[-11:]] if len(samples) > 12 else samples
|
||||||
|
)
|
||||||
|
# Evidence must not reset updated_at: that is the sync timeout's clock.
|
||||||
|
self.db.execute(
|
||||||
|
"UPDATE operations SET result=? WHERE id=?", (encode(result), op["id"])
|
||||||
|
)
|
||||||
|
return observation
|
||||||
|
|
||||||
|
def close_superseded_displays(self):
|
||||||
|
for op in self.db.all(
|
||||||
|
"SELECT * FROM operations WHERE action='display' AND write_started=1 "
|
||||||
|
"AND status IN ('SYNCING','UNKNOWN')"
|
||||||
|
):
|
||||||
|
result = json.loads(op["result"] or "{}")
|
||||||
|
if not result.get("image_id") or json.loads(op["payload"]).get(
|
||||||
|
"options", {}
|
||||||
|
).get("restore_previous"):
|
||||||
|
continue
|
||||||
|
newer = self.db.one(
|
||||||
|
"SELECT id,result FROM operations WHERE device_id=? AND action='display' "
|
||||||
|
"AND status='COMPLETED' AND write_started=1 AND created_at>? "
|
||||||
|
"AND COALESCE(json_extract(payload,'$.options.restore_previous'),0)=0 "
|
||||||
|
"AND json_extract(result,'$.image_id') IS NOT NULL "
|
||||||
|
"ORDER BY created_at DESC LIMIT 1",
|
||||||
|
(op["device_id"], op["created_at"]),
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not newer
|
||||||
|
or json.loads(newer["result"])["image_id"] == result["image_id"]
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
# A later confirmed permanent display ends this attempt, but cannot
|
||||||
|
# prove whether the earlier image was ever shown. Never replay it.
|
||||||
|
self.service.update(
|
||||||
|
op["id"],
|
||||||
|
"FAILED",
|
||||||
|
error={
|
||||||
|
"code": ErrorCode.DISPLAY_NOT_CONFIRMED,
|
||||||
|
"message": "Superseded by a later confirmed display; earlier display unconfirmed",
|
||||||
|
"request_id": op["request_id"],
|
||||||
|
"details": {
|
||||||
|
"superseded_by_operation_id": newer["id"],
|
||||||
|
"previous_error": json.loads(op["error"] or "null"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.db.history(op, "DISPLAY_NOT_CONFIRMED")
|
||||||
|
self.db.event(
|
||||||
|
"operation.failed",
|
||||||
|
{"operation_id": op["id"], "code": ErrorCode.DISPLAY_NOT_CONFIRMED},
|
||||||
|
)
|
||||||
|
|||||||
@@ -152,6 +152,31 @@ class DisplayService:
|
|||||||
)
|
)
|
||||||
return id
|
return id
|
||||||
|
|
||||||
|
def operation_image(self, device_id, result):
|
||||||
|
result = dict(result or {})
|
||||||
|
asset_id = result.get("rendered_asset_id") or result.get("asset_id")
|
||||||
|
if not asset_id and result.get("image_id") is not None:
|
||||||
|
# Cloud cleanup removes image_sources; the original upload receipt survives.
|
||||||
|
row = self.db.one(
|
||||||
|
"SELECT result FROM operations WHERE device_id=? "
|
||||||
|
"AND json_extract(result,'$.image_id')=? "
|
||||||
|
"AND json_extract(result,'$.rendered_asset_id') IS NOT NULL "
|
||||||
|
"ORDER BY created_at DESC LIMIT 1",
|
||||||
|
(device_id, result["image_id"]),
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
original = json.loads(row["result"])
|
||||||
|
asset_id = original["rendered_asset_id"]
|
||||||
|
result.setdefault("image_name", original.get("image_name"))
|
||||||
|
if asset_id:
|
||||||
|
result.setdefault("asset_id", asset_id)
|
||||||
|
asset = self.db.one("SELECT * FROM assets WHERE id=?", (asset_id,))
|
||||||
|
if asset:
|
||||||
|
result["image_name"] = result.get("image_name") or asset["name"]
|
||||||
|
if self.assets.path(asset_id).is_file():
|
||||||
|
result["preview_url"] = f"/api/v1/assets/{asset_id}"
|
||||||
|
return result or None
|
||||||
|
|
||||||
def operation(self, id):
|
def operation(self, id):
|
||||||
op = self.db.one("SELECT * FROM operations WHERE id=?", (id,))
|
op = self.db.one("SELECT * FROM operations WHERE id=?", (id,))
|
||||||
if not op:
|
if not op:
|
||||||
@@ -162,7 +187,9 @@ class DisplayService:
|
|||||||
device_id=op["device_id"],
|
device_id=op["device_id"],
|
||||||
created_at=op["created_at"],
|
created_at=op["created_at"],
|
||||||
updated_at=op["updated_at"],
|
updated_at=op["updated_at"],
|
||||||
result=json.loads(op["result"]) if op["result"] else None,
|
result=self.operation_image(
|
||||||
|
op["device_id"], json.loads(op["result"]) if op["result"] else None
|
||||||
|
),
|
||||||
error=json.loads(op["error"]) if op["error"] else None,
|
error=json.loads(op["error"]) if op["error"] else None,
|
||||||
request_id=op["request_id"],
|
request_id=op["request_id"],
|
||||||
client_id=op["client_id"],
|
client_id=op["client_id"],
|
||||||
@@ -172,9 +199,12 @@ class DisplayService:
|
|||||||
|
|
||||||
def update(self, id, status, result=None, error=None):
|
def update(self, id, status, result=None, error=None):
|
||||||
if result:
|
if result:
|
||||||
|
op = self.db.one("SELECT device_id FROM operations WHERE id=?", (id,))
|
||||||
|
result = self.operation_image(op["device_id"], result)
|
||||||
|
result.pop("preview_url", None)
|
||||||
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
row = self.db.one("SELECT result FROM operations WHERE id=?", (id,))
|
||||||
previous = json.loads(row["result"]) if row and row["result"] else {}
|
previous = json.loads(row["result"]) if row and row["result"] else {}
|
||||||
for key in ("photo_attempts", "upload_intent"):
|
for key in ("photo_attempts", "upload_intent", "sync_observations"):
|
||||||
if key in previous:
|
if key in previous:
|
||||||
result = {**result, key: previous[key]}
|
result = {**result, key: previous[key]}
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
@@ -518,6 +548,25 @@ class DisplayService:
|
|||||||
) and self.blocked(op["device_id"]):
|
) and self.blocked(op["device_id"]):
|
||||||
continue
|
continue
|
||||||
payload = json.loads(op["payload"])
|
payload = json.loads(op["payload"])
|
||||||
|
if (
|
||||||
|
op["action"] in ("display", "delete", "restore")
|
||||||
|
and op["client_id"]
|
||||||
|
in ("scheduler", "scheduler-retry", "cloud-library", "retention")
|
||||||
|
and self.db.one(
|
||||||
|
"SELECT id FROM operations WHERE device_id=? AND action='display' "
|
||||||
|
"AND status='SYNCING' AND write_started=1 AND updated_at>? LIMIT 1",
|
||||||
|
(
|
||||||
|
op["device_id"],
|
||||||
|
(
|
||||||
|
datetime.now(UTC)
|
||||||
|
- timedelta(seconds=self.settings.sync_timeout)
|
||||||
|
).isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
):
|
||||||
|
# Both upload and deletion update the cloud frame counter.
|
||||||
|
# Automatic writes must not invalidate a pending display ack.
|
||||||
|
continue
|
||||||
if op["action"] == "display":
|
if op["action"] == "display":
|
||||||
priority = payload["options"]["priority"]
|
priority = payload["options"]["priority"]
|
||||||
top = self.top(op["device_id"])
|
top = self.top(op["device_id"])
|
||||||
@@ -823,6 +872,7 @@ class DisplayService:
|
|||||||
try:
|
try:
|
||||||
driver = self.registry.get(op["device_id"])
|
driver = self.registry.get(op["device_id"])
|
||||||
device = driver.device(refresh=True)
|
device = driver.device(refresh=True)
|
||||||
|
self.recovery.observe_device(op, device=device)
|
||||||
if intent:
|
if intent:
|
||||||
expected = self.upload_reconciliation.check(op, driver, device)
|
expected = self.upload_reconciliation.check(op, driver, device)
|
||||||
if (
|
if (
|
||||||
@@ -850,7 +900,8 @@ class DisplayService:
|
|||||||
if device.current_content.id == expected
|
if device.current_content.id == expected
|
||||||
else "target_mismatch",
|
else "target_mismatch",
|
||||||
)
|
)
|
||||||
except FlowError:
|
except FlowError as exc:
|
||||||
|
self.recovery.observe_device(op, error=exc)
|
||||||
self.recovery.observe(op, "read_unavailable")
|
self.recovery.observe(op, "read_unavailable")
|
||||||
except Exception:
|
except Exception:
|
||||||
# Bad vendor metadata for one operation must not stop the worker.
|
# Bad vendor metadata for one operation must not stop the worker.
|
||||||
@@ -858,6 +909,7 @@ class DisplayService:
|
|||||||
for op in self.db.all("SELECT * FROM operations WHERE status='SYNCING'"):
|
for op in self.db.all("SELECT * FROM operations WHERE status='SYNCING'"):
|
||||||
try:
|
try:
|
||||||
device = self.registry.get(op["device_id"]).device()
|
device = self.registry.get(op["device_id"]).device()
|
||||||
|
observation = self.recovery.observe_device(op, device=device)
|
||||||
target = json.loads(op["result"])["image_id"]
|
target = json.loads(op["result"])["image_id"]
|
||||||
if (
|
if (
|
||||||
device.current_content.id == target
|
device.current_content.id == target
|
||||||
@@ -878,11 +930,13 @@ class DisplayService:
|
|||||||
"code": E.WRITE_RESULT_UNKNOWN,
|
"code": E.WRITE_RESULT_UNKNOWN,
|
||||||
"message": "Frame refresh was not confirmed",
|
"message": "Frame refresh was not confirmed",
|
||||||
"request_id": op["request_id"],
|
"request_id": op["request_id"],
|
||||||
"details": {},
|
"details": {"last_device_observation": observation},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except FlowError:
|
except FlowError as exc:
|
||||||
pass
|
self.recovery.observe_device(op, error=exc)
|
||||||
|
|
||||||
|
self.recovery.close_superseded_displays()
|
||||||
|
|
||||||
def expire(self):
|
def expire(self):
|
||||||
if not self.settings.mock and not self.settings.write_enabled:
|
if not self.settings.mock and not self.settings.write_enabled:
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ class ErrorResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class OperationResult(BaseModel):
|
class OperationResult(BaseModel):
|
||||||
|
image_name: str | None = None
|
||||||
|
preview_url: str | None = None
|
||||||
cover_id: int | None = None
|
cover_id: int | None = None
|
||||||
photo_attempts: list[dict[str, str]] = Field(default_factory=list)
|
photo_attempts: list[dict[str, str]] = Field(default_factory=list)
|
||||||
image_id: int | None = None
|
image_id: int | None = None
|
||||||
@@ -366,7 +368,7 @@ class SettingsView(BaseModel):
|
|||||||
|
|
||||||
class Health(BaseModel):
|
class Health(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
version: str = "0.1.18"
|
version: str = "0.1.26"
|
||||||
|
|
||||||
|
|
||||||
class Readiness(BaseModel):
|
class Readiness(BaseModel):
|
||||||
@@ -397,7 +399,7 @@ class RestoreRequest(StrictModel):
|
|||||||
|
|
||||||
class Version(BaseModel):
|
class Version(BaseModel):
|
||||||
product: str = "PIGWay Frame Flow"
|
product: str = "PIGWay Frame Flow"
|
||||||
version: str = "0.1.18"
|
version: str = "0.1.26"
|
||||||
api_version: str = "v1"
|
api_version: str = "v1"
|
||||||
commit: str = "development"
|
commit: str = "development"
|
||||||
build_time: str = "unknown"
|
build_time: str = "unknown"
|
||||||
|
|||||||
@@ -481,9 +481,7 @@ class PhotoSources:
|
|||||||
continue
|
continue
|
||||||
size = row.get("additional", {}).get("resolution", {})
|
size = row.get("additional", {}).get("resolution", {})
|
||||||
width, height = size.get("width", 0), size.get("height", 0)
|
width, height = size.get("width", 0), size.get("height", 0)
|
||||||
rotation = row.get("additional", {}).get("orientation", 1)
|
# Photos resolution is already oriented; EXIF must not swap it twice.
|
||||||
if rotation in (5, 6, 7, 8):
|
|
||||||
width, height = height, width
|
|
||||||
if not self.resolution_matches(source, width, height):
|
if not self.resolution_matches(source, width, height):
|
||||||
continue
|
continue
|
||||||
direction = orientation(width, height)
|
direction = orientation(width, height)
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ window.addEventListener('resize',revealCurrentNavigation);
|
|||||||
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
|
function deviceNameForId(id){return deviceDisplayName(state.devices.find(d=>d.id===id)||{id,name:id});}
|
||||||
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
|
function operationActionLabel(action){const labels={'render':['生成预览','Generate preview'],'render-ai':['AI 生成图片','Generate AI image'],'official-widget':['更新官方订阅','Update official widget'],'delete':['删除图片','Delete image'],'display':['显示图片','Display image'],'restore':['恢复图片','Restore image'],'local-upload':['保存到图库','Save to gallery'],'reconcile':['核对状态','Reconcile']};return labels[action]?.[state.lang==='zh'?0:1]||t('operation');}
|
||||||
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;}
|
function operationClientLabel(client){return client==='admin'?tr('管理员','Administrator'):client==='scheduler'?tr('自动调度','Scheduler'):client;}
|
||||||
|
function operationResultHTML(o){
|
||||||
|
const r=o.result||{},parts=[];
|
||||||
|
if(r.image_name)parts.push(`<div>${esc(r.image_name)}</div>`);
|
||||||
|
if(r.image_id!=null)parts.push(`<small>${t('cloudImageId')}: ${esc(r.image_id)}</small>`);
|
||||||
|
else if(r.asset_id||r.rendered_asset_id)parts.push(`<small>${t('localAssetId')}: ${esc(r.asset_id||r.rendered_asset_id)}</small>`);
|
||||||
|
if(o.error)parts.push(`<div class="history-status bad">${esc(operationErrorMessage(o.error))}</div>`);
|
||||||
|
return parts.join('')||'—';
|
||||||
|
}
|
||||||
function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;}
|
function operationErrorLabel(error){if(!error?.code)return '';const label=t(error.code);return label===error.code?t('error'):label;}
|
||||||
function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
|
function deviceDisplayName(device){const name=device.name||device.id;return device.id==='living-room'&&['living-room','Living room'].includes(name)?t('livingRoom'):name;}
|
||||||
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('');revealCurrentNavigation();}
|
function shell(){document.documentElement.lang=state.lang==='zh'?'zh-CN':'en';$('#lang').textContent=state.lang==='zh'?'EN':'中文';$('#theme').title=t('theme');$('#nav').innerHTML=Object.entries(icons).map(([id,icon])=>`<button data-page="${id}" class="${id===state.page?'active':''}" ${id===state.page?'aria-current="page"':''}><span class="nav-icon">${icon}</span><span>${t(id)}</span></button>`).join('');document.querySelectorAll('[data-page]').forEach(b=>b.onclick=()=>navigate(b.dataset.page));$('#page-title').textContent=t(state.page);$('#device').innerHTML=(state.devices.length?state.devices:[{id:state.device,name:state.device}]).map(d=>`<option value="${esc(d.id)}" ${d.id===state.device?'selected':''}>${esc(deviceDisplayName(d))}</option>`).join('');revealCurrentNavigation();}
|
||||||
@@ -118,7 +126,7 @@ async function templateEditor(selected){
|
|||||||
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
|
const reset=document.createElement('button');reset.id='reset-template';reset.textContent=t('resetTemplate');$('#save-template').after(reset);bind('reset-template',()=>confirmDialog(t('resetConfirm'),async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'DELETE'});await navigate();}));
|
||||||
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
|
const save=$('#save-template');const oldSave=save.onclick;save.onclick=async e=>{if([...panel.querySelectorAll('textarea'),raw].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}await oldSave(e);};
|
||||||
}
|
}
|
||||||
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);form.onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
|
function accountSettings(){const section=$('#logout').parentElement;const form=document.createElement('form');form.id='password-form';form.innerHTML=`<h3>${t('changePassword')}</h3><div class="form-grid password-fields"><div class="field"><label for="current-password">${t('currentPassword')}</label><input id="current-password" type="password" required autocomplete="current-password"></div><div class="field"><label for="new-password">${t('newPassword')}</label><input id="new-password" type="password" minlength="8" required autocomplete="new-password"></div><div class="field"><label for="confirm-password">${t('confirmPassword')}</label><input id="confirm-password" type="password" minlength="8" required autocomplete="new-password"></div></div><button type="submit">${t('changePassword')}</button>`;section.append(form);const confirm=$('#confirm-password');const validate=()=>confirm.setCustomValidity(confirm.value!==$('#new-password').value?t('passwordMismatch'):'');confirm.oninput=validate;$('#new-password').oninput=validate;form.onsubmit=busy(async e=>{e.preventDefault();validate();if(!form.reportValidity())return;await api('/api/v1/auth/password',{method:'PUT',body:{current_password:$('#current-password').value,new_password:$('#new-password').value}});forgetLogin();shell();login();toast(t('passwordChanged'));});}
|
||||||
const views={
|
const views={
|
||||||
studio:()=>layoutStudioPage(),
|
studio:()=>layoutStudioPage(),
|
||||||
connections:()=>connectionsPage(),
|
connections:()=>connectionsPage(),
|
||||||
@@ -143,7 +151,7 @@ const views={
|
|||||||
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
|
async upload(){state.preview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('uploadNotice')}</div><div class="columns"><section class="card"><h2>${t('upload')}</h2><div class="dropzone"><strong>${t('selectFile')}</strong><p class="hint">${t('uploadHint')}</p><input id="file" type="file" accept="image/jpeg,image/png,image/webp" aria-label="${t('selectFile')}"></div><div class="form-grid" style="margin-top:24px"><div class="field"><label for="fit">${t('mode')}</label><select id="fit">${['contain','cover','smart'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div><div class="field"><label for="profile">${t('profile')}</label><select id="profile">${['color','grayscale'].map(x=>`<option value="${x}">${t(x)}</option>`).join('')}</select></div></div>${buttons('make-preview','preview',true)}<p class="hint">${t('previewHint')}</p></section><section class="card"><h3>${t('preview')}</h3><div id="preview-box" class="empty">${t('previewHint')}</div><div class="actions" style="margin-top:18px"><button id="save-local" disabled>${t('staging')}</button><button id="display-upload" class="primary" disabled>${t('display')}</button></div></section></div>`;bind('make-preview',async()=>{const file=$('#file').files[0];if(!file)throw new Error(t('selectFile'));const form=new FormData();form.append('file',file);form.append('fit',$('#fit').value);form.append('profile',$('#profile').value);state.preview=await api('/api/v1/preview',{method:'POST',body:form});$('#preview-box').className='';$('#preview-box').innerHTML='<img id="upload-preview" class="preview" alt="Preview">';await blobImage($('#upload-preview'),state.preview.url);$('#save-local').disabled=false;$('#display-upload').disabled=!liveWriteAllowed();});for(const id of ['file','fit','profile'])$('#'+id).onchange=()=>{state.preview=null;$('#save-local').disabled=true;$('#display-upload').disabled=true;};bind('save-local',async()=>{const id=await accepted('/api/v1/devices/'+state.device+'/upload',{type:'image',asset_id:state.preview.asset_id});await waitOperation(id);state.tab='local';return navigate('gallery');});bind('display-upload',()=>displayDialog({type:'image',asset_id:state.preview.asset_id}));},
|
||||||
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
async templates(){state.templates=await api('/api/v1/templates');const selected=state.templates.find(x=>x.id===state.selectedTemplate)||state.templates[0];state.selectedTemplate=selected.id;if(state.pendingTemplateData?.template===selected.id){selected.defaults=state.pendingTemplateData.data;state.initialProfile=state.pendingTemplateData.profile;state.pendingTemplateData=null;}state.templatePreview=null;$('#main').innerHTML=notice()+`<div class="notice">${t('templateHint')}<p>${t('templateSteps')}</p></div><div class="template-grid">${state.templates.map((x,i)=>`<div class="card template-card ${x.id===selected.id?'selected':''}" role="button" tabindex="0" data-template="${x.id}"><span>${{'daily-poetry':'詩','calendar':'▦','daily-dashboard':'☀','photo-text':'▣','quote':'❝','system-alert':'◒','weather':'☁'}[x.id]||'▦'}</span><div><h3>${esc(x.name.split(' / ')[state.lang==='zh'?1:0]||x.name)}</h3><small>1200 × 1600 · ${esc(x.id)}</small></div></div>`).join('')}</div><div class="columns"><section class="card"><h3>${t('templateData')}</h3><p class="hint">${t('templateGuideTitle')}</p><p class="hint">${t({'photo-text':'guidePhotoText','daily-dashboard':'guideDashboard',weather:'guideWeather',calendar:'guideCalendar',quote:'guideQuote','daily-poetry':'guidePoetry','system-alert':'guideAlert'}[selected.id]||'templateContentHelp')}</p>${['weather','daily-dashboard'].includes(selected.id)?`<p class="hint">${t('templateWeatherHelp')}</p>`:''}<textarea id="template-data" aria-label="${t('templateData')}">${esc(JSON.stringify(selected.defaults,null,2))}</textarea><div class="field" style="margin-top:16px"><label for="template-profile">${t('profile')}</label><select id="template-profile"><option value="color">${t('color')}</option><option value="grayscale">${t('grayscale')}</option></select><p class="hint">${t('helpProfile')}</p></div><p class="hint">${t('templateActionHelp')}</p><p class="hint">${t('templateAutomationHelp')}</p><div class="actions">${buttons('render-template','render',true)}${buttons('save-template','saveTemplate')}</div></section><section class="card"><h3>${t('preview')}</h3><div id="template-preview" class="empty">${t('templatePreviewHint')}</div><button id="display-template" class="primary" style="margin-top:18px" disabled>${t('display')}</button></section></div>`;document.querySelectorAll('[data-template]').forEach(b=>{b.onclick=()=>{state.selectedTemplate=b.dataset.template;navigate(['collage','poster'].includes(b.dataset.template)?'studio':undefined);};b.onkeydown=e=>{if(e.key==='Enter')b.click();};});bind('render-template',async()=>{const content={type:'template',template:selected.id,data:JSON.parse($('#template-data').value),profile:$('#template-profile').value};if([...document.querySelectorAll('#template-fields textarea'),$('#template-data')].some(x=>!x.checkValidity())){toast(t('INVALID_REQUEST'));return;}const id=await accepted('/api/v1/render',content);$('#template-preview').textContent=t('previewLoading');const op=await waitOperation(id);state.templatePreview=op.result.asset_id;$('#template-preview').className='';$('#template-preview').innerHTML='<img id="template-image" class="preview" alt="Template preview">';await blobImage($('#template-image'),op.result.url);$('#display-template').disabled=!liveWriteAllowed();});bind('save-template',async()=>{await api('/api/v1/templates/'+selected.id+'/config',{method:'PUT',body:{data:JSON.parse($('#template-data').value)}});toast(t('done'));});$('#template-data').oninput=()=>$('#display-template').disabled=true;$('#template-profile').onchange=()=>$('#display-template').disabled=true;bind('display-template',()=>displayDialog({type:'image',asset_id:state.templatePreview}));await templateEditor(selected);if(state.initialProfile){$('#template-profile').value=state.initialProfile;state.initialProfile=null;}},
|
||||||
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
async playlists(epoch){const playlists=await api('/api/v1/playlists');if(epoch!==state.epoch)return;$('#main').innerHTML=notice()+`<div class="notice">${t('playlistNotice')}</div><div class="section-heading"><h2>${t('playlists')}</h2>${buttons('new-playlist','newPlaylist',true)}</div><div class="gallery">${playlists.map((p,i)=>`<section class="card"><span class="badge">${t(p.enabled?'enabled':'disabled')}</span><h3 style="margin-top:16px">${esc(p.name)}</h3><p class="hint">${p.image_ids.length} ${t('count')} · ${p.interval_minutes} ${t('minutes')} · ${t(p.mode)}</p><p><code>${esc(p.image_ids.join(' → '))}</code></p><small>${t('lastScheduled')}: ${p.position>=0?esc(p.image_ids[p.position]):'—'}</small><div class="actions" style="margin-top:16px"><button data-pl-edit="${i}">${t('edit')}</button><button class="danger" data-pl-delete="${i}">${t('remove')}</button></div></section>`).join('')||`<div class="card empty"><strong>${t('noTasks')}</strong></div>`}</div>`;bind('new-playlist',()=>playlistEditor());document.querySelectorAll('[data-pl-edit]').forEach(b=>b.onclick=()=>playlistEditor(playlists[b.dataset.plEdit]));document.querySelectorAll('[data-pl-delete]').forEach(b=>b.onclick=()=>confirmDialog(t('removeConfirm'),async()=>{await api('/api/v1/playlists/'+playlists[b.dataset.plDelete].id,{method:'DELETE'});await navigate();}));},
|
||||||
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${esc(o.result?.image_id||o.result?.asset_id||operationErrorLabel(o.error)||'—')}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
async history(epoch){const ops=await api('/api/v1/operations');if(epoch!==state.epoch)return;$('#main').innerHTML=`<div class="section-heading"><h2>${t('history')}</h2>${buttons('history-refresh','refresh')}</div>${ops.some(x=>x.status==='UNKNOWN')?`<div class="notice warning">${t('unknown')}</div>`:''}<div class="card table-wrap">${ops.length?`<table><thead><tr>${['created','action','status','result','client','requestId'].map(x=>`<th>${t(x)}</th>`).join('')}</tr></thead><tbody>${ops.map((o,i)=>`<tr data-operation-id="${esc(o.operation_id)}"><td>${fmt(o.created_at)}</td><td>${esc(operationActionLabel(o.action))}<br><small>${esc(deviceNameForId(o.device_id))}</small></td><td><span class="history-status ${['FAILED','UNKNOWN'].includes(o.status)?'bad':''}">${t(o.status)}</span>${o.status==='UNKNOWN'?`<div><button data-reconcile="${i}">${t('reconcile')}</button></div>`:''}</td><td>${operationResultHTML(o)}</td><td>${esc(operationClientLabel(o.client_id))}</td><td><code>${esc(o.request_id)}<br>${esc(o.operation_id)}</code></td></tr>`).join('')}</tbody></table>`:`<div class="empty">${t('noHistory')}</div>`}</div>`;bind('history-refresh',()=>navigate());document.querySelectorAll('[data-reconcile]').forEach(b=>b.onclick=busy(async()=>{const o=ops[b.dataset.reconcile];const r=await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:false}});showModal(`<h2>${t('reconcile')}</h2><p>${t('current')}: ${esc(r.current_image_id)}</p><p>${t('result')}: ${esc(r.expected_image_id)}</p><p class="hint">${t('unknown')}</p><div class="modal-actions">${buttons('modal-cancel','cancel')}${r.resolved?'':buttons('acknowledge','acknowledge')}</div>`);bind('acknowledge',async()=>{await api('/api/v1/operations/'+o.operation_id+'/reconcile',{method:'POST',body:{acknowledge:true}});$('#modal').close();await navigate();});}));},
|
||||||
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
|
async settings(){state.settings=await api('/api/v1/settings');const s=state.settings;$('#main').innerHTML=notice()+`<div class="columns settings-columns"><section class="card"><h3>${t('settings')}</h3><div class="form-grid settings-preferences"><div class="field"><label for="theme-select">${t('theme')}</label><select id="theme-select">${['system','light','dark'].map(x=>`<option value="${x}" ${state.theme===x?'selected':''}>${t(x==='system'?'systemTheme':x)}</option>`).join('')}</select></div><div class="field"><label for="language-select">${t('language')}</label><select id="language-select"><option value="zh" ${state.lang==='zh'?'selected':''}>简体中文</option><option value="en" ${state.lang==='en'?'selected':''}>English</option></select></div></div><p class="hint">${t('tokenHint')}</p>${buttons('logout','logout')}</section><section class="card"><h3>${t('auth')}</h3><div class="detail-grid">${detail('version',s.version)}${detail('writeProtection',t(!s.write_enabled?'yes':'no'))}${detail('keyConfigured',t(s.api_key_configured?'yes':'no'))}${detail('timezone',s.timezone)}</div><p class="hint">${t('refreshInterval')}</p><a href="/docs" target="_blank" rel="noopener">${t('apiDocs')} →</a><p class="hint">GET /openapi.json · /api/v1</p></section></div>`;$('#theme-select').onchange=()=>{state.theme=$('#theme-select').value;localStorage.setItem('ff-theme',state.theme);applyTheme();};$('#language-select').onchange=()=>{state.lang=$('#language-select').value;localStorage.setItem('ff-language',state.lang);navigate();};bind('logout',async()=>{await api('/api/v1/auth/logout',{method:'POST'});forgetLogin();shell();login();});accountSettings();}
|
||||||
};
|
};
|
||||||
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
|
function jobPayload(j,changes={}){return {name:j.name,enabled:j.enabled,device_id:j.device_id,schedule:j.schedule,source:j.source,display_duration:j.display_duration,restore_previous:j.restore_previous,priority:j.priority,cleanup_unstarred:j.cleanup_unstarred||false,...changes};}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -45,7 +45,7 @@ async function weatherSettings(){
|
|||||||
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
|
result(weather);$('#weather-form').onsubmit=busy(async e=>{e.preventDefault();await api('/api/v1/settings/weather',{method:'PUT',body:{provider:$('#weather-provider').value,location:$('#weather-location').value,latitude:Number($('#weather-latitude').value),longitude:Number($('#weather-longitude').value)}});result(await api('/api/v1/providers/weather'));toast(t('done'));});
|
||||||
}
|
}
|
||||||
async function enhanceHistory(){
|
async function enhanceHistory(){
|
||||||
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];const ops=await api('/api/v1/operations');rows.forEach((row,i)=>{const op=ops[i];if(!op)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+op.operation_id);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p><p>${esc(operationErrorLabel(current.error)||current.error?.message||'')}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${current.result?.url?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.url)await blobImage($('#operation-preview'),current.result.url);});});}
|
const table=document.querySelector('#main table');if(!table)return;table.classList.add('history-table');const heading=document.createElement('th');heading.textContent=t('operation');table.querySelector('thead tr').append(heading);const rows=[...table.querySelectorAll('tbody tr')];rows.forEach(row=>{const operationId=row.dataset.operationId;if(!operationId)return;const button=document.createElement('button');button.textContent=t('details');const actions=document.createElement('td');actions.className='history-actions';actions.append(button);row.append(actions);button.onclick=busy(async()=>{const current=await api('/api/v1/operations/'+operationId);showModal(`<h2>${t('details')}</h2><p>${t(current.status)} · ${esc(operationActionLabel(current.action))}</p>${operationResultHTML(current)}<pre style="white-space:pre-wrap;overflow-wrap:anywhere">${esc(JSON.stringify({action:current.action,device_id:current.device_id,error_code:current.error?.code,operation_id:current.operation_id,request_id:current.request_id,created_at:current.created_at,updated_at:current.updated_at,result:current.result},null,2))}</pre>${(current.result?.preview_url||current.result?.url)?'<img id="operation-preview" class="preview" alt="Preview">':''}<div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);if(current.result?.preview_url||current.result?.url)await blobImage($('#operation-preview'),current.result.preview_url||current.result.url);});});}
|
||||||
async function enhanceAutomation(){
|
async function enhanceAutomation(){
|
||||||
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
|
const jobs=await api('/api/v1/jobs');const rows=[...document.querySelectorAll('tbody tr')];rows.forEach((row,i)=>{const job=jobs[i];if(!job)return;const actions=row.querySelector('.row-actions');const run=document.createElement('button');run.textContent=t('runNow');run.disabled=!liveWriteAllowed();run.title=run.disabled?t('readOnlyWarning'):'';actions.append(run);run.onclick=()=>confirmDialog(t('runConfirm'),async()=>{await accepted('/api/v1/jobs/'+job.id+'/run',{});await navigate('history');});if(['template','auto_collage'].includes(job.source.type)){const preview=document.createElement('button');preview.textContent=t('jobPreview');actions.append(preview);preview.onclick=busy(async()=>{const id=await accepted('/api/v1/jobs/'+job.id+'/preview',{});const op=await waitOperation(id);showModal(`<h2>${t('jobPreview')}</h2><img id="job-preview-image" class="preview" alt="Preview"><div class="modal-actions">${buttons('modal-cancel','cancel')}</div>`);await blobImage($('#job-preview-image'),op.result.url);});}});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="stylesheet" href="/ui/style.css"></head>
|
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="light dark"><title>PIGWay Frame Flow</title><link rel="icon" type="image/svg+xml" href="/ui/favicon.svg"><link rel="icon" href="/ui/favicon.ico" sizes="16x16 32x32 48x48"><link rel="apple-touch-icon" sizes="180x180" href="/ui/apple-touch-icon.png"><link rel="stylesheet" href="/ui/style.css"></head>
|
||||||
<body><aside class="sidebar"><a class="brand" href="/"><span>PIGWay Frame Flow<small>Smart Frame Automation</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.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>
|
<body><aside class="sidebar"><a class="brand" href="/" aria-label="PIGWay Frame Flow"><img class="pigway-logo" src="/ui/logo-symbol.svg" alt="" width="56" height="56"><span>PIGWAY<small>FRAME FLOW</small></span></a><nav id="nav"></nav><div class="sidebar-bottom"><small>Version 0.1.26</small></div></aside><div class="workspace"><header><div class="workspace-heading"><img class="mobile-brand-logo pigway-logo" src="/ui/logo-symbol.svg" alt="PIGWay" width="56" height="56"><div><span class="eyebrow">PIGWay Frame Flow</span><h1 id="page-title"></h1></div></div><div class="toolbar"><select id="device" aria-label="Device"></select><button id="lang" class="icon-button" aria-label="Change language">EN</button><button id="theme" class="icon-button" aria-label="Change theme">◐</button></div></header><main id="main"></main><footer id="footer"></footer></div><div id="toast" role="status" aria-live="polite"></div><dialog id="modal"><div id="modal-content"></div></dialog><script src="/ui/i18n.js?v=0.1.26"></script><script src="/ui/features.js?v=0.1.26"></script><script src="/ui/extensions.js?v=0.1.26"></script><script src="/ui/app.js?v=0.1.26"></script></body></html>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 9.9 KiB |
@@ -49,13 +49,13 @@ input[type="file"]::file-selector-button{margin-inline-end:12px}
|
|||||||
|
|
||||||
.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px}
|
.official-account-panel{margin:20px 0;padding:20px;border:1px solid var(--border);border-radius:12px}.official-account-panel>p{margin:14px 0}#official-device-picker{margin-top:18px}
|
||||||
|
|
||||||
.history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{vertical-align:top;white-space:nowrap}.history-actions button{white-space:nowrap}
|
.history-table{min-width:1060px;table-layout:fixed}.history-table th:nth-child(1){width:145px}.history-table th:nth-child(2){width:180px}.history-table th:nth-child(3){width:100px}.history-table th:nth-child(5){width:110px}.history-table th:last-child{width:120px}.history-table td{overflow-wrap:anywhere}.history-table .history-actions{white-space:nowrap}.history-actions button{white-space:nowrap}
|
||||||
|
|
||||||
.photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px}
|
.photo-sources-panel{margin-top:24px}.source-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:20px}.source-card{border:1px solid var(--border);border-radius:12px;padding:18px}.source-card h4{margin:0 0 12px}.source-card .actions{margin-top:16px}.source-items{display:flex;flex-wrap:wrap;gap:12px;margin:18px 0}.source-items button{max-width:100%;overflow-wrap:anywhere}#source-browser>h4{margin-top:24px}
|
||||||
|
|
||||||
.source-items button small{display:block;margin-top:8px;color:var(--muted)}
|
.source-items button small{display:block;margin-top:8px;color:var(--muted)}
|
||||||
|
|
||||||
.history-table [data-reconcile]{white-space:nowrap;overflow-wrap:normal;margin-top:8px}
|
.history-table [data-reconcile]{white-space:nowrap;overflow-wrap:normal;margin-top:8px;padding:0;border:0;border-radius:0;background:transparent;color:var(--accent);font-weight:500;text-decoration-line:underline;text-decoration-style:dashed;text-underline-offset:4px}
|
||||||
|
|
||||||
/* Reserve actual header/footer space rather than guessing image height. */
|
/* Reserve actual header/footer space rather than guessing image height. */
|
||||||
body.image-viewer-open{overflow:hidden}
|
body.image-viewer-open{overflow:hidden}
|
||||||
@@ -187,7 +187,10 @@ body.login-mode header,body.login-mode footer{flex-shrink:0}
|
|||||||
.history-status{color:var(--accent)}
|
.history-status{color:var(--accent)}
|
||||||
.history-status.bad{color:var(--bad)}
|
.history-status.bad{color:var(--bad)}
|
||||||
|
|
||||||
.settings-columns{align-items:stretch}
|
.settings-columns{align-items:stretch;grid-template-columns:minmax(0,2fr) minmax(240px,1fr)}
|
||||||
|
.password-fields{grid-template-columns:repeat(3,minmax(0,1fr));max-width:780px;align-items:end}
|
||||||
|
@media(max-width:1050px){.settings-columns{grid-template-columns:minmax(0,1fr)}}
|
||||||
|
@media(max-width:720px){.password-fields{grid-template-columns:minmax(0,1fr)}}
|
||||||
.settings-preferences .field,.password-fields .field,#weather-form .field{min-width:0;margin-bottom:0}
|
.settings-preferences .field,.password-fields .field,#weather-form .field{min-width:0;margin-bottom:0}
|
||||||
#password-form>button{align-self:flex-start}
|
#password-form>button{align-self:flex-start}
|
||||||
form#weather-form{display:grid;grid-template-columns:minmax(160px,1fr) minmax(220px,2fr) repeat(2,minmax(100px,1fr)) auto;gap:18px;align-items:end}
|
form#weather-form{display:grid;grid-template-columns:minmax(160px,1fr) minmax(220px,2fr) repeat(2,minmax(100px,1fr)) auto;gap:18px;align-items:end}
|
||||||
@@ -224,6 +227,14 @@ form#mqtt-config{grid-template-columns:minmax(160px,1fr) 110px minmax(180px,1.5f
|
|||||||
.automation-table{min-width:1200px}
|
.automation-table{min-width:1200px}
|
||||||
.automation-table td:is(:nth-child(2),:nth-child(5),:nth-child(6)){white-space:nowrap}
|
.automation-table td:is(:nth-child(2),:nth-child(5),:nth-child(6)){white-space:nowrap}
|
||||||
.automation-table .row-actions{flex-wrap:nowrap}
|
.automation-table .row-actions{flex-wrap:nowrap}
|
||||||
.history-table td:is(:nth-child(3),:nth-child(4),:nth-child(5)),.history-table .history-actions{vertical-align:middle}
|
.history-table td{vertical-align:middle}
|
||||||
|
|
||||||
.image-card h3.source-path{white-space:normal;overflow-wrap:anywhere}
|
.image-card h3.source-path{white-space:normal;overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
.pigway-logo{display:block;width:56px;height:56px;padding:6px;flex:0 0 56px;object-fit:contain}
|
||||||
|
.brand>span{min-width:0}.workspace-heading{display:flex;align-items:center;gap:12px}.workspace-heading>div{min-width:0}.mobile-brand-logo{display:none}
|
||||||
|
@media(max-width:720px){.mobile-brand-logo{display:block}}
|
||||||
|
|
||||||
|
/* Stacked wordmark keeps the product name readable within the sidebar. */
|
||||||
|
.sidebar .brand{font-size:26px;font-weight:800;line-height:1.15;letter-spacing:1px}
|
||||||
|
.sidebar .brand small{margin-top:6px;font-size:12px;font-weight:700;letter-spacing:4px;line-height:1.4;white-space:nowrap}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "pigway-frame-flow"
|
name = "pigway-frame-flow"
|
||||||
version = "0.1.18"
|
version = "0.1.26"
|
||||||
|
readme = "README.md"
|
||||||
|
license = {file = "LICENSE"}
|
||||||
requires-python = ">=3.14,<3.15"
|
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]
|
||||||
|
|||||||
@@ -6,5 +6,9 @@ cd "$(dirname "$0")/.."
|
|||||||
DOCKER_BIN=${DOCKER_BIN:-docker}
|
DOCKER_BIN=${DOCKER_BIN:-docker}
|
||||||
"$DOCKER_BIN" info >/dev/null
|
"$DOCKER_BIN" info >/dev/null
|
||||||
"$DOCKER_BIN" compose config --quiet
|
"$DOCKER_BIN" compose config --quiet
|
||||||
"$DOCKER_BIN" compose build --pull
|
set -- --pull
|
||||||
|
if [ -n "${CI_PYTHON_BASE:-}" ]; then
|
||||||
|
set -- "$@" --build-arg "PYTHON_BASE=$CI_PYTHON_BASE"
|
||||||
|
fi
|
||||||
|
"$DOCKER_BIN" compose build "$@"
|
||||||
"$DOCKER_BIN" image inspect "${FRAME_FLOW_IMAGE:-pigway-frame-flow:latest}" --format 'image={{.Id}} size_bytes={{.Size}} architecture={{.Architecture}}'
|
"$DOCKER_BIN" image inspect "${FRAME_FLOW_IMAGE:-pigway-frame-flow:latest}" --format 'image={{.Id}} size_bytes={{.Size}} architecture={{.Architecture}}'
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ if len(password) < 8 or password != getpass.getpass("Confirm password: "):
|
|||||||
args.config_dir.mkdir(parents=True, exist_ok=True)
|
args.config_dir.mkdir(parents=True, exist_ok=True)
|
||||||
path = args.config_dir / "accounts.json"
|
path = args.config_dir / "accounts.json"
|
||||||
users = json.loads(path.read_text()).get("users", []) if path.exists() else []
|
users = json.loads(path.read_text()).get("users", []) if path.exists() else []
|
||||||
if any(u["username"] == args.username for u in users):
|
if users:
|
||||||
sys.exit("Bootstrap user already exists. Change the password in Settings.")
|
sys.exit("Single-user account already exists. Change the password in Settings.")
|
||||||
users.append({"username": args.username, "password_hash": password_hash(password)})
|
users.append({"username": args.username, "password_hash": password_hash(password)})
|
||||||
path.write_text(json.dumps({"users": users}) + "\n")
|
path.write_text(json.dumps({"users": users}) + "\n")
|
||||||
path.chmod(0o600)
|
path.chmod(0o600)
|
||||||
|
|||||||
@@ -375,9 +375,7 @@ class Deploy:
|
|||||||
"DOCKER_PIP_FALLBACK_INDEX_URL"
|
"DOCKER_PIP_FALLBACK_INDEX_URL"
|
||||||
)
|
)
|
||||||
or "https://pypi.org/simple",
|
or "https://pypi.org/simple",
|
||||||
"DEBIAN_MIRROR": self.env.get("DEBIAN_MIRROR") or "deb.debian.org",
|
"ALPINE_MIRROR": self.env.get("ALPINE_MIRROR") or "dl-cdn.alpinelinux.org",
|
||||||
"DEBIAN_SECURITY_MIRROR": self.env.get("DEBIAN_SECURITY_MIRROR")
|
|
||||||
or "deb.debian.org",
|
|
||||||
"BUILD_COMMIT": commit,
|
"BUILD_COMMIT": commit,
|
||||||
"BUILD_TIME": now,
|
"BUILD_TIME": now,
|
||||||
}.items():
|
}.items():
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import shutil
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
import tomllib
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -126,6 +127,48 @@ def main():
|
|||||||
release(lock)
|
release(lock)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_prebuilt_image(image, commit, version, hashes):
|
||||||
|
expected = os.getenv("FRAME_FLOW_PREBUILT_IMAGE_ID", "")
|
||||||
|
if not expected:
|
||||||
|
return False
|
||||||
|
assert re.fullmatch(r"sha256:[0-9a-f]{64}", expected), "Require immutable image ID"
|
||||||
|
metadata = json.loads(run("docker", "image", "inspect", image))[0]
|
||||||
|
assert metadata["Id"] == expected, "Prebuilt image ID mismatch"
|
||||||
|
labels = metadata["Config"].get("Labels") or {}
|
||||||
|
assert labels.get("org.opencontainers.image.revision") == commit
|
||||||
|
assert labels.get("org.opencontainers.image.version") == version
|
||||||
|
project = {
|
||||||
|
n: h
|
||||||
|
for n, h in hashes.items()
|
||||||
|
if n.startswith(("pigway_frame_flow/", "vendor/"))
|
||||||
|
or n in {"requirements.lock", "LICENSE", "NOTICE.md"}
|
||||||
|
}
|
||||||
|
# Validate the artifact before it can access production config or data.
|
||||||
|
code = "import hashlib,json,pathlib,sys; h=json.load(sys.stdin); assert h and all(hashlib.sha256((pathlib.Path('/app')/n).read_bytes()).hexdigest()==v for n,v in h.items())"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"-i",
|
||||||
|
"--network=none",
|
||||||
|
"--read-only",
|
||||||
|
"--cap-drop=ALL",
|
||||||
|
"--security-opt=no-new-privileges",
|
||||||
|
"--user=10333:10333",
|
||||||
|
"--entrypoint=python",
|
||||||
|
expected,
|
||||||
|
"-c",
|
||||||
|
code,
|
||||||
|
],
|
||||||
|
input=json.dumps(project),
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def release(lock):
|
def release(lock):
|
||||||
ctx = Path(__file__).resolve().parent.parent
|
ctx = Path(__file__).resolve().parent.parent
|
||||||
assert ctx.parent == ROOT / "migration-inbox"
|
assert ctx.parent == ROOT / "migration-inbox"
|
||||||
@@ -139,9 +182,6 @@ def release(lock):
|
|||||||
before = inspect()
|
before = inspect()
|
||||||
old_env = dict(s.split("=", 1) for s in before["Config"]["Env"])
|
old_env = dict(s.split("=", 1) for s in before["Config"]["Env"])
|
||||||
verify_runtime(old_env["BUILD_COMMIT"])
|
verify_runtime(old_env["BUILD_COMMIT"])
|
||||||
assert run("docker", "exec", CONTAINER, "cat", "/app/requirements.lock") == (
|
|
||||||
(ctx / "requirements.lock").read_text().strip()
|
|
||||||
), "Dependencies differ; full build required"
|
|
||||||
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
backup = RECOVERY / ("release-" + stamp + "-" + commit[:12])
|
backup = RECOVERY / ("release-" + stamp + "-" + commit[:12])
|
||||||
backup.mkdir(mode=0o700)
|
backup.mkdir(mode=0o700)
|
||||||
@@ -153,34 +193,40 @@ def release(lock):
|
|||||||
with sqlite3.connect(backup / "frameflow.before.db") as dst:
|
with sqlite3.connect(backup / "frameflow.before.db") as dst:
|
||||||
src.backup(dst)
|
src.backup(dst)
|
||||||
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]
|
version = tomllib.loads((ctx / "pyproject.toml").read_text())["project"]["version"]
|
||||||
run("docker", "image", "tag", before["Image"], base)
|
assert re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version)
|
||||||
image = "pigway-frame-flow:0.1.18-" + commit[:12]
|
image = "pigway-frame-flow:" + version + "-" + commit[:12]
|
||||||
try:
|
if not verify_prebuilt_image(image, commit, version, hashes):
|
||||||
|
# A source-only overlay would retain vulnerable packages from the old image.
|
||||||
run(
|
run(
|
||||||
"docker",
|
"docker",
|
||||||
"build",
|
"build",
|
||||||
"--pull=false",
|
"--pull",
|
||||||
"--network=none",
|
|
||||||
"-f",
|
"-f",
|
||||||
str(ctx / "deploy/Dockerfile.runtime-update"),
|
str(ctx / "Dockerfile"),
|
||||||
"--build-arg",
|
|
||||||
"BASE_IMAGE=" + base,
|
|
||||||
"--build-arg",
|
"--build-arg",
|
||||||
"BUILD_COMMIT=" + commit,
|
"BUILD_COMMIT=" + commit,
|
||||||
"--build-arg",
|
"--build-arg",
|
||||||
"BUILD_TIME=" + stamp,
|
"BUILD_TIME=" + stamp,
|
||||||
|
"--build-arg",
|
||||||
|
"ALPINE_MIRROR=" + os.getenv("ALPINE_MIRROR", "dl-cdn.alpinelinux.org"),
|
||||||
"-t",
|
"-t",
|
||||||
image,
|
image,
|
||||||
str(ctx),
|
str(ctx),
|
||||||
)
|
)
|
||||||
finally:
|
|
||||||
run("docker", "image", "rm", base)
|
|
||||||
compose = json.loads((APP / "compose.json").read_text())
|
compose = json.loads((APP / "compose.json").read_text())
|
||||||
service = compose["services"][CONTAINER]
|
service = compose["services"][CONTAINER]
|
||||||
service["image"] = image
|
service["image"] = image
|
||||||
service["environment"]["BUILD_COMMIT"] = commit
|
service["environment"]["BUILD_COMMIT"] = commit
|
||||||
service["environment"]["BUILD_TIME"] = stamp
|
service["environment"]["BUILD_TIME"] = stamp
|
||||||
|
labels = service.get("labels") or {}
|
||||||
|
if isinstance(labels, list):
|
||||||
|
labels = {item.partition("=")[0]: item.partition("=")[2] for item in labels}
|
||||||
|
service["labels"] = {
|
||||||
|
**labels,
|
||||||
|
"org.opencontainers.image.version": version,
|
||||||
|
"org.opencontainers.image.revision": commit,
|
||||||
|
}
|
||||||
changed = False
|
changed = False
|
||||||
try:
|
try:
|
||||||
changed = True
|
changed = True
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const root=path.join(__dirname,'../pigway_frame_flow/web');
|
|||||||
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
|
const p=await b.newPage({viewport:{width,height:900}});let errors=[];p.on('pageerror',e=>errors.push(e.message));let saved;
|
||||||
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
|
await p.addInitScript(lang=>localStorage.setItem('ff-language',lang),lang);
|
||||||
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
|
await p.route('**/*',async r=>{const u=new URL(r.request().url());if(u.pathname.startsWith('/api/')){
|
||||||
|
if(u.pathname==='/api/v1/assets/fixture')return r.fulfill({body:Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jBz8AAAAASUVORK5CYII=','base64'),contentType:'image/png'});
|
||||||
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
|
if(['PUT','POST'].includes(r.request().method())){saved=r.request().postDataJSON();return r.fulfill({json:{}})}
|
||||||
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
|
if(['/api/v1/jobs','/api/v1/assets','/api/v1/devices/living-room/images'].includes(u.pathname))return r.fulfill({json:[]});
|
||||||
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
|
if(u.pathname==='/api/v1/photo-sources')return r.fulfill({json:[]});
|
||||||
@@ -12,6 +13,6 @@ const root=path.join(__dirname,'../pigway_frame_flow/web');
|
|||||||
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
|
return r.fulfill({status:401,json:{error:{code:'UNAUTHORIZED',message:'Sign in required'}}});}
|
||||||
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
|
const f=u.pathname==='/'?'index.html':u.pathname.replace(/^\/ui\//,'');return r.fulfill({body:fs.readFileSync(path.join(root,f)),contentType:f.endsWith('.js')?'application/javascript':f.endsWith('.css')?'text/css':'text/html'});});
|
||||||
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
|
await p.goto('http://frame.test/');await p.locator('#login-form').waitFor();
|
||||||
await p.evaluate(async()=>{const previous=api;api=async()=>[{operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',error:{code:'WRITE_DISABLED'},request_id:'fixture'}];state.devices=[{id:'living-room',name:'living-room'}];try{await views.history(state.epoch);}finally{api=previous;}});
|
await p.evaluate(async()=>{const op={operation_id:'fixture',action:'display',device_id:'living-room',status:'FAILED',client_id:'scheduler',result:{image_id:999,image_name:'example <photo>.jpg',preview_url:'/api/v1/assets/fixture'},error:{code:'WRITE_DISABLED'},request_id:'fixture'};api=async(path,options)=>options?.binary?(await fetch(path)).blob():path.endsWith('/fixture')?op:[op];state.devices=[{id:'living-room',name:'living-room'}];document.body.classList.remove('login-mode');shell();await views.history(state.epoch);await enhanceHistory();});
|
||||||
const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
|
const text=await p.locator('#main').innerText();assert(!text.includes('living-room'));assert(!text.includes('WRITE_DISABLED'));assert(text.includes('example <photo>.jpg'));assert(text.includes('999'));assert(text.includes(lang==='zh'?'云端图片 ID':'Cloud image ID'));assert(await p.locator('td div.bad').count()===1);assert(text.includes(lang==='zh'?'客厅':'Living room'));assert(text.includes(lang==='zh'?'显示图片':'Display image'));assert(text.includes(lang==='zh'?'自动调度':'Scheduler'));await p.locator('.history-actions button').click();await p.locator('#operation-preview').waitFor();assert(await p.locator('#operation-preview').evaluate(img=>img.complete&&img.naturalWidth>0));assert(await p.locator('#modal-content').innerText().then(x=>x.includes('example <photo>.jpg')));assert(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth));assert.equal(errors.length,0);console.log('PASS history',width,lang);await p.close();
|
||||||
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
}}finally{await b.close()}})().catch(e=>{console.error(e);process.exit(1)});
|
||||||
|
|||||||
@@ -690,6 +690,28 @@
|
|||||||
},
|
},
|
||||||
"OperationResult": {
|
"OperationResult": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"image_name": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Image Name"
|
||||||
|
},
|
||||||
|
"preview_url": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Preview Url"
|
||||||
|
},
|
||||||
"cover_id": {
|
"cover_id": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -141,3 +141,104 @@ def test_browser_admin_can_read_other_clients_operation(client, app, service):
|
|||||||
assert operation_id in [
|
assert operation_id in [
|
||||||
op["operation_id"] for op in client.get("/api/v1/operations").json()
|
op["operation_id"] for op in client.get("/api/v1/operations").json()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_minimum_and_https_cookie(client, app):
|
||||||
|
response = login(client, app)
|
||||||
|
client.headers["X-CSRF-Token"] = response.json()["csrf_token"]
|
||||||
|
assert (
|
||||||
|
client.put(
|
||||||
|
"/api/v1/auth/password",
|
||||||
|
json={"current_password": "fixture-password", "new_password": "1234567"},
|
||||||
|
).status_code
|
||||||
|
== 422
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.put(
|
||||||
|
"/api/v1/auth/password",
|
||||||
|
json={"current_password": "fixture-password", "new_password": "12345678"},
|
||||||
|
).status_code
|
||||||
|
== 204
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
"https://testserver/api/v1/auth/login",
|
||||||
|
json={"username": "test-user", "password": "12345678"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Secure" in response.headers["set-cookie"]
|
||||||
|
assert response.headers["cache-control"] == "no-store"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_rejects_cross_origin(client, app):
|
||||||
|
account(app)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
headers={"Origin": "https://evil.example"},
|
||||||
|
json={"username": "test-user", "password": "fixture-password"},
|
||||||
|
).status_code
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_throttle_across_source_addresses(client, app):
|
||||||
|
account(app)
|
||||||
|
for _ in range(5):
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "test-user", "password": "wrong"},
|
||||||
|
).status_code
|
||||||
|
== 401
|
||||||
|
)
|
||||||
|
app.state.service.db.execute(
|
||||||
|
"DELETE FROM login_attempts WHERE identity NOT LIKE 'account:%'"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "test-user", "password": "fixture-password"},
|
||||||
|
).status_code
|
||||||
|
== 429
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_hash_upgrade(client, app):
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from pigway_frame_flow.core.accounts import verify_password
|
||||||
|
|
||||||
|
salt = "12" * 16
|
||||||
|
digest = hashlib.scrypt(
|
||||||
|
b"fixture-password", salt=bytes.fromhex(salt), n=16384, r=8, p=1
|
||||||
|
).hex()
|
||||||
|
legacy = f"scrypt${salt}${digest}"
|
||||||
|
app.state.service.db.execute(
|
||||||
|
"INSERT INTO users VALUES(?,?,1)", ("test-user", legacy)
|
||||||
|
)
|
||||||
|
assert verify_password("fixture-password", legacy)
|
||||||
|
assert (
|
||||||
|
client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"username": "test-user", "password": "fixture-password"},
|
||||||
|
).status_code
|
||||||
|
== 200
|
||||||
|
)
|
||||||
|
stored = app.state.service.db.one("SELECT password_hash FROM users")[
|
||||||
|
"password_hash"
|
||||||
|
]
|
||||||
|
assert stored.startswith("scrypt-v2$") and not hmac.compare_digest(stored, legacy)
|
||||||
|
assert verify_password("fixture-password", stored)
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_one_browser_account(app):
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
account(app)
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
app.state.service.db.execute(
|
||||||
|
"INSERT INTO users VALUES(?,?,1)",
|
||||||
|
("other", password_hash("another-password")),
|
||||||
|
)
|
||||||
|
|||||||
@@ -291,6 +291,28 @@ def test_core_has_no_client_special_case():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_official_brand_assets(client):
|
||||||
|
html = client.get("/").text
|
||||||
|
assert "/ui/favicon.svg" in html
|
||||||
|
assert "/ui/apple-touch-icon.png" in html
|
||||||
|
assert 'class="mobile-brand-logo pigway-logo"' in html
|
||||||
|
for filename, content_type in [
|
||||||
|
("logo-symbol.svg", "image/svg+xml"),
|
||||||
|
("favicon.svg", "image/svg+xml"),
|
||||||
|
("favicon.ico", "image/vnd.microsoft.icon"),
|
||||||
|
("apple-touch-icon.png", "image/png"),
|
||||||
|
]:
|
||||||
|
response = client.get("/ui/" + filename)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"].split(";")[0] in (
|
||||||
|
content_type,
|
||||||
|
"image/x-icon",
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
response.content == (Path("pigway_frame_flow/web") / filename).read_bytes()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_ui_languages(client):
|
def test_ui_languages(client):
|
||||||
assert client.get("/ui/app.js").status_code == 200
|
assert client.get("/ui/app.js").status_code == 200
|
||||||
i18n = client.get("/ui/i18n.js").text
|
i18n = client.get("/ui/i18n.js").text
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pigway_frame_flow.models import DisplayRequest
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_image_survives_cloud_cleanup(client, service):
|
||||||
|
data = BytesIO()
|
||||||
|
Image.new("RGB", (40, 60), "blue").save(data, "JPEG")
|
||||||
|
asset = service.assets.put(data.getvalue(), "example.jpg", "images")
|
||||||
|
request = DisplayRequest(content={"type": "image", "asset_id": asset["asset_id"]})
|
||||||
|
op = service.submit(
|
||||||
|
"display", request.model_dump(mode="json"), "living-room", "admin", "fixture"
|
||||||
|
)
|
||||||
|
service.process_one()
|
||||||
|
service.sync()
|
||||||
|
result = service.operation(op)["result"]
|
||||||
|
image_id = result["image_id"]
|
||||||
|
# Simulate an older receipt without the new name snapshot, then cloud cleanup.
|
||||||
|
service.db.execute(
|
||||||
|
"UPDATE operations SET result=? WHERE id=?",
|
||||||
|
(
|
||||||
|
'{"image_id":%d,"rendered_asset_id":"%s"}' % (image_id, asset["asset_id"]),
|
||||||
|
op,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
service.db.execute("DELETE FROM image_sources WHERE device_id=?", ("living-room",))
|
||||||
|
deletion = service.submit(
|
||||||
|
"delete",
|
||||||
|
{"image_id": image_id, "confirm_current": True},
|
||||||
|
"living-room",
|
||||||
|
"admin",
|
||||||
|
"delete-fixture",
|
||||||
|
)
|
||||||
|
service.update(deletion, "COMPLETED", {"image_id": image_id})
|
||||||
|
receipt = client.get("/api/v1/operations/" + deletion).json()["result"]
|
||||||
|
assert receipt["image_name"] == "example.jpg"
|
||||||
|
assert client.get(receipt["preview_url"]).status_code == 200
|
||||||
|
service.assets.path(asset["asset_id"]).unlink()
|
||||||
|
service.db.execute("DELETE FROM assets WHERE id=?", (asset["asset_id"],))
|
||||||
|
receipt = client.get("/api/v1/operations/" + deletion).json()["result"]
|
||||||
|
assert receipt["image_name"] == "example.jpg"
|
||||||
|
assert receipt["preview_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_history_missing_local_image_does_not_break_list(client, service):
|
||||||
|
op = service.submit("delete", {"image_id": 999}, "living-room", "admin", "fixture")
|
||||||
|
service.update(
|
||||||
|
op,
|
||||||
|
"FAILED",
|
||||||
|
{"image_id": 999},
|
||||||
|
{
|
||||||
|
"code": "DEVICE_OFFLINE",
|
||||||
|
"message": "Device offline",
|
||||||
|
"request_id": "fixture",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
receipt = client.get("/api/v1/operations/" + op).json()
|
||||||
|
assert receipt["result"]["image_id"] == 999
|
||||||
|
assert receipt["result"]["preview_url"] is None
|
||||||
|
assert receipt["error"]["code"] == "DEVICE_OFFLINE"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
from test_display import submit
|
from test_display import submit
|
||||||
|
|
||||||
from pigway_frame_flow.display.service import DisplayService
|
from pigway_frame_flow.display.service import DisplayService
|
||||||
@@ -60,7 +61,9 @@ def test_persistent_block_escalates_once_survives_restart_and_preserves_evidence
|
|||||||
== 1
|
== 1
|
||||||
)
|
)
|
||||||
assert service.operation(operation)["recovery"]["checks"] == 3
|
assert service.operation(operation)["recovery"]["checks"] == 3
|
||||||
assert service.operation(operation)["result"] == original["result"]
|
current_result = service.operation(operation)["result"]
|
||||||
|
assert current_result.pop("sync_observations")
|
||||||
|
assert current_result == original["result"]
|
||||||
service.reconcile(operation, acknowledge=True)
|
service.reconcile(operation, acknowledge=True)
|
||||||
assert service.operation(operation)["recovery"]["state"] == "resolved"
|
assert service.operation(operation)["recovery"]["state"] == "resolved"
|
||||||
assert not service.operation(operation)["recovery"]["requires_manual_review"]
|
assert not service.operation(operation)["recovery"]["requires_manual_review"]
|
||||||
@@ -107,3 +110,184 @@ def test_malformed_reconciliation_does_not_abort_other_background_work(
|
|||||||
assert op["recovery"]["reason"] == "reconciliation_error"
|
assert op["recovery"]["reason"] == "reconciliation_error"
|
||||||
assert "private" not in str(op["recovery"])
|
assert "private" not in str(op["recovery"])
|
||||||
assert service.blocked("living-room")
|
assert service.blocked("living-room")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["SYNCING", "UNKNOWN"])
|
||||||
|
def test_later_confirmed_display_closes_earlier_syncing_attempt_without_replay(
|
||||||
|
service, status
|
||||||
|
):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
assert service.operation(first)["status"] == "SYNCING"
|
||||||
|
second = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
driver = service.registry.get("living-room")
|
||||||
|
uploads = driver.client.uploads
|
||||||
|
previous_error = {"code": "WRITE_RESULT_UNKNOWN", "message": "Refresh unconfirmed"}
|
||||||
|
if status == "UNKNOWN":
|
||||||
|
service.update(first, status, error=previous_error)
|
||||||
|
service.sync()
|
||||||
|
assert service.operation(second)["status"] == "COMPLETED"
|
||||||
|
old = service.operation(first)
|
||||||
|
assert old["status"] == "FAILED"
|
||||||
|
assert old["error"]["code"] == "DISPLAY_NOT_CONFIRMED"
|
||||||
|
assert old["error"]["details"]["superseded_by_operation_id"] == second
|
||||||
|
if status == "UNKNOWN":
|
||||||
|
assert old["error"]["details"]["previous_error"] == previous_error
|
||||||
|
assert driver.client.uploads == uploads
|
||||||
|
assert not service.blocked("living-room")
|
||||||
|
service.sync()
|
||||||
|
assert (
|
||||||
|
len(
|
||||||
|
service.db.all(
|
||||||
|
"SELECT * FROM history WHERE operation_id=? AND result='DISPLAY_NOT_CONFIRMED'",
|
||||||
|
(first,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfirmed_later_display_does_not_close_old_attempt(service):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
second = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
service.recovery.close_superseded_displays()
|
||||||
|
assert service.operation(first)["status"] == "SYNCING"
|
||||||
|
assert service.operation(second)["status"] == "SYNCING"
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_temporary_override_does_not_close_earlier_display(service):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
second = submit(service, 60, True)
|
||||||
|
service.process_one()
|
||||||
|
service.sync()
|
||||||
|
assert service.operation(second)["status"] == "COMPLETED"
|
||||||
|
assert service.operation(first)["status"] == "SYNCING"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("client_id", ["scheduler", "scheduler-retry"])
|
||||||
|
def test_automatic_upload_waits_for_prior_sync_confirmation(service, client_id):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
payload = service.db.one("SELECT payload FROM operations WHERE id=?", (first,))[
|
||||||
|
"payload"
|
||||||
|
]
|
||||||
|
import json
|
||||||
|
|
||||||
|
second = service.submit(
|
||||||
|
"display", json.loads(payload), "living-room", client_id, "fixture"
|
||||||
|
)
|
||||||
|
uploads = service.registry.get("living-room").client.uploads
|
||||||
|
service.process_one()
|
||||||
|
assert service.operation(second)["status"] == "QUEUED"
|
||||||
|
assert service.registry.get("living-room").client.uploads == uploads
|
||||||
|
service.sync()
|
||||||
|
assert service.operation(first)["status"] == "COMPLETED"
|
||||||
|
service.process_one()
|
||||||
|
service.sync()
|
||||||
|
assert service.operation(second)["status"] == "COMPLETED"
|
||||||
|
assert service.registry.get("living-room").client.uploads == uploads + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_evidence_is_bounded_and_does_not_extend_sync_timeout(service):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
op = service.db.one("SELECT * FROM operations WHERE id=?", (first,))
|
||||||
|
before = service.operation(first)["updated_at"]
|
||||||
|
device = service.registry.get("living-room").device()
|
||||||
|
for i in range(20):
|
||||||
|
sample = device.model_copy(
|
||||||
|
update={
|
||||||
|
"current_content": device.current_content.model_copy(
|
||||||
|
update={"id": 1000 + i}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service.recovery.observe_device(op, device=sample)
|
||||||
|
result = service.operation(first)
|
||||||
|
samples = result["result"]["sync_observations"]
|
||||||
|
assert len(samples) == 12
|
||||||
|
assert samples[0]["image_id"] == 1000 and samples[-1]["image_id"] == 1019
|
||||||
|
assert result["updated_at"] == before
|
||||||
|
service.update(first, "UNKNOWN", {"image_id": 7})
|
||||||
|
assert service.operation(first)["result"]["sync_observations"] == samples
|
||||||
|
|
||||||
|
|
||||||
|
def test_device_read_failure_evidence_never_copies_raw_error_details(service):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
op = service.db.one("SELECT * FROM operations WHERE id=?", (first,))
|
||||||
|
service.recovery.observe_device(
|
||||||
|
op,
|
||||||
|
error=FlowError(
|
||||||
|
ErrorCode.CLOUD_UNAVAILABLE,
|
||||||
|
"unavailable",
|
||||||
|
details={
|
||||||
|
"transport_error": "read_timeout",
|
||||||
|
"url": "private-url-must-not-be-stored",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sample = service.operation(first)["result"]["sync_observations"][-1]
|
||||||
|
assert sample["read_error"]["transport_error"] == "read_timeout"
|
||||||
|
assert "private-url" not in str(sample)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_timeout_includes_observed_device_state(service):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
service.registry.get("living-room").client.synced = False
|
||||||
|
service.registry.get("living-room").invalidate()
|
||||||
|
service.settings.sync_timeout = 0
|
||||||
|
service.sync()
|
||||||
|
op = service.operation(first)
|
||||||
|
assert op["status"] == "UNKNOWN"
|
||||||
|
assert op["error"]["details"]["last_device_observation"]["synced"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_automatic_wait_does_not_extend_past_sync_timeout(service):
|
||||||
|
import json
|
||||||
|
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
payload = service.db.one("SELECT payload FROM operations WHERE id=?", (first,))[
|
||||||
|
"payload"
|
||||||
|
]
|
||||||
|
service.db.execute(
|
||||||
|
"UPDATE operations SET updated_at=? WHERE id=?",
|
||||||
|
(
|
||||||
|
(
|
||||||
|
datetime.now(UTC) - timedelta(seconds=service.settings.sync_timeout + 1)
|
||||||
|
).isoformat(),
|
||||||
|
first,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
second = service.submit(
|
||||||
|
"display", json.loads(payload), "living-room", "scheduler", "fixture"
|
||||||
|
)
|
||||||
|
service.process_one()
|
||||||
|
assert service.operation(second)["status"] == "SYNCING"
|
||||||
|
|
||||||
|
|
||||||
|
def test_automatic_cleanup_waits_for_display_confirmation(service, monkeypatch):
|
||||||
|
first = submit(service)
|
||||||
|
service.process_one()
|
||||||
|
monkeypatch.setattr(service.cloud_library, "eligible", lambda *args: True)
|
||||||
|
cleanup = service.submit(
|
||||||
|
"delete",
|
||||||
|
{"image_id": 1, "cloud_cleanup": True},
|
||||||
|
"living-room",
|
||||||
|
"cloud-library",
|
||||||
|
"fixture-cleanup",
|
||||||
|
)
|
||||||
|
service.process_one()
|
||||||
|
assert service.operation(cleanup)["status"] == "QUEUED"
|
||||||
|
assert 1 in [x.image_id for x in service.registry.get("living-room").images()]
|
||||||
|
service.sync()
|
||||||
|
service.process_one()
|
||||||
|
assert service.operation(first)["status"] == "COMPLETED"
|
||||||
|
assert service.operation(cleanup)["status"] == "COMPLETED"
|
||||||
|
assert 1 not in [x.image_id for x in service.registry.get("living-room").images()]
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ def due(service, image_id):
|
|||||||
"UPDATE image_retention SET expires_at=? WHERE image_id=?",
|
"UPDATE image_retention SET expires_at=? WHERE image_id=?",
|
||||||
((datetime.now(UTC) - timedelta(days=1)).isoformat(), image_id),
|
((datetime.now(UTC) - timedelta(days=1)).isoformat(), image_id),
|
||||||
)
|
)
|
||||||
|
# Complete the upload before simulating expiry days later and switching away.
|
||||||
|
service.sync()
|
||||||
# The test driver switches to a different image, not a real device.
|
# The test driver switches to a different image, not a real device.
|
||||||
driver = service.registry.get("living-room")
|
driver = service.registry.get("living-room")
|
||||||
driver.display(1)
|
driver.display(1)
|
||||||
|
|||||||
@@ -92,3 +92,53 @@ def test_release_rejects_wrong_storage_uuid(release_tool, monkeypatch):
|
|||||||
monkeypatch.setattr(release_tool, "run", lambda *args: "different-storage-uuid")
|
monkeypatch.setattr(release_tool, "run", lambda *args: "different-storage-uuid")
|
||||||
with pytest.raises(AssertionError):
|
with pytest.raises(AssertionError):
|
||||||
release_tool.main()
|
release_tool.main()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid", [None, "id", "commit", "version", "content"])
|
||||||
|
def test_prebuilt_image_is_bound_to_source_before_deployment(
|
||||||
|
release_tool, monkeypatch, invalid
|
||||||
|
):
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
digest = "sha256:" + "a" * 64
|
||||||
|
monkeypatch.setenv("FRAME_FLOW_PREBUILT_IMAGE_ID", digest)
|
||||||
|
metadata = {
|
||||||
|
"Id": digest,
|
||||||
|
"Config": {
|
||||||
|
"Labels": {
|
||||||
|
"org.opencontainers.image.revision": "fixture-commit",
|
||||||
|
"org.opencontainers.image.version": "0.1.22",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if invalid == "id":
|
||||||
|
metadata["Id"] = "sha256:" + "b" * 64
|
||||||
|
if invalid == "commit":
|
||||||
|
metadata["Config"]["Labels"]["org.opencontainers.image.revision"] = "wrong"
|
||||||
|
if invalid == "version":
|
||||||
|
metadata["Config"]["Labels"]["org.opencontainers.image.version"] = "wrong"
|
||||||
|
monkeypatch.setattr(release_tool, "run", lambda *args: json.dumps([metadata]))
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def isolated(command, **kwargs):
|
||||||
|
calls.append(command)
|
||||||
|
assert "--network=none" in command and "--read-only" in command
|
||||||
|
assert "--cap-drop=ALL" in command and "--user=10333:10333" in command
|
||||||
|
assert "-v" not in command and "--mount" not in command
|
||||||
|
assert json.loads(kwargs["input"]) == {"LICENSE": "fixture-hash"}
|
||||||
|
if invalid == "content":
|
||||||
|
raise subprocess.CalledProcessError(1, command)
|
||||||
|
|
||||||
|
monkeypatch.setattr(release_tool.subprocess, "run", isolated)
|
||||||
|
if invalid:
|
||||||
|
with pytest.raises((AssertionError, subprocess.CalledProcessError)):
|
||||||
|
release_tool.verify_prebuilt_image(
|
||||||
|
"fixture-image", "fixture-commit", "0.1.22", {"LICENSE": "fixture-hash"}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert release_tool.verify_prebuilt_image(
|
||||||
|
"fixture-image", "fixture-commit", "0.1.22", {"LICENSE": "fixture-hash"}
|
||||||
|
)
|
||||||
|
if invalid in ("id", "commit", "version"):
|
||||||
|
assert not calls
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ def test_explicit_offline_conversion_preserves_task_reference(service, settings)
|
|||||||
|
|
||||||
@pytest.mark.parametrize("space", ["shared", "personal"])
|
@pytest.mark.parametrize("space", ["shared", "personal"])
|
||||||
@pytest.mark.parametrize("exif", [5, 6, 7, 8])
|
@pytest.mark.parametrize("exif", [5, 6, 7, 8])
|
||||||
def test_api_requests_orientation_before_filtering_rotated_portraits(
|
def test_api_filters_already_oriented_resolution_without_rotating_twice(
|
||||||
service, settings, monkeypatch, space, exif
|
service, settings, monkeypatch, space, exif
|
||||||
):
|
):
|
||||||
session(settings)
|
session(settings)
|
||||||
@@ -415,7 +415,8 @@ def test_api_requests_orientation_before_filtering_rotated_portraits(
|
|||||||
def pages(api, version, **params):
|
def pages(api, version, **params):
|
||||||
requested[:] = json.loads(params["additional"])
|
requested[:] = json.loads(params["additional"])
|
||||||
item = row(1)
|
item = row(1)
|
||||||
# Model Photos' opt-in fields: stored pixels alone look landscape.
|
# Photos returns display dimensions even when EXIF retains a rotation.
|
||||||
|
item["additional"]["resolution"] = {"width": 16, "height": 24}
|
||||||
if "orientation" in requested:
|
if "orientation" in requested:
|
||||||
item["additional"]["orientation"] = exif
|
item["additional"]["orientation"] = exif
|
||||||
return [item]
|
return [item]
|
||||||
@@ -469,6 +470,7 @@ def test_fetched_photo_direction_is_checked_before_cropping(
|
|||||||
# A correctly selected portrait remains readable, including EXIF-only rotation.
|
# A correctly selected portrait remains readable, including EXIF-only rotation.
|
||||||
item = row(1)
|
item = row(1)
|
||||||
item["additional"]["orientation"] = 8
|
item["additional"]["orientation"] = 8
|
||||||
|
item["additional"]["resolution"] = {"width": 16, "height": 24}
|
||||||
monkeypatch.setattr(photos.synology, "items", lambda *a, **k: [item])
|
monkeypatch.setattr(photos.synology, "items", lambda *a, **k: [item])
|
||||||
photos.save(
|
photos.save(
|
||||||
"direction",
|
"direction",
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
This Font Software is licensed under the SIL Open Font License,
|
||||||
|
Version 1.1.
|
||||||
|
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font
|
||||||
|
creation efforts of academic and linguistic communities, and to
|
||||||
|
provide a free and open framework in which fonts may be shared and
|
||||||
|
improved in partnership with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply to
|
||||||
|
any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software
|
||||||
|
components as distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to,
|
||||||
|
deleting, or substituting -- in part or in whole -- any of the
|
||||||
|
components of the Original Version, by changing formats or by porting
|
||||||
|
the Font Software to a new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||||
|
modify, redistribute, and sell modified and unmodified copies of the
|
||||||
|
Font Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components, in
|
||||||
|
Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the
|
||||||
|
corresponding Copyright Holder. This restriction only applies to the
|
||||||
|
primary font name as presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created using
|
||||||
|
the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||