Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ FABLE_RATE_LIMIT_MAX_CLIENT_BUCKETS=10000
FABLE_UID=1000
FABLE_GID=1000

# Optional scheduled backup service: set FABLE_BACKUP_HOST_DIR to a mounted durable/off-host location.
FABLE_BACKUP_HOST_DIR=./backups
FABLE_BACKUP_INTERVAL_SECONDS=86400
FABLE_BACKUP_RETENTION_DAYS=30

# Local development uses the build image; production should set a release tag or GHCR digest.
FABLE_BACKEND_IMAGE=fable-backend:local

# Optional Figma API configuration
FIGMA_PERSONAL_ACCESS_TOKEN=optional_figma_token
FIGMA_FILE_KEY=k90h1If7gNsEl56fQ1HxPq
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ jobs:
uses: actions/checkout@v4
- name: Build backend image
run: docker build -t fable-backend:test -f backend/Dockerfile .
- name: Verify backup runner is packaged
run: docker run --rm --entrypoint python fable-backend:test /app/scripts/sqlite_backup.py --help
- name: Verify Docker Compose configuration
run: |
cp .env.example .env
trap 'rm -f .env' EXIT
docker compose config --quiet

ios-verification:
runs-on: macos-15-intel
Expand Down
102 changes: 102 additions & 0 deletions .github/workflows/release-container.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
name: Publish backend release image

on:
push:
tags:
- "v*"

permissions:
contents: read

jobs:
verify-backend:
runs-on: ubuntu-latest
steps:
- name: Check out tagged source
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install backend dependencies
run: pip install -r backend/requirements.txt
- name: Run backend suite
run: PYTHONPATH=.:backend pytest backend/test_main.py -v
- name: Build backend image
run: docker build -t fable-backend:release-check -f backend/Dockerfile .
- name: Verify backup runner is packaged
run: docker run --rm --entrypoint python fable-backend:release-check /app/scripts/sqlite_backup.py --help
- name: Verify Docker Compose configuration
run: |
cp .env.example .env
trap 'rm -f .env' EXIT
docker compose config --quiet

verify-ios:
runs-on: macos-15-intel
steps:
- name: Check out tagged source
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- name: Verify Release API URL guard
run: |
if CONFIGURATION=Release FABLE_API_BASE_URL=http://127.0.0.1:8000/api/v1 scripts/validate_ios_release_config.sh; then
echo "The Release URL guard accepted an HTTP loopback URL."
exit 1
fi
if CONFIGURATION=Release FABLE_API_BASE_URL= scripts/validate_ios_release_config.sh; then
echo "The Release URL guard accepted a missing API URL."
exit 1
fi
CONFIGURATION=Release FABLE_API_BASE_URL=https://api.example.com/api/v1 scripts/validate_ios_release_config.sh
- name: Build and test iOS app
run: >-
xcodebuild
-project frontend/FableApp.xcodeproj
-scheme FableApp
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5'
-derivedDataPath build/DerivedData
CODE_SIGNING_ALLOWED=NO
test
- name: Verify Release build
run: >-
xcodebuild
-project frontend/FableApp.xcodeproj
-scheme FableApp
-configuration Release
-destination 'generic/platform=iOS Simulator'
-derivedDataPath build/ReleaseDerivedData
FABLE_API_BASE_URL=https://api.example.com/api/v1
CODE_SIGNING_ALLOWED=NO
build

publish:
needs: [verify-backend, verify-ios]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Check out tagged source
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683

- name: Publish versioned images and report the registry digest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
image="ghcr.io/${GITHUB_REPOSITORY,,}"
docker buildx create --name fable-release --use
docker buildx inspect --bootstrap
printf '%s' "$GITHUB_TOKEN" | docker login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
docker buildx build \
--file backend/Dockerfile \
--label "org.opencontainers.image.source=https://github.com/${GITHUB_REPOSITORY}" \
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
--label "org.opencontainers.image.version=${GITHUB_REF_NAME}" \
--tag "${image}:${GITHUB_REF_NAME}" \
--tag "${image}:${GITHUB_SHA}" \
--metadata-file /tmp/fable-image-metadata.json \
--push \
.
digest="$(python -c 'import json; print(json.load(open("/tmp/fable-image-metadata.json"))["containerimage.digest"])')"
printf 'Published image: `%s@%s`\n' "$image" "$digest" >> "$GITHUB_STEP_SUMMARY"
3 changes: 2 additions & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid "${FABLE_GID}" fable \
&& useradd --system --uid "${FABLE_UID}" --gid fable --home-dir /app --shell /usr/sbin/nologin fableuser \
&& mkdir -p /app/data \
&& mkdir -p /app/data /app/scripts \
&& chown -R fableuser:fable /app

