diff --git a/caddy/README.md b/caddy/README.md index f571827..c8868bd 100644 --- a/caddy/README.md +++ b/caddy/README.md @@ -1,3 +1,7 @@ # caddy -`site.caddy` routes `{$FIREFLY_UI_DOMAIN}` to the project over the external `caddy` network. In a shared Caddy, mount this directory as `/etc/caddy/projects.d/firefly-ui:ro` and set `FIREFLY_UI_DOMAIN`. Keep it portable: domain only from the env, no `tls`, snippets prefixed with `firefly_ui_`. +`fireflyiii.site.caddy` is the routing that runs in production: a copy of `fleet/personal/personal-main/fireflyiii/site.caddy` in `infra/komodo`. Firefly UI has no domain of its own; it lives on Firefly's domain and takes only its page paths on GET/HEAD. The path list comes from `src/lib/spa-paths.ts`: `bun scripts/caddy-matcher.ts` prints the `@ffui_pages` block. When the list changes, paste it into both copies. + +`site.caddy` is the template's routing for a separate domain and is not used. + +`stand/` runs the same file locally (`docker compose -f stand/docker-compose.yml --profile prodlike up -d`), in front of the v3 stand and a production build of the client. diff --git a/caddy/fireflyiii.site.caddy b/caddy/fireflyiii.site.caddy new file mode 100644 index 0000000..3a52eb9 --- /dev/null +++ b/caddy/fireflyiii.site.caddy @@ -0,0 +1,55 @@ +# Firefly III, поверх него Firefly UI (репа apps/firefly-ui, стек firefly-ui). +# Новый UI отдаёт свои ассеты и свои страницы на GET/HEAD. Всё остальное: +# API, вход, MFA, OAuth, cron, ассеты Firefly, любые не-GET и страницы, +# которых в новом UI нет, отдаёт сам Firefly. Список путей генерирует +# scripts/caddy-matcher.ts в apps/firefly-ui (из src/lib/spa-paths.ts). +# Отказ от нового UI: вернуть этот файл к одному reverse_proxy и задеплоить +# personal-main-caddy. Если фронт Firefly UI лежит, страницы сами уходят +# в Firefly (второй апстрим, lb_policy first). +# +# Переключатель на любом адресе: +# ?ffui=old только этот запрос в старый UI, без куки +# ?ffui=off старый UI до конца сессии браузера (кука ffui=off) +# ?ffui=on снять куку, снова новый UI +{$FIREFLY_DOMAIN} { + import cf_tls + import compress + + @ffui_off query ffui=off + header @ffui_off Set-Cookie "ffui=off; Path=/; Secure; HttpOnly; SameSite=Lax" + @ffui_on query ffui=on + header @ffui_on Set-Cookie "ffui=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Lax" + + @ffui_assets path /_app/* /favicon.svg + handle @ffui_assets { + reverse_proxy firefly-ui-frontend:3000 + } + + @ffui_pages { + method GET HEAD + path / /home /accounts /budgets /categories /tags + path /bills /subscriptions /piggy-banks /groups /reports /search + path /currencies /exchange-rates /attachments /preferences /export /administrations + path /recurring /webhooks/index /settings/links /accounts/* /inactive-accounts/* /transactions/* + path /budgets/* /categories/* /tags/* /bills/* /subscriptions/* /piggy-banks/* + path /groups/* /reports/* /currencies/* /exchange-rates/* /attachments/* /administrations/* + path /recurring/show/* /webhooks/show/* /settings/links/show/* + not path /accounts/reconcile/* /attachments/download/* /attachments/view/* /transactions/convert/* /transactions/debug/* /transactions/link/* + not path /transactions/mass/* /transactions/bulk/* /transactions/show-by-journal/* /piggy-banks/add/* /piggy-banks/remove/* /reports/options/* + not expression `{query.ffui} in ["off", "old"] || ({cookie.ffui} == "off" && {query.ffui} != "on")` + } + handle @ffui_pages { + reverse_proxy firefly-ui-frontend:3000 firefly_iii_app:8080 { + lb_policy first + lb_try_duration 3s + fail_duration 10s + transport http { + dial_timeout 1s + } + } + } + + handle { + reverse_proxy firefly_iii_app:8080 + } +} diff --git a/scripts/caddy-matcher.ts b/scripts/caddy-matcher.ts new file mode 100644 index 0000000..b9b5d32 --- /dev/null +++ b/scripts/caddy-matcher.ts @@ -0,0 +1,29 @@ +import { + FIREFLY_PREFIXES, + SPA_EXACT, + SPA_PREFIXES, +} from "../src/lib/spa-paths.ts"; + +const PER_LINE = 6; + +function lines(keyword: string, paths: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < paths.length; i += PER_LINE) { + out.push(`\t\t${keyword} ${paths.slice(i, i + PER_LINE).join(" ")}`); + } + return out; +} + +const pages = [...SPA_EXACT, ...SPA_PREFIXES.map((prefix) => `${prefix}*`)]; +const firefly = FIREFLY_PREFIXES.map((prefix) => `${prefix}*`); + +console.log( + [ + "\t@ffui_pages {", + "\t\tmethod GET HEAD", + ...lines("path", pages), + ...lines("not path", firefly), + '\t\tnot expression `{query.ffui} in ["off", "old"] || ({cookie.ffui} == "off" && {query.ffui} != "on")`', + "\t}", + ].join("\n") +); diff --git a/scripts/smoke.ts b/scripts/smoke.ts new file mode 100644 index 0000000..9b29ff5 --- /dev/null +++ b/scripts/smoke.ts @@ -0,0 +1,107 @@ +const base = Bun.env.FFUI_URL ?? "http://127.0.0.1:40869"; +const tokenFile = + Bun.env.FFUI_TOKEN_FILE ?? `${Bun.env.HOME}/.config/ffui/stand-v3.token`; +const token = (await Bun.file(tokenFile).text()).trim(); + +const month = new Date().toISOString().slice(0, 7); +const start = `${month}-01`; +const end = new Date().toISOString().slice(0, 10); +const range = `start=${start}&end=${end}`; + +type Check = { path: string; keys: string[] }; + +const checks: Check[] = [ + { keys: ["data.version", "data.api_version"], path: "/v1/about" }, + { keys: ["data.attributes.email"], path: "/v1/about/user" }, + { keys: ["data", "meta.pagination"], path: "/v1/preferences?limit=500" }, + { keys: ["data.0.attributes.code"], path: "/v1/currencies?limit=500" }, + { + keys: ["data.0.attributes.current_balance"], + path: `/v1/accounts?type=asset&${range}`, + }, + { + keys: ["data.0.attributes.transactions.0.amount"], + path: `/v1/transactions?type=all&${range}`, + }, + { + keys: ["data", "meta.pagination"], + path: "/v1/search/transactions?query=amount_more%3A0", + }, + { keys: ["data"], path: `/v1/budgets?${range}` }, + { keys: ["data"], path: `/v1/budget-limits?${range}` }, + { keys: ["data"], path: `/v1/available-budgets?${range}` }, + { keys: ["data"], path: `/v1/categories?${range}` }, + { keys: ["data"], path: "/v1/tags" }, + { keys: ["data"], path: `/v1/bills?${range}` }, + { keys: ["data"], path: "/v1/piggy-banks" }, + { keys: ["data"], path: "/v1/object-groups" }, + { keys: ["data"], path: "/v1/link-types" }, + { keys: ["data"], path: "/v1/recurrences" }, + { keys: ["data"], path: "/v1/attachments" }, + { keys: ["data"], path: "/v1/user-groups" }, + { keys: [], path: `/v1/summary/basic?${range}` }, + { + keys: ["0.entries"], + path: `/v1/chart/account/overview?${range}&period=1D`, + }, + { keys: [], path: `/v1/chart/budget/overview?${range}` }, + { keys: [], path: `/v1/chart/category/overview?${range}` }, + { keys: [], path: `/v1/insight/expense/expense?${range}` }, + { keys: [], path: `/v1/insight/income/revenue?${range}` }, + { keys: [], path: `/v1/insight/expense/category?${range}` }, + { keys: [], path: "/v1/autocomplete/accounts?query=&limit=5" }, + { keys: [], path: "/v1/autocomplete/categories?query=&limit=5" }, + { keys: [], path: "/v1/configuration/search.operators" }, +]; + +function pick(value: unknown, path: string): unknown { + let current: unknown = value; + for (const part of path.split(".")) { + if (current === null || typeof current !== "object") { + return undefined; + } + current = (current as Record)[part]; + } + return current; +} + +async function run(check: Check): Promise { + const response = await fetch(`${base}/api${check.path}`, { + headers: { Accept: "application/json", Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + return `${response.status} ${check.path}`; + } + const body = (await response.json()) as unknown; + const emptyData = + Array.isArray(pick(body, "data")) && pick(body, "data.0") === undefined; + const emptyRoot = Array.isArray(body) && body.length === 0; + const keys = check.keys.filter( + (key) => + !( + (emptyData && key.startsWith("data.0")) || + (emptyRoot && key.startsWith("0")) + ) + ); + const missing = keys.filter((key) => pick(body, key) === undefined); + if (missing.length > 0) { + return `shape ${check.path}: missing ${missing.join(", ")}`; + } + return null; +} + +const results = await Promise.all(checks.map(run)); +const failures = results.filter((result): result is string => result !== null); +const about = (await ( + await fetch(`${base}/api/v1/about`, { + headers: { Accept: "application/json", Authorization: `Bearer ${token}` }, + }) +).json()) as { data: { version: string } }; + +console.log( + `Firefly ${about.data.version} at ${base}: ${checks.length - failures.length}/${checks.length} ok` +); +for (const failure of failures) { + console.log(`FAIL ${failure}`); +} +process.exit(failures.length > 0 ? 1 : 0); diff --git a/src/app.html b/src/app.html index f498274..1c7072b 100644 --- a/src/app.html +++ b/src/app.html @@ -1,5 +1,5 @@ - + diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..88c1aa8 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,29 @@ + + +{#if missing} +

