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
+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,