fix(web-app-fider): put the design logo into the object store

With BLOB_STORAGE=s3 Fider reads blobs only from its bucket, so the logo
the role wrote into the blobs table never showed: the header had no logo,
the favicon stayed upstream's and the sign-in dialog loaded its image
from login.fider.io. The role now writes
tenants/<tenant id>/logos/infinito-design-logo.png into its bucket from
inside the SeaweedFS container (credentials on stdin, curl --aws-sigv4,
md5 compare so a repeated run reports UNCHANGED) and sets logo_bkey
through the new logo_stored argument. With design or logo off it removes
only its own key.

The SeaweedFS storage check now proves the bucket write with a post
attachment and deletes the post again. It used to upload a tenant logo,
which replaces logo_bkey; the role sets that key only while it is empty,
so the design logo would never have come back.

changed_when compares the script output exactly, because a search for
CHANGED also matches UNCHANGED.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-16 20:58:41 +02:00
1 parent 1ea0390347
commit d912664538
8 files changed
+124 -96

No files matched your search

-1
View File
@@ -1,4 +1,3 @@
# TODO
- With `services.sso.enabled` false the role creates no tenant: `tasks/00_core.yml` includes `tasks/01_setup_oidc.yml` (tenant bootstrap, administrator, activation, provider row) only under `FIDER_OIDC_ENABLED`. Upstream then redirects every route to its installation form `/signup`, which any visitor can submit (read from the task conditions and from `app/middlewares/tenant.go` and `app/handlers/signup.go` of the deployed commit `67c52826`; the compose stack of the design pass ran with `sso` on). Fider has no password sign-in, so such a deployment also has no account. Options: bootstrap tenant and administrator in every variant and keep only the provider row under the flag; or refuse `/signup` at the proxy while no tenant exists.
- With the object store enabled (`BLOB_STORAGE=s3`) the board keeps upstream's favicon and shows no logo, and the sign-in dialog loads `https://login.fider.io/static/assets/logo.png` from the browser (measured on the compose stack before a logo was set: the image rendered in the dialog). Cause: `tasks/02_design.yml` places the logo in the `blobs` table, which the S3 blob store never reads, so `FIDER_DESIGN_LOGO_ENABLED` is false there and `tenants.logo_bkey` stays empty; upstream's upload route `POST /_api/admin/settings/general` needs a session cookie. Options: put the object `tenants/<tenant id>/logos/infinito-design-logo.png` into the role's bucket with the role's object store credentials and set `logo_bkey`; or upload through the app's API with a session obtained from the identity provider.
@@ -9,7 +9,7 @@ const {
galleryEnabled,
tokenValue,
} = require("./design");
const { decodeDotenvQuotedValue, gotoOnion, performKeycloakLoginForm } = require("./personas");
const { apiGetOnion, decodeDotenvQuotedValue, gotoOnion, performKeycloakLoginForm } = require("./personas");
const { isServiceEnabled, skipUnlessServiceEnabled } = require("./service-gating");
const { resolveTimeout } = require("./timeouts");
const { clickFiderSsoButton } = require("./_shared");
@@ -17,8 +17,6 @@ const { clickFiderSsoButton } = require("./_shared");
const base = decodeDotenvQuotedValue(process.env.FIDER_BASE_URL || "").replace(/\/$/, "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || "");
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || "");
const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE || "");
const logoEnabled = process.env.DESIGN_LOGO_ENABLED === "true";
const MODES = ["light", "dark"];
const READY_MS = 60_000;
@@ -270,14 +268,18 @@ exports.register = function () {
});
test("design: the board carries the configured title", async ({ page }) => {
const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE || "");
test.skip(!title, "title replacement is disabled for this role");
await openHome(page);
expect(title, "DESIGN_TITLE must be set in the Playwright env file").toBeTruthy();
await expect(page).toHaveTitle(title);
await expect(page.locator(`${BRAND} h1`)).toHaveText(title);
});
test("design: logo and favicon are the configured ones", async ({ page }) => {
test.skip(!logoEnabled, "the role places the logo in the database blob store only; with the object store enabled the tenant keeps its own logo");
test("design: logo and favicon are the configured ones", async ({ page, request }) => {
test.skip(process.env.DESIGN_LOGO_ENABLED !== "true", "logo replacement is disabled for this role");
const image = await apiGetOnion(request, `${base}/static/images/logos/infinito-design-logo.png?size=100`);
expect(image.status(), "the logo blob is served from the active blob store").toBe(200);
expect(image.headers()["content-type"]).toBe("image/png");
await openHome(page);
const logo = page.locator(`${BRAND} img`);
await expect(logo).toBeVisible();
@@ -288,6 +290,10 @@ exports.register = function () {
const brandBox = await page.locator(BRAND).boundingBox();
expect(brandBox.width, "logo and title form a wide brand").toBeGreaterThan(brandBox.height);
await expect(page.locator("link[rel='icon']").first()).toHaveAttribute("href", LOGO_FAVICON);
await page.locator(PRIMARY).first().click();
await modalOpened(page);
await expect(page.locator(`${MODAL} img`).first()).toHaveAttribute("src", LOGO_IMAGE);
await expect(page.locator(`${MODAL} img[src*='login.fider.io']`)).toHaveCount(0);
});
test("design: the board follows the browser until the switch is used, then the switch wins", async ({ page }) => {
@@ -1,16 +1,3 @@
// SeaweedFS object-store scenario for Fider.
//
// Fider is configured with the S3 blob backend (env.j2: BLOB_STORAGE=s3 +
// BLOB_STORAGE_S3_BUCKET / _ACCESS_KEY_ID / _SECRET_ACCESS_KEY /
// _ENDPOINT_URL / _REGION), so a tenant logo the administrator uploads on the
// admin General settings page is written to the consumer bucket as a new
// private blob (Fider stores blobs with ACL=private and reverse-proxies them
// through its own /images route, which is why the role declares no public
// flag). The action signs the administrator in through Fider's SSO button +
// Keycloak form, opens the admin General settings page, sets the "Your Logo"
// file input to a small PNG and clicks Save; the shared check proves the
// bucket grew via the Filer UI.
//
// Required env (rendered by templates/playwright.env.j2):
// FIDER_BASE_URL, the OIDC login vars consumed by performKeycloakLoginForm,
// and the SEAWEEDFS_* keys consumed by runSeaweedfsStorageCheck.
@@ -19,34 +6,21 @@ const { test, expect } = require("@playwright/test");
const { resolveTimeout, isOnionTarget } = require("./timeouts");
const { skipUnlessServiceEnabled } = require("./service-gating");
const { runSeaweedfsStorageCheck, performKeycloakLoginForm, decodeDotenvQuotedValue } = require("./personas");
const { clickFiderSsoButton } = require("./_shared");
const LOGO_PNG = Buffer.from(
const ATTACHMENT_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAIAAAAiOjnJAAABeElEQVR42u3SMQ0AAAgEsVeHMDQhEBMM" +
"DE2q4HKpHjgXCTAWxsJYYCyMhbHAWBgLY4GxMBbGAmNhLIwFxsJYGAuMhbEwFhgLY2EsMBbGwlhgLIyF" +
"scBYGAtjgbEwFsYCY2EsjAXGwlgYC4yFsTAWGAtjYSwwFsbCWGAsjIWxwFgYC2OBsTAWxgJjYSyMBcbC" +
"WBgLjIWxMBYYC2NhLDAWxsJYYCyMhbHAWBgLY4GxMBbGAmNhLIyFsVTAWBgLY4GxMBbGAmNhLIwFxsJY" +
"GAuMhbEwFhgLY2EsMBbGwlhgLIyFscBYGAtjgbEwFsYCY2EsjAXGwlgYC4yFsTAWGAtjYSwwFsbCWGAs" +
"jIWxwFgYC2OBsTAWxgJjYSyMBcbCWBgLjIWxMBYYC2NhLDAWxsJYYCyMhbHAWBgLY4GxMBbGAmNhLIyF" +
"sVTAWBgLY4GxMBbGAmNhLIwFxsJYGAtjqYCxMBbGAmNhLIwFxsJYGAuMhbEwFhgLY2EsMBbGwlhgLIyF" +
"scBYGAtjgbH4ZgHTqvyKw+KRzwAAAABJRU5ErkJggg==",
"base64",
);
async function clickFiderSsoButton(locator) {
const signInLink = locator
.getByRole("button", { name: /sign in/i })
.or(locator.getByRole("link", { name: /sign in/i }));
await signInLink.first().waitFor({ state: "visible", timeout: resolveTimeout(30_000) });
await signInLink.first().click();
const ssoButton = locator.getByRole("link", { name: /continue with/i });
await ssoButton.first().waitFor({ state: "visible", timeout: resolveTimeout(15_000) });
await ssoButton.first().click({ force: true, timeout: resolveTimeout(30_000) });
}
"DE2q4HKpHjgXCTAWxsJYYCyMhbHAWBgLY4GxMBbGAmNhLIwFxsJYGAuMhbEwFhgLY2EsMBbGwlhgLIyF" +
"scBYGAtjgbEwFsYCY2EsjAXGwlgYC4yFsTAWGAtjYSwwFsbCWGAsjIWxwFgYC2OBsTAWxgJjYSyMBcbC" +
"WBgLjIWxMBYYC2NhLDAWxsJYYCyMhbHAWBgLY4GxMBbGAmNhLIyFsVTAWBgLY4GxMBbGAmNhLIwFxsJY" +
"GAuMhbEwFhgLY2EsMBbGwlhgLIyFscBYGAtjgbEwFsYCY2EsjAXGwlgYC4yFsTAWGAtjYSwwFsbCWGAs" +
"jIWxwFgYC2OBsTAWxgJjYSyMBcbCWBgLjIWxMBYYC2NhLDAWxsJYYCyMhbHAWBgLY4GxMBbGAmNhLIyF" +
"sVTAWBgLY4GxMBbGAmNhLIwFxsJYGAtjqYCxMBbGAmNhLIwFxsJYGAuMhbEwFhgLY2EsMBbGwlhgLIyF" +
"scBYGAtjgbH4ZgHTqvyKw+KRzwAAAABJRU5ErkJggg==";
test.use({ ignoreHTTPSErrors: true });
test("seaweedfs: an uploaded Fider tenant logo is stored in the SeaweedFS bucket", async ({ page, browser }) => {
test("seaweedfs: a Fider post attachment is stored in the SeaweedFS bucket", async ({ page, browser }) => {
test.skip(isOnionTarget(), "SeaweedFS filer UI is not a Tor surface on an onion node (headless backend)");
skipUnlessServiceEnabled("seaweedfs");
test.setTimeout(resolveTimeout(180_000));
@@ -54,67 +28,52 @@ test("seaweedfs: an uploaded Fider tenant logo is stored in the SeaweedFS bucket
const fiderBaseUrl = decodeDotenvQuotedValue(process.env.FIDER_BASE_URL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || "");
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || "");
const marker = `Storage check ${Date.now()}`;
let post = null;
await runSeaweedfsStorageCheck(page, browser, {
label: "a Fider admin tenant-logo upload",
expectInPlaceOverwrite: true,
label: "a Fider post attachment",
action: async (appPage) => {
expect(fiderBaseUrl, "FIDER_BASE_URL must be set for the Fider seaweedfs check").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set for the Fider seaweedfs check").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set for the Fider seaweedfs check").toBeTruthy();
const baseUrl = fiderBaseUrl.replace(/\/$/, "");
await appPage.goto(`${baseUrl}/`, { waitUntil: "domcontentloaded" });
await appPage.goto(`${fiderBaseUrl.replace(/\/$/, "")}/`, { waitUntil: "domcontentloaded" });
await clickFiderSsoButton(appPage);
await performKeycloakLoginForm(appPage, adminUsername, adminPassword);
await expect(
appPage.locator(".c-menu-user").first(),
"the administrator must be authenticated before opening the admin settings",
"the administrator must be authenticated before creating the post",
).toBeVisible({ timeout: resolveTimeout(60_000) });
await appPage.goto(`${baseUrl}/admin`, { waitUntil: "domcontentloaded" });
const fileInput = appPage.locator('input[type="file"][accept="image/*"]').first();
await expect(
fileInput,
"the Fider admin General settings page must expose the 'Your Logo' image input",
).toBeAttached({ timeout: resolveTimeout(60_000) });
const marker = `infinito-storage-check-${Date.now()}.png`;
await fileInput.setInputFiles({
name: marker,
mimeType: "image/png",
buffer: LOGO_PNG,
});
await expect(
appPage.locator('.c-image-upload img[src^="data:image"], .preview img[src^="data:image"]').first(),
"the selected logo must be loaded into the form (preview rendered) before saving",
).toBeVisible({ timeout: resolveTimeout(30_000) });
const saveButton = appPage.getByRole("button", { name: /^save$/i }).first();
await expect(
saveButton,
"the Fider admin General settings page must expose a Save action that persists the logo",
).toBeVisible({ timeout: resolveTimeout(60_000) });
const [settingsResp] = await Promise.all([
appPage.waitForResponse(
(r) => /\/_api\/admin\/settings\/general/.test(r.url()) && r.request().method() === "POST",
{ timeout: resolveTimeout(60_000) },
),
saveButton.click(),
]);
expect(
settingsResp.ok(),
`Fider must accept the General-settings save that persists the logo (got HTTP ${settingsResp.status()})`,
).toBeTruthy();
const reqBody = settingsResp.request().postData() || "";
expect(
reqBody,
"the General-settings save must carry the logo upload payload (empty upload = silent no-op)",
).toMatch(/"upload"\s*:\s*\{[^}]*"content"/);
post = await appPage.evaluate(
async ({ title, content }) => {
const response = await fetch("/api/v1/posts", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({
title,
description: "A post that proves attachments reach the object store.",
attachments: [{ upload: { fileName: "storage-check.png", contentType: "image/png", content } }],
}),
});
return { status: response.status, body: await response.json().catch(() => ({})) };
},
{ title: marker, content: ATTACHMENT_PNG_BASE64 },
);
expect(post.status, "Fider must accept the post with its attachment").toBe(200);
},
});
const removal = await page.evaluate(async (number) => {
const response = await fetch(`/api/v1/posts/${number}`, {
method: "DELETE",
headers: { Accept: "application/json", "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ text: "" }),
});
return response.status;
}, post.body.number);
expect(removal, "the storage-check post is removed again").toBe(200);
});
@@ -0,0 +1,32 @@
#!/bin/sh
# Args: action (put|delete), object URL, S3 region.
# Stdin: "<access key>:<secret key>" on the first line; for put, the PNG as base64 after it.
# Prints CHANGED, UNCHANGED, REMOVED or ABSENT.
set -eu
action=$1
url=$2
region=$3
IFS= read -r credentials
config() {
printf 'user = "%s"\naws-sigv4 = "aws:amz:%s:s3"\nurl = "%s"\nsilent\nfail\n' "$credentials" "$region" "$url"
}
if [ "$action" = delete ]; then
if config | curl -K - --head -o /dev/null; then
config | curl -K - -X DELETE -o /dev/null
echo REMOVED
else
echo ABSENT
fi
exit 0
fi
body=$(mktemp)
trap 'rm -f "$body"' EXIT
base64 -d > "$body"
wanted=$(md5sum < "$body" | cut -d' ' -f1)
current=$(config | curl -K - | md5sum | cut -d' ' -f1)
if [ "$current" = "$wanted" ]; then
echo UNCHANGED
exit 0
fi
config | curl -K - -X PUT -H 'Content-Type: image/png' --data-binary "@$body" -o /dev/null
echo CHANGED
@@ -1,4 +1,4 @@
-- Args: title (empty keeps the name), bootstrap_name (the name the role bootstrapped the tenant with), css, logo_key, logo_base64 (empty keeps the logo).
-- Args: title (empty keeps the name), bootstrap_name (the name the role bootstrapped the tenant with), css, logo_key, logo_base64 (non-empty writes the logo into the blobs table), logo_stored (non-empty when the logo is in the active blob store).
-- Returns one row: the number of rows that changed.
WITH tenant AS (
SELECT id FROM tenants ORDER BY id LIMIT 1
@@ -18,7 +18,7 @@ WITH tenant AS (
THEN %(title)s ELSE tenants.name END AS name,
CASE WHEN tenants.custom_css = '' OR position('--infinito-design-carrier' IN tenants.custom_css) > 0
THEN %(css)s ELSE tenants.custom_css END AS custom_css,
CASE WHEN %(logo_base64)s <> '' AND COALESCE(tenants.logo_bkey, '') = ''
CASE WHEN %(logo_stored)s <> '' AND COALESCE(tenants.logo_bkey, '') = ''
THEN %(logo_key)s ELSE tenants.logo_bkey END AS logo_bkey
FROM tenants
JOIN tenant ON tenant.id = tenants.id
@@ -0,0 +1,2 @@
-- Returns one row: the id of the tenant the role designs, 0 while no tenant exists.
SELECT COALESCE(MIN(id), 0) FROM tenants;
+30 -1
View File
@@ -17,6 +17,34 @@
register: fider_design_logo
when: FIDER_DESIGN_LOGO_ENABLED | bool
- name: "🪣 Fider | Logo in the object store"
when: FIDER_OBJSTORE_ENABLED | bool
block:
- name: "🪣 Fider | Read the tenant id"
database_query:
config: "{{ lookup('database', application_id) }}"
query_file: "{{ role_path }}/files/sql/select-tenant-id.sql"
expect_rows: true
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
register: fider_design_tenant
changed_when: false
- name: "🪣 Fider | Store or remove the generated logo in the bucket"
ansible.builtin.shell: >
container exec -i {{ FIDER_OBJSTORE_CONTAINER_ADDRESS }}
sh -c {{ lookup('file', 'shell/design-logo-object.sh') | quote }} design-logo-object
{{ 'put' if FIDER_DESIGN_LOGO_ENABLED | bool else 'delete' }}
{{ (lookup('objstore', application_id, 'url') ~ '/' ~ lookup('objstore', application_id, 'bucket') ~ '/tenants/' ~ fider_design_tenant.rows[0][0] ~ '/' ~
FIDER_DESIGN_LOGO_KEY) | quote }}
{{ lookup('objstore', application_id, 'region') | quote }}
args:
stdin: "{{ lookup('objstore', application_id, 'access_key') }}:{{ lookup('objstore', application_id, 'secret_key') }}\n{{ fider_design_logo.content if FIDER_DESIGN_LOGO_ENABLED
| bool else '' }}"
register: fider_design_logo_object
changed_when: fider_design_logo_object.stdout in ['CHANGED', 'REMOVED']
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
when: fider_design_tenant.rows[0][0] | int > 0
- name: "🎨 Fider | Apply title, custom CSS and logo"
database_query:
config: "{{ lookup('database', application_id) }}"
@@ -26,7 +54,8 @@
bootstrap_name: "{{ SOFTWARE_NAME }}"
css: "{{ lookup('template', 'custom.css.j2') }}"
logo_key: "{{ FIDER_DESIGN_LOGO_KEY }}"
logo_base64: "{{ fider_design_logo.content if FIDER_DESIGN_LOGO_ENABLED | bool else '' }}"
logo_base64: "{{ fider_design_logo.content if FIDER_DESIGN_LOGO_ENABLED | bool and not (FIDER_OBJSTORE_ENABLED | bool) else '' }}"
logo_stored: "{{ 'true' if FIDER_DESIGN_LOGO_ENABLED | bool else '' }}"
expect_rows: true
mask_values: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
register: fider_design_apply
+3 -2
View File
@@ -8,9 +8,10 @@ FIDER_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled')
FIDER_DESIGN_ENABLED: "{{ lookup('config', application_id, 'services.design.enabled') | bool }}"
FIDER_DESIGN_TITLE: "{{ lookup('design', application_id).title if FIDER_DESIGN_ENABLED | bool and lookup('design', application_id).title is string else '' }}"
FIDER_DESIGN_LOGO_ENABLED: "{{ FIDER_DESIGN_ENABLED | bool and lookup('design', application_id).logo is string and not (lookup('objstore', application_id, 'enabled')
| bool) }}"
FIDER_DESIGN_LOGO_ENABLED: "{{ FIDER_DESIGN_ENABLED | bool and lookup('design', application_id).logo is string }}"
FIDER_DESIGN_LOGO_KEY: "logos/infinito-design-logo.png"
FIDER_OBJSTORE_ENABLED: "{{ lookup('objstore', application_id, 'enabled') | bool }}"
FIDER_OBJSTORE_CONTAINER_ADDRESS: "{{ lookup('container_address', lookup('objstore', application_id, 'id'), lookup('objstore', application_id, 'engine')) }}"
FIDER_TENANT_BOOTSTRAP_BODY:
tenantName: "{{ SOFTWARE_NAME }}"