COPY --from=dependencies /install/ /usr/local/
WORKDIR /app
COPY backend/ ./
COPY --chown=fableuser:fable scripts/sqlite_backup.py /app/scripts/sqlite_backup.py

USER fableuser

Expand Down
59 changes: 59 additions & 0 deletions backend/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1513,6 +1513,65 @@ def test_sqlite_online_backup_and_integrity_check(tmp_path):
assert connection.execute("SELECT value FROM entries").fetchone()[0] == "preserved"
assert backup.stat().st_mode & 0o777 == 0o600

existing_snapshot = backup.read_bytes()
with pytest.raises(FileExistsError):
create_backup(source, backup)
assert backup.read_bytes() == existing_snapshot
assert list(backup.parent.glob(".snapshot.sqlite3.*.partial")) == []


def test_scheduled_sqlite_backup_verifies_snapshot_and_prunes_only_expired_backups(tmp_path):
import sqlite3
from scripts.sqlite_backup import create_scheduled_backup, verify_database

source = tmp_path / "source.sqlite3"
backup_directory = tmp_path / "backups"
backup_directory.mkdir()
with sqlite3.connect(source) as connection:
connection.execute("CREATE TABLE entries (value TEXT NOT NULL)")
connection.execute("INSERT INTO entries (value) VALUES ('preserved')")

now = datetime(2026, 10, 29, tzinfo=timezone.utc)
expired_backup = backup_directory / "fable-expired.sqlite3"
retained_backup = backup_directory / "fable-retained.sqlite3"
unrelated_file = backup_directory / "notes.sqlite3"
for path in (expired_backup, retained_backup, unrelated_file):
path.write_text("placeholder")
os.utime(expired_backup, (now.timestamp() - 31 * 86_400,) * 2)
os.utime(retained_backup, (now.timestamp() - 2 * 86_400,) * 2)
os.utime(unrelated_file, (now.timestamp() - 31 * 86_400,) * 2)

backup = create_scheduled_backup(source, backup_directory, retention_days=30, now=now)

assert backup.name == "fable-20261029T000000.000000Z.sqlite3"
verify_database(backup)
assert not expired_backup.exists()
assert retained_backup.exists()
assert unrelated_file.exists()
with sqlite3.connect(backup) as connection:
assert connection.execute("SELECT value FROM entries").fetchone()[0] == "preserved"


def test_scheduled_sqlite_backup_requires_timezone_and_positive_retention(tmp_path):
import sqlite3
from datetime import datetime
from scripts.sqlite_backup import create_scheduled_backup

source = tmp_path / "source.sqlite3"
with sqlite3.connect(source) as connection:
connection.execute("CREATE TABLE entries (value TEXT NOT NULL)")

with pytest.raises(ValueError, match="at least one day"):
create_scheduled_backup(source, tmp_path / "backups", retention_days=0)
with pytest.raises(ValueError, match="include a timezone"):
create_scheduled_backup(
source,
tmp_path / "backups",
retention_days=30,
now=datetime(2026, 10, 29),
)


def test_init_db_migrates_legacy_demo_stories_and_shelf_references_once():
from core.database import LEGACY_DEMO_STORY_IDS, get_db

Expand Down
32 changes: 32 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
services:
fable-backend:
image: ${FABLE_BACKEND_IMAGE:-fable-backend:local}
user: "${FABLE_UID:-1000}:${FABLE_GID:-1000}"
build:
context: .
dockerfile: backend/Dockerfile
Expand All @@ -15,3 +17,33 @@ services:
environment:
FABLE_DB_PATH: /app/data/fable.sqlite3
restart: unless-stopped

