Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3a3f5368d | |||
| 5f925be29b | |||
| 8530016d35 | |||
| cae77e9401 | |||
| 709f319bc5 |
Generated
+1
-1
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.1.4"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.1.4"
|
||||
version = "0.1.8"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ Full OpenID Connect authorization code flow with PKCE:
|
||||
|
||||
Provider metadata is cached for 1 hour and invalidated when OIDC config changes.
|
||||
|
||||
**Group-to-role mapping:** The `oidc_admin_groups` config field lists OIDC group names (comma-separated) that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
|
||||
**Group access and role mapping:** The `oidc_user_groups` config field lists OIDC group names (comma-separated) allowed to access the service. When it is set, users outside both `oidc_user_groups` and `oidc_admin_groups` are denied before provisioning/login. The `oidc_admin_groups` config field lists OIDC group names that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
|
||||
|
||||
**User provisioning order:**
|
||||
1. Find existing `OidcLink` by issuer+sub → update claims, update role
|
||||
@@ -197,4 +197,5 @@ All prefixed with `FURU_`. Priority: env var > DB override > compiled default.
|
||||
| `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* |
|
||||
| `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` |
|
||||
| `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* |
|
||||
| `FURU_OIDC_USER_GROUPS` | Comma-separated OIDC groups allowed to access the service. Empty means any authenticated SSO user is allowed. | *(empty)* |
|
||||
| `FURU_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
|
||||
|
||||
+13
-1
@@ -129,6 +129,11 @@ fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec<Co
|
||||
config.oidc_admin_groups.clone(),
|
||||
defaults.oidc_admin_groups.clone()
|
||||
),
|
||||
entry!(
|
||||
oidc_user_groups,
|
||||
config.oidc_user_groups.clone(),
|
||||
defaults.oidc_user_groups.clone()
|
||||
),
|
||||
entry!(
|
||||
swagger_enabled,
|
||||
config.swagger_enabled.to_string(),
|
||||
@@ -248,6 +253,8 @@ struct SettingsTemplate {
|
||||
oidc_client_secret_source: &'static str,
|
||||
oidc_admin_groups: String,
|
||||
oidc_admin_groups_source: &'static str,
|
||||
oidc_user_groups: String,
|
||||
oidc_user_groups_source: &'static str,
|
||||
swagger_enabled: bool,
|
||||
swagger_enabled_source: &'static str,
|
||||
agent_enabled: bool,
|
||||
@@ -298,6 +305,8 @@ pub async fn settings_handler(
|
||||
oidc_client_secret_source: sources.oidc_client_secret.code(),
|
||||
oidc_admin_groups: config.oidc_admin_groups,
|
||||
oidc_admin_groups_source: sources.oidc_admin_groups.code(),
|
||||
oidc_user_groups: config.oidc_user_groups,
|
||||
oidc_user_groups_source: sources.oidc_user_groups.code(),
|
||||
swagger_enabled: config.swagger_enabled,
|
||||
swagger_enabled_source: sources.swagger_enabled.code(),
|
||||
agent_enabled: config.agent_enabled,
|
||||
@@ -331,6 +340,7 @@ pub struct OidcSettingsForm {
|
||||
oidc_client_id: Option<String>,
|
||||
oidc_client_secret: Option<String>,
|
||||
oidc_admin_groups: Option<String>,
|
||||
oidc_user_groups: Option<String>,
|
||||
swagger_enabled: Option<String>,
|
||||
agent_enabled: Option<String>,
|
||||
agent_inbox_dir: Option<String>,
|
||||
@@ -378,6 +388,7 @@ pub async fn settings_submit(
|
||||
let oidc_client_id = data.oidc_client_id.unwrap_or_default();
|
||||
let oidc_client_secret = data.oidc_client_secret.unwrap_or_default();
|
||||
let oidc_admin_groups = data.oidc_admin_groups.unwrap_or_default();
|
||||
let oidc_user_groups = data.oidc_user_groups.unwrap_or_default();
|
||||
let agent_inbox_dir = data.agent_inbox_dir.unwrap_or_default();
|
||||
let agent_storage_dir = data.agent_storage_dir.unwrap_or_default();
|
||||
let agent_llm_url = data.agent_llm_url.unwrap_or_default();
|
||||
@@ -386,7 +397,7 @@ pub async fn settings_submit(
|
||||
let agent_confidence_threshold = data.agent_confidence_threshold.unwrap_or_default();
|
||||
let agent_context_limit = data.agent_context_limit.unwrap_or_default();
|
||||
let agent_concurrency = data.agent_concurrency.unwrap_or_default();
|
||||
let fields: [(&str, &str); 17] = [
|
||||
let fields: [(&str, &str); 18] = [
|
||||
("auth_password_enabled", pw_enabled),
|
||||
("auth_sso_enabled", sso_enabled),
|
||||
("oidc_button_text", &oidc_button_text),
|
||||
@@ -394,6 +405,7 @@ pub async fn settings_submit(
|
||||
("oidc_client_id", &oidc_client_id),
|
||||
("oidc_client_secret", &oidc_client_secret),
|
||||
("oidc_admin_groups", &oidc_admin_groups),
|
||||
("oidc_user_groups", &oidc_user_groups),
|
||||
("swagger_enabled", swagger),
|
||||
("agent_enabled", agent_en),
|
||||
("agent_inbox_dir", &agent_inbox_dir),
|
||||
|
||||
@@ -360,6 +360,8 @@ pub async fn save_cover_to_storage(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("UFO"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to create cover MediaFile: {e}"))?;
|
||||
|
||||
@@ -122,6 +122,7 @@ pub struct ConfigSources {
|
||||
pub auth_sso_enabled: ConfigSource,
|
||||
pub oidc_button_text: ConfigSource,
|
||||
pub oidc_admin_groups: ConfigSource,
|
||||
pub oidc_user_groups: ConfigSource,
|
||||
pub swagger_enabled: ConfigSource,
|
||||
pub agent_enabled: ConfigSource,
|
||||
pub agent_inbox_dir: ConfigSource,
|
||||
@@ -146,6 +147,7 @@ impl Default for ConfigSources {
|
||||
auth_sso_enabled: ConfigSource::Default,
|
||||
oidc_button_text: ConfigSource::Default,
|
||||
oidc_admin_groups: ConfigSource::Default,
|
||||
oidc_user_groups: ConfigSource::Default,
|
||||
swagger_enabled: ConfigSource::Default,
|
||||
agent_enabled: ConfigSource::Default,
|
||||
agent_inbox_dir: ConfigSource::Default,
|
||||
@@ -238,6 +240,8 @@ pub struct AppConfig {
|
||||
pub oidc_button_text: String,
|
||||
/// Comma-separated list of OIDC group names that grant admin role.
|
||||
pub oidc_admin_groups: String,
|
||||
/// Comma-separated list of OIDC group names that are allowed to use the service.
|
||||
pub oidc_user_groups: String,
|
||||
/// Whether the Swagger UI is served at /swagger/.
|
||||
pub swagger_enabled: bool,
|
||||
/// Whether the AI agent background loop is enabled.
|
||||
@@ -272,6 +276,7 @@ impl Default for AppConfig {
|
||||
auth_sso_enabled: false,
|
||||
oidc_button_text: "Sign in with SSO".into(),
|
||||
oidc_admin_groups: String::new(),
|
||||
oidc_user_groups: String::new(),
|
||||
swagger_enabled: false,
|
||||
agent_enabled: false,
|
||||
agent_inbox_dir: String::new(),
|
||||
@@ -297,6 +302,7 @@ impl_env_overrides!(
|
||||
auth_sso_enabled,
|
||||
oidc_button_text,
|
||||
oidc_admin_groups,
|
||||
oidc_user_groups,
|
||||
swagger_enabled,
|
||||
agent_enabled,
|
||||
agent_inbox_dir,
|
||||
@@ -372,6 +378,7 @@ impl AppConfig {
|
||||
apply_db_field!(auth_sso_enabled);
|
||||
apply_db_field!(oidc_button_text);
|
||||
apply_db_field!(oidc_admin_groups);
|
||||
apply_db_field!(oidc_user_groups);
|
||||
apply_db_field!(swagger_enabled);
|
||||
apply_db_field!(agent_enabled);
|
||||
apply_db_field!(agent_inbox_dir);
|
||||
|
||||
@@ -70,6 +70,8 @@ translations! {
|
||||
settings_oidc_issuer_help: "Base URL of the OIDC provider (e.g. https://accounts.google.com)" , "Базовый URL провайдера OIDC (напр. https://accounts.google.com)";
|
||||
settings_oidc_admin_groups: "Admin groups" , "Группы администраторов";
|
||||
settings_oidc_admin_groups_help: "Comma-separated OIDC group names that grant admin role (e.g. /admin,/furumusic-admins)" , "OIDC группы через запятую, дающие роль администратора (напр. /admin,/furumusic-admins)";
|
||||
settings_oidc_user_groups: "User groups" , "Группы пользователей";
|
||||
settings_oidc_user_groups_help: "Comma-separated OIDC group names allowed to access the service. If empty, any authenticated SSO user is allowed." , "OIDC группы через запятую, которым разрешён доступ к сервису. Если пусто, разрешён любой SSO пользователь.";
|
||||
|
||||
// User management
|
||||
nav_users: "Users" , "Пользователи";
|
||||
@@ -97,6 +99,7 @@ translations! {
|
||||
// OIDC login errors
|
||||
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
||||
login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен.";
|
||||
login_access_denied: "Access denied. Contact your administrator." , "Доступ запрещён. Обратитесь к администратору.";
|
||||
|
||||
// Artist management
|
||||
nav_artists: "Artists" , "Артисты";
|
||||
|
||||
@@ -140,7 +140,9 @@ impl Job for InboxDiscoverJob {
|
||||
|
||||
// Parse path hints
|
||||
let relative = file_path.strip_prefix(inbox).unwrap_or(file_path);
|
||||
let hints = crate::agent::path_hints::parse(relative);
|
||||
let uploader = crate::jobs::uploader_from_relative_path(&ctx.pool, relative).await;
|
||||
let hinted_relative = crate::jobs::strip_user_upload_prefix(relative);
|
||||
let hints = crate::agent::path_hints::parse(&hinted_relative);
|
||||
|
||||
// Build context JSON
|
||||
let context = serde_json::json!({
|
||||
@@ -156,6 +158,8 @@ impl Job for InboxDiscoverJob {
|
||||
"audio_bitrate": raw_meta.audio_bitrate,
|
||||
"audio_sample_rate": raw_meta.audio_sample_rate,
|
||||
"audio_bit_depth": raw_meta.audio_bit_depth,
|
||||
"uploaded_by_user_id": uploader.user_id,
|
||||
"uploader_name": uploader.name,
|
||||
"path_title": hints.title,
|
||||
"path_artist": hints.artist,
|
||||
"path_album": hints.album,
|
||||
|
||||
@@ -337,7 +337,9 @@ async fn process_folder_batch(
|
||||
|
||||
// Parse path hints
|
||||
let relative = file_path.strip_prefix(inbox_path).unwrap_or(file_path);
|
||||
let hints = crate::agent::path_hints::parse(relative);
|
||||
let uploader = crate::jobs::uploader_from_relative_path(pool, relative).await;
|
||||
let hinted_relative = crate::jobs::strip_user_upload_prefix(relative);
|
||||
let hints = crate::agent::path_hints::parse(&hinted_relative);
|
||||
if let Some(context_obj) = context.as_object_mut() {
|
||||
context_obj.insert(
|
||||
"audio_bitrate".to_owned(),
|
||||
@@ -351,6 +353,15 @@ async fn process_folder_batch(
|
||||
"audio_bit_depth".to_owned(),
|
||||
serde_json::json!(raw_meta.audio_bit_depth),
|
||||
);
|
||||
if !context_obj.contains_key("uploaded_by_user_id") {
|
||||
context_obj.insert(
|
||||
"uploaded_by_user_id".to_owned(),
|
||||
serde_json::json!(uploader.user_id),
|
||||
);
|
||||
}
|
||||
if !context_obj.contains_key("uploader_name") {
|
||||
context_obj.insert("uploader_name".to_owned(), serde_json::json!(uploader.name));
|
||||
}
|
||||
}
|
||||
|
||||
prepared.push(PreparedFile {
|
||||
@@ -737,6 +748,12 @@ pub async fn finalize_approved(
|
||||
.get("audio_bit_depth")
|
||||
.and_then(|v| v.as_i64())
|
||||
.and_then(|v| i32::try_from(v).ok());
|
||||
let uploaded_by_user_id = context.get("uploaded_by_user_id").and_then(|v| v.as_i64());
|
||||
let uploader_name = context
|
||||
.get("uploader_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("UFO");
|
||||
|
||||
let source_path = Path::new(input_path_str);
|
||||
let original_filename = source_path
|
||||
@@ -805,6 +822,8 @@ pub async fn finalize_approved(
|
||||
audio_bitrate,
|
||||
audio_sample_rate,
|
||||
audio_bit_depth,
|
||||
uploaded_by_user_id,
|
||||
Some(uploader_name),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to create media file: {e}"))?;
|
||||
|
||||
@@ -4,3 +4,73 @@ pub mod cover_backfill;
|
||||
pub mod inbox_discover;
|
||||
pub mod inbox_process;
|
||||
pub mod metadata_backfill;
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploaderAttribution {
|
||||
pub user_id: Option<i64>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl UploaderAttribution {
|
||||
pub fn unknown() -> Self {
|
||||
Self {
|
||||
user_id: None,
|
||||
name: "UFO".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn strip_user_upload_prefix(relative_path: &Path) -> PathBuf {
|
||||
let components: Vec<_> = relative_path.components().collect();
|
||||
if components.len() >= 3
|
||||
&& matches!(components[0], Component::Normal(value) if value == "user_uploads")
|
||||
{
|
||||
components[2..].iter().collect()
|
||||
} else {
|
||||
relative_path.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn uploader_from_relative_path(
|
||||
pool: &sqlx::PgPool,
|
||||
relative_path: &Path,
|
||||
) -> UploaderAttribution {
|
||||
let components: Vec<_> = relative_path.components().collect();
|
||||
let Some(Component::Normal(root)) = components.first() else {
|
||||
return UploaderAttribution::unknown();
|
||||
};
|
||||
if *root != "user_uploads" {
|
||||
return UploaderAttribution::unknown();
|
||||
}
|
||||
|
||||
let Some(Component::Normal(user_id_os)) = components.get(1) else {
|
||||
return UploaderAttribution::unknown();
|
||||
};
|
||||
let Some(user_id_str) = user_id_os.to_str() else {
|
||||
return UploaderAttribution::unknown();
|
||||
};
|
||||
let Ok(user_id) = user_id_str.parse::<i64>() else {
|
||||
return UploaderAttribution::unknown();
|
||||
};
|
||||
|
||||
let name: Option<String> = sqlx::query_scalar(
|
||||
r#"SELECT COALESCE(NULLIF(display_name, ''), username)::text
|
||||
FROM furumusic__user
|
||||
WHERE id = $1 AND is_active = true"#,
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
match name {
|
||||
Some(name) if !name.trim().is_empty() => UploaderAttribution {
|
||||
user_id: Some(user_id),
|
||||
name,
|
||||
},
|
||||
_ => UploaderAttribution::unknown(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +281,7 @@ impl Project for FuruProject {
|
||||
" FURU_OIDC_CLIENT_SECRET OIDC client secret\n",
|
||||
" FURU_OIDC_BUTTON_TEXT SSO button label (default: Sign in with SSO)\n",
|
||||
" FURU_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n",
|
||||
" FURU_OIDC_USER_GROUPS OIDC groups allowed to access the service\n",
|
||||
"\n",
|
||||
" API:\n",
|
||||
" FURU_SWAGGER_ENABLED Enable Swagger UI at /swagger/ (default: false)\n",
|
||||
|
||||
@@ -36,6 +36,10 @@ pub struct MediaFile {
|
||||
pub audio_sample_rate: Option<i32>,
|
||||
/// Bit depth (16, 24, 32)
|
||||
pub audio_bit_depth: Option<i32>,
|
||||
/// FK -> user who imported/uploaded the source, NULL when unknown.
|
||||
pub uploaded_by_user_id: Option<i64>,
|
||||
/// Stable display label for the uploader. Unknown uploads are stored as "UFO".
|
||||
pub uploader_name: LimitedString<255>,
|
||||
pub created_at: LimitedString<32>,
|
||||
}
|
||||
|
||||
@@ -607,8 +611,13 @@ impl MediaFile {
|
||||
audio_bitrate: Option<i32>,
|
||||
audio_sample_rate: Option<i32>,
|
||||
audio_bit_depth: Option<i32>,
|
||||
uploaded_by_user_id: Option<i64>,
|
||||
uploader_name: Option<&str>,
|
||||
) -> cot::db::Result<Self> {
|
||||
let now = now_iso();
|
||||
let uploader_name = uploader_name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.unwrap_or("UFO");
|
||||
let mut mf = Self {
|
||||
id: Auto::auto(),
|
||||
file_type: LimitedString::new(file_type).unwrap(),
|
||||
@@ -621,6 +630,8 @@ impl MediaFile {
|
||||
audio_bitrate,
|
||||
audio_sample_rate,
|
||||
audio_bit_depth,
|
||||
uploaded_by_user_id,
|
||||
uploader_name: LimitedString::new(uploader_name).unwrap(),
|
||||
created_at: now,
|
||||
};
|
||||
mf.insert(db).await?;
|
||||
@@ -1533,6 +1544,40 @@ pub mod db_migrations {
|
||||
const OPERATIONS: &'static [Operation] = &[Operation::custom(add_playback_volume).build()];
|
||||
}
|
||||
|
||||
// -- M0030: add uploader attribution to media_file ------------------------
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn add_media_file_uploader(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw("ALTER TABLE furumusic__media_file ADD COLUMN uploaded_by_user_id BIGINT DEFAULT NULL")
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw("ALTER TABLE furumusic__media_file ADD COLUMN uploader_name VARCHAR(255) NOT NULL DEFAULT 'UFO'")
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw("CREATE INDEX IF NOT EXISTS idx_media_file_uploaded_by_user ON furumusic__media_file (uploaded_by_user_id)")
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw("CREATE INDEX IF NOT EXISTS idx_media_file_uploader_name ON furumusic__media_file (uploader_name)")
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0030AddMediaFileUploader;
|
||||
|
||||
impl migrations::Migration for M0030AddMediaFileUploader {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0030_add_media_file_uploader";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0029_add_playback_volume",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(add_media_file_uploader).build()];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
&M0006CreateMediaFile,
|
||||
&M0007CreateArtist,
|
||||
@@ -1553,5 +1598,6 @@ pub mod db_migrations {
|
||||
&M0022CreateTrackTrgmIndex,
|
||||
&M0028AddModelNameColumns,
|
||||
&M0029AddPlaybackVolume,
|
||||
&M0030AddMediaFileUploader,
|
||||
];
|
||||
}
|
||||
|
||||
+36
-1
@@ -384,10 +384,24 @@ pub async fn oidc_callback_handler(
|
||||
.unwrap_or_default();
|
||||
|
||||
tracing::info!(
|
||||
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}",
|
||||
"OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}, user_groups={:?}",
|
||||
config.oidc_admin_groups,
|
||||
config.oidc_user_groups,
|
||||
);
|
||||
|
||||
if !is_allowed_by_groups(
|
||||
&groups,
|
||||
&config.oidc_user_groups,
|
||||
&config.oidc_admin_groups,
|
||||
) {
|
||||
tracing::warn!(
|
||||
"OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
|
||||
config.oidc_user_groups,
|
||||
config.oidc_admin_groups,
|
||||
);
|
||||
return redirect_login_with_error(i18n.t.login_access_denied);
|
||||
}
|
||||
|
||||
// User provisioning logic.
|
||||
let user = match provision_user(
|
||||
&db,
|
||||
@@ -458,6 +472,27 @@ fn resolve_role(groups: &[String], admin_groups: &str) -> &'static str {
|
||||
auth::Role::User.code()
|
||||
}
|
||||
|
||||
fn parse_group_set(groups: &str) -> std::collections::HashSet<&str> {
|
||||
groups
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_any_group(groups: &[String], allowed: &std::collections::HashSet<&str>) -> bool {
|
||||
groups.iter().any(|g| allowed.contains(g.as_str()))
|
||||
}
|
||||
|
||||
fn is_allowed_by_groups(groups: &[String], user_groups: &str, admin_groups: &str) -> bool {
|
||||
let user_set = parse_group_set(user_groups);
|
||||
if user_set.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let admin_set = parse_group_set(admin_groups);
|
||||
has_any_group(groups, &user_set) || has_any_group(groups, &admin_set)
|
||||
}
|
||||
|
||||
async fn provision_user(
|
||||
db: &Database,
|
||||
issuer: &str,
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ArtistCard {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
pub(super) image_url: Option<String>,
|
||||
pub(super) release_count: i64,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct Paginated<T: Serialize> {
|
||||
pub(super) items: Vec<T>,
|
||||
pub(super) total: i64,
|
||||
pub(super) page: i32,
|
||||
pub(super) per_page: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ReleaseCard {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) cover_url: Option<String>,
|
||||
pub(super) track_count: i64,
|
||||
pub(super) uploaders: Vec<UploaderSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ArtistDetail {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
pub(super) image_url: Option<String>,
|
||||
pub(super) total_track_count: i64,
|
||||
pub(super) total_play_count: i64,
|
||||
pub(super) releases: Vec<ReleaseCard>,
|
||||
pub(super) featured_tracks: Vec<ArtistAppearanceTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ArtistRef {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct TrackItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) artists: Vec<ArtistRef>,
|
||||
pub(super) featured_artists: Vec<ArtistRef>,
|
||||
pub(super) cover_url: Option<String>,
|
||||
pub(super) stream_url: String,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ArtistAppearanceTrack {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_id: i64,
|
||||
pub(super) release_title: String,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) artists: Vec<ArtistRef>,
|
||||
pub(super) featured_artists: Vec<ArtistRef>,
|
||||
pub(super) cover_url: Option<String>,
|
||||
pub(super) stream_url: String,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ReleaseDetail {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) cover_url: Option<String>,
|
||||
pub(super) artists: Vec<ArtistRef>,
|
||||
pub(super) tracks: Vec<TrackItem>,
|
||||
pub(super) uploaders: Vec<UploaderSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub(super) struct UploaderSummary {
|
||||
pub(super) name: String,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlaylistCard {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_count: i64,
|
||||
pub(super) is_own: bool,
|
||||
pub(super) kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
pub(super) struct PlaybackStateDto {
|
||||
pub(super) current_track_id: Option<i64>,
|
||||
pub(super) position_ms: i32,
|
||||
pub(super) queue: Vec<i64>,
|
||||
pub(super) queue_position: i32,
|
||||
pub(super) shuffle: bool,
|
||||
pub(super) repeat_mode: String,
|
||||
pub(super) volume: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlaylistDetail {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) description: Option<String>,
|
||||
pub(super) is_own: bool,
|
||||
pub(super) kind: String,
|
||||
pub(super) tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct SearchResults {
|
||||
pub(super) artists: Vec<ArtistCard>,
|
||||
pub(super) releases: Vec<ReleaseCard>,
|
||||
pub(super) tracks: Vec<TrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct UserStats {
|
||||
pub(super) liked_tracks: i64,
|
||||
pub(super) playlists: i64,
|
||||
pub(super) plays: i64,
|
||||
pub(super) listened_minutes: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct UserProfile {
|
||||
pub(super) name: String,
|
||||
pub(super) role: String,
|
||||
pub(super) stats: UserStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlayHistoryItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) track_id: i64,
|
||||
pub(super) track_title: String,
|
||||
pub(super) release_title: Option<String>,
|
||||
pub(super) played_at: String,
|
||||
pub(super) duration_listened: Option<i32>,
|
||||
pub(super) completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlayHistoryPage {
|
||||
pub(super) items: Vec<PlayHistoryItem>,
|
||||
pub(super) total: i64,
|
||||
pub(super) page: i32,
|
||||
pub(super) per_page: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct LikeStatus {
|
||||
pub(super) liked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct LikedIds {
|
||||
pub(super) track_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct FollowStatus {
|
||||
pub(super) followed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct FollowedArtists {
|
||||
pub(super) artist_ids: Vec<i64>,
|
||||
pub(super) artists: Vec<ArtistCard>,
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use crate::player::dto::UploaderSummary;
|
||||
use crate::player::rows::ReleaseUploaderRow;
|
||||
|
||||
pub(super) fn cover_url(file_id: Option<i64>) -> Option<String> {
|
||||
file_id.map(|id| format!("/api/player/cover/{id}"))
|
||||
}
|
||||
|
||||
pub(super) fn track_cover_url(
|
||||
track_cover: Option<i64>,
|
||||
release_cover: Option<i64>,
|
||||
) -> Option<String> {
|
||||
cover_url(track_cover.or(release_cover))
|
||||
}
|
||||
|
||||
pub(super) async fn load_release_uploaders(
|
||||
pool: &sqlx::PgPool,
|
||||
release_ids: &[i64],
|
||||
) -> Result<std::collections::HashMap<i64, Vec<UploaderSummary>>, sqlx::Error> {
|
||||
if release_ids.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
let rows = sqlx::query_as::<_, ReleaseUploaderRow>(
|
||||
r#"SELECT t.release_id,
|
||||
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||
COUNT(*)::bigint AS track_count
|
||||
FROM furumusic__track t
|
||||
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||
WHERE t.release_id = ANY($1) AND t.is_hidden = false
|
||||
GROUP BY t.release_id, COALESCE(mf.uploader_name, 'UFO')
|
||||
ORDER BY t.release_id, track_count DESC, uploader_name"#,
|
||||
)
|
||||
.bind(release_ids)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut map: std::collections::HashMap<i64, Vec<UploaderSummary>> =
|
||||
std::collections::HashMap::new();
|
||||
for row in rows {
|
||||
map.entry(row.release_id)
|
||||
.or_default()
|
||||
.push(UploaderSummary {
|
||||
name: row.uploader_name,
|
||||
track_count: row.track_count,
|
||||
});
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
+458
-366
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct HistoryEntry {
|
||||
pub(super) track_id: i64,
|
||||
pub(super) duration_listened: Option<i32>,
|
||||
pub(super) completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct HistoryQuery {
|
||||
pub(super) page: Option<i32>,
|
||||
pub(super) limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct TracksByIdsRequest {
|
||||
pub(super) ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct CreatePlaylistRequest {
|
||||
pub(super) title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct UpdatePlaylistRequest {
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct AddTracksRequest {
|
||||
pub(super) track_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct RemoveTrackRequest {
|
||||
pub(super) track_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PaginationQuery {
|
||||
pub(super) page: Option<i32>,
|
||||
pub(super) limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathId {
|
||||
pub(super) id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathStringId {
|
||||
pub(super) id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct SearchQuery {
|
||||
pub(super) q: String,
|
||||
pub(super) limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathTrackId {
|
||||
pub(super) track_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathMediaFileId {
|
||||
pub(super) media_file_id: i64,
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ArtistRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
pub(super) image_file_id: Option<i64>,
|
||||
pub(super) release_count: i64,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct CountRow {
|
||||
pub(super) count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ReleaseRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ArtistBriefRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct TrackRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct TrackArtistRow {
|
||||
pub(super) track_id: i64,
|
||||
pub(super) artist_id: i64,
|
||||
pub(super) artist_name: String,
|
||||
pub(super) role: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct MediaFileRow {
|
||||
pub(super) file_path: String,
|
||||
pub(super) mime_type: String,
|
||||
pub(super) file_size_bytes: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlaybackStateRow {
|
||||
pub(super) current_track_id: Option<i64>,
|
||||
pub(super) position_ms: i32,
|
||||
pub(super) queue_json: String,
|
||||
pub(super) queue_position: i32,
|
||||
pub(super) shuffle: bool,
|
||||
pub(super) repeat_mode: String,
|
||||
pub(super) volume: f64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlaylistRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_count: i64,
|
||||
pub(super) is_own: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlaylistInfoRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) description: Option<String>,
|
||||
pub(super) owner_id: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlaylistTrackRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct AppearanceTrackRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_id: i64,
|
||||
pub(super) release_title: String,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct SearchArtistRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
pub(super) image_file_id: Option<i64>,
|
||||
pub(super) release_count: i64,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct SearchReleaseRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct SearchTrackRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ReleaseUploaderRow {
|
||||
pub(super) release_id: i64,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlayHistoryRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) track_id: i64,
|
||||
pub(super) track_title: String,
|
||||
pub(super) release_title: Option<String>,
|
||||
pub(super) played_at: String,
|
||||
pub(super) duration_listened: Option<i32>,
|
||||
pub(super) completed: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ReleaseInfoRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
}
|
||||
+13
-3
@@ -316,6 +316,7 @@ impl TorrentService {
|
||||
id: &str,
|
||||
selected_files: Vec<usize>,
|
||||
inbox_dir: String,
|
||||
uploader_user_id: i64,
|
||||
) -> anyhow::Result<TorrentJobDto> {
|
||||
if selected_files.is_empty() {
|
||||
bail!("select at least one file");
|
||||
@@ -371,7 +372,10 @@ impl TorrentService {
|
||||
return;
|
||||
}
|
||||
service.stop_torrent(&handle).await;
|
||||
if let Err(err) = service.finalize_completed(&id, &inbox_dir).await {
|
||||
if let Err(err) = service
|
||||
.finalize_completed(&id, &inbox_dir, uploader_user_id)
|
||||
.await
|
||||
{
|
||||
service.fail_job(&id, err.to_string()).await;
|
||||
}
|
||||
});
|
||||
@@ -400,7 +404,12 @@ impl TorrentService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn finalize_completed(&self, id: &str, inbox_dir: &Path) -> anyhow::Result<()> {
|
||||
async fn finalize_completed(
|
||||
&self,
|
||||
id: &str,
|
||||
inbox_dir: &Path,
|
||||
uploader_user_id: i64,
|
||||
) -> anyhow::Result<()> {
|
||||
let (name, files, selected_files, output_dir) = {
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||
@@ -414,7 +423,8 @@ impl TorrentService {
|
||||
};
|
||||
|
||||
let destination_root = inbox_dir
|
||||
.join("torrents")
|
||||
.join("user_uploads")
|
||||
.join(uploader_user_id.to_string())
|
||||
.join(sanitize_path_component(&name));
|
||||
tokio::fs::create_dir_all(&destination_root).await?;
|
||||
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
<td><input name="oidc_admin_groups" id="oidc_admin_groups" value="{{ oidc_admin_groups }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_admin_groups_source }}">{{ oidc_admin_groups_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_user_groups">{{ t.settings_oidc_user_groups }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_user_groups_help }}</span></td>
|
||||
<td><input name="oidc_user_groups" id="oidc_user_groups" value="{{ oidc_user_groups }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_user_groups_source }}">{{ oidc_user_groups_source }}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
<h2>{{ t.settings_api }}</h2>
|
||||
<table>
|
||||
|
||||
+770
-14
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user