feat: new modpack permissions system (#6005)

* Begin external projects moderator database frontend

* add copy link button

* begin project page permissions settings

* MEL database backend routes

* include filename in external files

* wip: when uploading a version file, fetch its overrides as a list

* wip: override license checks

* improve FileHost ref counting

* file host read capability

* scan files when inserting version file

* add dependency sha1 field

* clean up version files

* wip: attributions

* update s3 file host

* attribution scanning basic works

* works

* insert attribution info after resolving

* add routes

* remove dep sha1 stuff

* prepr

* wip: override file sources

* add files_missing_attributions to versions

* return extended version info + attributed at/by

* hook up frontend to backend (mostly)

* expose version date published

* withholding version visibility

* frontend work

* prepr

* use api-client for img upload

* moar frontend

* prepr

* Add schema to attribution resolution and Flame project results

* sqlx prepare

* changes

* remove feature flag, fix optional proof images

* fix schema

* fmt

* fix deletion and file fetch

* prepare

* fix admonition

* update frontend stuff to new schema

* prepr

* attribution on dependencies

* fixes

* sqlx prepare

* fixes

* routes

* fix routes

* Version grandfathering

* prepare

* wip: bulk routes

* pushing what i've got rn

* include link in NoPermission

* change hash insert to bulk route

* query flame even if entry in MEL

* delete file with weird name

* Prioritise putting override files in existing groups even with ExternalLicense

* fix how hex bytes are handled in route

* feat: coolbot moderation changes (#6215)

* Update moderator checklist

* move permissions stage order

* Updated nagContext.versions to v3, added nag for permissions

* Update permissions.vue default messages

* prepr

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>

* QA

* prepr

* should group by project

* return attribution resolution correctly

* updated by moderator info

* Track what moderator reviewed an attribution moderation status

* default deser FMA field

* new version page

* clean up fetching + add a couple missing features

* qa items

* prepr

* provide moderation package stuff with DI

* format?

* don't redact moderated_at

* move supplementary resources

* Reorganize moderation messages.

* Quick replies for external content permissions.

* prepare

* QA

* allow exempting projects

* Ignore Flame projects which 404

* fix ci

* fix cross project attribution stuff

* Fix permission error

* change what files get cscanned

* add more logging

* QA Jun 22

* fix

* idempotency

* Expose route for rescanning

* update blog link

---------

Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: aecsocket <43144841+aecsocket@users.noreply.github.com>
This commit is contained in:
Prospector
2026-06-23 21:27:51 +02:00
committed by GitHub
co-authored by coolbot100s aecsocket aecsocket
parent a686a93858
commit e7926083fb
315 changed files with 11106 additions and 2209 deletions
+4
View File
@@ -20,6 +20,7 @@ import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
import { LabrinthAffiliateInternalModule } from './labrinth/affiliate/internal'
import { LabrinthAnalyticsV3Module } from './labrinth/analytics/v3'
import { LabrinthAttributionInternalModule } from './labrinth/attribution/internal'
import { LabrinthAuthInternalModule } from './labrinth/auth/internal'
import { LabrinthAuthV2Module } from './labrinth/auth/v2'
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
@@ -28,6 +29,7 @@ import { LabrinthCollectionsModule } from './labrinth/collections'
import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal'
import { LabrinthFriendsV3Module } from './labrinth/friends/v3'
import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal'
import { LabrinthImagesV3Module } from './labrinth/images/v3'
import { LabrinthLimitsV3Module } from './labrinth/limits/v3'
import { LabrinthModerationInternalModule } from './labrinth/moderation/internal'
import { LabrinthNotificationsV2Module } from './labrinth/notifications/v2'
@@ -91,12 +93,14 @@ export const MODULE_REGISTRY = {
labrinth_analytics_v3: LabrinthAnalyticsV3Module,
labrinth_auth_internal: LabrinthAuthInternalModule,
labrinth_auth_v2: LabrinthAuthV2Module,
labrinth_attribution_internal: LabrinthAttributionInternalModule,
labrinth_billing_internal: LabrinthBillingInternalModule,
labrinth_campaign_internal: LabrinthCampaignInternalModule,
labrinth_collections: LabrinthCollectionsModule,
labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule,
labrinth_friends_v3: LabrinthFriendsV3Module,
labrinth_globals_internal: LabrinthGlobalsInternalModule,
labrinth_images_v3: LabrinthImagesV3Module,
labrinth_moderation_internal: LabrinthModerationInternalModule,
labrinth_notifications_v2: LabrinthNotificationsV2Module,
labrinth_oauth_internal: LabrinthOAuthInternalModule,
@@ -0,0 +1,103 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
/**
* Decode a base62-encoded ID string into a number.
* The backend serializes attribution group IDs as base62 strings in responses,
* but the assign/update endpoints expect raw integer IDs in their request payloads.
*/
function decodeBase62Id(id: string): number {
let value = 0
for (const char of id) {
const digit = BASE62_CHARS.indexOf(char)
if (digit < 0) {
throw new Error(`Invalid base62 character "${char}" in id "${id}"`)
}
value = value * 62 + digit
if (!Number.isSafeInteger(value)) {
throw new Error(`Base62 id "${id}" exceeds safe integer range`)
}
}
return value
}
export class LabrinthAttributionInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_attribution_internal'
}
/**
* List attribution groups for a project
* GET /_internal/attribution/{project_id}
*/
public async listProjectAttribution(
projectId: string,
): Promise<Labrinth.Attribution.Internal.AttributionGroup[]> {
return this.client.request<Labrinth.Attribution.Internal.AttributionGroup[]>(
`/attribution/${projectId}`,
{
api: 'labrinth',
version: 'internal',
method: 'GET',
},
)
}
/**
* Update an attribution group's attribution payload.
* PATCH /_internal/attribution/group/{group_id}
*
* @param groupId - The base62 attribution group id (as returned from listProjectAttribution).
*/
public async updateGroup(
groupId: string,
body: Labrinth.Attribution.Internal.UpdateGroupRequest,
): Promise<void> {
const numericId = decodeBase62Id(groupId)
return this.client.request<void>(`/attribution/group/${numericId}`, {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body,
})
}
/**
* Reassign a file (by sha1) to another attribution group within the same project.
* POST /_internal/attribution/assign
*
* @param body.target_group_id - The base62 id of the attribution group to assign the file to.
*/
public async assignFileToGroup(body: {
sha1: string
target_group_id: string
project_id: string
}): Promise<void> {
const wireBody: Labrinth.Attribution.Internal.AssignRequest = {
sha1: body.sha1,
target_group_id: decodeBase62Id(body.target_group_id),
project_id: body.project_id,
}
return this.client.request<void>('/attribution/assign', {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: wireBody,
})
}
/**
* Split a file (by sha1) out of its current attribution group into a new group.
* POST /_internal/attribution/split
*/
public async splitFile(body: Labrinth.Attribution.Internal.SplitRequest): Promise<void> {
return this.client.request<void>('/attribution/split', {
api: 'labrinth',
version: 'internal',
method: 'POST',
body,
})
}
}
@@ -47,4 +47,18 @@ export class LabrinthExternalProjectsInternalModule extends AbstractModule {
},
)
}
public async addFile(
data: Labrinth.ExternalProjects.Internal.AddFileRequest,
): Promise<Labrinth.ExternalProjects.Internal.ExternalProject> {
return this.client.request<Labrinth.ExternalProjects.Internal.ExternalProject>(
'/moderation/external-license/file',
{
api: 'labrinth',
version: 'internal',
method: 'POST',
body: data,
},
)
}
}
@@ -0,0 +1,47 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle } from '../../../types/upload'
import type { Labrinth } from '../types'
function buildImageQueryParams(
ext: Labrinth.Images.v3.ImageExtension,
target: Labrinth.Images.v3.UploadImageParams,
): Record<string, string> {
const params: Record<string, string> = {
ext,
context: target.context,
}
switch (target.context) {
case 'project':
params.project_id = target.project_id
break
case 'version':
params.version_id = target.version_id
break
case 'thread_message':
params.thread_message_id = target.thread_message_id
break
case 'report':
params.report_id = target.report_id
break
}
return params
}
export class LabrinthImagesV3Module extends AbstractModule {
public getModuleID(): string {
return 'labrinth_images_v3'
}
public uploadImage(
file: File | Blob,
ext: Labrinth.Images.v3.ImageExtension,
target: Labrinth.Images.v3.UploadImageParams,
): UploadHandle<Labrinth.Images.v3.UploadedImage> {
return this.client.upload<Labrinth.Images.v3.UploadedImage>('/image', {
api: 'labrinth',
version: 3,
file,
params: buildImageQueryParams(ext, target),
})
}
}
@@ -1,4 +1,5 @@
export * from './analytics/v3'
export * from './attribution/internal'
export * from './auth/internal'
export * from './auth/v2'
export * from './billing/internal'
@@ -6,6 +7,7 @@ export * from './collections'
export * from './external-projects/internal'
export * from './friends/v3'
export * from './globals/internal'
export * from './images/v3'
export * from './limits/v3'
export * from './moderation/internal'
export * from './notifications/v2'
@@ -57,4 +57,15 @@ export class LabrinthModerationInternalModule extends AbstractModule {
},
)
}
public async setProjectJudgements(
judgements: Labrinth.Moderation.Internal.ProjectJudgements,
): Promise<void> {
return this.client.request<void>('/moderation/project', {
api: 'labrinth',
version: 'internal',
method: 'POST',
body: judgements,
})
}
}
@@ -249,6 +249,144 @@ export namespace Labrinth {
}
}
export namespace Attribution {
export namespace Internal {
export type AttributionPermissionKind =
| 'license'
| 'my_project'
| 'special_permissions'
| 'globally_allowed'
| 'no_permission'
export type AttributionResolutionKind = AttributionPermissionKind
export type AttributionLicense = string | { name: string }
export type AttributionModerationStatusKind = 'not_allowed' | 'approved' | 'bad_proof'
export type AttributionModerationStatus = {
kind: AttributionModerationStatusKind
reason: string
moderated_at?: string
moderated_by?: string
}
export type AttributionResolutionBase = {
notes: string
image_urls: string[]
moderation_status?: AttributionModerationStatus | null
updated_by_moderator: boolean
}
export type AttributionResolution =
| (AttributionResolutionBase & {
kind: 'license'
license: AttributionLicense
link_to_work: string
})
| (AttributionResolutionBase & {
kind: 'my_project'
license: AttributionLicense
})
| (AttributionResolutionBase & {
kind: 'special_permissions'
link_to_work: string
})
| (AttributionResolutionBase & {
kind: 'globally_allowed'
link_to_work: string
})
| (AttributionResolutionBase & {
kind: 'no_permission'
link_to_work?: string
})
export type FlameProject = {
id: number
title: string
url: string
icon_url: string
}
export type AttributionFile = {
name: string
sha1: string
versions: string[]
moderation_external_license_id?: number
moderation_external_license?: ExternalProjects.Internal.ExternalProject
}
export type AttributionVersionInfo = {
id: string
name: string
version_number: string
date_created: string
}
export type AttributionGroup = {
id: string
flame_project: FlameProject | null
attribution: AttributionResolution | null
attributed_at: string | null
attributed_by: string | null
files: AttributionFile[]
versions: AttributionVersionInfo[]
}
export type UpdateGroupRequest = {
attribution: AttributionResolution
}
export type AssignRequest = {
sha1: string
target_group_id: number
project_id: string
}
export type SplitRequest = {
sha1: string
project_id: string
}
}
}
export namespace Images {
export namespace v3 {
/** Extensions accepted by POST /v3/image (Labrinth image pipeline). */
export type ImageExtension = 'bmp' | 'gif' | 'jpeg' | 'jpg' | 'png' | 'webp'
/** `context` query values accepted by POST /v3/image. */
export type ImageUploadContext = 'project' | 'version' | 'thread_message' | 'report'
export type UploadedImage = {
id: string
url: string
size: number
created: string
owner_id: string
} & (
| { context: 'project'; project_id: string }
| { context: 'version'; version_id: string }
| { context: 'thread_message'; thread_message_id: string }
| { context: 'report'; report_id: string }
)
export type UploadedImageFor<C extends ImageUploadContext> = Extract<
UploadedImage,
{ context: C }
>
/**
* Target for POST /v3/image (per-context id query params, plus `context`).
* `ext` is passed as a separate argument on the client module.
*/
export type UploadImageParams =
| { context: 'project'; project_id: string }
| { context: 'version'; version_id: string }
| { context: 'thread_message'; thread_message_id: string }
| { context: 'report'; report_id: string }
}
}
export namespace Analytics {
export namespace v3 {
export type AnalyticsEventId = number
@@ -1147,11 +1285,47 @@ export namespace Labrinth {
// TODO: consolidate duplicated types between v2 and v3 versions
export namespace v3 {
export type FlameProject = {
id: number
title: string
url: string
icon_url: string
}
export type DependencyAttribution = {
flame_project?: FlameProject
resolution?: DependencyAttributionResolution
}
export type DependencyAttributionResolution =
| {
kind: 'license'
license: Labrinth.Attribution.Internal.AttributionLicense
link_to_work: string
}
| {
kind: 'globally_allowed'
link_to_work: string
}
| {
kind: 'my_project'
license: Labrinth.Attribution.Internal.AttributionLicense
}
| {
kind: 'special_permissions'
link_to_work: string
}
| {
kind: 'no_permission'
link_to_work?: string
}
export interface Dependency {
dependency_type: Labrinth.Versions.v2.DependencyType
project_id?: string
file_name?: string
version_id?: string
attribution?: DependencyAttribution
}
export interface GetProjectVersionsParams {
@@ -1174,12 +1348,12 @@ export namespace Labrinth {
| 'signature'
| 'unknown'
export interface VersionFileHash {
sha512: string
sha1: string
export type FileHashType = 'sha512' | 'sha1'
export type VersionFileHash = {
[key in FileHashType]: string
}
interface VersionFile {
export interface VersionFile {
hashes: VersionFileHash
url: string
filename: string
@@ -1211,6 +1385,7 @@ export namespace Labrinth {
date_published: string
downloads: number
files: VersionFile[]
files_missing_attribution?: string[]
environment?: Labrinth.Projects.v3.Environment
mrpack_loaders?: string[]
@@ -1658,6 +1833,28 @@ export namespace Labrinth {
export type ReleaseLockResponse = {
success: boolean
}
export type ProjectJudgementStatus = ExternalProjects.Internal.ExternalLicenseStatus
export type FlameJudgement = {
type: 'flame'
id: number
status: ProjectJudgementStatus
link: string
title: string
}
export type UnknownJudgement = {
type: 'unknown'
status: ProjectJudgementStatus
proof?: string
link?: string
title?: string
}
export type ProjectJudgement = FlameJudgement | UnknownJudgement
export type ProjectJudgements = Record<string, ProjectJudgement>
}
}
@@ -1819,7 +2016,7 @@ export namespace Labrinth {
inserted_by: number | null
updated_at: string | null
updated_by: number | null
linked_files: LinkedFile[]
linked_files?: LinkedFile[]
}
export type SearchRequest = {
@@ -1835,6 +2032,11 @@ export namespace Labrinth {
proof?: string
flame_project_id?: number
}
export type AddFileRequest = {
hashes: string[]
license_id: number
}
}
}