Opening this page in the old interface

+{:else} +
+

Something went wrong

+

{page.error?.message}

+ Open this page in the old interface +
+{/if} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 5040734..fc1b5e2 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -43,10 +43,12 @@ return; } lastHref = url.href; - if (fromUrl?.start === start && fromUrl?.end === end) { + const clean = !url.searchParams.has("ffui"); + if (clean && fromUrl?.start === start && fromUrl?.end === end) { return; } const next = new URL(url.href); + next.searchParams.delete("ffui"); next.searchParams.set("start", start); next.searchParams.set("end", end); lastHref = next.href; diff --git a/stand/docker-compose.yml b/stand/docker-compose.yml index 220344e..8213f8d 100644 --- a/stand/docker-compose.yml +++ b/stand/docker-compose.yml @@ -36,6 +36,30 @@ services: - v3_upload:/var/www/html/storage/upload ports: - "127.0.0.1:${FFUI_V3_PORT:-40869}:8080" + networks: + default: + aliases: + - firefly_iii_app + + frontend: + profiles: [prodlike] + build: .. + image: ffui-stand/frontend + networks: + default: + aliases: + - firefly-ui-frontend + + caddy: + profiles: [prodlike] + image: caddy:2-alpine + environment: + FIREFLY_DOMAIN: ":8080" + volumes: + - ./prodlike/Caddyfile:/etc/caddy/Caddyfile:ro + - ../caddy/fireflyiii.site.caddy:/etc/caddy/fireflyiii.site.caddy:ro + ports: + - "127.0.0.1:${FFUI_PROD_PORT:-34351}:8080" volumes: v1_db: diff --git a/stand/prodlike/Caddyfile b/stand/prodlike/Caddyfile new file mode 100644 index 0000000..6c132de --- /dev/null +++ b/stand/prodlike/Caddyfile @@ -0,0 +1,13 @@ +{ + admin off + auto_https off +} + +(cf_tls) { +} + +(compress) { + encode zstd gzip +} + +import /etc/caddy/fireflyiii.site.caddy diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..f3b8a69 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/vite.config.ts b/vite.config.ts index b25d8c9..75ab6f3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,7 @@ import { sveltekit } from "@sveltejs/kit/vite"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig, loadEnv } from "vite"; -import { isSpaPath } from "./src/lib/spa-paths"; +import { isSpaPath } from "./src/lib/spa-paths.ts"; const allowedHosts = process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(",")