Compare commits

...
15 changed files with 346 additions and 106 deletions
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tSELECT DISTINCT f.version_id AS \"version_id: DBVersionId\"\n\t\tFROM project_attribution_files paf\n\t\tINNER JOIN project_attribution_groups pag ON pag.id = paf.group_id\n\t\tINNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1\n\t\tINNER JOIN files f ON f.id = ofs.file_id\n\t\tINNER JOIN versions v ON v.id = f.version_id\n\t\tWHERE paf.group_id = $1\n\t\t\tAND pag.project_id = v.mod_id\n\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "version_id: DBVersionId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "00ef039799c162e21a8fa5ba7aec2a9044a3028f1cb34e8c9c565a670956d938"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n select id\n from mods\n where id = any($1)\n and status not in ('rejected', 'draft', 'withheld', 'withdrawn')\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false
]
},
"hash": "079846a1e6a6b080e0e7f2e3efbb53b6526332433faf66de8716bc5cd2b12afd"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tDELETE FROM project_attribution_groups\n\t\tWHERE id = $1\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "827534d191739cd2ea502142a65f62a052715c7441952ee4ee182afde07c44d7"
}
@@ -0,0 +1,46 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT\n\t\t\t\tofs.file_path AS \"file_path!\",\n\t\t\t\tCONVERT_FROM(ofs.sha1, 'UTF8') AS \"sha1!\",\n\t\t\t\tsource_file.version_id AS \"version_id: DBVersionId\",\n\t\t\t\tplatform_file.version_id AS \"platform_version_id: DBVersionId\",\n\t\t\t\tplatform_version.mod_id AS \"platform_project_id: DBProjectId\"\n\t\t\tFROM files source_file\n\t\t\tINNER JOIN versions source_version\n\t\t\t\tON source_version.id = source_file.version_id\n\t\t\tINNER JOIN override_file_sources ofs\n\t\t\t\tON ofs.file_id = source_file.id\n\t\t\tINNER JOIN hashes h\n\t\t\t\tON h.algorithm = 'sha1' AND h.hash = ofs.sha1\n\t\t\tINNER JOIN files platform_file\n\t\t\t\tON platform_file.id = h.file_id\n\t\t\tINNER JOIN versions platform_version\n\t\t\t\tON platform_version.id = platform_file.version_id\n\t\t\tWHERE source_version.mod_id = $1\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "file_path!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "sha1!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "version_id: DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "platform_version_id: DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "platform_project_id: DBProjectId",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
null,
false,
false,
false
]
},
"hash": "9a0a10cd75d0c9317ea599d21e7935c4ec289b1fc6417eb0bdb2feddd94f175e"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\tDELETE FROM project_attribution_files\n\t\tWHERE group_id = $1\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "b69317d234e934aa6bdf8be4d35bc7475308bde8b7f34a4ef3c7f3ef351f46a8"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO override_file_sources (sha1, file_id, file_path)\n SELECT source.sha1, $2, source.file_path\n FROM UNNEST($1::BYTEA[], $3::TEXT[]) AS source(sha1, file_path)\n ON CONFLICT DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"ByteaArray",
"Int8",
"TextArray"
]
},
"nullable": []
},
"hash": "c1482e5ded6536a24937a9396a3f141270b3acf96ec832a50b5f7c572e255947"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n insert into override_file_sources (sha1, file_id)\n select unnest($1::bytea[]), $2\n on conflict do nothing\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"ByteaArray",
"Int8"
]
},
"nullable": []
},
"hash": "e7a0481729efa9cba6140effcdddff1a076eb7269d5b22860db3e3d1a651f6eb"
}
@@ -0,0 +1,4 @@
alter table override_file_sources
add column file_path text not null default '',
drop constraint override_file_sources_pkey,
add primary key (sha1, file_id, file_path);
+12 -68
View File
@@ -231,7 +231,7 @@ async fn scan_pending_files_chunk(
.await
.wrap_err("beginning file scan transaction")?;
let resolved = resolve_overrides(&overrides, redis, &mut txn)
let resolved = resolve_overrides(&overrides, &mut txn)
.await
.wrap_err_with(|| {
eyre!("resolving overrides for file {file_id:?}")
@@ -325,9 +325,8 @@ async fn scan_file_inner(
if !overrides.is_empty() {
let before = count_project_attributions(project_id, txn).await?;
let resolved = resolve_overrides(&overrides, redis, txn)
.await
.wrap_err_with(|| {
let resolved =
resolve_overrides(&overrides, txn).await.wrap_err_with(|| {
eyre!("resolving overrides for file {file_id:?}")
})?;
@@ -494,7 +493,6 @@ pub struct OverrideFile {
#[derive(Debug)]
pub enum OverrideResolution {
OnModrinth,
ExternalLicense {
id: i64,
status: ApprovalType,
@@ -518,8 +516,7 @@ fn log_marked_override_projects(
| OverrideResolution::Flame(flame_project) => {
Some(format!("{} ({})", flame_project.title, flame_project.id))
}
OverrideResolution::OnModrinth
| OverrideResolution::ExternalLicense { .. }
OverrideResolution::ExternalLicense { .. }
| OverrideResolution::Unknown => None,
})
.collect::<Vec<_>>();
@@ -671,7 +668,6 @@ async fn persist_attribution_results(
}
match resolved.get(&file.sha1) {
Some(OverrideResolution::OnModrinth) => continue,
Some(OverrideResolution::ExternalLicense {
id,
status,
@@ -864,14 +860,19 @@ async fn persist_attribution_results(
}
if !all_sha1s.is_empty() {
let file_paths: Vec<String> =
overrides.iter().map(|f| f.path.clone()).collect();
sqlx::query!(
"
insert into override_file_sources (sha1, file_id)
select unnest($1::bytea[]), $2
on conflict do nothing
INSERT INTO override_file_sources (sha1, file_id, file_path)
SELECT source.sha1, $2, source.file_path
FROM UNNEST($1::BYTEA[], $3::TEXT[]) AS source(sha1, file_path)
ON CONFLICT DO NOTHING
",
&all_sha1s,
file_id as DBFileId,
&file_paths,
)
.execute(&mut *txn)
.await
@@ -937,7 +938,6 @@ fn default_external_license_attribution(
async fn resolve_overrides(
overrides: &[OverrideFile],
redis: &RedisPool,
txn: &mut PgTransaction<'_>,
) -> Result<HashMap<String, OverrideResolution>> {
let mut results: HashMap<String, OverrideResolution> = HashMap::new();
@@ -947,62 +947,6 @@ async fn resolve_overrides(
return Ok(results);
}
let hashes: Vec<String> =
overrides.iter().map(|x| x.sha1.clone()).collect();
let files = DBVersion::get_files_from_hash(
"sha1".to_string(),
&hashes,
&mut *txn,
redis,
)
.await
.wrap_err("fetching files on platform by hash")?;
let matching_project_ids: Vec<_> =
files.iter().map(|file| file.project_id.0).collect();
let valid_project_ids = sqlx::query_scalar!(
r#"
select id
from mods
where id = any($1)
and status not in ('rejected', 'draft', 'withheld', 'withdrawn')
"#,
&matching_project_ids,
)
.fetch_all(&mut *txn)
.await
.wrap_err("fetching matched file project statuses")?;
let version_ids: Vec<_> = files.iter().map(|x| x.version_id).collect();
let versions_data = DBVersion::get_many(&version_ids, &mut *txn, redis)
.await
.wrap_err("fetching versions")?;
for file in &files {
if !valid_project_ids.contains(&file.project_id.0) {
continue;
}
if !versions_data.iter().any(|v| v.inner.id == file.version_id) {
continue;
}
if let Some(hash) = file.hashes.get("sha1")
&& let Some(pos) =
remaining.iter().position(|i| overrides[*i].sha1 == *hash)
{
let idx = remaining.remove(pos);
results.insert(
overrides[idx].sha1.clone(),
OverrideResolution::OnModrinth,
);
}
}
if remaining.is_empty() {
return Ok(results);
}
let rows = sqlx::query!(
"
SELECT encode(mef.sha1, 'escape') sha1, mel.id, mel.status status, mel.link
@@ -1,4 +1,4 @@
use actix_web::{HttpRequest, get, patch, post, web};
use actix_web::{HttpRequest, delete, get, patch, post, web};
use chrono::{DateTime, Utc};
use eyre::eyre;
use serde::{Deserialize, Serialize};
@@ -31,6 +31,7 @@ use crate::util::error::Context;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(list)
.service(update_group)
.service(delete_group)
.service(scan)
.service(force_scan_file)
.service(assign)
@@ -46,6 +47,7 @@ struct AttributionGroupResponse {
attributed_by: Option<ariadne::ids::UserId>,
files: Vec<AttributionFileResponse>,
versions: Vec<VersionInfo>,
override_files_on_platform: Vec<OverrideFileOnPlatformResponse>,
}
#[derive(Clone, Serialize, utoipa::ToSchema)]
@@ -67,6 +69,15 @@ struct AttributionFileResponse {
moderation_external_license: Option<ModerationExternalLicenseResponse>,
}
#[derive(Clone, Serialize, utoipa::ToSchema)]
struct OverrideFileOnPlatformResponse {
file_path: String,
sha1: String,
version_id: VersionId,
platform_version_id: VersionId,
platform_project_id: ProjectId,
}
#[derive(Clone, Serialize, utoipa::ToSchema)]
struct ModerationExternalLicenseResponse {
id: i64,
@@ -358,6 +369,48 @@ pub async fn list(
.wrap_internal_err("failed to fetch attribution group files")?
};
let override_files_on_platform = if requester_is_mod {
sqlx::query!(
r#"
SELECT
ofs.file_path AS "file_path!",
CONVERT_FROM(ofs.sha1, 'UTF8') AS "sha1!",
source_file.version_id AS "version_id: DBVersionId",
platform_file.version_id AS "platform_version_id: DBVersionId",
platform_version.mod_id AS "platform_project_id: DBProjectId"
FROM files source_file
INNER JOIN versions source_version
ON source_version.id = source_file.version_id
INNER JOIN override_file_sources ofs
ON ofs.file_id = source_file.id
INNER JOIN hashes h
ON h.algorithm = 'sha1' AND h.hash = ofs.sha1
INNER JOIN files platform_file
ON platform_file.id = h.file_id
INNER JOIN versions platform_version
ON platform_version.id = platform_file.version_id
WHERE source_version.mod_id = $1
"#,
project_id as DBProjectId,
)
.fetch_all(pool.as_ref())
.await
.wrap_internal_err(
"failed to fetch override files already on platform",
)?
.into_iter()
.map(|row| OverrideFileOnPlatformResponse {
file_path: row.file_path,
sha1: row.sha1,
version_id: row.version_id.into(),
platform_version_id: row.platform_version_id.into(),
platform_project_id: row.platform_project_id.into(),
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
let moderation_external_licenses = if requester_is_mod {
let mut ids: Vec<i64> = files
.iter()
@@ -497,6 +550,15 @@ pub async fn list(
.filter(|version| group_version_ids.contains(&version.id))
.cloned()
.collect();
let group_file_sha1s = group_files
.iter()
.map(|file| file.sha1.as_str())
.collect::<std::collections::HashSet<_>>();
let group_override_files_on_platform = override_files_on_platform
.iter()
.filter(|file| group_file_sha1s.contains(file.sha1.as_str()))
.cloned()
.collect();
let mut attribution = group.attribution.and_then(|v| {
serde_json::from_value::<AttributionResolution>(v).ok()
@@ -530,6 +592,7 @@ pub async fn list(
attributed_by,
files: group_files,
versions: group_versions,
override_files_on_platform: group_override_files_on_platform,
});
}
@@ -620,6 +683,88 @@ pub async fn update_group(
Ok(())
}
/// Delete an attribution group and all files inside it.
#[utoipa::path(
context_path = "/attribution",
tag = "attribution",
responses((status = NO_CONTENT))
)]
#[delete("/group/{group_id}")]
pub async fn delete_group(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
path: web::Path<i64>,
) -> Result<(), ApiError> {
check_is_moderator_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::PROJECT_READ,
)
.await?;
let mut txn = pool.begin().await.wrap_internal_err(
"failed to begin attribution group deletion transaction",
)?;
let group_id = path.into_inner();
let version_ids = sqlx::query_scalar!(
r#"
SELECT DISTINCT f.version_id AS "version_id: DBVersionId"
FROM project_attribution_files paf
INNER JOIN project_attribution_groups pag ON pag.id = paf.group_id
INNER JOIN override_file_sources ofs ON ofs.sha1 = paf.sha1
INNER JOIN files f ON f.id = ofs.file_id
INNER JOIN versions v ON v.id = f.version_id
WHERE paf.group_id = $1
AND pag.project_id = v.mod_id
"#,
group_id,
)
.fetch_all(&mut txn)
.await
.wrap_internal_err("failed to fetch attribution group versions")?;
sqlx::query!(
"
DELETE FROM project_attribution_files
WHERE group_id = $1
",
group_id,
)
.execute(&mut txn)
.await
.wrap_internal_err("failed to delete attribution group files")?;
let result = sqlx::query!(
"
DELETE FROM project_attribution_groups
WHERE id = $1
",
group_id,
)
.execute(&mut txn)
.await
.wrap_internal_err("failed to delete attribution group")?;
if result.rows_affected() == 0 {
return Err(ApiError::NotFound);
}
txn.commit().await.wrap_internal_err(
"failed to commit attribution group deletion transaction",
)?;
DBVersion::clear_cache_ids(&version_ids, redis.as_ref())
.await
.wrap_internal_err("failed to clear version attribution cache")?;
Ok(())
}
#[derive(Deserialize, utoipa::ToSchema)]
struct AssignBody {
sha1: String,
+1
View File
@@ -142,6 +142,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
attribution::scan,
attribution::list,
attribution::update_group,
attribution::delete_group,
attribution::assign,
attribution::split,
billing::products,
@@ -64,6 +64,21 @@ export class LabrinthAttributionInternalModule extends AbstractModule {
})
}
/**
* Delete an attribution group and all files inside it.
* DELETE /_internal/attribution/group/{group_id}
*
* @param groupId - The base62 attribution group id (as returned from listProjectAttribution).
*/
public async deleteGroup(groupId: string): Promise<void> {
const numericId = decodeBase62Id(groupId)
return this.client.request<void>(`/attribution/group/${numericId}`, {
api: 'labrinth',
version: 'internal',
method: 'DELETE',
})
}
/**
* Reassign a file (by sha1) to another attribution group within the same project.
* POST /_internal/attribution/assign
@@ -373,6 +373,14 @@ export namespace Labrinth {
date_created: string
}
export type OverrideFileOnPlatform = {
file_path: string
sha1: string
version_id: string
platform_version_id: string
platform_project_id: string
}
export type AttributionGroup = {
id: string
flame_project: FlameProject | null
@@ -381,6 +389,7 @@ export namespace Labrinth {
attributed_by: string | null
files: AttributionFile[]
versions: AttributionVersionInfo[]
override_files_on_platform: OverrideFileOnPlatform[]
}
export type UpdateGroupRequest = {
@@ -9,6 +9,7 @@ import {
ReportIcon,
ScaleIcon,
SpinnerIcon,
TrashIcon,
VersionIcon,
XCircleIcon,
XIcon,
@@ -135,6 +136,10 @@ const messages = defineMessages({
id: 'external-files.permissions-card.add-files-to-group',
defaultMessage: 'Add files...',
},
removeGroup: {
id: 'external-files.permissions-card.remove-group',
defaultMessage: 'Remove group',
},
moderationReasonLabel: {
id: 'external-files.permissions-card.moderation-reason',
defaultMessage: 'Reason',
@@ -248,6 +253,26 @@ const splitFileMutation = useMutation({
},
})
const deleteGroupMutation = useMutation({
mutationFn: () => client.labrinth.attribution_internal.deleteGroup(props.group.id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['project-attribution', props.projectId] })
emit('updated')
},
onError: (error: Error) => {
addNotification({
type: 'error',
title: formatMessage(
defineMessage({
id: 'external-files.permissions-card.remove-group-error.title',
defaultMessage: 'Could not remove group',
}),
),
text: error.message,
})
},
})
function startEditingAttribution() {
editingMode.value = 'attribution'
collapsed.value = false
@@ -280,6 +305,10 @@ function handleConfirmAddFiles(sha1s: string[]) {
assignFilesMutation.mutate(sha1s)
}
function handleDeleteGroup() {
deleteGroupMutation.mutate()
}
async function handleAddFilesToGroup(event: MouseEvent) {
try {
const groups = await queryClient.ensureQueryData({
@@ -782,6 +811,18 @@ const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
@cancel="stopEditing"
/>
</div>
<div v-if="isModerator" class="flex justify-end pt-2">
<ButtonStyled color="red" type="outlined">
<button :disabled="deleteGroupMutation.isPending.value" @click="handleDeleteGroup">
<SpinnerIcon
v-if="deleteGroupMutation.isPending.value"
class="size-4 shrink-0 animate-spin"
/>
<TrashIcon v-else class="size-4 shrink-0" />
{{ formatMessage(messages.removeGroup) }}
</button>
</ButtonStyled>
</div>
</div>
</Collapsible>
<AddFilesToAttributionGroupModal
+6
View File
@@ -1142,6 +1142,12 @@
"external-files.permissions-card.reason.special-permission.description": {
"defaultMessage": "You have obtained special permission to redistribute this work in your modpack."
},
"external-files.permissions-card.remove-group": {
"defaultMessage": "Remove group"
},
"external-files.permissions-card.remove-group-error.title": {
"defaultMessage": "Could not remove group"
},
"external-files.permissions-card.split-file": {
"defaultMessage": "Remove from group"
},