fable-backup:
image: ${FABLE_BACKEND_IMAGE:-fable-backend:local}
user: "${FABLE_UID:-1000}:${FABLE_GID:-1000}"
build:
context: .
dockerfile: backend/Dockerfile
args:
FABLE_UID: ${FABLE_UID:-1000}
FABLE_GID: ${FABLE_GID:-1000}
profiles: ["backup"]
depends_on:
fable-backend:
condition: service_healthy
volumes:
- ./data:/app/data:ro
- ${FABLE_BACKUP_HOST_DIR:-./backups}:/app/backups
environment:
FABLE_DB_PATH: /app/data/fable.sqlite3
FABLE_BACKUP_INTERVAL_SECONDS: ${FABLE_BACKUP_INTERVAL_SECONDS:-86400}
FABLE_BACKUP_RETENTION_DAYS: ${FABLE_BACKUP_RETENTION_DAYS:-30}
command: ["python", "/app/scripts/sqlite_backup.py", "schedule", "/app/data/fable.sqlite3", "/app/backups"]
network_mode: none
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
healthcheck:
disable: true
restart: unless-stopped
28 changes: 27 additions & 1 deletion docs/OPERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,33 @@ python3 scripts/sqlite_backup.py backup data/fable.sqlite3 backups/fable-YYYY-MM
python3 scripts/sqlite_backup.py verify backups/fable-YYYY-MM-DD.sqlite3
```

Use the actual path configured by `FABLE_DB_PATH` if it differs. Store backups outside the application data directory, restrict access to them, and copy them to durable storage. This repository does not configure scheduled off-host backups or retention; the hosting operator must provide those.
Use the actual path configured by `FABLE_DB_PATH` if it differs. Store backups outside the application data directory and restrict access to them. The scheduled Compose service can maintain a host-mounted snapshot history; the hosting operator must mount durable off-host storage at that path for disaster recovery.

### Scheduled backups in Docker Compose

The optional `backup` profile runs the same verified SQLite backup utility once at startup and then at the configured interval. It removes matching `fable-*.sqlite3` snapshots older than the configured retention window; the default is one day between backups and 30 days of retention.

Set `FABLE_BACKUP_HOST_DIR` in `.env` to a mounted durable location. Ensure that directory exists and is writable only by the container's Fable UID/GID (defaults to `1000:1000`); for example, `sudo install -d -o 1000 -g 1000 -m 0700 /srv/fable/backups`. The backup container has no network access, a read-only root filesystem, and read-only access to the live database. Then start both services:

```bash
docker compose --profile backup up -d --build
docker compose --profile backup logs -f fable-backup
```

The sample `./backups` path is on the application host and is not an off-site backup by itself. Mount a separate durable or off-host filesystem at the configured path and monitor backup logs. If the destination is unavailable or a backup fails, the service logs the failure and retries within five minutes. Keep `FABLE_BACKUP_INTERVAL_SECONDS` at least 60 and `FABLE_BACKUP_RETENTION_DAYS` at least 1.

### Versioned image release and rollback

After CI passes on the commit, pushing a `v*` tag runs the backend, Docker/Compose, and iOS verification jobs before publishing the backend image to GitHub Container Registry with both a version tag and source commit SHA tag. The workflow reports the registry digest in its run summary; use that digest for deployments and rollback because tags can be moved. The workflow creates an image artifact; it does not deploy to a hosting provider.

Before upgrading, create and verify a database backup. Then set `FABLE_BACKEND_IMAGE` in `.env` to the new image digest reported by the release workflow, authenticate the host to `ghcr.io` if the package is private, and run:

```bash
docker compose --profile backup pull
docker compose --profile backup up -d --no-build
```

To roll back the application, set `FABLE_BACKEND_IMAGE` to the previous release image digest and run the same pull and `up` commands. Restore the pre-upgrade database backup only if the new version applied a migration that is incompatible with the previous image. Keep schema changes backward compatible where possible and verify the health endpoint plus a known account and story after either deployment or rollback.

## Restore

Expand Down
18 changes: 14 additions & 4 deletions docs/final_milestone/MASTER_PROGRESS_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
**Course:** ITWM101 (Integrative Programming & Technologies 2)
**Developer:** Roosc Zaño
**Role:** Lead Systems Engineer & Solutions Architect
**Active Feature Branch:** `fix/build-and-test-integrity`
**Active Feature Branch:** `codex/close-production-audit-gaps`

---

## Executive Summary & System Objectives

This progress log tracks the transition of **Fable** from an editorial prototype to a provider-backed, offline-first reading platform. The backend has **42/42 passing automated tests** covering content contracts, account isolation, live statistics, persistent sessions, sliding-window rate limiting, and CORS origin whitelisting. The iOS application has been compiled cleanly with Xcode for iOS Simulator (`iPhone 17 Pro`) with zero errors. All legacy demo stories and chapters have been purged, and the app operates with 100% offline-first resilience backed by SwiftData SQLite.
This progress log tracks the transition of **Fable** from an editorial prototype to a provider-backed, offline-first reading platform. The latest backend and iOS verification results are recorded in the dated entries below; the backend suite now covers content contracts, account isolation, live statistics, persistent sessions, rate limiting, CORS, provider bounds, and backup retention. The iOS application uses SwiftData for offline reading and the provider-backed API for live catalog content.

### The 8 Core Architectural Domains

Expand All @@ -21,7 +21,7 @@ This progress log tracks the transition of **Fable** from an editorial prototype
| **4. UI Interactions & Voice Accessibility** | [`04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md`](file:///Users/maclab/Documents/roosc/Fable-IOS/docs/final_milestone/04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md) | **Complete & Verified** | Actions are wired across writing, profile, library, reader, shelf, and settings; `AVSpeechSynthesizer` uses installed OS voices with tactile micro-interactions and haptics. |
| **5. Live Metadata & Pure User-State Isolation** | [`03_LIVE_METADATA_AND_USER_STATE_PURGING.md`](file:///Users/maclab/Documents/roosc/Fable-IOS/docs/final_milestone/03_LIVE_METADATA_AND_USER_STATE_PURGING.md) | **Complete & Verified** | Fresh installs have no bundled story catalog; provider and authored content uses live metadata and account-owned shelf/profile state with legacy demo stories purged. |
| **6. Multi-Format & Manga Architecture** | [`05_MULTI_FORMAT_AND_MANGA_ARCHITECTURE.md`](file:///Users/maclab/Documents/roosc/Fable-IOS/docs/final_milestone/05_MULTI_FORMAT_AND_MANGA_ARCHITECTURE.md) | **Complete & Verified** | Polymorphic reader dispatch, continuous vertical Webtoon scroll, horizontal swipe paging, Tachiyomi-style 3-zone tap overlay, and MangaDex direct CDN panel ingestion. |
| **7. Testing Suites & ADR Baseline** | [`04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md`](file:///Users/maclab/Documents/roosc/Fable-IOS/docs/final_milestone/04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md) | **Complete & Verified** | 42/42 backend tests pass in pytest. Swift compilation verified via Xcode on iOS Simulator (`** BUILD SUCCEEDED **`). |
| **7. Testing Suites & ADR Baseline** | [`04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md`](file:///Users/maclab/Documents/roosc/Fable-IOS/docs/final_milestone/04_UI_INTERACTIONS_AND_AUDIO_ACCESSIBILITY.md) | **Complete & Verified** | Backend and iOS verification results are tracked in dated entries; GitHub Actions runs the backend suite and native iOS simulator tests/build. |
| **8. App Health Diagnostics & Anti-Overlap Invariants** | [`MASTER_PROGRESS_LOG.md`](file:///Users/maclab/Documents/roosc/Fable-IOS/docs/final_milestone/MASTER_PROGRESS_LOG.md) | **Complete & Verified** | Image boundary clipping, `SystemDiagnosticsSheet`, and `AppHealthTests` verified in source and live Simulator executions. |

---
Expand Down Expand Up @@ -598,4 +598,14 @@ This progress log tracks the transition of **Fable** from an editorial prototype
- [x] Removed application trust in raw `X-Forwarded-For`, capped rate-limit client buckets, and bounded provider response bytes, fetch concurrency, and in-memory caches.
- [x] Improved VoiceOver error announcements and Dynamic Type support on sign-in and sign-up screens; removed stale README test counts and branch links.
- [x] Verification: backend suite **64/64 passed** in the project Python environment and in the Python 3.11 container; Docker image build, Compose configuration, and iOS Release URL guard passed.
- [ ] Native Xcode build and simulator tests are left to the macOS CI runner because this workspace does not have `xcodebuild`.
- [x] Native Xcode build and simulator tests passed on the macOS CI runner; this Fedora workspace does not have `xcodebuild`.

## Production Audit Follow-up (2026-10-29)

- [x] Replaced the unbounded SwiftUI `AsyncImage` fallback with a single streaming downloader that rejects non-HTTPS URLs, insecure redirects, non-image responses, oversized bodies, and images with excessive decoded dimensions.
- [x] Added image downloader regression tests for valid image responses, invalid content types, byte limits, URL policy, and dimension limits.
- [x] Added an opt-in Compose backup service that creates integrity-checked online SQLite snapshots, retries failures, and prunes expired Fable snapshots by retention policy.
- [x] Added versioned GitHub Container Registry image tags and digest-based deploy/rollback instructions; release publication waits for backend, Docker/Compose, and iOS checks.
- [x] Clarified that provider-only licensed MangaDex chapters open in an in-app browser, and refreshed the stale progress summary.
- [x] Verification: backend suite **66 passed** in the rebuilt Python 3.11 image; Docker build, Compose validation, packaged backup CLI, and a read-only/no-network backup container check passed.
- [ ] Native Xcode tests, including the new image downloader cases, and the Release build are pending macOS CI because this workspace has no `xcodebuild`.
Loading
Loading