mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(admin): upload instance branding images (#3476)
This commit is contained in:
1 parent
52c19e67cb
commit
36095ab66f
7 files changed
+591
-54
No files matched your search
@@ -4,10 +4,11 @@ use reqwest::Method;
|
|||||||
|
|
||||||
use super::client::{AdminApiClient, ApiResult};
|
use super::client::{AdminApiClient, ApiResult};
|
||||||
use super::types::{
|
use super::types::{
|
||||||
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
|
AccountIdentitySettings, BrandingAssetKind, BrandingAssetUploadRequest,
|
||||||
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
|
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceAccountIdentityDiscovery,
|
||||||
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery, PendingRegistrationListResponse,
|
InstanceConfigResponse, InstanceConfigUpdateRequest, InstanceEmailSmtpTestResponse,
|
||||||
PendingRegistrationResponse, PendingRegistrationStatus,
|
InstancePremiumDiscovery, PendingRegistrationListResponse, PendingRegistrationResponse,
|
||||||
|
PendingRegistrationStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl AdminApiClient {
|
impl AdminApiClient {
|
||||||
@@ -37,6 +38,20 @@ impl AdminApiClient {
|
|||||||
.await
|
.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(
|
pub async fn test_saved_instance_smtp_config(
|
||||||
&self,
|
&self,
|
||||||
) -> ApiResult<InstanceEmailSmtpTestResponse> {
|
) -> ApiResult<InstanceEmailSmtpTestResponse> {
|
||||||
|
|||||||
@@ -784,6 +784,72 @@ pub struct PendingRegistrationListResponse {
|
|||||||
pub pending_registrations: Vec<PendingRegistrationResponse>,
|
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)]
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
pub struct InstanceConfigUpdateRequest {
|
pub struct InstanceConfigUpdateRequest {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -6,25 +6,27 @@ use crate::{
|
|||||||
types::{
|
types::{
|
||||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||||
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
|
AppSetupConfigUpdateRequest, BRANDING_ASSET_MAX_IMAGE_CHARS, BrandingAssetKind,
|
||||||
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
|
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigUpdateRequest,
|
||||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES,
|
CreateRegistrationUrlRequest, DomainMigrationConfigUpdateRequest,
|
||||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
|
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||||
InstanceEmailSmtpTestResponse, InstanceGifIntegrationUpdateRequest,
|
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestResponse,
|
||||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||||
LimitConfigUpdateRequest, LimitKeyMetadata, LimitRule, LimitRuleFilters, PremiumMode,
|
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitKeyMetadata,
|
||||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
LimitRule, LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest,
|
||||||
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
RegistrationMode, SsoConfigUpdateRequest, VOICE_P2P_MAX_PARTICIPANTS_RANGE,
|
||||||
|
VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
config::AdminConfig,
|
config::AdminConfig,
|
||||||
middleware::{
|
middleware::{
|
||||||
|
auth::AuthContext,
|
||||||
csrf::CsrfToken,
|
csrf::CsrfToken,
|
||||||
flash::{self, FlashData},
|
flash::{self, FlashData},
|
||||||
htmx,
|
htmx,
|
||||||
@@ -41,6 +43,8 @@ use axum::{
|
|||||||
use maud::Markup;
|
use maud::Markup;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
const BRANDING_ASSET_FORM_BYTES: usize = BRANDING_ASSET_MAX_IMAGE_CHARS * 3 + 64 * 1024;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ActionQuery {
|
pub struct ActionQuery {
|
||||||
pub action: Option<String>,
|
pub action: Option<String>,
|
||||||
@@ -147,6 +151,15 @@ pub async fn instance_config_post(
|
|||||||
Query(aq): Query<ActionQuery>,
|
Query(aq): Query<ActionQuery>,
|
||||||
request: Request,
|
request: Request,
|
||||||
) -> Response {
|
) -> 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 config = state.config();
|
||||||
let base = &config.base_path;
|
let base = &config.base_path;
|
||||||
let form = match MultiValueForm::from_request(request).await {
|
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())
|
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(
|
fn remember_premium_branding(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
result: &Result<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
|
result: &Result<crate::api::types::InstanceConfigResponse, crate::api::client::ApiError>,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
api::types::{
|
api::types::{
|
||||||
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
|
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
|
||||||
|
BRANDING_ASSET_ACCEPT, BRANDING_ASSET_MAX_IMAGE_CHARS, BrandingAssetKind,
|
||||||
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
|
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
|
||||||
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
||||||
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
|
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
@@ -30,7 +31,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
utils::timestamps::format_admin_timestamp,
|
utils::timestamps::format_admin_timestamp,
|
||||||
};
|
};
|
||||||
use maud::{Markup, html};
|
use maud::{Markup, PreEscaped, html};
|
||||||
|
|
||||||
fn format_percent(value: f64) -> String {
|
fn format_percent(value: f64) -> String {
|
||||||
if value.fract() == 0.0 {
|
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" {
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
||||||
(text_input(
|
@for kind in BrandingAssetKind::ALL {
|
||||||
"app_icon_url",
|
(branding_asset_field(kind, kind.url(&app_public.branding)))
|
||||||
"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",
|
|
||||||
))
|
|
||||||
(text_input(
|
(text_input(
|
||||||
"app_status_page_url",
|
"app_status_page_url",
|
||||||
"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" {
|
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||||
h3 class="text-sm font-semibold text-neutral-900" { "Legal Documents" }
|
h3 class="text-sm font-semibold text-neutral-900" { "Legal Documents" }
|
||||||
form method="post" action={(base) "/instance-config?action=update_app_legal"} {
|
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(
|
fn gateway_rollout_section(
|
||||||
base: &str,
|
base: &str,
|
||||||
csrf_token: &str,
|
csrf_token: &str,
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ pub struct MultiValueForm {
|
|||||||
|
|
||||||
impl MultiValueForm {
|
impl MultiValueForm {
|
||||||
pub async fn from_request(request: Request) -> Option<Self> {
|
pub async fn from_request(request: Request) -> Option<Self> {
|
||||||
let bytes = body::to_bytes(request.into_body(), MAX_FORM_BYTES)
|
Self::from_request_with_limit(request, MAX_FORM_BYTES).await
|
||||||
.await
|
}
|
||||||
.ok()?;
|
|
||||||
|
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()))
|
Some(Self::parse(bytes.as_ref()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1281,3 +1281,28 @@ fn deserialize_guild_threads_response() {
|
|||||||
.is_some_and(|m| m.archived && !m.locked)
|
.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);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
body::{Body, to_bytes},
|
body::{Body, to_bytes},
|
||||||
extract::Path,
|
extract::{DefaultBodyLimit, Path},
|
||||||
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
|
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing,
|
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::<Value>::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<Value>| {
|
||||||
|
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#"<div id="branding-asset-logo""#),
|
||||||
|
"{fragment}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fragment.contains(r#"value="https://media.example.test/branding/0/new.png""#),
|
||||||
|
"{fragment}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
fragment.contains("data-branding-asset-remove"),
|
||||||
|
"{fragment}"
|
||||||
|
);
|
||||||
|
assert!(toast_of(&headers).contains("Logo uploaded"));
|
||||||
|
|
||||||
|
let (status, headers, fragment) =
|
||||||
|
post("remove_branding_asset", vec![("kind", "logo".to_owned())]).await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{fragment}");
|
||||||
|
assert!(
|
||||||
|
fragment.contains(r#"name="app_logo_url" value="""#),
|
||||||
|
"{fragment}"
|
||||||
|
);
|
||||||
|
assert!(!fragment.contains("<img"), "{fragment}");
|
||||||
|
assert!(
|
||||||
|
!fragment.contains("data-branding-asset-remove"),
|
||||||
|
"{fragment}"
|
||||||
|
);
|
||||||
|
assert!(toast_of(&headers).contains("Logo removed"));
|
||||||
|
|
||||||
|
let (status, headers, _) = post(
|
||||||
|
"upload_branding_asset",
|
||||||
|
vec![
|
||||||
|
("kind", "logo".to_owned()),
|
||||||
|
("image", "data:image/png;base64,bad".to_owned()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||||
|
let toast = toast_of(&headers);
|
||||||
|
assert!(toast.contains("error"), "{toast}");
|
||||||
|
assert!(
|
||||||
|
toast.contains("Failed to upload the logo: image: Unsupported image format"),
|
||||||
|
"{toast}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, headers, _) = post(
|
||||||
|
"upload_branding_asset",
|
||||||
|
vec![("kind", "logo".to_owned()), ("image", " ".to_owned())],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(toast_of(&headers).contains("Choose an image to upload"));
|
||||||
|
let (_, headers, _) = post(
|
||||||
|
"upload_branding_asset",
|
||||||
|
vec![("kind", "banner".to_owned()), ("image", image.clone())],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(toast_of(&headers).contains("Unknown branding asset"));
|
||||||
|
let (_, headers, _) = post(
|
||||||
|
"update_app_public",
|
||||||
|
vec![
|
||||||
|
("app_product_name", "Fluxer".to_owned()),
|
||||||
|
("app_logo_url", image.clone()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(toast_of(&headers).contains("Invalid form data"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*received.lock().unwrap(),
|
||||||
|
vec![
|
||||||
|
json!({"kind": "logo", "image": image}),
|
||||||
|
json!({"kind": "logo", "image": null}),
|
||||||
|
json!({"kind": "logo", "image": "data:image/png;base64,bad"}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn instance_config(logo_url: Option<&str>) -> Value {
|
||||||
|
json!({
|
||||||
|
"sso": {},
|
||||||
|
"gateway_rollout": {
|
||||||
|
"session_rollout_percentage": 100,
|
||||||
|
"session_rollout_mode": "modulo",
|
||||||
|
"guild_rollout_percentage": 100,
|
||||||
|
"rpc_request_timeout_ms": 10000,
|
||||||
|
"max_concurrent_session_starts": 512,
|
||||||
|
"max_concurrent_guild_starts": 256,
|
||||||
|
"voice_e2ee_scope": "guild_feature_only"
|
||||||
|
},
|
||||||
|
"self_hosted": true,
|
||||||
|
"app_public": {"branding": {"product_name": "Fluxer", "logo_url": logo_url}}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn setup_with_api(api: Router) -> TestApp {
|
async fn setup_with_api(api: Router) -> TestApp {
|
||||||
let api_endpoint = spawn_mock_api(api).await;
|
let api_endpoint = spawn_mock_api(api).await;
|
||||||
let router = build_router(test_config(api_endpoint));
|
let router = build_router(test_config(api_endpoint));
|
||||||
|
|||||||
Reference in new issue
Block a user