-
-
Projects
-
- {{ formatNumber(stats.projects, false) }}
-
-
-
-
Versions
-
- {{ formatNumber(stats.versions, false) }}
-
-
-
-
Files
-
- {{ formatNumber(stats.files, false) }}
-
-
-
-
Authors
-
- {{ formatNumber(stats.authors, false) }}
-
-
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+ {{ selected }} ({{ filteredProjects.length }})
+
+
+
+
+
+
+
+ {{ selected }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
diff --git a/apps/frontend/src/pages/moderation/report/[id].vue b/apps/frontend/src/pages/moderation/report/[id].vue
deleted file mode 100644
index d485333d..00000000
--- a/apps/frontend/src/pages/moderation/report/[id].vue
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
diff --git a/apps/frontend/src/pages/moderation/reports.vue b/apps/frontend/src/pages/moderation/reports.vue
deleted file mode 100644
index af15186e..00000000
--- a/apps/frontend/src/pages/moderation/reports.vue
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
-
diff --git a/apps/frontend/src/pages/moderation/reports/[id].vue b/apps/frontend/src/pages/moderation/reports/[id].vue
new file mode 100644
index 00000000..8ec2c4d6
--- /dev/null
+++ b/apps/frontend/src/pages/moderation/reports/[id].vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/pages/moderation/reports/index.vue b/apps/frontend/src/pages/moderation/reports/index.vue
new file mode 100644
index 00000000..4d375ffe
--- /dev/null
+++ b/apps/frontend/src/pages/moderation/reports/index.vue
@@ -0,0 +1,290 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selected }} ({{ filteredReports.length }})
+
+
+
+
+
+
+
+ {{ selected }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/pages/moderation/review.vue b/apps/frontend/src/pages/moderation/review.vue
deleted file mode 100644
index 0613982d..00000000
--- a/apps/frontend/src/pages/moderation/review.vue
+++ /dev/null
@@ -1,304 +0,0 @@
-
-
- Review projects
-
-
-
-
-
-
-
- Showing {{ projectsFiltered.length }} {{ projectTypePlural }} of {{ projects.length }} total
- projects in the queue.
-
- There are {{ projects.length }} projects in the queue.
-
-
- {{ projectsOver24Hours.length }} {{ projectTypePlural }}
- have been in the queue for over 24 hours.
-
-
-
- {{ projectsOver48Hours.length }} {{ projectTypePlural }}
- have been in the queue for over 48 hours.
-
-
-
-
-
-
-
- {{ project.name }}
- {{ formatProjectType(project.inferred_project_type) }}
-
-
-
-
- by
-
-
- {{ project.owner.user.username }}
-
-
-
- {{ project.org.name }}
-
-
-
- is requesting to be
-
-
-
-
-
-
- View project
-
-
-
-
- Submitted
- {{
- formatRelativeTime(project.queued)
- }}
-
-
Unknown queue date
-
-
-
-
-
-
diff --git a/apps/frontend/src/pages/moderation/technical-review-mockup.vue b/apps/frontend/src/pages/moderation/technical-review-mockup.vue
new file mode 100644
index 00000000..f18897fc
--- /dev/null
+++ b/apps/frontend/src/pages/moderation/technical-review-mockup.vue
@@ -0,0 +1,386 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ selected }} ({{ filteredReports.length }})
+
+
+
+
+
+
+
+ {{ selected }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/pages/moderation/technical-review.vue b/apps/frontend/src/pages/moderation/technical-review.vue
new file mode 100644
index 00000000..40f28fec
--- /dev/null
+++ b/apps/frontend/src/pages/moderation/technical-review.vue
@@ -0,0 +1,3 @@
+
+ Not yet implemented.
+
diff --git a/apps/frontend/src/store/moderation.ts b/apps/frontend/src/store/moderation.ts
new file mode 100644
index 00000000..d89a68fc
--- /dev/null
+++ b/apps/frontend/src/store/moderation.ts
@@ -0,0 +1,98 @@
+import { defineStore, createPinia } from "pinia";
+import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
+
+export interface ModerationQueue {
+ items: string[];
+ total: number;
+ completed: number;
+ skipped: number;
+ lastUpdated: Date;
+}
+
+const EMPTY_QUEUE: Partial
= {
+ items: [],
+
+ // TODO: Consider some form of displaying this in the checklist, maybe at the end
+ total: 0,
+ completed: 0,
+ skipped: 0,
+};
+
+function createEmptyQueue(): ModerationQueue {
+ return { ...EMPTY_QUEUE, lastUpdated: new Date() } as ModerationQueue;
+}
+
+const pinia = createPinia();
+pinia.use(piniaPluginPersistedstate);
+
+export const useModerationStore = defineStore("moderation", {
+ state: () => ({
+ currentQueue: createEmptyQueue(),
+ }),
+
+ getters: {
+ queueLength: (state) => state.currentQueue.items.length,
+ hasItems: (state) => state.currentQueue.items.length > 0,
+ progress: (state) => {
+ if (state.currentQueue.total === 0) return 0;
+ return (state.currentQueue.completed + state.currentQueue.skipped) / state.currentQueue.total;
+ },
+ },
+
+ actions: {
+ setQueue(projectIDs: string[]) {
+ this.currentQueue = {
+ items: [...projectIDs],
+ total: projectIDs.length,
+ completed: 0,
+ skipped: 0,
+ lastUpdated: new Date(),
+ };
+ },
+
+ setSingleProject(projectId: string) {
+ this.currentQueue = {
+ items: [projectId],
+ total: 1,
+ completed: 0,
+ skipped: 0,
+ lastUpdated: new Date(),
+ };
+ },
+
+ completeCurrentProject(projectId: string, status: "completed" | "skipped" = "completed") {
+ if (status === "completed") {
+ this.currentQueue.completed++;
+ } else {
+ this.currentQueue.skipped++;
+ }
+
+ this.currentQueue.items = this.currentQueue.items.filter((id: string) => id !== projectId);
+ this.currentQueue.lastUpdated = new Date();
+
+ return this.currentQueue.items.length > 0;
+ },
+
+ getCurrentProjectId(): string | null {
+ return this.currentQueue.items[0] || null;
+ },
+
+ resetQueue() {
+ this.currentQueue = createEmptyQueue();
+ },
+ },
+
+ persist: {
+ key: "moderation-store",
+ serializer: {
+ serialize: JSON.stringify,
+ deserialize: (value: string) => {
+ const parsed = JSON.parse(value);
+ if (parsed.currentQueue?.lastUpdated) {
+ parsed.currentQueue.lastUpdated = new Date(parsed.currentQueue.lastUpdated);
+ }
+ return parsed;
+ },
+ },
+ },
+});
diff --git a/apps/labrinth/.sqlx/query-32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55.json b/apps/labrinth/.sqlx/query-010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e.json
similarity index 71%
rename from apps/labrinth/.sqlx/query-32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55.json
rename to apps/labrinth/.sqlx/query-010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e.json
index 8d844ddf..082a6e4e 100644
--- a/apps/labrinth/.sqlx/query-32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55.json
+++ b/apps/labrinth/.sqlx/query-010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21\n )\n ",
+ "query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20, $21, $22\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -25,10 +25,11 @@
"Text",
"Text",
"Text",
+ "Bool",
"Bool"
]
},
"nullable": []
},
- "hash": "32fa1030a3a69f6bc36c6ec916ba8d0724dfd683576629ab05f5df321d5f9a55"
+ "hash": "010c69fa61e1329156020b251e75d46bc09344c1846b3098accce5801e571e5e"
}
diff --git a/apps/labrinth/.sqlx/query-b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0.json b/apps/labrinth/.sqlx/query-5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4.json
similarity index 88%
rename from apps/labrinth/.sqlx/query-b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0.json
rename to apps/labrinth/.sqlx/query-5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4.json
index 988ec11a..0c33202b 100644
--- a/apps/labrinth/.sqlx/query-b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0.json
+++ b/apps/labrinth/.sqlx/query-5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
+ "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ",
"describe": {
"columns": [
{
@@ -122,6 +122,11 @@
"ordinal": 23,
"name": "allow_friend_requests",
"type_info": "Bool"
+ },
+ {
+ "ordinal": 24,
+ "name": "is_subscribed_to_newsletter",
+ "type_info": "Bool"
}
],
"parameters": {
@@ -154,8 +159,9 @@
true,
true,
true,
+ false,
false
]
},
- "hash": "b5857aafa522ca62294bc296fb6cddd862eca2889203e43b4962df07ba1221a0"
+ "hash": "5fcdeeeb820ada62e10feb0beefa29b0535241bbb6d74143925e16cf8cd720c4"
}
diff --git a/apps/labrinth/.sqlx/query-c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623.json b/apps/labrinth/.sqlx/query-c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623.json
new file mode 100644
index 00000000..975dc151
--- /dev/null
+++ b/apps/labrinth/.sqlx/query-c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n UPDATE users\n SET is_subscribed_to_newsletter = TRUE\n WHERE id = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Int8"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "c960b09ddc19530383f143c349c7e34bf813ddbfb88bf31b9863078bc48c8623"
+}
diff --git a/apps/labrinth/migrations/20250727184120_user-newsletter-subscription-column.sql b/apps/labrinth/migrations/20250727184120_user-newsletter-subscription-column.sql
new file mode 100644
index 00000000..5a475b68
--- /dev/null
+++ b/apps/labrinth/migrations/20250727184120_user-newsletter-subscription-column.sql
@@ -0,0 +1 @@
+ALTER TABLE users ADD COLUMN is_subscribed_to_newsletter BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/apps/labrinth/src/auth/validate.rs b/apps/labrinth/src/auth/validate.rs
index 806eaa12..325adc3d 100644
--- a/apps/labrinth/src/auth/validate.rs
+++ b/apps/labrinth/src/auth/validate.rs
@@ -1,6 +1,6 @@
use super::AuthProvider;
use crate::auth::AuthenticationError;
-use crate::database::models::user_item;
+use crate::database::models::{DBUser, user_item};
use crate::database::redis::RedisPool;
use crate::models::pats::Scopes;
use crate::models::users::User;
@@ -44,17 +44,16 @@ where
Ok(Some((scopes, User::from_full(db_user))))
}
-pub async fn get_user_from_headers<'a, E>(
+pub async fn get_full_user_from_headers<'a, E>(
req: &HttpRequest,
executor: E,
redis: &RedisPool,
session_queue: &AuthQueue,
required_scopes: Scopes,
-) -> Result<(Scopes, User), AuthenticationError>
+) -> Result<(Scopes, DBUser), AuthenticationError>
where
E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
{
- // Fetch DB user record and minos user from headers
let (scopes, db_user) = get_user_record_from_bearer_token(
req,
None,
@@ -65,13 +64,33 @@ where
.await?
.ok_or_else(|| AuthenticationError::InvalidCredentials)?;
- let user = User::from_full(db_user);
-
if !scopes.contains(required_scopes) {
return Err(AuthenticationError::InvalidCredentials);
}
- Ok((scopes, user))
+ Ok((scopes, db_user))
+}
+
+pub async fn get_user_from_headers<'a, E>(
+ req: &HttpRequest,
+ executor: E,
+ redis: &RedisPool,
+ session_queue: &AuthQueue,
+ required_scopes: Scopes,
+) -> Result<(Scopes, User), AuthenticationError>
+where
+ E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy,
+{
+ let (scopes, db_user) = get_full_user_from_headers(
+ req,
+ executor,
+ redis,
+ session_queue,
+ required_scopes,
+ )
+ .await?;
+
+ Ok((scopes, User::from_full(db_user)))
}
pub async fn get_user_record_from_bearer_token<'a, 'b, E>(
diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs
index 6a2e4aba..4d447702 100644
--- a/apps/labrinth/src/database/models/user_item.rs
+++ b/apps/labrinth/src/database/models/user_item.rs
@@ -49,6 +49,8 @@ pub struct DBUser {
pub badges: Badges,
pub allow_friend_requests: bool,
+
+ pub is_subscribed_to_newsletter: bool,
}
impl DBUser {
@@ -63,13 +65,13 @@ impl DBUser {
avatar_url, raw_avatar_url, bio, created,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, paypal_id, paypal_country, paypal_email,
- venmo_handle, stripe_customer_id, allow_friend_requests
+ venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7,
$8, $9, $10, $11, $12, $13,
- $14, $15, $16, $17, $18, $19, $20, $21
+ $14, $15, $16, $17, $18, $19, $20, $21, $22
)
",
self.id as DBUserId,
@@ -93,6 +95,7 @@ impl DBUser {
self.venmo_handle,
self.stripe_customer_id,
self.allow_friend_requests,
+ self.is_subscribed_to_newsletter,
)
.execute(&mut **transaction)
.await?;
@@ -178,7 +181,7 @@ impl DBUser {
created, role, badges,
github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,
email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,
- venmo_handle, stripe_customer_id, allow_friend_requests
+ venmo_handle, stripe_customer_id, allow_friend_requests, is_subscribed_to_newsletter
FROM users
WHERE id = ANY($1) OR LOWER(username) = ANY($2)
",
@@ -212,6 +215,7 @@ impl DBUser {
stripe_customer_id: u.stripe_customer_id,
totp_secret: u.totp_secret,
allow_friend_requests: u.allow_friend_requests,
+ is_subscribed_to_newsletter: u.is_subscribed_to_newsletter,
};
acc.insert(u.id, (Some(u.username), user));
diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs
index 281f85be..cba2de5d 100644
--- a/apps/labrinth/src/routes/internal/flows.rs
+++ b/apps/labrinth/src/routes/internal/flows.rs
@@ -1,5 +1,7 @@
use crate::auth::email::send_email;
-use crate::auth::validate::get_user_record_from_bearer_token;
+use crate::auth::validate::{
+ get_full_user_from_headers, get_user_record_from_bearer_token,
+};
use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers};
use crate::database::models::DBUser;
use crate::database::models::flow_item::DBFlow;
@@ -232,6 +234,7 @@ impl TempUser {
role: Role::Developer.to_string(),
badges: Badges::default(),
allow_friend_requests: true,
+ is_subscribed_to_newsletter: false,
}
.insert(transaction)
.await?;
@@ -1291,37 +1294,6 @@ pub async fn delete_auth_provider(
Ok(HttpResponse::NoContent().finish())
}
-pub async fn sign_up_sendy(email: &str) -> Result<(), AuthenticationError> {
- let url = dotenvy::var("SENDY_URL")?;
- let id = dotenvy::var("SENDY_LIST_ID")?;
- let api_key = dotenvy::var("SENDY_API_KEY")?;
- let site_url = dotenvy::var("SITE_URL")?;
-
- if url.is_empty() || url == "none" {
- tracing::info!("Sendy URL not set, skipping signup");
- return Ok(());
- }
-
- let mut form = HashMap::new();
-
- form.insert("api_key", &*api_key);
- form.insert("email", email);
- form.insert("list", &*id);
- form.insert("referrer", &*site_url);
-
- let client = reqwest::Client::new();
- client
- .post(format!("{url}/subscribe"))
- .form(&form)
- .send()
- .await?
- .error_for_status()?
- .text()
- .await?;
-
- Ok(())
-}
-
pub async fn check_sendy_subscription(
email: &str,
) -> Result {
@@ -1456,6 +1428,9 @@ pub async fn create_account_with_password(
role: Role::Developer.to_string(),
badges: Badges::default(),
allow_friend_requests: true,
+ is_subscribed_to_newsletter: new_account
+ .sign_up_newsletter
+ .unwrap_or(false),
}
.insert(&mut transaction)
.await?;
@@ -1476,10 +1451,6 @@ pub async fn create_account_with_password(
&format!("Welcome to Modrinth, {}!", new_account.username),
)?;
- if new_account.sign_up_newsletter.unwrap_or(false) {
- sign_up_sendy(&new_account.email).await?;
- }
-
transaction.commit().await?;
Ok(HttpResponse::Ok().json(res))
@@ -2420,15 +2391,24 @@ pub async fn subscribe_newsletter(
.await?
.1;
- if let Some(email) = user.email {
- sign_up_sendy(&email).await?;
+ sqlx::query!(
+ "
+ UPDATE users
+ SET is_subscribed_to_newsletter = TRUE
+ WHERE id = $1
+ ",
+ user.id.0 as i64,
+ )
+ .execute(&**pool)
+ .await?;
- Ok(HttpResponse::NoContent().finish())
- } else {
- Err(ApiError::InvalidInput(
- "User does not have an email.".to_string(),
- ))
- }
+ crate::database::models::DBUser::clear_caches(
+ &[(user.id.into(), None)],
+ &redis,
+ )
+ .await?;
+
+ Ok(HttpResponse::NoContent().finish())
}
#[get("email/subscribe")]
@@ -2438,7 +2418,7 @@ pub async fn get_newsletter_subscription_status(
redis: Data,
session_queue: Data,
) -> Result {
- let user = get_user_from_headers(
+ let user = get_full_user_from_headers(
&req,
&**pool,
&redis,
@@ -2448,16 +2428,16 @@ pub async fn get_newsletter_subscription_status(
.await?
.1;
- if let Some(email) = user.email {
- let is_subscribed = check_sendy_subscription(&email).await?;
- Ok(HttpResponse::Ok().json(serde_json::json!({
- "subscribed": is_subscribed
- })))
- } else {
- Ok(HttpResponse::Ok().json(serde_json::json!({
- "subscribed": false
- })))
- }
+ let is_subscribed = user.is_subscribed_to_newsletter
+ || if let Some(email) = user.email {
+ check_sendy_subscription(&email).await?
+ } else {
+ false
+ };
+
+ Ok(HttpResponse::Ok().json(serde_json::json!({
+ "subscribed": is_subscribed
+ })))
}
fn send_email_verify(
diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts
index 249a53b6..f0104415 100644
--- a/packages/assets/generated-icons.ts
+++ b/packages/assets/generated-icons.ts
@@ -38,6 +38,7 @@ import _CodeIcon from './icons/code.svg?component'
import _CoffeeIcon from './icons/coffee.svg?component'
import _CogIcon from './icons/cog.svg?component'
import _CoinsIcon from './icons/coins.svg?component'
+import _CollapseIcon from './icons/collapse.svg?component'
import _CollectionIcon from './icons/collection.svg?component'
import _CompassIcon from './icons/compass.svg?component'
import _ContractIcon from './icons/contract.svg?component'
@@ -52,6 +53,7 @@ import _DatabaseIcon from './icons/database.svg?component'
import _DownloadIcon from './icons/download.svg?component'
import _DropdownIcon from './icons/dropdown.svg?component'
import _EditIcon from './icons/edit.svg?component'
+import _EllipsisVerticalIcon from './icons/ellipsis-vertical.svg?component'
import _ExpandIcon from './icons/expand.svg?component'
import _ExternalIcon from './icons/external.svg?component'
import _EyeOffIcon from './icons/eye-off.svg?component'
@@ -229,6 +231,7 @@ export const CodeIcon = _CodeIcon
export const CoffeeIcon = _CoffeeIcon
export const CogIcon = _CogIcon
export const CoinsIcon = _CoinsIcon
+export const CollapseIcon = _CollapseIcon
export const CollectionIcon = _CollectionIcon
export const CompassIcon = _CompassIcon
export const ContractIcon = _ContractIcon
@@ -243,6 +246,7 @@ export const DatabaseIcon = _DatabaseIcon
export const DownloadIcon = _DownloadIcon
export const DropdownIcon = _DropdownIcon
export const EditIcon = _EditIcon
+export const EllipsisVerticalIcon = _EllipsisVerticalIcon
export const ExpandIcon = _ExpandIcon
export const ExternalIcon = _ExternalIcon
export const EyeOffIcon = _EyeOffIcon
diff --git a/packages/assets/icons/collapse.svg b/packages/assets/icons/collapse.svg
new file mode 100644
index 00000000..49723c69
--- /dev/null
+++ b/packages/assets/icons/collapse.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/packages/assets/icons/ellipsis-vertical.svg b/packages/assets/icons/ellipsis-vertical.svg
new file mode 100644
index 00000000..ebe30683
--- /dev/null
+++ b/packages/assets/icons/ellipsis-vertical.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/moderation/data/messages/reports/antivirus.md b/packages/moderation/data/messages/reports/antivirus.md
new file mode 100644
index 00000000..338dd8d6
--- /dev/null
+++ b/packages/moderation/data/messages/reports/antivirus.md
@@ -0,0 +1,3 @@
+Unfortunately, anti-virus software has consistently been found to be an unreliable tool for Minecraft mods.
+
+If you have evidence of malicious activity concerning a specific mod, or of malicious code decompiled from a mod on Modrinth, please create a new Report and provide the required details, thank you.
diff --git a/packages/moderation/data/messages/reports/confirmed-malware.md b/packages/moderation/data/messages/reports/confirmed-malware.md
new file mode 100644
index 00000000..5f7cc2a5
--- /dev/null
+++ b/packages/moderation/data/messages/reports/confirmed-malware.md
@@ -0,0 +1,3 @@
+Thank you for your report.
+
+This project was confirmed to be malicious after a detailed investigation. Luckily, thanks to your report and quick action from our team, we have reason to believe this did not impact a significant amount of users and we have taken precautions to prevent this malicious code from appearing on Modrinth again.
diff --git a/packages/moderation/data/messages/reports/gameplay-issue.md b/packages/moderation/data/messages/reports/gameplay-issue.md
new file mode 100644
index 00000000..ee0c9dd9
--- /dev/null
+++ b/packages/moderation/data/messages/reports/gameplay-issue.md
@@ -0,0 +1,6 @@
+Unfortunately, the Moderation team is unable to assist with your issue.
+
+The reporting system is exclusively for reporting issues to Moderation staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported. The members of the project you're reporting do not see that you have submitted a report.
+
+If you are having issues with crashes, please check out [our FAQ section](https://support.modrinth.com/aen/articles/8792916) to learn how to diagnose and fix crashes.
+For other project-specific issues consider asking the project's own community, check for a Discord or Issues link on the project page.
diff --git a/packages/moderation/data/messages/reports/platform-issue.md b/packages/moderation/data/messages/reports/platform-issue.md
new file mode 100644
index 00000000..a6bcd943
--- /dev/null
+++ b/packages/moderation/data/messages/reports/platform-issue.md
@@ -0,0 +1,5 @@
+Unfortunately, the Moderation team is unable to assist with your issue.
+
+The reporting system is exclusively for reporting issues to Moderation staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported.
+
+Please reach out to the [Modrinth Help Center](https://support.modrinth.com/) so we can better assist you and bring up your concerns with our platform tean,
diff --git a/packages/moderation/data/messages/reports/spam.md b/packages/moderation/data/messages/reports/spam.md
new file mode 100644
index 00000000..876dda2b
--- /dev/null
+++ b/packages/moderation/data/messages/reports/spam.md
@@ -0,0 +1,3 @@
+The reporting system is exclusively for reporting issues to Modrinth staff; only violations of [Modrinth's Content Rules](https://modrinth.com/legal/rules) should be reported. The members of the project you're reporting do not see that you have submitted a report.
+
+Please ensure you are using the Reports system appropriately, repeated misuse may result in account suspension.
diff --git a/packages/moderation/data/messages/reports/stale.md b/packages/moderation/data/messages/reports/stale.md
new file mode 100644
index 00000000..cc21f5f1
--- /dev/null
+++ b/packages/moderation/data/messages/reports/stale.md
@@ -0,0 +1,3 @@
+We haven't received a response in some time, so we're closing this report thread.
+
+If you have additional information to share we ask that you create a new report.
diff --git a/packages/moderation/data/report-quick-replies.ts b/packages/moderation/data/report-quick-replies.ts
new file mode 100644
index 00000000..db1fd232
--- /dev/null
+++ b/packages/moderation/data/report-quick-replies.ts
@@ -0,0 +1,34 @@
+import type { ReportQuickReply } from '../types/reports'
+
+export default [
+ {
+ label: 'Antivirus',
+ message: async () => (await import('./messages/reports/antivirus.md?raw')).default,
+ private: false,
+ },
+ {
+ label: 'Spam',
+ message: async () => (await import('./messages/reports/spam.md?raw')).default,
+ private: false,
+ },
+ {
+ label: 'Gameplay Issue',
+ message: async () => (await import('./messages/reports/gameplay-issue.md?raw')).default,
+ private: false,
+ },
+ {
+ label: 'Platform Issue',
+ message: async () => (await import('./messages/reports/platform-issue.md?raw')).default,
+ private: false,
+ },
+ {
+ label: 'Stale',
+ message: async () => (await import('./messages/reports/stale.md?raw')).default,
+ private: false,
+ },
+ {
+ label: 'Confirmed Malware',
+ message: async () => (await import('./messages/reports/confirmed-malware.md?raw')).default,
+ private: false,
+ },
+] as ReadonlyArray
diff --git a/packages/moderation/data/stages/versions.ts b/packages/moderation/data/stages/versions.ts
index 9976fa96..7f5ca33a 100644
--- a/packages/moderation/data/stages/versions.ts
+++ b/packages/moderation/data/stages/versions.ts
@@ -68,7 +68,7 @@ const versions: Stage = {
message: async () => '',
enablesActions: [
{
- id: 'versions_incorrect_project_type_options',
+ id: 'versions_alternate_versions_options',
type: 'dropdown',
label: 'How are the alternate versions distributed?',
options: [
diff --git a/packages/moderation/index.ts b/packages/moderation/index.ts
index a7cebdcd..0ee0afdf 100644
--- a/packages/moderation/index.ts
+++ b/packages/moderation/index.ts
@@ -2,8 +2,10 @@ export * from './types/actions'
export * from './types/messages'
export * from './types/stage'
export * from './types/keybinds'
+export * from './types/reports'
export * from './utils'
-export { finalPermissionMessages } from './data/modpack-permissions-stage'
+export { finalPermissionMessages } from './data/modpack-permissions-stage'
export { default as checklist } from './data/checklist'
export { default as keybinds } from './data/keybinds'
+export { default as reportQuickReplies } from './data/report-quick-replies'
diff --git a/packages/moderation/types/reports.ts b/packages/moderation/types/reports.ts
new file mode 100644
index 00000000..c05dc348
--- /dev/null
+++ b/packages/moderation/types/reports.ts
@@ -0,0 +1,28 @@
+import type { Project, Report, Thread, User, Version, DelphiReport } from '@modrinth/utils'
+
+export interface OwnershipTarget {
+ name: string
+ slug: string
+ avatar_url?: string
+ type: 'user' | 'organization'
+}
+
+export interface ExtendedReport extends Report {
+ thread: Thread
+ reporter_user: User
+ project?: Project
+ user?: User
+ version?: Version
+ target?: OwnershipTarget
+}
+
+export interface ExtendedDelphiReport extends DelphiReport {
+ target?: OwnershipTarget
+}
+
+export interface ReportQuickReply {
+ label: string
+ message: string | ((report: ExtendedReport) => Promise | string)
+ shouldShow?: (report: ExtendedReport) => boolean
+ private?: boolean
+}
diff --git a/packages/ui/src/components/base/CollapsibleRegion.vue b/packages/ui/src/components/base/CollapsibleRegion.vue
new file mode 100644
index 00000000..14d06c83
--- /dev/null
+++ b/packages/ui/src/components/base/CollapsibleRegion.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui/src/components/base/DropdownSelect.vue b/packages/ui/src/components/base/DropdownSelect.vue
index c87ef340..9843eb15 100644
--- a/packages/ui/src/components/base/DropdownSelect.vue
+++ b/packages/ui/src/components/base/DropdownSelect.vue
@@ -163,7 +163,6 @@ const onFocus = () => {
}
const onBlur = (event) => {
- console.log(event)
if (!isChildOfDropdown(event.relatedTarget)) {
dropdownVisible.value = false
}
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index e1217fc5..85b7f2da 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -10,6 +10,7 @@ export { default as Card } from './base/Card.vue'
export { default as Checkbox } from './base/Checkbox.vue'
export { default as Chips } from './base/Chips.vue'
export { default as Collapsible } from './base/Collapsible.vue'
+export { default as CollapsibleRegion } from './base/CollapsibleRegion.vue'
export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
export { default as CopyCode } from './base/CopyCode.vue'
export { default as DoubleIcon } from './base/DoubleIcon.vue'
diff --git a/packages/utils/types.ts b/packages/utils/types.ts
index a56bc3c5..61f553a2 100644
--- a/packages/utils/types.ts
+++ b/packages/utils/types.ts
@@ -18,7 +18,7 @@ export type DonationPlatform =
| { short: 'ko-fi'; name: 'Ko-fi' }
| { short: 'other'; name: 'Other' }
-export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader'
+export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'plugin' | 'datapack'
export type MonetizationStatus = 'monetized' | 'demonetized' | 'force-demonetized'
export type GameVersion = string
@@ -65,7 +65,8 @@ export interface Project {
client_side: Environment
server_side: Environment
- team: ModrinthId
+ team?: ModrinthId
+ team_id: ModrinthId
thread_id: ModrinthId
organization: ModrinthId
@@ -76,6 +77,7 @@ export interface Project {
donation_urls: DonationLink[]
published: string
+ created?: string
updated: string
approved: string
queued: string
@@ -295,6 +297,60 @@ export type Report = {
body: string
}
+// Threads
+export interface Thread {
+ id: string
+ type: ThreadType
+ project_id: string | null
+ report_id: string | null
+ messages: ThreadMessage[]
+ members: User[]
+}
+
+export type ThreadType = 'project' | 'report' | 'direct_message'
+
+export interface ThreadMessage {
+ id: string | null
+ author_id: string | null
+ body: MessageBody
+ created: string
+ hide_identity: boolean
+}
+
+export type MessageBody =
+ | TextMessageBody
+ | StatusChangeMessageBody
+ | ThreadClosureMessageBody
+ | ThreadReopenMessageBody
+ | DeletedMessageBody
+
+export interface TextMessageBody {
+ type: 'text'
+ body: string
+ private: boolean
+ replying_to: string | null
+ associated_images: string[]
+}
+
+export interface StatusChangeMessageBody {
+ type: 'status_change'
+ new_status: ProjectStatus
+ old_status: ProjectStatus
+}
+
+export interface ThreadClosureMessageBody {
+ type: 'thread_closure'
+}
+
+export interface ThreadReopenMessageBody {
+ type: 'thread_reopen'
+}
+
+export interface DeletedMessageBody {
+ type: 'deleted'
+ private: boolean
+}
+
// Moderation
export interface ModerationModpackPermissionApprovalType {
id:
@@ -379,3 +435,38 @@ export interface ModerationJudgement {
export interface ModerationJudgements {
[sha1: string]: ModerationJudgement
}
+
+// Delphi
+export interface DelphiReport {
+ id: string
+ project: Project
+ version: Version
+ priority_score: number
+ detected_at: string
+ trace_type:
+ | 'reflection_indirection'
+ | 'xor_obfuscation'
+ | 'included_libraries'
+ | 'suspicious_binaries'
+ | 'corrupt_classes'
+ | 'suspicious_classes'
+ | 'url_usage'
+ | 'classloader_usage'
+ | 'processbuilder_usage'
+ | 'runtime_exec_usage'
+ | 'jni_usage'
+ | 'main_method'
+ | 'native_loading'
+ | 'malformed_jar'
+ | 'nested_jar_too_deep'
+ | 'failed_decompilation'
+ | 'analysis_failure'
+ | 'malware_easyforme'
+ | 'malware_simplyloader'
+ file_path: string
+ // pending = not reviewed yet.
+ // approved = approved as malicious, removed from modrinth
+ // rejected = not approved as malicious, remains on modrinth?
+ status: 'pending' | 'approved' | 'rejected'
+ content?: string
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 631f4c46..968cea07 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -284,6 +284,9 @@ importers:
pinia:
specifier: ^2.1.7
version: 2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
+ pinia-plugin-persistedstate:
+ specifier: ^4.4.1
+ version: 4.4.1(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1))(@pinia/nuxt@0.5.1(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(pinia@2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))
prettier:
specifier: ^3.6.2
version: 3.6.2
@@ -296,6 +299,9 @@ importers:
three:
specifier: ^0.172.0
version: 0.172.0
+ vue-confetti-explosion:
+ specifier: ^1.0.2
+ version: 1.0.2(vue@3.5.13(typescript@5.5.4))
vue-multiselect:
specifier: 3.0.0-alpha.2
version: 3.0.0-alpha.2
@@ -3941,6 +3947,9 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+ deep-pick-omit@1.2.1:
+ resolution: {integrity: sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw==}
+
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@@ -3990,6 +3999,9 @@ packages:
destr@2.0.3:
resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==}
+ destr@2.0.5:
+ resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
+
destroy@1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -6253,6 +6265,20 @@ packages:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
+ pinia-plugin-persistedstate@4.4.1:
+ resolution: {integrity: sha512-lmuMPpXla2zJKjxEq34e1E9P9jxkWEhcVwwioCCE0izG45kkTOvQfCzvwhW3i38cvnaWC7T1eRdkd15Re59ldw==}
+ peerDependencies:
+ '@nuxt/kit': '>=3.0.0'
+ '@pinia/nuxt': '>=0.10.0'
+ pinia: '>=3.0.0'
+ peerDependenciesMeta:
+ '@nuxt/kit':
+ optional: true
+ '@pinia/nuxt':
+ optional: true
+ pinia:
+ optional: true
+
pinia@2.1.7:
resolution: {integrity: sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==}
peerDependencies:
@@ -8089,6 +8115,12 @@ packages:
vue-bundle-renderer@2.1.1:
resolution: {integrity: sha512-+qALLI5cQncuetYOXp4yScwYvqh8c6SMXee3B+M7oTZxOgtESP0l4j/fXdEJoZ+EdMxkGWIj+aSEyjXkOdmd7g==}
+ vue-confetti-explosion@1.0.2:
+ resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ vue: ^3.0.5
+
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -12277,6 +12309,8 @@ snapshots:
deep-is@0.1.4: {}
+ deep-pick-omit@1.2.1: {}
+
deepmerge@4.3.1: {}
default-browser-id@5.0.0: {}
@@ -12314,6 +12348,8 @@ snapshots:
destr@2.0.3: {}
+ destr@2.0.5: {}
+
destroy@1.2.0: {}
detect-libc@1.0.3: {}
@@ -15509,6 +15545,16 @@ snapshots:
pify@4.0.1: {}
+ pinia-plugin-persistedstate@4.4.1(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.28.1))(@pinia/nuxt@0.5.1(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(pinia@2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))):
+ dependencies:
+ deep-pick-omit: 1.2.1
+ defu: 6.1.4
+ destr: 2.0.5
+ optionalDependencies:
+ '@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.28.1)
+ '@pinia/nuxt': 0.5.1(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
+ pinia: 2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
+
pinia@2.1.7(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)):
dependencies:
'@vue/devtools-api': 6.6.4
@@ -17508,6 +17554,10 @@ snapshots:
dependencies:
ufo: 1.5.4
+ vue-confetti-explosion@1.0.2(vue@3.5.13(typescript@5.5.4)):
+ dependencies:
+ vue: 3.5.13(typescript@5.5.4)
+
vue-demi@0.14.10(vue@3.5.13(typescript@5.5.4)):
dependencies:
vue: 3.5.13(typescript@5.5.4)