feat(labrinth): project disclosures (#6945)

* feat(labrinth): project disclosures model

* feat(labrinth): project disclosures database model

* feat(labrinth): project disclosures get endpoint

* feat(labrinth): censor user ids if set by moderator

* feat(labrinth): wrap disclosures in struct

* feat(labrinth): edit project disclosures endpoint

* style(labrinth): cargo fmt

* style(labrinth): fix typo

* feat(labrinth): add fields for ai content disclosure

* fix(labrinth): field typo

* chore(labrinth): update query cache

* feat(labrinth): index disclosures in search

* refactor(labrinth): use enum instead of bools

* feat(labrinth): trigger incremental index on disclosure change

* feat(labrinth): change archived status to disclosure

* feat(labrinth): use disclosure for archival status

* fix(labrinth): error type for deserialization

* fix(labrinth): migration timestamp

* fix(labrinth): add disclosures to elasticsearch schema

* fix(labrinth): don't remove archival disclosure when changing status via v3 api

* feat(labrinth): derive str for ai usages and telemtry consent

* feat(labrinth): include ai usages and telemetry consent in disclosure types

* refactor(labrinth): move disclosure parsing to document creation

* fix(labrinth): prevent removing moderator added archival disclosures

* fix(labrinth): dedupe ai usages

* fix(labrinth): auth logic on archived disclosure removal

* feat(labrinth): add interactions field

* style(labrinth): cargo fmt

* feat(labrinth): soft delete disclosures

* feat(labrinth): return soft-deleted disclosures

* fix(labrinth): update disclosure on removal

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Sychic
2026-08-12 17:26:14 -07:00
committed by GitHub
parent 4b82cca3bf
commit 549b333eb2
39 changed files with 1305 additions and 214 deletions
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET approved = NOW()\n WHERE id = $1 AND approved IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "115232c68c63fefe5f8f9fdaa056ba1029ebf8a7c52db69a297045e515d244f1"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT 1 FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = ANY($2) AND set_by_moderator AND deleted_at IS NULL\n\t\t\tLIMIT 1\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Int8",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "200036925be75644a292282e9638a8c006dcbfae6e06b568872a8fa2cfe90a7e"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "2fd123813896a880bc7a4a4cd195766099a04bda40cec97f0707b9c78c7fafdc"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT project_id, type, metadata\n FROM project_disclosures\n WHERE project_id = ANY($1) AND deleted_at IS NULL\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "type",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "metadata",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "398cd48fc641dc32a126232835cf3be21abbde6bd5dfe4ec064b471f6959f0c0"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator)\n\t\t\tVALUES ($1, $2, $3, $4, $5)\n\t\t\tON CONFLICT (project_id, type) DO UPDATE SET\n\t\t\t\tmetadata = $3,\n\t\t\t\tupdated_at = now(),\n\t\t\t\tupdated_by = $4,\n\t\t\t\tset_by_moderator = $5,\n\t\t\t\tdeleted_at = NULL\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Jsonb",
"Int8",
"Bool"
]
},
"nullable": []
},
"hash": "412be939314ecfe4de9a0725e5170699b86e431e6a500c635d37da8cd16022a1"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT project_id, type, metadata\n FROM project_disclosures\n WHERE project_id = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "type",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "metadata",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "4ccad9e07ae61ba87b1784b6ee9a136ddbe802cd3a4c649e4982d9f28ae097ed"
}
@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, set_by_moderator, deleted_at\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1 AND ($2 OR deleted_at IS NULL)\n\t\t\tORDER BY updated_at DESC\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "disclosure_type!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "metadata",
"type_info": "Jsonb"
},
{
"ordinal": 3,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "updated_by",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "set_by_moderator",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "deleted_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8",
"Bool"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "588bb489a5b8c7231a86d53906c2c632d85f6d595210cfaba364bb537c9e8442"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT 1 FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = ANY($2) AND set_by_moderator\n\t\t\tLIMIT 1\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Int8",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "763762b9995a6d7b00b6cf71c7661a8dff02ed316fc0b98754605a6e16e8e846"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT project_id\n\t\t\tFROM project_disclosures\n\t\t\tWHERE type = $1 AND project_id = ANY($2)\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8Array"
]
},
"nullable": [
false
]
},
"hash": "87d4778e52ac3e9fd885f9c77595705f8efc09ac21e0cf9a15c4cce019aaeb05"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET approved = NOW()\n WHERE id = $1 AND approved IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT project_id\n\t\t\tFROM project_disclosures\n\t\t\tWHERE type = $1 AND project_id = ANY($2) AND deleted_at IS NULL\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8Array"
]
},
"nullable": [
false
]
},
"hash": "964f1acc9dfc7b0c6b2ce6adec3c535c77a6e6e98d13c4543f503cf8a86a743c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n ",
"query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n ",
"describe": {
"columns": [
{
@@ -18,5 +18,5 @@
false
]
},
"hash": "d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b"
"hash": "9e5170dddcd089edd4a5c89ec8929f0bdf6f6c2c56d944e7af3b2b13d9d95f3d"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tDELETE FROM project_disclosures\n\t\t\tWHERE project_id = $1 AND type = $2\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "9eea063cb153da407548f957cef7da1a33a059b0a1d00b51e27b4a5399492b13"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET status = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tINSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator)\n\t\t\tVALUES ($1, $2, $3, $4, $5)\n\t\t\tON CONFLICT (project_id, type) DO UPDATE SET\n\t\t\t\tmetadata = $3,\n\t\t\t\tupdated_at = now(),\n\t\t\t\tupdated_by = $4,\n\t\t\t\tset_by_moderator = $5\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Jsonb",
"Int8",
"Bool"
]
},
"nullable": []
},
"hash": "b6b9b0cd345eddb4c4a9daf701dd0d2e5a708e59b897961f00164a28ab6294a4"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET status = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "c33d32e876b003a82dcf0036ef483924a801d01b426e93050600ae678e2e46bd"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tUPDATE project_disclosures\n\t\t\tSET deleted_at = now(), updated_at = now(), updated_by = $3, set_by_moderator = $4\n\t\t\tWHERE project_id = $1 AND type = $2 AND deleted_at IS NULL\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Int8",
"Bool"
]
},
"nullable": []
},
"hash": "ca976dee555cef5affd47c5ca69179c9862e760eb5ebb3951d0546f59e46a2fb"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT project_id, type AS \"disclosure_type!\", metadata, updated_at, updated_by, set_by_moderator\n\t\t\tFROM project_disclosures\n\t\t\tWHERE project_id = $1\n\t\t\tORDER BY updated_at DESC\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "project_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "disclosure_type!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "metadata",
"type_info": "Jsonb"
},
{
"ordinal": 3,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "updated_by",
"type_info": "Int8"
},
{
"ordinal": 5,
"name": "set_by_moderator",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false
]
},
"hash": "ef602037ceb20dbce5313e4a90c16fb59fbbe0adf002d21fc40ca0ddd9d6ab10"
}
@@ -0,0 +1,9 @@
CREATE TABLE project_disclosures (
project_id BIGINT NOT NULL REFERENCES mods(id) ON DELETE CASCADE,
type TEXT NOT NULL,
metadata JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
set_by_moderator BOOLEAN NOT NULL,
PRIMARY KEY (project_id, type)
);
@@ -0,0 +1,9 @@
INSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator)
SELECT id, 'archived', '{"note": null}'::jsonb, 0, FALSE
FROM mods
WHERE status = 'archived'
ON CONFLICT (project_id, type) DO NOTHING;
UPDATE mods
SET status = 'approved'
WHERE status = 'archived';
@@ -0,0 +1,2 @@
ALTER TABLE project_disclosures
ADD COLUMN deleted_at TIMESTAMPTZ;
+2
View File
@@ -30,6 +30,7 @@ pub mod payout_item;
pub mod payouts_values_notifications;
pub mod product_item;
pub mod products_tax_identifier_item;
pub mod project_disclosure_item;
pub mod project_item;
pub mod report_item;
pub mod session_item;
@@ -53,6 +54,7 @@ pub use image_item::DBImage;
pub use oauth_client_item::DBOAuthClient;
pub use organization_item::DBOrganization;
pub use passkey_item::DBPasskey;
pub use project_disclosure_item::DBProjectDisclosure;
pub use project_item::DBProject;
pub use team_item::DBTeam;
pub use team_item::DBTeamMember;
@@ -0,0 +1,161 @@
use std::collections::HashSet;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{
database::models::{DBProjectId, DBUserId, DatabaseError},
models::v3::disclosures::{ProjectDisclosure, ProjectDisclosureType},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DBProjectDisclosure {
pub project_id: DBProjectId,
pub disclosure: ProjectDisclosure,
pub updated_at: DateTime<Utc>,
pub updated_by: DBUserId,
pub set_by_moderator: bool,
pub deleted_at: Option<DateTime<Utc>>,
}
impl DBProjectDisclosure {
pub async fn upsert(
&self,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<(), DatabaseError> {
let (disclosure_type, metadata) =
self.disclosure.to_parts().map_err(|e| {
DatabaseError::Internal(eyre::Report::new(e).wrap_err(
"failed to serialize project disclosure metadata",
))
})?;
sqlx::query!(
r#"
INSERT INTO project_disclosures (project_id, type, metadata, updated_by, set_by_moderator)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, type) DO UPDATE SET
metadata = $3,
updated_at = now(),
updated_by = $4,
set_by_moderator = $5,
deleted_at = NULL
"#,
self.project_id as DBProjectId,
disclosure_type,
metadata,
self.updated_by as DBUserId,
self.set_by_moderator,
)
.execute(exec)
.await?;
Ok(())
}
pub async fn get_many_for_project(
project_id: DBProjectId,
include_deleted: bool,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<Vec<DBProjectDisclosure>, DatabaseError> {
let rows = sqlx::query!(
r#"
SELECT project_id, type AS "disclosure_type!", metadata, updated_at, updated_by, set_by_moderator, deleted_at
FROM project_disclosures
WHERE project_id = $1 AND ($2 OR deleted_at IS NULL)
ORDER BY updated_at DESC
"#,
project_id as DBProjectId,
include_deleted,
)
.fetch_all(exec)
.await?;
rows.into_iter()
.map(|row| {
Ok(DBProjectDisclosure {
project_id: DBProjectId(row.project_id),
disclosure: ProjectDisclosure::from_parts(
&row.disclosure_type,
row.metadata,
)
.map_err(|e| {
DatabaseError::Internal(eyre::Report::new(e).wrap_err(
"failed to deserialize project disclosure metadata",
))
})?,
updated_at: row.updated_at,
updated_by: DBUserId(row.updated_by),
set_by_moderator: row.set_by_moderator,
deleted_at: row.deleted_at,
})
})
.collect()
}
/// Returns the subset of `project_ids` that carry a disclosure of the given type.
pub async fn projects_with_type(
disclosure_type: ProjectDisclosureType,
project_ids: &[DBProjectId],
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<HashSet<DBProjectId>, DatabaseError> {
let ids = project_ids.iter().map(|id| id.0).collect::<Vec<_>>();
let rows = sqlx::query_scalar!(
r#"
SELECT project_id
FROM project_disclosures
WHERE type = $1 AND project_id = ANY($2) AND deleted_at IS NULL
"#,
<&'static str>::from(disclosure_type),
&ids,
)
.fetch_all(exec)
.await?;
Ok(rows.into_iter().map(DBProjectId).collect())
}
pub async fn any_set_by_moderator(
project_id: DBProjectId,
types: &[String],
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, DatabaseError> {
let existing = sqlx::query_scalar!(
r#"
SELECT 1 FROM project_disclosures
WHERE project_id = $1 AND type = ANY($2) AND set_by_moderator AND deleted_at IS NULL
LIMIT 1
"#,
project_id as DBProjectId,
types,
)
.fetch_optional(exec)
.await?;
Ok(existing.is_some())
}
pub async fn remove(
project_id: DBProjectId,
disclosure_type: ProjectDisclosureType,
updated_by: DBUserId,
set_by_moderator: bool,
exec: impl crate::database::Executor<'_, Database = sqlx::Postgres>,
) -> Result<bool, DatabaseError> {
let result = sqlx::query!(
r#"
UPDATE project_disclosures
SET deleted_at = now(), updated_at = now(), updated_by = $3, set_by_moderator = $4
WHERE project_id = $1 AND type = $2 AND deleted_at IS NULL
"#,
project_id as DBProjectId,
<&'static str>::from(disclosure_type),
updated_by as DBUserId,
set_by_moderator,
)
.execute(exec)
.await?;
Ok(result.rows_affected() > 0)
}
}
+1
View File
@@ -6,6 +6,7 @@ pub mod v3;
pub use v3::analytics;
pub use v3::billing;
pub use v3::collections;
pub use v3::disclosures;
pub use v3::ids;
pub use v3::images;
pub use v3::moderation_notes;
+26 -9
View File
@@ -3,7 +3,10 @@ use std::convert::TryFrom;
use std::collections::HashMap;
use super::super::ids::OrganizationId;
use crate::database::models::{DatabaseError, version_item};
use crate::database::models::{
DBProjectDisclosure, DBProjectId, DatabaseError, version_item,
};
use crate::models::disclosures::ProjectDisclosureType;
use crate::models::ids::{ProjectId, TeamId, ThreadId, VersionId};
use crate::models::projects::{
Dependency, License, Link, Loader, ModeratorMessage, MonetizationStatus,
@@ -238,28 +241,42 @@ impl LegacyProject {
}
// Because from needs a version_item, this is a helper function to get many from one db query.
pub async fn from_many<'a, E>(
pub async fn from_many(
data: Vec<Project>,
exec: E,
pool: &crate::database::PgPool,
redis: &RedisPool,
) -> Result<Vec<Self>, DatabaseError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
) -> Result<Vec<Self>, DatabaseError> {
let version_ids: Vec<_> = data
.iter()
.filter_map(|p| p.versions.first().map(|i| (*i).into()))
.collect();
let project_ids: Vec<DBProjectId> =
data.iter().map(|p| p.id.into()).collect();
let example_versions =
version_item::DBVersion::get_many(&version_ids, exec, redis)
version_item::DBVersion::get_many(&version_ids, pool, redis)
.await?;
let archived_disclosure_ids = DBProjectDisclosure::projects_with_type(
ProjectDisclosureType::Archived,
&project_ids,
pool,
)
.await?;
let mut legacy_projects = Vec::new();
for project in data {
let version_item = example_versions
.iter()
.find(|v| v.inner.project_id == project.id.into())
.cloned();
let project = LegacyProject::from(project, version_item);
let has_archived_disclosure =
archived_disclosure_ids.contains(&project.id.into());
let mut project = LegacyProject::from(project, version_item);
if has_archived_disclosure
&& project.status == ProjectStatus::Approved
{
project.status = ProjectStatus::Archived;
}
legacy_projects.push(project);
}
Ok(legacy_projects)
+145
View File
@@ -0,0 +1,145 @@
use crate::database::models::DBProjectDisclosure;
use ariadne::ids::UserId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use strum::{EnumDiscriminants, EnumString, IntoStaticStr};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, EnumDiscriminants)]
#[serde(rename_all = "snake_case", tag = "type")]
#[strum_discriminants(
name(ProjectDisclosureType),
derive(IntoStaticStr, EnumString),
strum(serialize_all = "snake_case")
)]
pub enum ProjectDisclosure {
AiContent {
note: Option<String>,
uses: BTreeSet<AiUsages>,
},
Advertisements {
note: Option<String>,
},
EpilepsyTriggers {
note: Option<String>,
},
SystemInteractions {
note: Option<String>,
interactions: Vec<String>,
},
Telemetry {
consent: TelemetryConsent,
data_collected: Vec<String>,
},
DerivativeWork {
sources: Vec<DerivativeSource>,
},
PaidFeatures {
features: Vec<String>,
},
Archived {
note: Option<String>,
},
}
impl ProjectDisclosure {
pub fn to_parts(
&self,
) -> Result<(&'static str, serde_json::Value), serde_json::Error> {
let serde_json::Value::Object(mut object) = serde_json::to_value(self)?
else {
return Err(serde::ser::Error::custom(
"project disclosure must serialize to a JSON object",
));
};
object.remove("type");
Ok((
ProjectDisclosureType::from(self).into(),
serde_json::Value::Object(object),
))
}
pub fn from_parts(
kind: &str,
metadata: serde_json::Value,
) -> Result<Self, serde_json::Error> {
let serde_json::Value::Object(mut object) = metadata else {
return Err(serde::de::Error::custom(
"project disclosure metadata must be a JSON object",
));
};
object.insert(
"type".to_owned(),
serde_json::Value::String(kind.to_owned()),
);
serde_json::from_value(serde_json::Value::Object(object))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProjectDisclosureData {
#[serde(flatten)]
pub disclosure: ProjectDisclosure,
pub set_by_moderator: bool,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_by: Option<UserId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deleted_at: Option<DateTime<Utc>>,
}
impl ProjectDisclosureData {
pub fn from_db(
value: DBProjectDisclosure,
viewer_is_moderator: bool,
) -> Self {
let updated_by = (!value.set_by_moderator || viewer_is_moderator)
.then_some(value.updated_by.into());
Self {
disclosure: value.disclosure,
set_by_moderator: value.set_by_moderator,
updated_at: value.updated_at,
updated_by,
deleted_at: value.deleted_at,
}
}
}
#[derive(
Debug,
Clone,
Serialize,
Deserialize,
ToSchema,
IntoStaticStr,
PartialEq,
Eq,
PartialOrd,
Ord,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum AiUsages {
Code,
Assets,
Text,
Functionality,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoStaticStr)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum TelemetryConsent {
OptIn,
OptOut,
AlwaysActive,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DerivativeSource {
pub link: Option<String>,
pub label: String,
pub note: Option<String>,
}
+1
View File
@@ -3,6 +3,7 @@ pub mod analytics;
pub mod analytics_event;
pub mod billing;
pub mod collections;
pub mod disclosures;
pub mod ids;
pub mod images;
pub mod moderation_notes;
+3 -1
View File
@@ -570,11 +570,13 @@ impl ProjectStatus {
pub fn can_be_requested(&self) -> bool {
match self {
ProjectStatus::Approved => true,
ProjectStatus::Archived => true,
ProjectStatus::Unlisted => true,
ProjectStatus::Private => true,
ProjectStatus::Draft => true,
// `archived` is represented by a disclosure, not a status, so it
// can no longer be requested or set as a status.
ProjectStatus::Archived => false,
ProjectStatus::Rejected => false,
ProjectStatus::Processing => false,
ProjectStatus::Unknown => false,
+19 -4
View File
@@ -1,7 +1,9 @@
use crate::database::models as db_models;
use crate::database::models::categories::LinkPlatform;
use crate::database::models::{project_item, version_item};
use crate::database::{PgPool, ReadOnlyPgPool};
use crate::file_hosting::FileHost;
use crate::models::disclosures::ProjectDisclosureType;
use crate::models::projects::{
Link, MonetizationStatus, Project, ProjectStatus, Version,
};
@@ -206,7 +208,7 @@ pub async fn random_projects_get(
match v2_reroute::extract_ok_json::<Vec<Project>>(response).await {
Ok(project) => {
let legacy_projects =
LegacyProject::from_many(project, &**pool, &redis)
LegacyProject::from_many(project, &pool, &redis)
.await
.wrap_internal_err(
"executing `LegacyProject::from_many`",
@@ -252,7 +254,7 @@ pub async fn projects_get(
match v2_reroute::extract_ok_json::<Vec<Project>>(response).await {
Ok(project) => {
let legacy_projects =
LegacyProject::from_many(project, &**pool, &redis)
LegacyProject::from_many(project, &pool, &redis)
.await
.wrap_internal_err(
"executing `LegacyProject::from_many`",
@@ -315,7 +317,19 @@ pub async fn project_get(
}
None => None,
};
let project = LegacyProject::from(project, version_item);
let has_archived_disclosure =
db_models::DBProjectDisclosure::projects_with_type(
ProjectDisclosureType::Archived,
&[project.id.into()],
&**pool,
)
.await
.wrap_internal_err("fetching archival disclosure status")?
.contains(&project.id.into());
let mut project = LegacyProject::from(project, version_item);
if has_archived_disclosure && project.status == ProjectStatus::Approved {
project.status = ProjectStatus::Archived;
}
Ok(HttpResponse::Ok().json(project))
}
@@ -402,7 +416,7 @@ pub async fn dependency_list(
Ok(dependency_info) => {
let converted_projects = LegacyProject::from_many(
dependency_info.projects,
&**pool,
&pool,
&redis,
)
.await
@@ -671,6 +685,7 @@ pub async fn project_edit(
redis.clone(),
session_queue.clone(),
search_state.clone(),
true,
)
.await
.or_else(v2_reroute::flatten_404_error)
+2 -2
View File
@@ -193,7 +193,7 @@ pub async fn projects_list(
match v2_reroute::extract_ok_json::<Vec<Project>>(response).await {
Ok(project) => {
let legacy_projects =
LegacyProject::from_many(project, &**pool, &redis)
LegacyProject::from_many(project, &pool, &redis)
.await
.wrap_internal_err(
"executing `LegacyProject::from_many`",
@@ -465,7 +465,7 @@ pub async fn user_follows(
match v2_reroute::extract_ok_json::<Vec<Project>>(response).await {
Ok(project) => {
let legacy_projects =
LegacyProject::from_many(project, &**pool, &redis)
LegacyProject::from_many(project, &pool, &redis)
.await
.wrap_internal_err(
"executing `LegacyProject::from_many`",
+1 -1
View File
@@ -364,7 +364,7 @@ pub async fn get_projects_from_hashes(
.collect::<HashMap<_, _>>();
let legacy_projects = LegacyProject::from_many(
projects_hashes.into_values().collect(),
&**pool,
&pool,
&redis,
)
.await
+244
View File
@@ -0,0 +1,244 @@
use actix_web::{HttpRequest, get, patch, web};
use chrono::Utc;
use eyre::eyre;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use xredis::RedisPool;
use crate::auth::checks::{is_team_member_project, is_visible_project};
use crate::auth::get_user_from_headers;
use crate::database::{DBProject, models as db_models};
use crate::database::{PgPool, ReadOnlyPgPool};
use crate::models::disclosures::{
ProjectDisclosure, ProjectDisclosureData, ProjectDisclosureType,
};
use crate::models::pats::Scopes;
use crate::models::teams::ProjectPermissions;
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use crate::search::SearchState;
use crate::util::error::Context;
use std::str::FromStr;
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(get_project_disclosures)
.service(modify_project_disclosures);
}
#[derive(Serialize, ToSchema)]
pub struct GetProjectDisclosures {
pub disclosures: Vec<ProjectDisclosureData>,
}
#[utoipa::path(
context_path = "/project",
tag = "project_disclosures",
responses((status = OK, body = GetProjectDisclosures), (status = BAD_REQUEST, description = "Invalid request input"))
)]
#[get("/{project_id}/disclosures")]
pub async fn get_project_disclosures(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
ro_pool: web::Data<ReadOnlyPgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<GetProjectDisclosures>, ApiError> {
let (string,) = info.into_inner();
let project = db_models::DBProject::get(&string, &***ro_pool, &redis)
.await
.wrap_internal_err("failed to fetch project")?
.wrap_not_found_err("resource not found")?;
let user_option = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await
.map(|(_, user)| user)
.ok();
if !is_visible_project(&project.inner, &user_option, &pool, false)
.await
.wrap_internal_err("failed to check project visibility")?
{
return Err(ApiError::NotFound(eyre!("resource not found")));
}
let viewer_is_moderator =
user_option.as_ref().is_some_and(|user| user.role.is_mod());
let include_deleted = viewer_is_moderator
|| is_team_member_project(&project.inner, &user_option, &pool)
.await
.wrap_internal_err("failed to check project team membership")?;
let disclosures = db_models::DBProjectDisclosure::get_many_for_project(
project.inner.id,
include_deleted,
&***ro_pool,
)
.await
.wrap_internal_err("failed to fetch project disclosures")?;
Ok(web::Json(GetProjectDisclosures {
disclosures: disclosures
.into_iter()
.map(|disclosure| {
ProjectDisclosureData::from_db(disclosure, viewer_is_moderator)
})
.collect(),
}))
}
#[derive(Deserialize, ToSchema)]
pub struct ModifyProjectDisclosures {
pub set: Vec<ProjectDisclosure>,
pub remove: Vec<String>,
}
#[utoipa::path(
context_path = "/project",
tag = "project_disclosures",
request_body = ModifyProjectDisclosures,
responses((status = NO_CONTENT))
)]
#[patch("/{project_id}/disclosures")]
pub async fn modify_project_disclosures(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
search_state: web::Data<SearchState>,
session_queue: web::Data<AuthQueue>,
body: web::Json<ModifyProjectDisclosures>,
) -> Result<(), ApiError> {
let (string,) = info.into_inner();
let body = body.into_inner();
if body.set.is_empty() && body.remove.is_empty() {
return Err(ApiError::Request(eyre!(
"must provide at least one disclosure to set or remove"
)));
}
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_WRITE,
)
.await
.wrap_auth_err("authenticating API request")?
.1;
let project = db_models::DBProject::get(&string, &**pool, &redis)
.await
.wrap_internal_err("failed to fetch project")?
.wrap_not_found_err("resource not found")?;
let (team_member, organization_team_member) =
db_models::DBTeamMember::get_for_project_permissions(
&project.inner,
user.id.into(),
&**pool,
)
.await
.wrap_internal_err("failed to fetch project permissions")?;
let can_edit_details = ProjectPermissions::get_permissions_by_role(
&user.role,
&team_member,
&organization_team_member,
)
.is_some_and(|perms| perms.contains(ProjectPermissions::EDIT_DETAILS));
if !can_edit_details {
return Err(ApiError::Auth(eyre!(
"you do not have permission to edit this project's disclosures"
)));
}
if !user.role.is_mod() {
let modified_types = body
.set
.iter()
.map(|disclosure| {
<&'static str>::from(ProjectDisclosureType::from(disclosure))
.to_owned()
})
.chain(body.remove.iter().cloned())
.collect::<Vec<_>>();
if db_models::DBProjectDisclosure::any_set_by_moderator(
project.inner.id,
&modified_types,
&**pool,
)
.await
.wrap_internal_err("failed to check moderator disclosures")?
{
return Err(ApiError::Auth(eyre!(
"you cannot modify a disclosure set by a moderator"
)));
}
}
let mut transaction = pool
.begin()
.await
.wrap_internal_err("starting database transaction")?;
for disclosure in body.set {
db_models::DBProjectDisclosure {
project_id: project.inner.id,
disclosure,
updated_at: Utc::now(),
updated_by: user.id.into(),
set_by_moderator: user.role.is_mod(),
deleted_at: None,
}
.upsert(&mut transaction)
.await
.wrap_internal_err("failed to upsert project disclosure")?;
}
for disclosure_type in &body.remove {
let disclosure_type = ProjectDisclosureType::from_str(disclosure_type)
.map_err(|_| {
ApiError::Request(eyre!(
"unknown disclosure type `{disclosure_type}`"
))
})?;
db_models::DBProjectDisclosure::remove(
project.inner.id,
disclosure_type,
user.id.into(),
user.role.is_mod(),
&mut transaction,
)
.await
.wrap_internal_err("failed to remove project disclosure")?;
}
transaction
.commit()
.await
.wrap_internal_err("failed to commit project disclosure changes")?;
DBProject::clear_cache(project.inner.id, project.inner.slug, None, &redis)
.await
.wrap_internal_err("clearing cached data from Redis")?;
search_state
.queue
.push_project_change(project.inner.id.into())
.await;
Ok(())
}
+5 -1
View File
@@ -9,6 +9,7 @@ pub mod analytics_get;
pub mod blocked_users;
pub mod collections;
pub mod content;
pub mod disclosures;
pub mod friends;
pub mod images;
pub mod limits;
@@ -46,7 +47,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
web::scope("/v3/project")
.wrap(default_cors())
.configure(projects::project_config)
.configure(project_creation::config),
.configure(project_creation::config)
.configure(disclosures::config),
);
cfg.service(
web::scope("/v3")
@@ -111,6 +113,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
projects::project_unfollow,
projects::project_get_organization,
projects::dependency_list,
disclosures::get_project_disclosures,
disclosures::modify_project_disclosures,
project_creation::project_create,
project_creation::project_create_with_id,
project_creation::new::create,
+209 -151
View File
@@ -15,6 +15,7 @@ use crate::database::{self, models as db_models};
use crate::database::{PgPool, PgTransaction, ReadOnlyPgPool};
use crate::env::ENV;
use crate::file_hosting::{FileHost, FileHostPublicity};
use crate::models::disclosures::{ProjectDisclosure, ProjectDisclosureType};
use crate::models::ids::{ProjectId, VersionId};
use crate::models::images::ImageContext;
use crate::models::notifications::NotificationBody;
@@ -376,6 +377,7 @@ pub async fn project_edit(
redis,
session_queue,
search_state,
false,
)
.await
}
@@ -388,6 +390,7 @@ pub async fn project_edit_internal(
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
search_state: web::Data<SearchState>,
sync_archival_disclosure: bool,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
@@ -488,105 +491,141 @@ pub async fn project_edit_internal(
)));
}
if !(user.role.is_mod()
|| !project_item.inner.status.is_approved()
&& status == &ProjectStatus::Processing
|| project_item.inner.status.is_approved()
&& status.can_be_requested())
{
return Err(ApiError::Auth(eyre::eyre!(
"You don't have permission to set this status!",
)));
}
let archival_disclosure =
db_models::DBProjectDisclosure::get_many_for_project(
project_item.inner.id,
false,
&mut transaction,
)
.await
.wrap_internal_err("failed to fetch project disclosures")?
.into_iter()
.find(|disclosure| {
matches!(
disclosure.disclosure,
ProjectDisclosure::Archived { .. }
)
});
let has_archived_disclosure = archival_disclosure.is_some();
// If a moderator (non-admin) is completing a review while another moderator holds an
// active checklist lock, block them from changing the project status.
if user.role.is_mod()
&& !user.role.is_admin()
&& project_item.inner.status == ProjectStatus::Processing
&& status != &ProjectStatus::Processing
&& let Some(lock) =
DBModerationLock::get_with_user(project_item.inner.id, &pool)
.await
.wrap_internal_err(
"fetching moderation lock from database",
)?
&& lock.moderator_id != db_ids::DBUserId::from(user.id)
&& !lock.expired
{
return Err(ApiError::Auth(eyre::eyre!(format!(
"This project is currently being moderated by @{}. Please wait for them to finish or for the lock to expire.",
lock.moderator_username
))));
}
if status == &ProjectStatus::Archived {
if !has_archived_disclosure {
db_models::DBProjectDisclosure {
project_id: project_item.inner.id,
disclosure: ProjectDisclosure::Archived { note: None },
updated_at: Utc::now(),
updated_by: user.id.into(),
set_by_moderator: user.role.is_mod(),
deleted_at: None,
}
.upsert(&mut transaction)
.await
.wrap_internal_err("failed to upsert archival disclosure")?;
}
} else {
if !(user.role.is_mod()
|| !project_item.inner.status.is_approved()
&& status == &ProjectStatus::Processing
|| project_item.inner.status.is_approved()
&& status.can_be_requested())
{
return Err(ApiError::Auth(eyre::eyre!(
"You don't have permission to set this status!",
)));
}
if status == &ProjectStatus::Processing {
if project_item.versions.is_empty() {
return Err(ApiError::Request(eyre::eyre!(String::from(
"Project submitted for review with no initial versions",
// If a moderator (non-admin) is completing a review while another moderator holds an
// active checklist lock, block them from changing the project status.
if user.role.is_mod()
&& !user.role.is_admin()
&& project_item.inner.status == ProjectStatus::Processing
&& status != &ProjectStatus::Processing
&& let Some(lock) = DBModerationLock::get_with_user(
project_item.inner.id,
&pool,
)
.await
.wrap_internal_err("fetching moderation lock from database")?
&& lock.moderator_id != db_ids::DBUserId::from(user.id)
&& !lock.expired
{
return Err(ApiError::Auth(eyre::eyre!(format!(
"This project is currently being moderated by @{}. Please wait for them to finish or for the lock to expire.",
lock.moderator_username
))));
}
sqlx::query!(
"
UPDATE mods
SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()
WHERE (id = $1)
",
id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await.wrap_internal_err("querying database for `project_edit_internal`")?;
}
if status == &ProjectStatus::Processing {
if project_item.versions.is_empty() {
return Err(ApiError::Request(eyre::eyre!(String::from(
"Project submitted for review with no initial versions",
))));
}
if status.is_approved() && !project_item.inner.status.is_approved() {
sqlx::query!(
"
UPDATE mods
SET approved = NOW()
WHERE id = $1 AND approved IS NULL
",
id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err(
"querying database for `project_edit_internal`",
)?;
}
sqlx::query!(
"
UPDATE mods
SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()
WHERE (id = $1)
",
id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err(
"querying database for `project_edit_internal`",
)?;
}
if status.is_searchable()
&& !project_item.inner.webhook_sent
&& !ENV.PUBLIC_DISCORD_WEBHOOK.is_empty()
&& project_item.inner.components.minecraft_server.is_none()
{
crate::util::webhook::send_discord_webhook(
project_item.inner.id.into(),
&pool,
&redis,
&ENV.PUBLIC_DISCORD_WEBHOOK,
None,
)
.await
.ok();
if status.is_approved() && !project_item.inner.status.is_approved()
{
sqlx::query!(
"
UPDATE mods
SET approved = NOW()
WHERE id = $1 AND approved IS NULL
",
id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err(
"querying database for `project_edit_internal`",
)?;
}
sqlx::query!(
"
if status.is_searchable()
&& !project_item.inner.webhook_sent
&& !ENV.PUBLIC_DISCORD_WEBHOOK.is_empty()
&& project_item.inner.components.minecraft_server.is_none()
{
crate::util::webhook::send_discord_webhook(
project_item.inner.id.into(),
&pool,
&redis,
&ENV.PUBLIC_DISCORD_WEBHOOK,
None,
)
.await
.ok();
sqlx::query!(
"
UPDATE mods
SET webhook_sent = TRUE
WHERE id = $1
",
id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err(
"querying database for `project_edit_internal`",
)?;
}
id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err(
"querying database for `project_edit_internal`",
)?;
}
if user.role.is_mod() && !ENV.MODERATION_SLACK_WEBHOOK.is_empty() {
crate::util::webhook::send_slack_project_webhook(
if user.role.is_mod() && !ENV.MODERATION_SLACK_WEBHOOK.is_empty() {
crate::util::webhook::send_slack_project_webhook(
project_item.inner.id.into(),
&pool,
&redis,
@@ -605,83 +644,102 @@ pub async fn project_edit_internal(
)
.await
.ok();
}
if team_member.is_none_or(|x| !x.accepted) {
let notified_members = sqlx::query!(
"
SELECT tm.user_id id
FROM team_members tm
WHERE tm.team_id = $1 AND tm.accepted
",
project_item.inner.team_id as db_ids::DBTeamId
)
.fetch(&mut transaction)
.map_ok(|c| db_models::DBUserId(c.id))
.try_collect::<Vec<_>>()
.await
.wrap_internal_err("fetching notified members from database")?;
NotificationBuilder {
body: NotificationBody::StatusChange {
project_id: project_item.inner.id.into(),
old_status: project_item.inner.status,
new_status: *status,
},
}
.insert_many(notified_members.clone(), &mut transaction, &redis)
.await
.wrap_internal_err(
"inserting database records for `project_edit_internal`",
)?;
NotificationBuilder {
body: if status.is_approved() {
NotificationBody::ProjectStatusApproved {
project_id: project_item.inner.id.into(),
}
} else {
NotificationBody::ProjectStatusNeutral {
if team_member.is_none_or(|x| !x.accepted) {
let notified_members = sqlx::query!(
"
SELECT tm.user_id id
FROM team_members tm
WHERE tm.team_id = $1 AND tm.accepted
",
project_item.inner.team_id as db_ids::DBTeamId
)
.fetch(&mut transaction)
.map_ok(|c| db_models::DBUserId(c.id))
.try_collect::<Vec<_>>()
.await
.wrap_internal_err("fetching notified members from database")?;
NotificationBuilder {
body: NotificationBody::StatusChange {
project_id: project_item.inner.id.into(),
old_status: project_item.inner.status,
new_status: *status,
}
},
},
}
.insert_many(notified_members.clone(), &mut transaction, &redis)
.await
.wrap_internal_err(
"inserting database records for `project_edit_internal`",
)?;
NotificationBuilder {
body: if status.is_approved() {
NotificationBody::ProjectStatusApproved {
project_id: project_item.inner.id.into(),
}
} else {
NotificationBody::ProjectStatusNeutral {
project_id: project_item.inner.id.into(),
old_status: project_item.inner.status,
new_status: *status,
}
},
}
.insert_many(notified_members, &mut transaction, &redis)
.await
.wrap_internal_err(
"inserting database records for `project_edit_internal`",
)?;
}
.insert_many(notified_members, &mut transaction, &redis)
ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: MessageBody::StatusChange {
new_status: *status,
old_status: project_item.inner.status,
},
thread_id: project_item.thread_id,
hide_identity: user.role.is_mod(),
}
.insert(&mut transaction)
.await
.wrap_internal_err(
"inserting database records for `project_edit_internal`",
)?;
}
ThreadMessageBuilder {
author_id: Some(user.id.into()),
body: MessageBody::StatusChange {
new_status: *status,
old_status: project_item.inner.status,
},
thread_id: project_item.thread_id,
hide_identity: user.role.is_mod(),
}
.insert(&mut transaction)
.await
.wrap_internal_err(
"inserting database records for `project_edit_internal`",
)?;
sqlx::query!(
"
UPDATE mods
SET status = $1
WHERE (id = $2)
",
status.as_str(),
id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err(
"querying database for `project_edit_internal`",
)?;
sqlx::query!(
"
UPDATE mods
SET status = $1
WHERE (id = $2)
",
status.as_str(),
id as db_ids::DBProjectId,
)
.execute(&mut transaction)
.await
.wrap_internal_err("querying database for `project_edit_internal`")?;
if sync_archival_disclosure
&& archival_disclosure.is_some_and(|disclosure| {
user.role.is_mod() || !disclosure.set_by_moderator
})
{
db_models::DBProjectDisclosure::remove(
project_item.inner.id,
ProjectDisclosureType::Archived,
user.id.into(),
user.role.is_mod(),
&mut transaction,
)
.await
.wrap_internal_err("failed to remove archival disclosure")?;
}
}
}
if let Some(requested_status) = &new_project.requested_status {
@@ -688,6 +688,7 @@ impl Elasticsearch {
"compatible_dependency_project_ids": {
"type": "keyword"
},
"disclosure_types": {"type": "keyword"},
"required_dependency_project_ids": {"type": "keyword"},
"optional_dependency_project_ids": {"type": "keyword"},
"embedded_dependency_project_ids": {"type": "keyword"},
@@ -715,6 +715,14 @@ impl SearchField {
optional: true,
token_separators: &["-"],
},
SearchField::DisclosureTypes => TypesenseFieldSpec {
path: "disclosure_types",
ty: "string[]",
facet: true,
sort: false,
optional: true,
token_separators: &["_"],
},
SearchField::RequiredDependencyProjectIds => TypesenseFieldSpec {
path: "required_dependency_project_ids",
ty: "string[]",
+67
View File
@@ -18,6 +18,7 @@ use crate::database::models::{
DBOrganizationId, DBProjectId, DBUserId, DBVersionId, LoaderFieldEnumId,
LoaderFieldEnumValueId, LoaderFieldId,
};
use crate::models::disclosures::ProjectDisclosure;
use crate::models::exp;
use crate::models::ids::{ProjectId, VersionId};
use crate::models::projects::{DependencyType, from_duplicate_version_fields};
@@ -316,6 +317,32 @@ async fn build_search_documents(
)
.await?;
info!("Indexing local disclosures!");
let project_disclosures: DashMap<
DBProjectId,
Vec<(String, serde_json::Value)>,
> = sqlx::query!(
"
SELECT project_id, type, metadata
FROM project_disclosures
WHERE project_id = ANY($1) AND deleted_at IS NULL
",
&*project_ids,
)
.fetch(pool)
.try_fold(
DashMap::new(),
|acc: DashMap<DBProjectId, Vec<(String, serde_json::Value)>>, m| {
acc.entry(DBProjectId(m.project_id))
.or_default()
.push((m.r#type, m.metadata));
async move { Ok(acc) }
},
)
.await
.wrap_err("failed to fetch project disclosures")?;
info!("Indexing local versions!");
let mut versions = load_project_versions(pool, project_ids.clone()).await?;
@@ -497,6 +524,45 @@ async fn build_search_documents(
let mut project_categories = categories;
project_categories.sort();
project_categories.dedup();
let disclosure_types = project_disclosures
.remove(&project.id)
.map(|(_, disclosures)| disclosures)
.unwrap_or_default()
.into_iter()
.flat_map(|(kind, metadata)| {
let disclosure =
match ProjectDisclosure::from_parts(&kind, metadata) {
Ok(disclosure) => disclosure,
Err(e) => {
warn!(
project_id = project.id.0,
disclosure_type = kind,
"indexing project disclosure without its metadata: {e}"
);
return vec![kind];
}
};
let mut disclosure_type_tokens = vec![kind.clone()];
match &disclosure {
ProjectDisclosure::AiContent { uses, .. } => {
disclosure_type_tokens.extend(uses.iter().map(|usage| {
format!("{kind}_{}", <&'static str>::from(usage))
}));
}
ProjectDisclosure::Telemetry { consent, .. } => {
disclosure_type_tokens.push(format!(
"{kind}_{}",
<&'static str>::from(consent)
));
}
_ => {}
}
disclosure_type_tokens
})
.collect::<Vec<_>>();
let dependencies = dependencies
.get(&project.id)
.map(|x| x.clone())
@@ -764,6 +830,7 @@ async fn build_search_documents(
embedded_dependency_project_ids,
incompatible_dependency_project_ids,
dependencies,
disclosure_types,
project_loader_fields,
loader_fields,
loaders: project_loaders,
+6
View File
@@ -218,6 +218,7 @@ pub enum SearchField {
MinecraftJavaServerPingData,
DependencyProjectIds,
CompatibleDependencyProjectIds,
DisclosureTypes,
RequiredDependencyProjectIds,
OptionalDependencyProjectIds,
EmbeddedDependencyProjectIds,
@@ -302,6 +303,8 @@ pub struct UploadSearchProject {
pub incompatible_dependency_project_ids: Vec<String>,
#[serde(default)]
pub dependencies: Vec<SearchProjectDependency>,
#[serde(default)]
pub disclosure_types: Vec<String>,
// Hidden fields to get the Project model out of the search results.
pub loaders: Vec<String>, // Search uses loaders as categories- this is purely for the Project model.
@@ -408,6 +411,8 @@ pub struct ResultSearchProject {
pub incompatible_dependency_project_ids: Vec<String>,
#[serde(default)]
pub dependencies: Vec<SearchProjectDependency>,
#[serde(default)]
pub disclosure_types: Vec<String>,
// Hidden fields to get the Project model out of the search results.
pub loaders: Vec<String>, // Search uses loaders as categories- this is purely for the Project model.
@@ -458,6 +463,7 @@ impl From<UploadSearchProject> for ResultSearchProject {
incompatible_dependency_project_ids: source
.incompatible_dependency_project_ids,
dependencies: source.dependencies,
disclosure_types: source.disclosure_types,
loaders: source.loaders,
project_loader_fields: source.project_loader_fields,
components: source.components,