feat(admin): upload instance branding images (#3476)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-14 19:26:31 +02:00
1 parent 52c19e67cb
commit 36095ab66f
7 files changed
+591 -54

No files matched your search

+19 -4
View File
@@ -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<String>,
) -> ApiResult<InstanceConfigResponse> {
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<InstanceEmailSmtpTestResponse> {
@@ -784,6 +784,72 @@ pub struct PendingRegistrationListResponse {
pub pending_registrations: Vec<PendingRegistrationResponse>,
}
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> {
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<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
+110 -15
View File
@@ -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<String>,
@@ -147,6 +151,15 @@ pub async fn instance_config_post(
Query(aq): Query<ActionQuery>,
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<String>), &'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<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
@@ -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,
+5 -3
View File
@@ -33,9 +33,11 @@ pub struct MultiValueForm {
impl MultiValueForm {
pub async fn from_request(request: Request) -> Option<Self> {
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<Self> {
let bytes = body::to_bytes(request.into_body(), max_bytes).await.ok()?;
Some(Self::parse(bytes.as_ref()))
}