Compare commits

..

11 Commits

Author SHA1 Message Date
bvandeusen 4e82208926 Merge pull request 'v26.05.26.5 — extension CORS unblock + UI gap closes + CI workflow cleanup' (#27) from dev into main 2026-05-26 20:15:07 -04:00
bvandeusen 85b640f32e fix(views): close the 24-32px gap below TopNav across all views — every v-container had py-6 (or py-8 on PlaceholderView) which pushed the first content item well below where the TopNav's fade-to-transparent gradient bottoms out. Switch to pt-2 pb-6 (8px top, 24px bottom) so content sits comfortably right below the nav, matches the ArtistHeader's 'continuous with TopNav' feel. PlaceholderView uses pt-3 pb-8 keeping its larger bottom padding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:47:23 -04:00
bvandeusen c7001f4aed fix(extension): CORS preflight for moz-extension:// + chrome-extension:// origins — operator-flagged 2026-05-26 that the extension's Test connection returned NetworkError because /api/credentials POSTs with X-Extension-Key trigger a browser preflight OPTIONS that hit a 405 (no OPTIONS method registered) with no Access-Control-Allow-* headers. Adds two app-level hooks: before_request short-circuits OPTIONS from extension origins with 204, after_request stamps the necessary ACL headers on responses to extension-origin requests. Whitelist is intentionally narrow (extension schemes only) so normal browser usage doesn't get permissive CORS. Five integration tests pin the contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:43:31 -04:00
bvandeusen f827612930 fix(artist-header): close gap below TopNav (top:64px → 48px to match TopNav's actual ~48px height) + center the tab strip via 1fr|auto|1fr layout with a right-side spacer cell
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:36:19 -04:00
bvandeusen 3f0153cba5 ci(workflows): dedupe + versioned image tags
ci.yml: drop pull_request: trigger — push: branches: [dev, main] already covers it; pull_request was duplicating ci.yml runs on every dev push with an open PR. (No fork PRs in this repo.)

build.yml: drop dev from push triggers — operator doesn't use the :dev image. Add tags: ['v*'] trigger + tag-push branch in the Determine-tag logic so cutting a release tag publishes an immutable :v26.05.26.X image (rollback story) without re-publishing :latest. Extend the XPI-download step to fire on tag pushes too so the versioned image carries the signed extension.

Net per hotfix cycle: 5 runs → 3 (no tag) / 4 (with tag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:26:56 -04:00
bvandeusen 52fff00353 Merge pull request 'v26.05.26.4 — hotfix: migration 0022 pre-DELETE colliding ImageProvenance before UPDATE' (#26) from dev into main 2026-05-26 18:06:20 -04:00
bvandeusen f3e8f30a8f fix(migration-0022): pre-DELETE colliding image_provenance rows before the UPDATE post_id — same row-by-row UNIQUE pattern as the post-collision case, just one level deeper. When image X has provenance under both keep and drop, UPDATE drop→keep would fire uq_image_provenance_image_post on the row that'd collide with the existing (X, keep). Pre-delete those rows (their info is already represented by the keep-side provenance) before the UPDATE moves the rest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:05:49 -04:00
bvandeusen eee107766e fix(migration-0022): rename unused _epid loop var (ruff B007)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:03:11 -04:00
bvandeusen c14338cbce Merge pull request 'v26.05.26.3 — hotfix: migration 0022 pre-merge across ENTIRE (canonical+others) group' (#25) from dev into main 2026-05-26 17:52:59 -04:00
bvandeusen 7a64730bd2 fix(migration-0022): pre-merge ALL duplicate-external_post_id Posts across the (canonical+others) group, not just canonical-vs-others — operator's v26.05.26.2 deploy still tripped uq_post_source_external_id because two non-canonical Sources both had Posts with epid=6166997. Bulk UPDATE moved the first cleanly then collided on the second. New pre-merge groups all Posts in the (artist, platform) by external_post_id; for any group with count>1, picks the keep (prefer one under canonical; else lowest id) and merges the rest before the bulk reparent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:52:29 -04:00
bvandeusen 1803a09306 ci(workflow): remove the 4 Cache pip wheels steps entirely — act_runner's cache backend has been broken for 11+ days and the cached path (~/.cache/pip) wasn't even the primary install tool's cache anyway (uv uses ~/.cache/uv). Net cost ~30s/job of wheel downloads. Long-term: mount ~/.cache/uv as a docker volume at the runner level (skips actions/cache entirely) or fix the runner-side cache backend.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 16:54:05 -04:00
18 changed files with 276 additions and 134 deletions
+38 -5
View File
@@ -2,7 +2,17 @@ name: Build images
on:
push:
branches: [dev, main]
# `:dev` builds dropped 2026-05-26 — operator tests from `:latest` after
# merge-to-main, not from the dev branch image. Saves one full docker
# build per dev push.
branches: [main]
# Tag-push triggers an immutable per-version image build (e.g.
# `:v26.05.26.5`) — gives a real rollback story alongside the floating
# `:main` / `:latest`. Layer reuse keeps the registry-storage cost
# negligible per tag. Doesn't overlap with the push-to-main build (that
# one publishes `:main` + `:latest`; the tag-push build publishes only
# `:<tag>`).
tags: ['v*']
# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
# - write:package, read:package (for docker push to git.fabledsword.com)
@@ -158,8 +168,12 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Download signed XPI from Forgejo release asset (main only)
if: github.ref == 'refs/heads/main'
- name: Download signed XPI from Forgejo release asset (main + tags)
# Fires on main-push AND on tag-push. Tag-push builds re-package the
# same source code as the preceding main-push build but with an
# immutable version tag — they need the XPI too, otherwise the
# versioned image ships without the signed extension.
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
@@ -200,7 +214,20 @@ jobs:
- name: Determine tag
id: tag
run: |
if [ "${GITHUB_REF##*/}" = "main" ]; then
# Three trigger shapes:
# refs/tags/v… → tag-push: publish ONLY the immutable version
# tag (e.g. :v26.05.26.5). Don't touch :latest;
# that already got published by the main-push
# build for the merge commit.
# refs/heads/main → push to main (incl. PR merge commits):
# publish :main + :latest (floating).
# anything else → safety net; shouldn't fire given the `on:`
# config above (dev was dropped). Tag :dev to
# surface the unexpected run in the registry.
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF##*/}" = "main" ]; then
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
@@ -231,7 +258,13 @@ jobs:
- name: Determine tag
id: tag
run: |
if [ "${GITHUB_REF##*/}" = "main" ]; then
# Mirrors build-web's three-shape logic (tag-push / main-push /
# safety-net dev). The -ml image follows the same release cadence
# as the web image.
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF##*/}" = "main" ]; then
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
+15 -66
View File
@@ -8,8 +8,10 @@ name: CI
on:
push:
branches: [dev, main]
pull_request:
branches: [main]
# pull_request trigger intentionally absent — with branches: [dev, main]
# above, every PR commit already fires CI via the push event on dev. Adding
# pull_request would duplicate runs on dev→main PRs. FC has no fork PRs
# (single-operator Forgejo repo) so push coverage is complete.
jobs:
backend-lint-and-test:
@@ -24,22 +26,17 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Cache pip wheels
# continue-on-error: act_runner's cache backend has been broken on
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
# but the action's JS bundle now fails to load at all
# ("Cannot find module .../dist/restore/index.js"), hard-failing
# the whole job. The install step that follows handles cold caches
# natively (uv pip / pip install both work without it), so net
# cost of disabling the cache here is ~30s of wheel downloads per
# job. Re-enable once the runner-side cache backend is fixed.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-py314-
# Cache step removed 2026-05-26: act_runner's cache backend has been
# broken on this homelab runner since 2026-05-15 (first as request-
# timeout warnings, then as hard "Cannot find module .../dist/restore/
# index.js" failures that tank the whole job). The cache step targeted
# ~/.cache/pip but the install below uses `uv pip install` primarily,
# whose own cache lives at ~/.cache/uv — so the cache step's real
# benefit was marginal even when working. Cost of removal: ~30s of
# wheel downloads per job. Future re-enable: mount ~/.cache/uv as a
# docker volume at the runner level (skips actions/cache entirely),
# or fix the runner-side cache backend (clear /var/run/act/actions/*,
# pin act_runner version, etc.).
- name: Install Python deps
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
@@ -133,22 +130,6 @@ jobs:
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Cache pip wheels
# continue-on-error: act_runner's cache backend has been broken on
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
# but the action's JS bundle now fails to load at all
# ("Cannot find module .../dist/restore/index.js"), hard-failing
# the whole job. The install step that follows handles cold caches
# natively (uv pip / pip install both work without it), so net
# cost of disabling the cache here is ~30s of wheel downloads per
# job. Re-enable once the runner-side cache backend is fixed.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-py314-
- name: API integration shard (resolve service IPs, migrate, test)
run: |
set -eux
@@ -207,22 +188,6 @@ jobs:
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Cache pip wheels
# continue-on-error: act_runner's cache backend has been broken on
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
# but the action's JS bundle now fails to load at all
# ("Cannot find module .../dist/restore/index.js"), hard-failing
# the whole job. The install step that follows handles cold caches
# natively (uv pip / pip install both work without it), so net
# cost of disabling the cache here is ~30s of wheel downloads per
# job. Re-enable once the runner-side cache backend is fixed.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-py314-
- name: Importer integration shard (resolve service IPs, migrate, test)
run: |
set -eux
@@ -281,22 +246,6 @@ jobs:
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Cache pip wheels
# continue-on-error: act_runner's cache backend has been broken on
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
# but the action's JS bundle now fails to load at all
# ("Cannot find module .../dist/restore/index.js"), hard-failing
# the whole job. The install step that follows handles cold caches
# natively (uv pip / pip install both work without it), so net
# cost of disabling the cache here is ~30s of wheel downloads per
# job. Re-enable once the runner-side cache backend is fixed.
continue-on-error: true
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-py314-
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
run: |
set -eux
@@ -83,55 +83,80 @@ def upgrade() -> None:
if not other_ids:
continue
# STEP 2: PRE-merge colliding Posts BEFORE the bulk reparent.
# Find pairs (keep, drop) where:
# keep = a Post under the canonical source
# drop = a Post under one of the non-canonical sources whose
# external_post_id equals keep.external_post_id
# If we let the bulk UPDATE try to repoint drop onto canonical,
# uq_post_source_external_id fires row-by-row and aborts the
# whole migration.
colliding = conn.execute(
# STEP 2: PRE-merge ALL Posts with duplicate external_post_id
# across the entire (canonical + others) group, BEFORE the bulk
# reparent. Two cases must both be handled:
# (A) canonical has Post X with epid=N; an "other" source has
# Post Y with epid=N → after bulk UPDATE, (canonical, N)
# collides with itself.
# (B) two different "other" sources each have a Post with
# epid=N; canonical has none → after bulk UPDATE, both
# are repointed to (canonical, N) and the second collides.
# The earlier version of this migration only handled (A); the
# operator's deploy 2026-05-26 tripped (B) at line 139.
# Fix: group ALL Posts in the (artist, platform) by epid; for
# any group with count>1, pick the keep (prefer one already
# under canonical; else lowest id) and merge the rest into it.
all_posts = conn.execute(
text("""
SELECT keep.id AS keep_id, drop_.id AS drop_id
FROM post AS keep
JOIN post AS drop_
ON drop_.external_post_id = keep.external_post_id
AND drop_.id != keep.id
WHERE keep.source_id = :canonical
AND drop_.source_id = ANY(:others)
SELECT external_post_id, id, source_id
FROM post
WHERE source_id = :canonical OR source_id = ANY(:others)
ORDER BY external_post_id, id
"""),
{"canonical": canonical_id, "others": other_ids},
).fetchall()
for keep_id, drop_id in colliding:
conn.execute(
text("""
UPDATE image_provenance SET post_id = :keep
WHERE post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_record SET primary_post_id = :keep
WHERE primary_post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
# Repointed provenance may now collide on
# uq_image_provenance_image_post (alembic 0021). Dedupe:
# keep min(id) per (image_record_id, post_id).
conn.execute(text("""
DELETE FROM image_provenance ip1
USING image_provenance ip2
WHERE ip1.image_record_id = ip2.image_record_id
AND ip1.post_id = ip2.post_id
AND ip1.id > ip2.id
"""))
conn.execute(
text("DELETE FROM post WHERE id = :drop_"),
{"drop_": drop_id},
)
by_epid: dict = {}
for epid, post_id, src_id in all_posts:
by_epid.setdefault(epid, []).append((post_id, src_id))
for _epid, posts in by_epid.items():
if len(posts) <= 1:
continue
# Prefer a Post already under canonical as the keep.
canonical_posts = [p for p in posts if p[1] == canonical_id]
if canonical_posts:
keep_id = canonical_posts[0][0]
else:
keep_id = posts[0][0] # already sorted by id ASC
drop_ids = [p[0] for p in posts if p[0] != keep_id]
for drop_id in drop_ids:
# Pre-delete image_provenance rows under drop_ whose
# image_record_id ALREADY has a provenance under keep —
# the UPDATE below would otherwise repoint them and
# trip uq_image_provenance_image_post (alembic 0021)
# row-by-row before any after-the-fact dedupe could
# run. Operator's v26.05.26.3 deploy 2026-05-26 tripped
# this at line 123.
conn.execute(
text("""
DELETE FROM image_provenance
WHERE post_id = :drop_
AND image_record_id IN (
SELECT image_record_id FROM image_provenance
WHERE post_id = :keep
)
"""),
{"keep": keep_id, "drop_": drop_id},
)
# Now safe to repoint the survivors.
conn.execute(
text("""
UPDATE image_provenance SET post_id = :keep
WHERE post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("""
UPDATE image_record SET primary_post_id = :keep
WHERE primary_post_id = :drop_
"""),
{"keep": keep_id, "drop_": drop_id},
)
conn.execute(
text("DELETE FROM post WHERE id = :drop_"),
{"drop_": drop_id},
)
# STEP 3: Bulk reparent the remaining Posts off the other
# Sources. After step 2, no collisions on
+38 -1
View File
@@ -3,13 +3,23 @@
import logging
from pathlib import Path
from quart import Quart
from quart import Quart, request
from .api import all_blueprints
from .config import get_config
from .frontend import frontend_bp
from .services.credential_crypto import CredentialCrypto
# Browser-extension origins. The FabledCurator extension fetches from
# moz-extension://<uuid>/ on Firefox and chrome-extension://<uuid>/ on
# Chromium-based browsers. Operator-flagged 2026-05-26: extension's
# 'Test connection' returned `NetworkError` because the X-Extension-Key
# header on /api/credentials triggers a CORS preflight that our routes
# don't handle. Whitelisting only these two schemes (not opening CORS
# up generally) lets the extension talk to a plain-HTTP self-hosted FC
# without weakening the no-CORS posture for normal browser usage.
_EXTENSION_ORIGIN_SCHEMES = ("moz-extension://", "chrome-extension://")
_CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64")
@@ -35,6 +45,33 @@ def create_app() -> Quart:
# Registered last so /api/* routes win over the SPA catch-all.
app.register_blueprint(frontend_bp)
@app.before_request
async def _extension_cors_preflight():
# Short-circuit OPTIONS preflight from the browser extension with a
# 204 + CORS headers (the after_request hook below adds them).
# Without this, OPTIONS lands on routes that only declared POST/GET
# methods and 405s before the after_request gets a chance.
if request.method != "OPTIONS":
return None
origin = request.headers.get("Origin", "")
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
return "", 204
return None
@app.after_request
async def _extension_cors_headers(response):
origin = request.headers.get("Origin", "")
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Methods"] = (
"GET, POST, PATCH, DELETE, OPTIONS"
)
response.headers["Access-Control-Allow-Headers"] = (
"Content-Type, X-Extension-Key"
)
response.headers["Access-Control-Max-Age"] = "86400"
return response
@app.after_serving
async def _dispose_db_engine() -> None:
from .extensions import dispose_engine
@@ -25,6 +25,11 @@
</v-tab>
<v-tab value="management">Management</v-tab>
</v-tabs>
<!-- Right-side spacer: balances the left cell's flex weight so the
centered tabs stay geometrically centered regardless of the
artist-name length. Mirrors TopNav's 1fr | auto | 1fr layout. -->
<div class="fc-artist-header__right" />
</header>
</template>
@@ -54,11 +59,13 @@ const stats = computed(() => {
</script>
<style scoped>
/* Matches TopNav.vue's frosted recipe exactly top:64px parks it under
the 64px-tall TopNav with no visible seam. */
/* Matches TopNav.vue's frosted recipe exactly. top:48px parks it flush
against TopNav's bottom edge (TopNav is 0.75rem padding + ~24px content
= ~48px tall; operator-flagged 2026-05-26 that top:64px left a visible
gap). The two bars now read as one continuous frosted strip. */
.fc-artist-header {
position: sticky;
top: 64px;
top: 48px;
z-index: 4;
display: flex;
align-items: center;
@@ -107,6 +114,11 @@ const stats = computed(() => {
flex: 0 0 auto;
}
.fc-artist-header__right {
flex: 1 1 0;
min-width: 0;
}
.fc-artist-header__tab-count {
margin-left: 4px;
font-size: 12px;
+3 -3
View File
@@ -3,11 +3,11 @@
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<v-container v-else-if="store.notFound" class="py-6">
<v-container v-else-if="store.notFound" class="pt-2 pb-6">
<v-alert type="warning" variant="tonal">Artist not found.</v-alert>
</v-container>
<v-container v-else-if="store.error && !store.overview" class="py-6">
<v-container v-else-if="store.error && !store.overview" class="pt-2 pb-6">
<v-alert type="error" variant="tonal" closable>{{ store.error }}</v-alert>
</v-container>
@@ -19,7 +19,7 @@
:post-count="store.postCount"
:last-added="store.lastAdded"
/>
<v-container fluid class="py-4">
<v-container fluid class="pt-2 pb-4">
<v-window v-model="tab">
<v-window-item value="posts">
<ArtistPostsTab
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<div class="fc-artists__controls">
<v-text-field
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<ExtensionKeyBar />
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<FilterPills v-model="pill" />
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<Teleport to="#fc-nav-actions">
<v-btn
:color="sel.isSelectMode ? 'accent' : undefined"
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container class="py-8">
<v-container class="pt-3 pb-8">
<h1 class="fc-h1 mb-4">{{ title }}</h1>
<v-alert type="info" variant="tonal" icon="mdi-toolbox">
This surface is a placeholder. It will be implemented in
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container class="py-6" max-width="900">
<v-container class="pt-2 pb-6" max-width="900">
<PostsFilterBar
:artist-id="artistFilter"
:platform="platformFilter"
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<div class="fc-series__head">
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
<span class="fc-series__count">{{ store.pages.length }} page(s)</span>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<!-- Sticky tabs: operator-flagged 2026-05-25 long Import / Maintenance
panels pushed the tab strip out of the viewport, forcing a scroll-
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<Teleport to="#fc-nav-actions">
<v-btn
prepend-icon="mdi-shuffle-variant" variant="tonal" color="accent"
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<div class="fc-subs__bar">
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
Add subscription
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<v-container fluid class="py-6">
<v-container fluid class="pt-2 pb-6">
<div class="fc-tags__controls">
<v-text-field
+86
View File
@@ -0,0 +1,86 @@
"""CORS preflight + response headers for moz-extension:// + chrome-extension://.
Operator-flagged 2026-05-26: extension's first 'Test connection' tap
returned `NetworkError` because /api/credentials had no OPTIONS handler
and no Access-Control-Allow-Origin response header. Browser preflight
failed → fetch blocked.
These tests pin the contract: any request from a moz-extension:// or
chrome-extension:// origin gets the right ACL headers. Plain browser
requests (no Origin header, or a regular https:// Origin) get nothing
— we don't want to open CORS up generally.
"""
import pytest
from backend.app import create_app
pytestmark = pytest.mark.integration
@pytest.fixture
async def client():
app = create_app()
async with app.test_client() as c:
yield c
@pytest.mark.asyncio
async def test_extension_preflight_returns_204_with_acl_headers(client):
resp = await client.options(
"/api/credentials",
headers={
"Origin": "moz-extension://abcd1234-uuid-fake",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "X-Extension-Key",
},
)
assert resp.status_code == 204
assert resp.headers["Access-Control-Allow-Origin"] == "moz-extension://abcd1234-uuid-fake"
assert "OPTIONS" in resp.headers["Access-Control-Allow-Methods"]
assert "X-Extension-Key" in resp.headers["Access-Control-Allow-Headers"]
@pytest.mark.asyncio
async def test_extension_get_carries_acl_headers(client):
# The actual response (post-preflight) also needs ACL headers — the
# browser checks them again before exposing the response body.
resp = await client.get(
"/api/credentials",
headers={"Origin": "moz-extension://abcd1234-uuid-fake"},
)
# 200 with empty list (no creds seeded) — what matters here is the
# CORS header is present.
assert resp.headers["Access-Control-Allow-Origin"] == "moz-extension://abcd1234-uuid-fake"
@pytest.mark.asyncio
async def test_chrome_extension_origin_also_allowed(client):
resp = await client.options(
"/api/credentials",
headers={
"Origin": "chrome-extension://abcd1234-uuid-fake",
"Access-Control-Request-Method": "POST",
},
)
assert resp.status_code == 204
assert resp.headers["Access-Control-Allow-Origin"] == "chrome-extension://abcd1234-uuid-fake"
@pytest.mark.asyncio
async def test_normal_browser_request_gets_no_cors_headers(client):
# A regular browser tab (https://example.com / file:// / no Origin
# at all) should NOT get any Access-Control-Allow-* — the extension
# whitelist is intentionally narrow.
resp = await client.get(
"/api/credentials",
headers={"Origin": "https://evil.example.com"},
)
assert "Access-Control-Allow-Origin" not in resp.headers
@pytest.mark.asyncio
async def test_no_origin_header_unaffected(client):
# Same-origin requests (no Origin header) — unaffected.
resp = await client.get("/api/credentials")
assert "Access-Control-Allow-Origin" not in resp.headers