feat(caddy,stand,smoke): prod-like routing stand, api smoke, old ui fallback for unknown paths
This commit is contained in:
1 parent
c74dac3e00
commit
f6d76bb307
11 files changed
+271
-4
No files matched your search
+5
-1
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
@@ -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<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
async function run(check: Check): Promise<string | null> {
|
||||
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);
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { oldUiUrl } from "#lib/spa-paths.js";
|
||||
import { page } from "$app/state";
|
||||
|
||||
const NOT_FOUND = 404;
|
||||
|
||||
const missing = $derived(page.status === NOT_FOUND);
|
||||
|
||||
$effect(() => {
|
||||
if (missing) {
|
||||
location.replace(oldUiUrl(page.url.href));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if missing}
|
||||
<p class="p-6 text-cladd-fg-soft">Opening this page in the old interface</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2 p-6">
|
||||
<h1 class="font-semibold text-[18px]">Something went wrong</h1>
|
||||
<p class="text-cladd-fg-soft">{page.error?.message}</p>
|
||||
<a
|
||||
class="text-brand underline"
|
||||
data-sveltekit-reload
|
||||
href={oldUiUrl(page.url.href)}
|
||||
>Open this page in the old interface</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
admin off
|
||||
auto_https off
|
||||
}
|
||||
|
||||
(cf_tls) {
|
||||
}
|
||||
|
||||
(compress) {
|
||||
encode zstd gzip
|
||||
}
|
||||
|
||||
import /etc/caddy/fireflyiii.site.caddy
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect fill="#3c8dbc" height="32" rx="7" width="32" />
|
||||
<path d="M9 23V9h12v3h-8.5v3H20v3h-7.5v5z" fill="#fff" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 184 B |
+1
-1
@@ -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(",")
|
||||
|
||||
Reference in new issue
Block a user