diff --git a/fluxer_admin/src/api/instance_config.rs b/fluxer_admin/src/api/instance_config.rs index d1b76b746..d62859fb8 100644 --- a/fluxer_admin/src/api/instance_config.rs +++ b/fluxer_admin/src/api/instance_config.rs @@ -4,10 +4,11 @@ use reqwest::Method; use super::client::{AdminApiClient, ApiResult}; use super::types::{ - AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, - InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest, - InstanceEmailSmtpTestResponse, InstancePremiumDiscovery, PendingRegistrationListResponse, - PendingRegistrationResponse, PendingRegistrationStatus, + AccountIdentitySettings, BrandingAssetKind, BrandingAssetUploadRequest, + CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceAccountIdentityDiscovery, + InstanceConfigResponse, InstanceConfigUpdateRequest, InstanceEmailSmtpTestResponse, + InstancePremiumDiscovery, PendingRegistrationListResponse, PendingRegistrationResponse, + PendingRegistrationStatus, }; impl AdminApiClient { @@ -37,6 +38,20 @@ impl AdminApiClient { .await } + pub async fn upload_branding_asset( + &self, + kind: BrandingAssetKind, + image: Option, + ) -> ApiResult { + self.send( + Method::POST, + "/admin/instance/config/branding-assets", + Some(&BrandingAssetUploadRequest { kind, image }), + None, + ) + .await + } + pub async fn test_saved_instance_smtp_config( &self, ) -> ApiResult { diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index d48d0351f..27ca782ea 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -784,6 +784,72 @@ pub struct PendingRegistrationListResponse { pub pending_registrations: Vec, } +pub const BRANDING_ASSET_MAX_IMAGE_CHARS: usize = 16_000_000; + +pub const BRANDING_ASSET_ACCEPT: &str = ".png,.jpeg,.webp,.gif,.apng,.avif,.heic,.heif,.jxl,.svg,\ + image/png,image/jpeg,image/webp,image/gif,image/apng,image/avif,image/heic,image/heif,\ + image/jxl,image/svg+xml"; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BrandingAssetKind { + Icon, + Symbol, + Logo, + Wordmark, + Favicon, +} + +impl BrandingAssetKind { + pub const ALL: [Self; 5] = [ + Self::Icon, + Self::Symbol, + Self::Logo, + Self::Wordmark, + Self::Favicon, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Icon => "icon", + Self::Symbol => "symbol", + Self::Logo => "logo", + Self::Wordmark => "wordmark", + Self::Favicon => "favicon", + } + } + + pub fn parse(value: &str) -> Option { + Self::ALL.into_iter().find(|kind| kind.as_str() == value) + } + + pub fn label(self) -> &'static str { + match self { + Self::Icon => "Icon", + Self::Symbol => "Symbol", + Self::Logo => "Logo", + Self::Wordmark => "Wordmark", + Self::Favicon => "Favicon", + } + } + + pub fn url(self, branding: &AppBrandingConfigResponse) -> Option<&str> { + match self { + Self::Icon => branding.icon_url.as_deref(), + Self::Symbol => branding.symbol_url.as_deref(), + Self::Logo => branding.logo_url.as_deref(), + Self::Wordmark => branding.wordmark_url.as_deref(), + Self::Favicon => branding.favicon_url.as_deref(), + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct BrandingAssetUploadRequest { + pub kind: BrandingAssetKind, + pub image: Option, +} + #[derive(Clone, Debug, Default, Serialize)] pub struct InstanceConfigUpdateRequest { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index fd5144d1a..c02afaf50 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -6,25 +6,27 @@ use crate::{ types::{ AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest, AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest, - AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, - CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest, - DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, - EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest, - GatewayRolloutConfigUpdateRequest, GatewayRolloutMode, - InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest, - InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest, - InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest, - InstanceEmailSmtpTestResponse, InstanceGifIntegrationUpdateRequest, - InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest, - InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest, - InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest, - LimitConfigUpdateRequest, LimitKeyMetadata, LimitRule, LimitRuleFilters, PremiumMode, - PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, - VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest, + AppSetupConfigUpdateRequest, BRANDING_ASSET_MAX_IMAGE_CHARS, BrandingAssetKind, + CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigUpdateRequest, + CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest, + EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS, + ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest, + GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest, + InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest, + InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest, + InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestResponse, + InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest, + InstanceMediaUpdateRequest, InstancePolicyUpdateRequest, + InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest, + InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitKeyMetadata, + LimitRule, LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, + RegistrationMode, SsoConfigUpdateRequest, VOICE_P2P_MAX_PARTICIPANTS_RANGE, + VoiceE2eeScope, VoiceP2pConfigUpdateRequest, }, }, config::AdminConfig, middleware::{ + auth::AuthContext, csrf::CsrfToken, flash::{self, FlashData}, htmx, @@ -41,6 +43,8 @@ use axum::{ use maud::Markup; use serde::Deserialize; +const BRANDING_ASSET_FORM_BYTES: usize = BRANDING_ASSET_MAX_IMAGE_CHARS * 3 + 64 * 1024; + #[derive(Deserialize)] pub struct ActionQuery { pub action: Option, @@ -147,6 +151,15 @@ pub async fn instance_config_post( Query(aq): Query, request: Request, ) -> Response { + match aq.action.as_deref() { + Some("upload_branding_asset") => { + return branding_asset_post(&state, &headers, &auth.0, true, request).await; + } + Some("remove_branding_asset") => { + return branding_asset_post(&state, &headers, &auth.0, false, request).await; + } + _ => {} + } let config = state.config(); let base = &config.base_path; let form = match MultiValueForm::from_request(request).await { @@ -312,6 +325,88 @@ pub async fn instance_config_post( redirect_back_with_flash(base, "/instance-config", flash, config.secure_cookies()) } +async fn branding_asset_post( + state: &AppState, + headers: &HeaderMap, + auth: &AuthContext, + upload: bool, + request: Request, +) -> Response { + let config = state.config(); + let form = MultiValueForm::from_request_with_limit(request, BRANDING_ASSET_FORM_BYTES).await; + let flash = match build_branding_asset_change(form.as_ref(), upload) { + Err(message) => FlashData::error(message), + Ok((kind, image)) => { + let client = AdminApiClient::new(state.http_client(), config, &auth.session); + let result = client.upload_branding_asset(kind, image).await; + remember_premium_branding(state, &result); + match result { + Ok(instance_config) => { + let flash = branding_asset_success(kind, upload); + if htmx::targets( + headers, + &templates::pages::instance_config::branding_asset_field_id(kind), + ) { + return render_fragment_with_toast( + templates::pages::instance_config::branding_asset_field( + kind, + kind.url(&instance_config.app_public.branding), + ), + &flash, + ); + } + flash + } + Err(error) => branding_asset_failure(kind, upload, &error), + } + } + }; + if htmx::is_htmx_request(headers) { + return htmx::toast_response(&flash); + } + redirect_back_with_flash( + &config.base_path, + "/instance-config", + flash, + config.secure_cookies(), + ) +} + +fn build_branding_asset_change( + form: Option<&MultiValueForm>, + upload: bool, +) -> Result<(BrandingAssetKind, Option), &'static str> { + let form = form.ok_or("Invalid form data")?; + let kind = form + .first("kind") + .and_then(BrandingAssetKind::parse) + .ok_or("Unknown branding asset")?; + if !upload { + return Ok((kind, None)); + } + let image = form.clean("image").ok_or("Choose an image to upload")?; + Ok((kind, Some(image))) +} + +fn branding_asset_success(kind: BrandingAssetKind, upload: bool) -> FlashData { + let verb = if upload { "uploaded" } else { "removed" }; + FlashData::success(format!("{} {verb}", kind.label())) +} + +fn branding_asset_failure( + kind: BrandingAssetKind, + upload: bool, + error: &crate::api::client::ApiError, +) -> FlashData { + let verb = if upload { "upload" } else { "remove" }; + let label = kind.as_str(); + tracing::warn!(%error, kind = label, "admin API request failed: {verb} branding asset"); + match super::billing_actions::validation_message(error) { + Some(message) => FlashData::error(format!("Failed to {verb} the {label}: {message}")), + None => FlashData::error(format!("Failed to {verb} the {label}")), + } +} + fn remember_premium_branding( state: &AppState, result: &Result, diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index ea43664a4..006a34b13 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -3,6 +3,7 @@ use crate::{ api::types::{ AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse, + BRANDING_ASSET_ACCEPT, BRANDING_ASSET_MAX_IMAGE_CHARS, BrandingAssetKind, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse, EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS, @@ -30,7 +31,7 @@ use crate::{ }, utils::timestamps::format_admin_timestamp, }; -use maud::{Markup, html}; +use maud::{Markup, PreEscaped, html}; fn format_percent(value: f64) -> String { if value.fract() == 0.0 { @@ -956,36 +957,9 @@ fn app_public_config_section( )) } div class="grid grid-cols-1 gap-4 sm:grid-cols-2" { - (text_input( - "app_icon_url", - "Icon URL", - app_public.branding.icon_url.as_deref().unwrap_or(""), - "https://example.com/icon.png", - )) - (text_input( - "app_symbol_url", - "Symbol URL", - app_public.branding.symbol_url.as_deref().unwrap_or(""), - "https://example.com/symbol.png", - )) - (text_input( - "app_logo_url", - "Logo URL", - app_public.branding.logo_url.as_deref().unwrap_or(""), - "https://example.com/logo.png", - )) - (text_input( - "app_wordmark_url", - "Wordmark URL", - app_public.branding.wordmark_url.as_deref().unwrap_or(""), - "https://example.com/wordmark.png", - )) - (text_input( - "app_favicon_url", - "Favicon URL", - app_public.branding.favicon_url.as_deref().unwrap_or(""), - "https://example.com/favicon.ico", - )) + @for kind in BrandingAssetKind::ALL { + (branding_asset_field(kind, kind.url(&app_public.branding))) + } (text_input( "app_status_page_url", "Status page URL", @@ -1014,6 +988,7 @@ fn app_public_config_section( })) } } + script { (PreEscaped(BRANDING_ASSET_SCRIPT)) } div class="space-y-4 border-t border-neutral-200 pt-6" { h3 class="text-sm font-semibold text-neutral-900" { "Legal Documents" } form method="post" action={(base) "/instance-config?action=update_app_legal"} { @@ -1056,6 +1031,173 @@ fn app_public_config_section( ) } +pub fn branding_asset_field_id(kind: BrandingAssetKind) -> String { + format!("branding-asset-{}", kind.as_str()) +} + +pub fn branding_asset_field(kind: BrandingAssetKind, url: Option<&str>) -> Markup { + let name = format!("app_{}_url", kind.as_str()); + let placeholder = match kind { + BrandingAssetKind::Favicon => "https://example.com/favicon.ico".to_owned(), + _ => format!("https://example.com/{}.png", kind.as_str()), + }; + html! { + div id=(branding_asset_field_id(kind)) class="flex flex-col gap-2" + data-branding-asset=(kind.as_str()) + data-branding-asset-max-chars=(BRANDING_ASSET_MAX_IMAGE_CHARS) { + (text_input(&name, &format!("{} URL", kind.label()), url.unwrap_or(""), &placeholder)) + div class="flex items-center gap-2" { + @if let Some(url) = url { + img src=(url) alt={(kind.label()) " preview"} + onerror="this.hidden=true" + class="h-8 w-8 shrink-0 rounded-md border border-neutral-200 bg-neutral-50 object-contain"; + } + input type="file" accept=(BRANDING_ASSET_ACCEPT) class="hidden" + data-branding-asset-file + aria-label={"Upload " (kind.as_str())}; + button type="button" data-branding-asset-upload + class=(BRANDING_ASSET_BUTTON_CLASS) { + "Upload" + } + @if url.is_some() { + button type="button" data-branding-asset-remove + class=(BRANDING_ASSET_BUTTON_CLASS) { + "Remove" + } + } + } + } + } +} + +const BRANDING_ASSET_BUTTON_CLASS: &str = "inline-flex h-8 shrink-0 items-center justify-center \ + rounded-lg border border-neutral-300 bg-neutral-50 px-3 text-xs font-medium \ + text-neutral-700 transition-all hover:border-neutral-400 hover:text-neutral-900 \ + focus:outline-none focus:ring-2 focus:ring-brand-primary/20 \ + disabled:cursor-not-allowed disabled:opacity-50"; + +const BRANDING_ASSET_SCRIPT: &str = r#" +(function () { + if (window.__adminBrandingAssetBound) return; + window.__adminBrandingAssetBound = true; + function toast(level, message) { + document.body.dispatchEvent(new CustomEvent('showFlash', {detail: {level: level, message: message}})); + } + function toastFromResponse(response) { + var raw = response.headers.get('X-Fluxer-Admin-Toast'); + if (!raw) return null; + try { + var parsed = JSON.parse(raw); + return { + level: parsed && parsed.level ? String(parsed.level) : 'info', + message: parsed && parsed.message ? String(parsed.message) : '' + }; + } catch (error) { + return null; + } + } + function fieldOf(element) { + return element.closest('[data-branding-asset]'); + } + function setBusy(field, busy) { + field.setAttribute('aria-busy', busy ? 'true' : 'false'); + field.querySelectorAll('button').forEach(function (button) { + button.disabled = busy; + }); + } + function send(field, action, image, failure) { + var form = field.closest('form'); + var csrfInput = form ? form.querySelector('input[name="_csrf"]') : null; + var token = csrfInput ? csrfInput.value || '' : ''; + var url = new URL(form ? form.getAttribute('action') || window.location.href : window.location.href, window.location.href); + url.searchParams.set('action', action); + var body = new URLSearchParams(); + body.set('_csrf', token); + body.set('kind', field.getAttribute('data-branding-asset') || ''); + if (image !== null) body.set('image', image); + setBusy(field, true); + fetch(url.toString(), { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'HX-Request': 'true', + 'HX-Target': field.id, + 'X-CSRF-Token': token + }, + body: body.toString() + }).then(function (response) { + var parsed = toastFromResponse(response); + if (response.status !== 200) { + setBusy(field, false); + toast(parsed ? parsed.level : 'error', parsed ? parsed.message : failure); + return; + } + return response.text().then(function (html) { + var template = document.createElement('template'); + template.innerHTML = html.trim(); + var replacement = template.content.firstElementChild; + if (replacement && replacement.id === field.id) { + field.replaceWith(replacement); + } else { + setBusy(field, false); + } + if (parsed) toast(parsed.level, parsed.message); + }); + }).catch(function () { + setBusy(field, false); + toast('error', failure); + }); + } + document.addEventListener('click', function (event) { + var target = event.target; + if (!(target instanceof Element)) return; + var upload = target.closest('[data-branding-asset-upload]'); + if (upload) { + var uploadField = fieldOf(upload); + var file = uploadField ? uploadField.querySelector('[data-branding-asset-file]') : null; + if (file) file.click(); + return; + } + var remove = target.closest('[data-branding-asset-remove]'); + if (remove) { + var removeField = fieldOf(remove); + if (removeField) send(removeField, 'remove_branding_asset', null, 'Failed to remove the image'); + } + }); + document.addEventListener('change', function (event) { + var input = event.target; + if (!(input instanceof HTMLInputElement) || !input.hasAttribute('data-branding-asset-file')) return; + var field = fieldOf(input); + var selected = input.files && input.files[0]; + input.value = ''; + if (!field || !selected) return; + var maxChars = Number(field.getAttribute('data-branding-asset-max-chars')) || 0; + setBusy(field, true); + var reader = new FileReader(); + reader.onload = function () { + var dataUrl = typeof reader.result === 'string' ? reader.result : ''; + if (!dataUrl) { + setBusy(field, false); + toast('error', 'Failed to read the image'); + return; + } + if (maxChars > 0 && dataUrl.length > maxChars) { + setBusy(field, false); + toast('error', 'This image is too large to upload'); + return; + } + send(field, 'upload_branding_asset', dataUrl, 'Failed to upload the image'); + }; + reader.onerror = function () { + setBusy(field, false); + toast('error', 'Failed to read the image'); + }; + reader.readAsDataURL(selected); + }); +})(); +"#; + fn gateway_rollout_section( base: &str, csrf_token: &str, diff --git a/fluxer_admin/src/utils/forms.rs b/fluxer_admin/src/utils/forms.rs index db9674bc3..b40b0efc6 100644 --- a/fluxer_admin/src/utils/forms.rs +++ b/fluxer_admin/src/utils/forms.rs @@ -33,9 +33,11 @@ pub struct MultiValueForm { impl MultiValueForm { pub async fn from_request(request: Request) -> Option { - let bytes = body::to_bytes(request.into_body(), MAX_FORM_BYTES) - .await - .ok()?; + Self::from_request_with_limit(request, MAX_FORM_BYTES).await + } + + pub async fn from_request_with_limit(request: Request, max_bytes: usize) -> Option { + let bytes = body::to_bytes(request.into_body(), max_bytes).await.ok()?; Some(Self::parse(bytes.as_ref())) } diff --git a/fluxer_admin/tests/api_deserialization.rs b/fluxer_admin/tests/api_deserialization.rs index 9b104fe52..76537e7b0 100644 --- a/fluxer_admin/tests/api_deserialization.rs +++ b/fluxer_admin/tests/api_deserialization.rs @@ -1281,3 +1281,28 @@ fn deserialize_guild_threads_response() { .is_some_and(|m| m.archived && !m.locked) ); } + +#[test] +fn branding_asset_upload_request_matches_the_generated_contract() { + for kind in types::BrandingAssetKind::ALL { + for image in [Some("data:image/png;base64,iVBORw0KGgo=".to_owned()), None] { + let request = types::BrandingAssetUploadRequest { + kind, + image: image.clone(), + }; + let value = serde_json::to_value(&request).unwrap(); + assert_eq!( + value, + serde_json::json!({"kind": kind.as_str(), "image": image}) + ); + let generated: generated_types::BrandingAssetUploadRequest = + serde_json::from_value(value.clone()).expect("generated branding asset contract"); + assert_eq!( + serde_json::to_value(generated).unwrap()["kind"], + value["kind"] + ); + assert_eq!(types::BrandingAssetKind::parse(kind.as_str()), Some(kind)); + } + } + assert_eq!(types::BrandingAssetKind::parse("banner"), None); +} diff --git a/fluxer_admin/tests/htmx_acceptance.rs b/fluxer_admin/tests/htmx_acceptance.rs index 54492a7ea..c16a53db2 100644 --- a/fluxer_admin/tests/htmx_acceptance.rs +++ b/fluxer_admin/tests/htmx_acceptance.rs @@ -5,7 +5,7 @@ use axum::{ Json, Router, body::{Body, to_bytes}, - extract::Path, + extract::{DefaultBodyLimit, Path}, http::{HeaderMap, Method, Request, StatusCode, Uri, header}, response::{IntoResponse, Response}, routing, @@ -358,6 +358,198 @@ async fn pending_registration_decision_refreshes_the_list_and_sidebar_count() { ); } +#[tokio::test] +async fn branding_asset_upload_takes_a_large_image_and_swaps_the_field() { + let received = Arc::new(Mutex::new(Vec::::new())); + let sink = received.clone(); + let api = Router::new() + .route( + "/admin/instance/config", + routing::get(|| async { + json_response(instance_config(Some( + "https://media.example.test/branding/0/old.png", + ))) + }), + ) + .route( + "/admin/instance/config/branding-assets", + routing::post(move |Json(body): Json| { + let sink = sink.clone(); + async move { + sink.lock().unwrap().push(body.clone()); + if body["image"] == "data:image/png;base64,bad" { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "code": "INVALID_FORM_BODY", + "message": "Input form validation failed", + "errors": [{"path": "image", "message": "Unsupported image format"}] + })), + ) + .into_response(); + } + let logo_url = body["image"] + .is_string() + .then_some("https://media.example.test/branding/0/new.png"); + json_response(instance_config(logo_url)) + } + }), + ) + .layer(DefaultBodyLimit::disable()) + .fallback(mock_api); + let app = setup_with_api(api).await; + let (headers, page) = get_with_headers(&app, "/instance-config", &[]).await; + let csrf_token = csrf_cookie(&headers) + .unwrap_or_else(|| panic!("instance config page did not set csrf_token cookie\n{page}")); + assert_form_has_csrf( + &page, + "/instance-config?action=update_app_public", + &csrf_token, + ); + assert!(page.contains(r#"id="branding-asset-logo""#), "{page}"); + assert!(page.contains(r#"id="branding-asset-favicon""#), "{page}"); + assert!( + page.contains(r#"src="https://media.example.test/branding/0/old.png""#), + "{page}" + ); + assert!(page.contains(r#"accept=".png,.jpeg,.webp"#), "{page}"); + + let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token); + let post = |action: &'static str, fields: Vec<(&'static str, String)>| { + let app = &app; + let cookie = cookie.clone(); + let csrf_token = csrf_token.clone(); + async move { + let mut body = url::form_urlencoded::Serializer::new(String::new()); + body.append_pair("_csrf", &csrf_token); + for (name, value) in &fields { + body.append_pair(name, value); + } + post_form_with_headers( + app, + &format!("/instance-config?action={action}"), + &[ + ("HX-Request", "true"), + ("HX-Target", "branding-asset-logo"), + ("X-CSRF-Token", &csrf_token), + ("Cookie", &cookie), + ], + &body.finish(), + ) + .await + } + }; + let toast_of = |headers: &HeaderMap| { + headers + .get("X-Fluxer-Admin-Toast") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned() + }; + + let image = format!( + "data:image/png;base64,{}", + "iVBO+w/A".repeat(2 * 1024 * 1024 / 8) + ); + let (status, headers, fragment) = post( + "upload_branding_asset", + vec![("kind", "logo".to_owned()), ("image", image.clone())], + ) + .await; + assert_eq!(status, StatusCode::OK, "{fragment}"); + assert!( + fragment.starts_with(r#"