feat(admin,api): restrict community creation on self-hosted (#3055)

This commit is contained in:
omster
2026-09-30 16:32:45 +02:00
committed by GitHub
parent ab4069ed0e
commit ca719e7b5e
95 changed files with 508 additions and 13 deletions
+4
View File
@@ -10659,6 +10659,7 @@
"feature_custom_notification_sounds",
"feature_early_access",
"feature_global_expressions",
"feature_guild_create",
"feature_higher_video_quality",
"feature_per_guild_profiles",
"feature_voice_entrance_sounds",
@@ -10966,6 +10967,7 @@
"single_community_guild_id": {"nullable": true, "type": "string"},
"direct_messages_disabled": {"type": "boolean"},
"direct_messages_locked": {"type": "boolean"},
"guild_create_access": {"type": "boolean"},
"premium_mode": {"type": "string", "enum": ["mirror", "everyone"]},
"services": {
"type": "object",
@@ -11003,6 +11005,7 @@
"single_community_guild_id",
"direct_messages_disabled",
"direct_messages_locked",
"guild_create_access",
"premium_mode",
"services",
"services_resolved",
@@ -11523,6 +11526,7 @@
"direct_messages_disabled": {"type": "boolean"},
"direct_messages_locked": {"type": "boolean", "enum": [false]},
"premium_mode": {"type": "string", "enum": ["mirror", "everyone"]},
"guild_create_access": {"type": "boolean"},
"services": {
"nullable": true,
"type": "object",
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::templates::components::tooltip::{Hint, HintLink};
pub fn limit_key_hint(key: &str) -> Option<Hint<'static>> {
match key {
"feature_guild_create" => Some(Hint {
name: Some("Community Creation Access"),
body: "Admins with the wildcard ACL can always create communities.",
link: Some(HintLink::new(
"/instance-config#community-creation",
"Community creation policy",
)),
}),
_ => None,
}
}
@@ -43,6 +43,8 @@ pub struct InstancePolicyResponse {
pub direct_messages_locked: bool,
#[serde(default)]
pub premium_mode: PremiumMode,
#[serde(default = "default_guild_create_access")]
pub guild_create_access: bool,
#[serde(default)]
pub services: InstanceServicesOverrides,
#[serde(default)]
@@ -51,6 +53,10 @@ pub struct InstancePolicyResponse {
pub services_available: InstanceServicesAvailable,
}
fn default_guild_create_access() -> bool {
true
}
impl Default for InstancePolicyResponse {
fn default() -> Self {
Self {
@@ -59,6 +65,7 @@ impl Default for InstancePolicyResponse {
direct_messages_disabled: false,
direct_messages_locked: false,
premium_mode: PremiumMode::Everyone,
guild_create_access: default_guild_create_access(),
services: InstanceServicesOverrides::default(),
services_resolved: InstanceServicesResolved::default(),
services_available: InstanceServicesAvailable::default(),
@@ -656,6 +663,8 @@ pub struct InstancePolicyUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub direct_messages_disabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guild_create_access: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub premium_mode: Option<PremiumMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub services: Option<InstanceServicesUpdateRequest>,
+1
View File
@@ -2,6 +2,7 @@
pub mod acl;
pub mod admin_flags;
pub mod admin_hints;
pub mod api;
pub mod config;
pub mod fonts;
+5 -4
View File
@@ -734,6 +734,9 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
let direct_messages_disabled = form
.first("policy_direct_messages_disabled")
.map(|value| value == "true");
let guild_create_access = form
.first("policy_guild_create_access")
.map(|value| value == "true");
let premium_mode = match form.first("policy_premium_mode") {
Some("mirror") => Some(PremiumMode::Mirror),
Some("everyone") => Some(PremiumMode::Everyone),
@@ -745,6 +748,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
single_community_enabled: None,
single_community_name: None,
direct_messages_disabled,
guild_create_access,
premium_mode,
services,
}),
@@ -875,10 +879,7 @@ fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
InstanceConfigUpdateRequest {
policy: Some(InstancePolicyUpdateRequest {
single_community_enabled: Some(enabled),
single_community_name: None,
direct_messages_disabled: None,
premium_mode: None,
services: None,
..Default::default()
}),
..Default::default()
}
+25
View File
@@ -238,3 +238,28 @@ input:disabled + .checkbox-custom {
border: 2px solid transparent;
background-clip: content-box;
}
:target {
padding: 0.5rem;
border-radius: 0.25rem;
scroll-margin-top: 6rem;
animation: target-pulse 700ms ease-in-out 3;
}
@keyframes target-pulse {
0%,
100% {
background-color: transparent;
}
50% {
background-color: hsl(242 70% 55% / 0.18);
}
}
@media (prefers-reduced-motion: reduce) {
:target {
background-color: hsl(242 70% 55% / 0.12);
animation: none;
}
}
@@ -21,6 +21,7 @@ pub mod resource_link;
pub mod section_card;
pub mod stack;
pub mod table;
pub mod tooltip;
pub mod typography;
pub mod user_display;
pub mod user_profile_badges;
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use super::icons::paperclip_icon;
use maud::{Markup, html};
use std::sync::atomic::{AtomicUsize, Ordering};
static HINT_TOGGLE_ID: AtomicUsize = AtomicUsize::new(0);
pub struct HintLink<'a> {
href: &'a str,
label: &'a str,
}
impl<'a> HintLink<'a> {
pub fn new(href: &'a str, label: &'a str) -> Self {
debug_assert!(
href.starts_with('/'),
"hint link href must be admin-absolute: {href:?}"
);
debug_assert!(
href.contains('#'),
"hint link href should point at an anchor: {href:?}"
);
debug_assert!(!label.trim().is_empty(), "hint link needs a label");
Self { href, label }
}
}
pub struct Hint<'a> {
pub name: Option<&'a str>,
pub body: &'a str,
pub link: Option<HintLink<'a>>,
}
pub fn info(base: &str, hint: &Hint<'_>) -> Markup {
let aria_label = match hint.name {
Some(name) => format!("About {name}"),
None => "More information".to_owned(),
};
let toggle_id = format!(
"hint-toggle-{}",
HINT_TOGGLE_ID.fetch_add(1, Ordering::Relaxed)
);
html! {
span class="group relative inline-flex items-center" {
input type="checkbox" id=(toggle_id) class="peer sr-only";
label for=(toggle_id) tabindex="0" aria-label=(aria_label)
class="flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full \
font-semibold text-brand-primary leading-none active:scale-97 \
hover:text-brand-primary-dark" {
"?"
}
label for=(toggle_id) aria-hidden="true"
class="invisible fixed inset-0 z-20 cursor-default peer-checked:visible" {}
div class="invisible absolute bottom-full left-2 z-30 w-64 pb-3 pl-2 opacity-0 \
transition-[opacity,visibility] duration-200 ease-out motion-reduce:transition-none \
group-hover:visible group-hover:opacity-100 \
group-focus-within:visible group-focus-within:opacity-100 \
peer-checked:visible peer-checked:opacity-100" {
div class="rounded-lg border border-neutral-200 bg-white p-3 text-neutral-600 \
text-xs shadow-lg" {
@if let Some(name) = hint.name {
p class="font-semibold text-neutral-900" { (name) }
}
p class=[hint.name.is_some().then_some("mt-1")] { (hint.body) }
@if let Some(link) = &hint.link {
a href={(base) (link.href)} hx-boost="false"
class="mt-2 inline-flex items-center gap-1 text-blue-600 hover:underline" {
(paperclip_icon(""))(link.label)
}
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::HintLink;
#[test]
#[should_panic(expected = "anchor")]
fn rejects_a_link_that_points_at_no_anchor() {
let _ = HintLink::new("/instance-config", "Instance policy");
}
#[test]
#[should_panic(expected = "label")]
fn rejects_a_link_with_no_label() {
let _ = HintLink::new("/instance-config#community-creation", " ");
}
}
@@ -245,6 +245,7 @@ fn policy_config_section(
(single_community_form(base, csrf_token, policy))
(direct_messages_form(base, csrf_token, policy))
(premium_mode_form(base, csrf_token, policy, premium_name))
(community_creation_form(base, csrf_token, policy))
(services_form(base, csrf_token, policy))
}
},
@@ -364,6 +365,37 @@ fn premium_mode_form(
}
}
fn community_creation_form(
base: &str,
csrf_token: &str,
policy: &InstancePolicyResponse,
) -> Markup {
html! {
div id="community-creation" class="space-y-4 border-t border-neutral-200 pt-6" {
h3 class="text-sm font-semibold text-neutral-900" { "Community creation" }
form method="post" action={(base) "/instance-config?action=update_policy"} {
(csrf_input(csrf_token))
div class="space-y-4" {
(select_input("policy_guild_create_access", "Who can create communities", &[
("true", "Everyone"),
("false", "Restricted"),
], if policy.guild_create_access { "true" } else { "false" }))
p class="text-xs text-neutral-500" {
"When restricted, only admins with the wildcard ACL and users matched by a "
a href={(base) "/limit-config"} class="text-blue-600 hover:underline" {
"limit rule"
}
" that grants Community Creation Access can create communities."
}
(form_actions(html! {
(submit_button("Save community creation policy"))
}))
}
}
}
}
}
fn service_select(name: &str, label: &str, override_value: Option<bool>, resolved: bool) -> Markup {
let selected = match override_value {
None => "inherit",
@@ -2,6 +2,7 @@
use crate::{
acl::{self, INSTANCE_LIMIT_CONFIG_UPDATE},
admin_hints,
api::types::{LimitConfigResponse, LimitKeyMetadata, LimitRule},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -9,6 +10,7 @@ use crate::{
components::{
form::{FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, submit_button},
page_container::{card_with_header, page_header},
tooltip,
},
layout::admin_layout,
},
@@ -208,7 +210,7 @@ fn rule_editor(
@for category in CATEGORY_ORDER {
@let keys = keys_for_category(response, category);
@if !keys.is_empty() {
(category_section(response, rule, category, &keys, can_update))
(category_section(&config.base_path, response, rule, category, &keys, can_update))
}
}
@if can_update {
@@ -274,6 +276,7 @@ fn keys_for_category(response: &LimitConfigResponse, category: &str) -> Vec<Stri
}
fn category_section(
base: &str,
response: &LimitConfigResponse,
rule: &LimitRule,
category: &str,
@@ -292,7 +295,7 @@ fn category_section(
div class="space-y-4" {
@for key in keys {
@if let Some(metadata) = response.metadata.get(key) {
(limit_field(response, rule, key, metadata, can_update))
(limit_field(base, response, rule, key, metadata, can_update))
}
}
}
@@ -301,6 +304,7 @@ fn category_section(
}
fn limit_field(
base: &str,
response: &LimitConfigResponse,
rule: &LimitRule,
key: &str,
@@ -319,9 +323,10 @@ fn limit_field(
.as_ref()
.is_some_and(|fields| fields.iter().any(|field| field == key));
if metadata.is_toggle {
toggle_field(key, metadata, current_value, modified, can_update)
toggle_field(base, key, metadata, current_value, modified, can_update)
} else {
numeric_field(
base,
key,
metadata,
current_value,
@@ -333,6 +338,7 @@ fn limit_field(
}
fn toggle_field(
base: &str,
key: &str,
metadata: &LimitKeyMetadata,
current_value: Option<u64>,
@@ -343,7 +349,7 @@ fn toggle_field(
html! {
div class={(field_class(modified, false))} {
div class="flex-1 space-y-1" {
(field_label_row(key, metadata, modified))
(field_label_row(base, key, metadata, modified))
p class="text-xs text-neutral-500" { (metadata.description) }
}
div class="shrink-0" {
@@ -369,6 +375,7 @@ fn toggle_field(
}
fn numeric_field(
base: &str,
key: &str,
metadata: &LimitKeyMetadata,
current_value: Option<u64>,
@@ -385,7 +392,7 @@ fn numeric_field(
html! {
div class={(field_class(modified, true))} {
div class="flex flex-wrap items-center justify-between gap-2" {
(field_label_row(key, metadata, modified))
(field_label_row(base, key, metadata, modified))
}
p class="text-xs text-neutral-500" {
(metadata.description)
@@ -417,10 +424,13 @@ fn numeric_field(
}
}
fn field_label_row(key: &str, metadata: &LimitKeyMetadata, modified: bool) -> Markup {
fn field_label_row(base: &str, key: &str, metadata: &LimitKeyMetadata, modified: bool) -> Markup {
html! {
div class="flex flex-wrap items-center gap-2" {
label for=(key) class="font-medium text-neutral-900 text-sm" { (metadata.label) }
@if let Some(hint) = admin_hints::limit_key_hint(key) {
(tooltip::info(base, &hint))
}
span class=(scope_class(&metadata.scope)) { (scope_label(&metadata.scope)) }
@if modified {
span class="rounded bg-neutral-100 px-1.5 py-0.5 text-neutral-700 text-xs" { "Modified" }
@@ -461,6 +461,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"single_community_guild_id": null,
"direct_messages_disabled": false,
"direct_messages_locked": false,
"guild_create_access": false,
"premium_mode": "mirror",
"services": {
"gif_enabled": true,