fix: moderation issues with shared instances

This commit is contained in:
Calum H. (IMB11)
2026-07-27 13:33:24 +01:00
parent a04555dc91
commit 1c22503d01
10 changed files with 155 additions and 36 deletions
@@ -64,11 +64,18 @@
</template> </template>
</div> </div>
<div v-else-if="report.item_type === 'shared-instance'" class="item-info"> <div v-else-if="report.item_type === 'shared-instance'" class="item-info">
<div class="backed-svg" :class="{ raised: raised }"> <Avatar
v-if="report.shared_instance"
:src="report.shared_instance.icon"
size="xs"
no-shadow
:raised="raised"
/>
<div v-else class="backed-svg" :class="{ raised: raised }">
<BoxesIcon /> <BoxesIcon />
</div> </div>
<div class="stacked"> <div class="stacked">
<span class="title">Shared instance</span> <span class="title">{{ report.shared_instance?.name ?? 'Shared instance' }}</span>
<span> <span>
Version {{ report.shared_instance_version_id ?? 'unknown' }} · Version {{ report.shared_instance_version_id ?? 'unknown' }} ·
<CopyCode :text="report.item_id" /> <CopyCode :text="report.item_id" />
@@ -89,8 +96,7 @@
<ThreadSummary <ThreadSummary
v-if="thread" v-if="thread"
:thread="thread" :thread="thread"
class="thread-summary" class="thread-summary !bg-surface-2"
:raised="raised"
:link="`/${moderation ? 'moderation' : 'dashboard'}/report/${report.id}`" :link="`/${moderation ? 'moderation' : 'dashboard'}/report/${report.id}`"
:auth="auth" :auth="auth"
/> />
@@ -1,5 +1,5 @@
<template> <template>
<div> <div class="pb-20">
<section> <section>
<Breadcrumbs <Breadcrumbs
v-if="breadcrumbsStack" v-if="breadcrumbsStack"
@@ -111,6 +111,16 @@ const { data: project } = useQuery({
enabled: computed(() => !!projectId.value), enabled: computed(() => !!projectId.value),
}) })
const sharedInstanceId = computed(() =>
rawReport.value?.item_type === 'shared-instance' ? rawReport.value.item_id : null,
)
const { data: sharedInstance } = useQuery({
queryKey: computed(() => ['shared-instance', sharedInstanceId.value]),
queryFn: () => client.sharedinstances.instances_v1.get(sharedInstanceId.value),
enabled: computed(() => !!sharedInstanceId.value),
})
// Assemble the full report object // Assemble the full report object
const report = computed(() => { const report = computed(() => {
if (!rawReport.value) return null if (!rawReport.value) return null
@@ -118,6 +128,7 @@ const report = computed(() => {
...rawReport.value, ...rawReport.value,
project: project.value ?? null, project: project.value ?? null,
version: version.value ?? null, version: version.value ?? null,
shared_instance: sharedInstance.value ?? null,
reporterUser: (users.value || []).find((user) => user.id === rawReport.value.reporter), reporterUser: (users.value || []).find((user) => user.id === rawReport.value.reporter),
user: user:
rawReport.value.item_type === 'user' rawReport.value.item_type === 'user'
@@ -19,7 +19,7 @@
:moderation="moderation" :moderation="moderation"
raised raised
:auth="auth" :auth="auth"
class="universal-card recessed" class="card-shadow mb-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4"
/> />
<p v-if="filteredReports.length === 0">You don't have any active reports.</p> <p v-if="filteredReports.length === 0">You don't have any active reports.</p>
</template> </template>
@@ -51,7 +51,7 @@ const MAX_REPORTS = 1500
const { data: rawReportsData } = useQuery({ const { data: rawReportsData } = useQuery({
queryKey: ['reports', MAX_REPORTS], queryKey: ['reports', MAX_REPORTS],
queryFn: () => client.labrinth.reports_v3.list({ count: MAX_REPORTS }), queryFn: () => client.labrinth.reports_v3.list({ count: MAX_REPORTS, all: false }),
placeholderData: [], placeholderData: [],
}) })
@@ -67,6 +67,13 @@ const versionReports = computed(() =>
const versionIds = computed(() => [ const versionIds = computed(() => [
...new Set(versionReports.value.map((report) => report.item_id)), ...new Set(versionReports.value.map((report) => report.item_id)),
]) ])
const sharedInstanceIds = computed(() => [
...new Set(
rawReports.value
.filter((report) => report.item_type === 'shared-instance')
.map((report) => report.item_id),
),
])
const userIds = computed(() => [...new Set(reporterUsers.value.concat(reportedUsers.value))]) const userIds = computed(() => [...new Set(reporterUsers.value.concat(reportedUsers.value))])
const threadIds = computed(() => [ const threadIds = computed(() => [
...new Set( ...new Set(
@@ -94,6 +101,21 @@ const { data: versions } = useQuery({
placeholderData: [], placeholderData: [],
}) })
const { data: sharedInstances } = useQuery({
queryKey: computed(() => ['shared-instances', sharedInstanceIds.value]),
queryFn: async () => {
const results = await Promise.allSettled(
sharedInstanceIds.value.map(async (id) => ({
id,
instance: await client.sharedinstances.instances_v1.get(id),
})),
)
return results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []))
},
enabled: computed(() => sharedInstanceIds.value.length > 0),
placeholderData: [],
})
const { data: threads } = useQuery({ const { data: threads } = useQuery({
queryKey: computed(() => ['threads', threadIds.value]), queryKey: computed(() => ['threads', threadIds.value]),
queryFn: () => queryFn: () =>
@@ -123,6 +145,9 @@ const { data: projects } = useQuery({
const userMap = computed(() => new Map(users.value.map((u) => [u.id, u]))) const userMap = computed(() => new Map(users.value.map((u) => [u.id, u])))
const versionMap = computed(() => new Map(versions.value.map((v) => [v.id, v]))) const versionMap = computed(() => new Map(versions.value.map((v) => [v.id, v])))
const projectMap = computed(() => new Map(projects.value.map((p) => [p.id, p]))) const projectMap = computed(() => new Map(projects.value.map((p) => [p.id, p])))
const sharedInstanceMap = computed(
() => new Map(sharedInstances.value.map(({ id, instance }) => [id, instance])),
)
const threadMap = computed(() => new Map(threads.value.map((t) => [t.id, t]))) const threadMap = computed(() => new Map(threads.value.map((t) => [t.id, t])))
const reports = computed(() => const reports = computed(() =>
@@ -136,6 +161,8 @@ const reports = computed(() =>
} else if (report.item_type === 'version') { } else if (report.item_type === 'version') {
enrichedReport.version = versionMap.value.get(report.item_id) enrichedReport.version = versionMap.value.get(report.item_id)
enrichedReport.project = projectMap.value.get(enrichedReport.version?.project_id) enrichedReport.project = projectMap.value.get(enrichedReport.version?.project_id)
} else if (report.item_type === 'shared-instance') {
enrichedReport.shared_instance = sharedInstanceMap.value.get(report.item_id)
} }
if (report.thread_id) { if (report.thread_id) {
const thread = threadMap.value.get(report.thread_id) const thread = threadMap.value.get(report.thread_id)
@@ -149,7 +176,7 @@ const reports = computed(() =>
const filteredReports = computed(() => const filteredReports = computed(() =>
reports.value?.filter( reports.value?.filter(
(x) => (x) =>
(props.moderation || x.reporterUser?.id === props.auth.user.id) && (props.moderation || x.reporter === props.auth.user.id) &&
(viewMode.value === 'open' ? x.open : !x.open) && (viewMode.value === 'open' ? x.open : !x.open) &&
(reasonFilter.value === 'All' || reasonFilter.value === x.report_type), (reasonFilter.value === 'All' || reasonFilter.value === x.report_type),
), ),
@@ -1,9 +1,9 @@
<template> <template>
<div> <div class="flex flex-col gap-4 pb-20 lg:pl-4 lg:pt-1.5">
<section class="universal-card"> <h2 class="m-0 text-xl font-semibold text-contrast md:text-2xl">
<h2 class="text-2xl">{{ formatMessage(messages.reportsTitle) }}</h2> {{ formatMessage(messages.reportsTitle) }}
<ReportsList :auth="auth" /> </h2>
</section> <ReportsList :auth="auth" />
</div> </div>
</template> </template>
<script setup> <script setup>
@@ -235,9 +235,12 @@ const { data: allReports } = await useLazyAsyncData('new-moderation-reports', as
let reports: Labrinth.Reports.v3.Report[] let reports: Labrinth.Reports.v3.Report[]
let hasMoreReports = true let hasMoreReports = true
while (hasMoreReports) { while (hasMoreReports) {
reports = (await useBaseFetch(`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}`, { reports = (await useBaseFetch(
apiVersion: 3, `report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}&all=true`,
})) as Labrinth.Reports.v3.Report[] {
apiVersion: 3,
},
)) as Labrinth.Reports.v3.Report[]
hasMoreReports = reports.length > 0 hasMoreReports = reports.length > 0
if (!hasMoreReports) { if (!hasMoreReports) {
+81 -2
View File
@@ -4,6 +4,7 @@ use crate::database::models::ids::*;
use crate::database::models::notifications_template_item::{ use crate::database::models::notifications_template_item::{
NotificationTemplate, get_or_set_cached_dynamic_html, NotificationTemplate, get_or_set_cached_dynamic_html,
}; };
use crate::database::models::report_item::DBReport;
use crate::database::models::{ use crate::database::models::{
DBOrganization, DBProject, DBUser, DatabaseError, DBOrganization, DBProject, DBUser, DatabaseError,
}; };
@@ -11,10 +12,12 @@ use crate::env::ENV;
use crate::models::v3::notifications::NotificationBody; use crate::models::v3::notifications::NotificationBody;
use crate::routes::ApiError; use crate::routes::ApiError;
use crate::util::error::Context; use crate::util::error::Context;
use crate::util::http::HTTP_CLIENT;
use ariadne::ids::base62_impl::to_base62; use ariadne::ids::base62_impl::to_base62;
use futures::TryFutureExt; use futures::TryFutureExt;
use lettre::Message; use lettre::Message;
use lettre::message::{Mailbox, MultiPart, SinglePart}; use lettre::message::{Mailbox, MultiPart, SinglePart};
use serde::Deserialize;
use sqlx::query; use sqlx::query;
use std::collections::HashMap; use std::collections::HashMap;
use std::time::Duration; use std::time::Duration;
@@ -312,6 +315,66 @@ enum EmailTemplate {
}, },
} }
#[derive(Deserialize)]
struct SharedInstance {
name: String,
}
async fn resolve_report_title(
exec: &mut PgTransaction<'_>,
report_id: DBReportId,
title: String,
) -> Result<String, ApiError> {
if title != "unknown" {
return Ok(title);
}
let Some(shared_instance_id) = DBReport::get(report_id, &mut *exec)
.await?
.and_then(|report| report.shared_instance_id)
else {
return Ok(title);
};
let instance_id = to_base62(shared_instance_id.0 as u64);
let fallback = format!("shared instance {instance_id}");
let response = HTTP_CLIENT
.get(format!(
"{}/v1/instances/{instance_id}",
ENV.SHARED_INSTANCES_URL
))
.bearer_auth(&ENV.SHARED_INSTANCES_KEY)
.send()
.await
.and_then(reqwest::Response::error_for_status);
let response = match response {
Ok(response) => response,
Err(error) => {
warn!(
%error,
report_id = report_id.0,
%instance_id,
"Failed to fetch shared instance name for report email"
);
return Ok(fallback);
}
};
match response.json::<SharedInstance>().await {
Ok(instance) => Ok(instance.name),
Err(error) => {
warn!(
%error,
report_id = report_id.0,
%instance_id,
"Failed to parse shared instance name for report email"
);
Ok(fallback)
}
}
}
async fn collect_template_variables( async fn collect_template_variables(
exec: &mut PgTransaction<'_>, exec: &mut PgTransaction<'_>,
redis: &RedisPool, redis: &RedisPool,
@@ -359,7 +422,15 @@ async fn collect_template_variables(
.await?; .await?;
map.insert(REPORT_ID, to_base62(report_id.0)); map.insert(REPORT_ID, to_base62(report_id.0));
map.insert(REPORT_TITLE, result.title); map.insert(
REPORT_TITLE,
resolve_report_title(
exec,
DBReportId(report_id.0 as i64),
result.title,
)
.await?,
);
map.insert(REPORT_DATE, date_human_readable(result.created)); map.insert(REPORT_DATE, date_human_readable(result.created));
Ok(EmailTemplate::Static(map)) Ok(EmailTemplate::Static(map))
} }
@@ -380,7 +451,15 @@ async fn collect_template_variables(
.fetch_one(&mut *exec) .fetch_one(&mut *exec)
.await?; .await?;
map.insert(REPORT_TITLE, result.title); map.insert(
REPORT_TITLE,
resolve_report_title(
exec,
DBReportId(report_id.0 as i64),
result.title,
)
.await?,
);
map.insert(NEWREPORT_ID, to_base62(report_id.0)); map.insert(NEWREPORT_ID, to_base62(report_id.0));
Ok(EmailTemplate::Static(map)) Ok(EmailTemplate::Static(map))
} }
+1 -4
View File
@@ -60,16 +60,13 @@ pub async fn report_create(
pub struct ReportsRequestOptions { pub struct ReportsRequestOptions {
#[serde(default = "default_count")] #[serde(default = "default_count")]
count: u16, count: u16,
#[serde(default = "default_all")] #[serde(default)]
all: bool, all: bool,
} }
fn default_count() -> u16 { fn default_count() -> u16 {
100 100
} }
fn default_all() -> bool {
true
}
/// Get open reports for the current user. /// Get open reports for the current user.
#[utoipa::path( #[utoipa::path(
+1 -4
View File
@@ -320,16 +320,13 @@ pub struct ReportsRequestOptions {
pub count: u16, pub count: u16,
#[serde(default)] #[serde(default)]
pub offset: u32, pub offset: u32,
#[serde(default = "default_all")] #[serde(default)]
pub all: bool, pub all: bool,
} }
fn default_count() -> u16 { fn default_count() -> u16 {
100 100
} }
fn default_all() -> bool {
true
}
#[utoipa::path( #[utoipa::path(
tag = "reports", tag = "reports",
+6 -7
View File
@@ -44,18 +44,17 @@ pub use self::shared::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview, SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
SharedInstanceInvite, SharedInstanceInviteInstallPreview, SharedInstanceInvite, SharedInstanceInviteInstallPreview,
SharedInstanceInviteLink, SharedInstanceJoinType, SharedInstanceInviteLink, SharedInstanceJoinType,
SharedInstancePublishPreview, SharedInstancePublishPreview, SharedInstanceUpdateDiff,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType, SharedInstanceUpdateDiffType, SharedInstanceUpdatePreview,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers, SharedInstanceUser, SharedInstanceUsers,
accept_pending_shared_instance_invite, accept_pending_shared_instance_invite,
accept_shared_instance_invite_for_install, accept_shared_instance_invite_for_install,
can_active_user_use_shared_instances, create_shared_instance_invite_link, can_active_user_use_shared_instances, create_shared_instance_invite_link,
decline_pending_shared_instance_invite, decline_pending_shared_instance_invite,
get_shared_instance_install_preview, get_shared_instance_publish_preview, get_shared_instance_install_preview, get_shared_instance_invites,
get_shared_instance_invites, get_shared_instance_update_preview, get_shared_instance_publish_preview, get_shared_instance_update_preview,
get_shared_instance_users, install_shared_instance, get_shared_instance_users, install_shared_instance,
invite_shared_instance_users, publish_shared_instance, invite_shared_instance_users, publish_shared_instance,
remove_shared_instance_users, revoke_shared_instance_invite, remove_shared_instance_users, revoke_shared_instance_invite,
unlink_shared_instance, unpublish_shared_instance, unlink_shared_instance, unpublish_shared_instance, update_shared_instance,
update_shared_instance,
}; };
@@ -122,9 +122,9 @@ pub use self::types::{
SharedInstanceExternalFilePreview, SharedInstanceInstallPreview, SharedInstanceExternalFilePreview, SharedInstanceInstallPreview,
SharedInstanceInvite, SharedInstanceInviteInstallPreview, SharedInstanceInvite, SharedInstanceInviteInstallPreview,
SharedInstanceInviteLink, SharedInstanceJoinType, SharedInstanceInviteLink, SharedInstanceJoinType,
SharedInstancePublishPreview, SharedInstancePublishPreview, SharedInstanceUpdateDiff,
SharedInstanceUpdateDiff, SharedInstanceUpdateDiffType, SharedInstanceUpdateDiffType, SharedInstanceUpdatePreview,
SharedInstanceUpdatePreview, SharedInstanceUser, SharedInstanceUsers, SharedInstanceUser, SharedInstanceUsers,
}; };
pub async fn can_active_user_use_shared_instances() -> crate::Result<bool> { pub async fn can_active_user_use_shared_instances() -> crate::Result<bool> {