diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts index be15100be2..6ea39583d2 100644 --- a/apps/frontend/src/composables/featureFlags.ts +++ b/apps/frontend/src/composables/featureFlags.ts @@ -47,6 +47,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({ showDiscoverProjectButtons: false, useV1ContentTabAPI: true, labrinthApiCanary: false, + dismissedExternalProjectsInfo: false, + modpackPermissionsPage: false, } as const) export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index 97c220481a..cee61433e2 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -323,6 +323,11 @@ color: 'orange', link: '/moderation/reports', }, + { + id: 'external-projects', + color: 'orange', + link: '/moderation/external-projects', + }, { divider: true, }, @@ -377,6 +382,9 @@ + @@ -705,6 +713,7 @@ import { DropdownIcon, FileIcon, GlassesIcon, + GlobeIcon, HamburgerIcon, HomeIcon, IssuesIcon, @@ -880,6 +889,10 @@ const messages = defineMessages({ id: 'layout.action.reports', defaultMessage: 'Review reports', }, + externalProjects: { + id: 'layout.action.external-projects', + defaultMessage: 'External projects', + }, lookupByEmail: { id: 'layout.action.lookup-by-email', defaultMessage: 'Lookup by email', diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 3ff2360a0f..2b08888886 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -1670,6 +1670,9 @@ "layout.action.create-new": { "message": "Create new..." }, + "layout.action.external-projects": { + "message": "External projects" + }, "layout.action.file-lookup": { "message": "File lookup" }, @@ -1922,6 +1925,9 @@ "moderation.moderate": { "message": "Moderate" }, + "moderation.page.external-projects": { + "message": "External projects" + }, "moderation.page.projects": { "message": "Projects" }, @@ -1929,7 +1935,7 @@ "message": "Reports" }, "moderation.page.technicalReview": { - "message": "Technical Review" + "message": "Tech review" }, "muralpay.account-type.checking": { "message": "Checking" @@ -2615,6 +2621,45 @@ "project.settings.general.url.title": { "message": "URL" }, + "project.settings.permissions.attention-needed.description.proj-approved": { + "message": "Please provide proof that you have permission to redistribute all of the following files and any withheld versions will be automatically published." + }, + "project.settings.permissions.attention-needed.description.proj-draft": { + "message": "Please provide proof that you have permission to redistribute all of the following files before you can submit your project for review." + }, + "project.settings.permissions.attention-needed.title": { + "message": "Unknown embedded content" + }, + "project.settings.permissions.completed.description": { + "message": "All external content has attributions provided." + }, + "project.settings.permissions.completed.title": { + "message": "Attributions completed!" + }, + "project.settings.permissions.empty-state.description": { + "message": "None of your versions contain external content, so you don't need to worry about obtaining permissions." + }, + "project.settings.permissions.empty-state.heading": { + "message": "You're all set!" + }, + "project.settings.permissions.fail.description": { + "message": "You don't have permission to redistribute some of the external content you've added. In order to publish on Modrinth, remove the infringing content." + }, + "project.settings.permissions.fail.title": { + "message": "Some content can't be included" + }, + "project.settings.permissions.info-banner.description": { + "message": "If you include content that isn’t hosted on Modrinth, you need to let us know where it’s from and verify that you have permission to distribute the files. Check out our guide to learn about how to do this properly!" + }, + "project.settings.permissions.info-banner.title": { + "message": "Learn how attributions work" + }, + "project.settings.permissions.learn-more": { + "message": "Learn more" + }, + "project.settings.permissions.search-placeholder": { + "message": "Search {count} {count, plural, one {external project} other {external projects}}..." + }, "project.settings.title": { "message": "Settings" }, @@ -2633,6 +2678,15 @@ "project.versions.title": { "message": "Versions" }, + "project.versions.withheld-versions-warning.description": { + "message": "{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}." + }, + "project.versions.withheld-versions-warning.resolve-button": { + "message": "Resolve" + }, + "project.versions.withheld-versions-warning.title": { + "message": "{count, plural, one {Version {version_name}} other {Versions}} withheld due to unknown embedded content" + }, "report.already-reported": { "message": "You've already reported {title}" }, diff --git a/apps/frontend/src/pages/[type]/[id]/settings.vue b/apps/frontend/src/pages/[type]/[id]/settings.vue index ebf5745c9b..45ea37ca34 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings.vue @@ -8,6 +8,7 @@ import { InfoIcon, LinkIcon, ServerIcon, + SignatureIcon, TagsIcon, UsersIcon, VersionIcon, @@ -46,6 +47,12 @@ const navItems = computed(() => { projectV3.value?.project_types?.some((type) => ['mod', 'modpack'].includes(type)) && isStaff(currentMember.value?.user) + const hasPermissionsPage = computed( + () => + flags.value.modpackPermissionsPage && + projectV3.value?.project_types?.some((type) => ['modpack'].includes(type)), + ) + const items = [ { link: `/${base}/settings`, @@ -75,6 +82,11 @@ const navItems = computed(() => { label: formatMessage(commonProjectSettingsMessages.description), icon: AlignLeftIcon, }, + hasPermissionsPage.value && { + link: `/${base}/settings/permissions`, + label: formatMessage(commonProjectSettingsMessages.permissions), + icon: SignatureIcon, + }, !isServerProject.value && { link: `/${base}/settings/versions`, label: formatMessage(commonProjectSettingsMessages.versions), diff --git a/apps/frontend/src/pages/[type]/[id]/settings/permissions.vue b/apps/frontend/src/pages/[type]/[id]/settings/permissions.vue new file mode 100644 index 0000000000..2d50a45c5d --- /dev/null +++ b/apps/frontend/src/pages/[type]/[id]/settings/permissions.vue @@ -0,0 +1,184 @@ + + diff --git a/apps/frontend/src/pages/[type]/[id]/settings/versions.vue b/apps/frontend/src/pages/[type]/[id]/settings/versions.vue index 20b7d65943..ca35d02f8e 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/versions.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/versions.vue @@ -15,6 +15,37 @@ @proceed="deleteVersion()" /> + + + ['4.0.0']) + +const messages = defineMessages({ + withheldVersionsWarningTitle: { + id: 'project.versions.withheld-versions-warning.title', + defaultMessage: + '{count, plural, one {Version {version_name}} other {Versions}} withheld due to unknown embedded content', + }, + withheldVersionsWarningDescription: { + id: 'project.versions.withheld-versions-warning.description', + defaultMessage: + '{count, plural, one {This version is} other {These versions are}} currently withheld and not publicly listed. Please provide proof that you have permission to redistribute certain files included in the modpack {count, plural, one {version} other {versions}}.', + }, + withheldVersionsWarningResolve: { + id: 'project.versions.withheld-versions-warning.resolve-button', + defaultMessage: 'Resolve', + }, +}) diff --git a/apps/frontend/src/pages/moderation.vue b/apps/frontend/src/pages/moderation.vue index 1fab4216d0..0b8a0db17d 100644 --- a/apps/frontend/src/pages/moderation.vue +++ b/apps/frontend/src/pages/moderation.vue @@ -15,7 +15,7 @@ diff --git a/apps/labrinth/.sqlx/query-56428d0eec87c0cdaca1183c642f0478b9974de6b6d95f7ca48de605b3bf1103.json b/apps/labrinth/.sqlx/query-56428d0eec87c0cdaca1183c642f0478b9974de6b6d95f7ca48de605b3bf1103.json new file mode 100644 index 0000000000..2a6d0ac4ce --- /dev/null +++ b/apps/labrinth/.sqlx/query-56428d0eec87c0cdaca1183c642f0478b9974de6b6d95f7ca48de605b3bf1103.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO moderation_external_licenses (id, title, status, link, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by)\n SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::varchar[], $5::varchar[], $6::integer[], $7::timestamptz[], $8::bigint[], $7::timestamptz[], $8::bigint[])\n ON CONFLICT (id) DO UPDATE SET\n title = EXCLUDED.title,\n status = EXCLUDED.status,\n link = EXCLUDED.link,\n proof = EXCLUDED.proof,\n flame_project_id = EXCLUDED.flame_project_id,\n updated_at = EXCLUDED.updated_at,\n updated_by = EXCLUDED.updated_by\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "VarcharArray", + "VarcharArray", + "VarcharArray", + "VarcharArray", + "Int4Array", + "TimestamptzArray", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "56428d0eec87c0cdaca1183c642f0478b9974de6b6d95f7ca48de605b3bf1103" +} diff --git a/apps/labrinth/.sqlx/query-6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57.json b/apps/labrinth/.sqlx/query-6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57.json new file mode 100644 index 0000000000..8668834ed4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_licenses mel\n WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%')\n AND ($2::integer IS NULL OR mel.flame_project_id = $2)\n ORDER BY mel.id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "link", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "exceptions", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "proof", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "flame_project_id", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "inserted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "inserted_by", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "6542c79dffa73e462c527f1bd4ba67e658814d7a788259dc4b3874c54cb5ae57" +} diff --git a/apps/labrinth/.sqlx/query-7720108c0a9e93119f5252e2102eeea0ee67b228924e288d1d6c3e169e941688.json b/apps/labrinth/.sqlx/query-7720108c0a9e93119f5252e2102eeea0ee67b228924e288d1d6c3e169e941688.json new file mode 100644 index 0000000000..816a68f801 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7720108c0a9e93119f5252e2102eeea0ee67b228924e288d1d6c3e169e941688.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n mef.external_license_id,\n mef.sha1,\n mef.filename\n FROM moderation_external_files mef\n WHERE mef.external_license_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "external_license_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "sha1", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "filename", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "7720108c0a9e93119f5252e2102eeea0ee67b228924e288d1d6c3e169e941688" +} diff --git a/apps/labrinth/.sqlx/query-99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4.json b/apps/labrinth/.sqlx/query-99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4.json new file mode 100644 index 0000000000..890112c0a6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n mel.id,\n mel.title,\n mel.status,\n mel.link,\n mel.exceptions,\n mel.proof,\n mel.flame_project_id,\n mel.inserted_at,\n mel.inserted_by,\n mel.updated_at,\n mel.updated_by\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id\n WHERE mef.sha1 = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "link", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "exceptions", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "proof", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "flame_project_id", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "inserted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "inserted_by", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "99749414f92886a904158484661cae8928f78206c1cdf8adb66fde999bbe94d4" +} diff --git a/apps/labrinth/.sqlx/query-b539eccb9fbb13270748f5c102dc0eb3325a39daa45f8964704713fb704a3e26.json b/apps/labrinth/.sqlx/query-b539eccb9fbb13270748f5c102dc0eb3325a39daa45f8964704713fb704a3e26.json new file mode 100644 index 0000000000..102c385b3f --- /dev/null +++ b/apps/labrinth/.sqlx/query-b539eccb9fbb13270748f5c102dc0eb3325a39daa45f8964704713fb704a3e26.json @@ -0,0 +1,90 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE moderation_external_licenses\n SET title = COALESCE($2, title),\n status = $3,\n link = COALESCE($4, link),\n exceptions = COALESCE($5, exceptions),\n proof = COALESCE($6, proof),\n flame_project_id = COALESCE($7, flame_project_id),\n updated_at = $8,\n updated_by = $9\n WHERE id = $1\n RETURNING id, title, status, link, exceptions, proof, flame_project_id,\n inserted_at, inserted_by, updated_at, updated_by\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "link", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "exceptions", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "proof", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "flame_project_id", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "inserted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "inserted_by", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_by", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Text", + "Text", + "Int4", + "Timestamptz", + "Int8" + ] + }, + "nullable": [ + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "b539eccb9fbb13270748f5c102dc0eb3325a39daa45f8964704713fb704a3e26" +} diff --git a/apps/labrinth/.sqlx/query-f26e41a619a51d5b5a39af6117b7d2f6106ec67a209a285b0a523a677dba4a5b.json b/apps/labrinth/.sqlx/query-f26e41a619a51d5b5a39af6117b7d2f6106ec67a209a285b0a523a677dba4a5b.json new file mode 100644 index 0000000000..8fe2ff889c --- /dev/null +++ b/apps/labrinth/.sqlx/query-f26e41a619a51d5b5a39af6117b7d2f6106ec67a209a285b0a523a677dba4a5b.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO moderation_external_files (sha1, filename, external_license_id, inserted_at, inserted_by, updated_at, updated_by)\n SELECT * FROM UNNEST ($1::bytea[], $2::varchar[], $3::bigint[], $4::timestamptz[], $5::bigint[], $4::timestamptz[], $5::bigint[])\n ON CONFLICT (sha1) DO UPDATE SET\n filename = COALESCE(EXCLUDED.filename, moderation_external_files.filename),\n external_license_id = EXCLUDED.external_license_id,\n updated_at = EXCLUDED.updated_at,\n updated_by = EXCLUDED.updated_by\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "ByteaArray", + "VarcharArray", + "Int8Array", + "TimestamptzArray", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "f26e41a619a51d5b5a39af6117b7d2f6106ec67a209a285b0a523a677dba4a5b" +} diff --git a/apps/labrinth/.sqlx/query-f297b517bc3bbd8628c0c222c0e3daf8f4efbe628ee2e8ddbbb4b9734cc9c915.json b/apps/labrinth/.sqlx/query-f297b517bc3bbd8628c0c222c0e3daf8f4efbe628ee2e8ddbbb4b9734cc9c915.json deleted file mode 100644 index dc923578a9..0000000000 --- a/apps/labrinth/.sqlx/query-f297b517bc3bbd8628c0c222c0e3daf8f4efbe628ee2e8ddbbb4b9734cc9c915.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO moderation_external_files (sha1, external_license_id)\n SELECT * FROM UNNEST ($1::bytea[], $2::bigint[])\n ON CONFLICT (sha1) DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "ByteaArray", - "Int8Array" - ] - }, - "nullable": [] - }, - "hash": "f297b517bc3bbd8628c0c222c0e3daf8f4efbe628ee2e8ddbbb4b9734cc9c915" -} diff --git a/apps/labrinth/CLAUDE.md b/apps/labrinth/CLAUDE.md index fe4b705308..8d770e2555 100644 --- a/apps/labrinth/CLAUDE.md +++ b/apps/labrinth/CLAUDE.md @@ -1,30 +1 @@ -# Labrinth - -Labrinth is the backend API service for Modrinth, written in Rust. - -## Pre-PR Checks - -When the user refers to "perform[ing] pre-PR checks", do the following: - -- Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail -- DO NOT run tests unless explicitly requested (they take a long time) -- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests` - - NEVER run `cargo sqlx prepare --workspace` - -## Testing - -- Run `cargo test -p labrinth --all-targets` to test your changes — all tests must pass - -## Local Services - -- Read the root `docker-compose.yml` to see what running services are available while developing -- Use `docker exec` to access these services - -### Clickhouse - -- Access: `docker exec labrinth-clickhouse clickhouse-client` -- Database: `staging_ariadne` - -### Postgres - -- Access: `docker exec labrinth-postgres psql -U labrinth -d labrinth -c ""` +Read @AGENTS.md diff --git a/apps/labrinth/fixtures/license.sql b/apps/labrinth/fixtures/license.sql new file mode 100644 index 0000000000..318f2fa043 --- /dev/null +++ b/apps/labrinth/fixtures/license.sql @@ -0,0 +1,18 @@ +-- Dummy moderation_external_licenses (explicit IDs required) +INSERT INTO moderation_external_licenses (id, title, status, link, exceptions, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by) +VALUES + (9001, 'Example Mod', 'yes', 'https://example.com/license', NULL, 'Verified by team', 101, now(), 0, now(), 0), + (9002, 'Cool Resource Pack', 'no', 'https://coolpack.com/terms', 'Non-commercial only', 'DMCA takedown filed', 202, now(), 0, now(), 0), + (9003, 'Mystery Project', 'unidentified', NULL, NULL, NULL, NULL, now(), 0, now(), 0), + (9004, 'Widget Lib', 'with-attribution', 'https://widgets.dev/MIT', NULL, 'License header in JAR', 303, now(), 0, now(), 0), + (9005, 'Shadow Mod', 'permanent-no', 'https://shadow.net/eula', 'Redistribution restricted','Under review', NULL, now(), 0, now(), 0); + +-- Dummy moderation_external_files (sha1 stored as ASCII bytes of hex string, matching Rust's .as_bytes()) +INSERT INTO moderation_external_files (sha1, filename, external_license_id) +VALUES + ('aabbccdd11223344aabbccdd11223344aabbccdd', 'example-mod-1.0.jar', 9001), + ('11223344aabbccdd11223344aabbccdd11223344', 'example-mod-1.1.jar', 9001), + ('deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', 'coolpack-v2.zip', 9002), + ('cafebabecafebabecafebabecafebabecafebabe', 'mystery.dat', 9003), + ('0102030405060708090a0b0c0d0e0f1011121314', 'widget-lib.jar', 9004); +-- License 9005 intentionally has no files (tests empty linked_files case) diff --git a/apps/labrinth/fixtures/moderation-data.sql b/apps/labrinth/fixtures/moderation-data.sql new file mode 100644 index 0000000000..318f2fa043 --- /dev/null +++ b/apps/labrinth/fixtures/moderation-data.sql @@ -0,0 +1,18 @@ +-- Dummy moderation_external_licenses (explicit IDs required) +INSERT INTO moderation_external_licenses (id, title, status, link, exceptions, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by) +VALUES + (9001, 'Example Mod', 'yes', 'https://example.com/license', NULL, 'Verified by team', 101, now(), 0, now(), 0), + (9002, 'Cool Resource Pack', 'no', 'https://coolpack.com/terms', 'Non-commercial only', 'DMCA takedown filed', 202, now(), 0, now(), 0), + (9003, 'Mystery Project', 'unidentified', NULL, NULL, NULL, NULL, now(), 0, now(), 0), + (9004, 'Widget Lib', 'with-attribution', 'https://widgets.dev/MIT', NULL, 'License header in JAR', 303, now(), 0, now(), 0), + (9005, 'Shadow Mod', 'permanent-no', 'https://shadow.net/eula', 'Redistribution restricted','Under review', NULL, now(), 0, now(), 0); + +-- Dummy moderation_external_files (sha1 stored as ASCII bytes of hex string, matching Rust's .as_bytes()) +INSERT INTO moderation_external_files (sha1, filename, external_license_id) +VALUES + ('aabbccdd11223344aabbccdd11223344aabbccdd', 'example-mod-1.0.jar', 9001), + ('11223344aabbccdd11223344aabbccdd11223344', 'example-mod-1.1.jar', 9001), + ('deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', 'coolpack-v2.zip', 9002), + ('cafebabecafebabecafebabecafebabecafebabe', 'mystery.dat', 9003), + ('0102030405060708090a0b0c0d0e0f1011121314', 'widget-lib.jar', 9004); +-- License 9005 intentionally has no files (tests empty linked_files case) diff --git a/apps/labrinth/migrations/20260406134638_moderation_external_audit_columns.sql b/apps/labrinth/migrations/20260406134638_moderation_external_audit_columns.sql new file mode 100644 index 0000000000..7cef380a67 --- /dev/null +++ b/apps/labrinth/migrations/20260406134638_moderation_external_audit_columns.sql @@ -0,0 +1,12 @@ +ALTER TABLE moderation_external_licenses + ADD COLUMN inserted_at timestamptz, + ADD COLUMN inserted_by bigint REFERENCES users(id), + ADD COLUMN updated_at timestamptz, + ADD COLUMN updated_by bigint REFERENCES users(id); + +ALTER TABLE moderation_external_files + ADD COLUMN filename text, + ADD COLUMN inserted_at timestamptz, + ADD COLUMN inserted_by bigint REFERENCES users(id), + ADD COLUMN updated_at timestamptz, + ADD COLUMN updated_by bigint REFERENCES users(id); diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 8945c4bb81..0db87c5082 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -11,6 +11,7 @@ pub mod ids; pub mod image_item; pub mod legacy_loader_fields; pub mod loader_fields; +pub mod moderation_external_item; pub mod moderation_lock_item; pub mod notification_item; pub mod notifications_deliveries_item; diff --git a/apps/labrinth/src/database/models/moderation_external_item.rs b/apps/labrinth/src/database/models/moderation_external_item.rs new file mode 100644 index 0000000000..f6c885f240 --- /dev/null +++ b/apps/labrinth/src/database/models/moderation_external_item.rs @@ -0,0 +1,98 @@ +use chrono::{DateTime, Utc}; + +use crate::database::models::DBUserId; + +pub struct ExternalLicense { + pub id: i64, + pub title: Option, + pub status: String, + pub link: Option, + pub proof: Option, + pub flame_project_id: Option, +} + +impl ExternalLicense { + pub async fn insert_many( + exec: impl sqlx::PgExecutor<'_>, + licenses: &[ExternalLicense], + user_id: DBUserId, + ) -> sqlx::Result<()> { + let now = Utc::now(); + + let ids: Vec = licenses.iter().map(|x| x.id).collect(); + let titles: Vec> = + licenses.iter().map(|x| x.title.clone()).collect(); + let statuses: Vec = + licenses.iter().map(|x| x.status.clone()).collect(); + let links: Vec> = + licenses.iter().map(|x| x.link.clone()).collect(); + let proofs: Vec> = + licenses.iter().map(|x| x.proof.clone()).collect(); + let flame_ids: Vec> = + licenses.iter().map(|x| x.flame_project_id).collect(); + let nows: Vec> = vec![now; licenses.len()]; + let user_ids: Vec = vec![user_id.0; licenses.len()]; + + sqlx::query!( + r#" + INSERT INTO moderation_external_licenses (id, title, status, link, proof, flame_project_id, inserted_at, inserted_by, updated_at, updated_by) + SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::varchar[], $5::varchar[], $6::integer[], $7::timestamptz[], $8::bigint[], $7::timestamptz[], $8::bigint[]) + ON CONFLICT (id) DO UPDATE SET + title = EXCLUDED.title, + status = EXCLUDED.status, + link = EXCLUDED.link, + proof = EXCLUDED.proof, + flame_project_id = EXCLUDED.flame_project_id, + updated_at = EXCLUDED.updated_at, + updated_by = EXCLUDED.updated_by + "#, + &ids, + &titles as _, + &statuses, + &links as _, + &proofs as _, + &flame_ids as _, + &nows, + &user_ids, + ) + .execute(exec) + .await?; + + Ok(()) + } + + pub async fn insert_files( + exec: impl sqlx::PgExecutor<'_>, + hashes: &[Vec], + filenames: &[Option], + license_ids: &[i64], + user_id: DBUserId, + ) -> sqlx::Result<()> { + let now = Utc::now(); + let nows: Vec> = vec![now; license_ids.len()]; + let user_ids: Vec = vec![user_id.0; license_ids.len()]; + + let filenames: Vec> = filenames.to_vec(); + + sqlx::query!( + r#" + INSERT INTO moderation_external_files (sha1, filename, external_license_id, inserted_at, inserted_by, updated_at, updated_by) + SELECT * FROM UNNEST ($1::bytea[], $2::varchar[], $3::bigint[], $4::timestamptz[], $5::bigint[], $4::timestamptz[], $5::bigint[]) + ON CONFLICT (sha1) DO UPDATE SET + filename = COALESCE(EXCLUDED.filename, moderation_external_files.filename), + external_license_id = EXCLUDED.external_license_id, + updated_at = EXCLUDED.updated_at, + updated_by = EXCLUDED.updated_by + "#, + hashes, + &filenames as _, + license_ids, + &nows, + &user_ids, + ) + .execute(exec) + .await?; + + Ok(()) + } +} diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs index 4ff4b69e21..7d852f4eb3 100644 --- a/apps/labrinth/src/queue/moderation.rs +++ b/apps/labrinth/src/queue/moderation.rs @@ -1,6 +1,7 @@ use crate::auth::checks::filter_visible_versions; use crate::database; use crate::database::PgPool; +use crate::database::models::DBUserId; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::redis::RedisPool; @@ -507,6 +508,7 @@ impl AutomatedModerationQueue { .fetch_all(&pool).await?; let mut insert_hashes = Vec::new(); + let mut insert_filenames = Vec::new(); let mut insert_ids = Vec::new(); for row in rows { @@ -518,6 +520,7 @@ impl AutomatedModerationQueue { }); insert_hashes.push(hash.clone().as_bytes().to_vec()); + insert_filenames.push(Some(file_name.clone())); insert_ids.push(row.id); hashes.remove(index); @@ -526,16 +529,13 @@ impl AutomatedModerationQueue { } if !insert_ids.is_empty() && !insert_hashes.is_empty() { - sqlx::query!( - " - INSERT INTO moderation_external_files (sha1, external_license_id) - SELECT * FROM UNNEST ($1::bytea[], $2::bigint[]) - ON CONFLICT (sha1) DO NOTHING - ", - &insert_hashes[..], - &insert_ids[..] + crate::database::models::moderation_external_item::ExternalLicense::insert_files( + &pool, + &insert_hashes, + &insert_filenames, + &insert_ids, + DBUserId(0), ) - .execute(&pool) .await?; } diff --git a/apps/labrinth/src/routes/internal/moderation/external_license.rs b/apps/labrinth/src/routes/internal/moderation/external_license.rs new file mode 100644 index 0000000000..cbcf03b245 --- /dev/null +++ b/apps/labrinth/src/routes/internal/moderation/external_license.rs @@ -0,0 +1,334 @@ +use std::collections::HashMap; + +use actix_web::{HttpRequest, get, patch, post, web}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::database::PgPool; +use crate::database::redis::RedisPool; +use crate::models::pats::Scopes; +use crate::queue::moderation::ApprovalType; +use crate::routes::ApiError; +use crate::{auth::check_is_moderator_from_headers, queue::session::AuthQueue}; + +pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { + cfg.service(search) + .service(get_by_sha1) + .service(update_license); +} + +#[derive(Serialize, Deserialize, utoipa::ToSchema)] +pub struct ExternalProject { + pub id: i64, + pub title: Option, + pub status: ApprovalType, + pub link: Option, + pub exceptions: Option, + pub proof: Option, + pub flame_project_id: Option, + pub inserted_at: Option>, + pub inserted_by: Option, + pub updated_at: Option>, + pub updated_by: Option, + pub linked_files: Vec, +} + +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] +pub struct LinkedFile { + pub name: Option, + pub sha1: String, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct SearchRequest { + pub title: Option, + pub flame_id: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateLicenseRequest { + pub title: Option, + pub status: ApprovalType, + pub link: Option, + pub exceptions: Option, + pub proof: Option, + pub flame_project_id: Option, +} + +struct LicenseRow { + id: i64, + title: Option, + status: String, + link: Option, + exceptions: Option, + proof: Option, + flame_project_id: Option, + inserted_at: Option>, + inserted_by: Option, + updated_at: Option>, + updated_by: Option, +} + +impl LicenseRow { + fn into_external_project( + self, + linked_files: Vec, + ) -> ExternalProject { + ExternalProject { + id: self.id, + title: self.title, + status: ApprovalType::from_string(&self.status) + .unwrap_or(ApprovalType::Unidentified), + link: self.link, + exceptions: self.exceptions, + proof: self.proof, + flame_project_id: self.flame_project_id, + inserted_at: self.inserted_at, + inserted_by: self.inserted_by, + updated_at: self.updated_at, + updated_by: self.updated_by, + linked_files, + } + } +} + +async fn fetch_linked_files( + pool: &PgPool, + license_ids: &[i64], +) -> Result>, ApiError> { + if license_ids.is_empty() { + return Ok(HashMap::new()); + } + + let file_rows = sqlx::query!( + r#" + SELECT + mef.external_license_id, + mef.sha1, + mef.filename + FROM moderation_external_files mef + WHERE mef.external_license_id = ANY($1) + "#, + license_ids, + ) + .fetch_all(pool) + .await?; + + let mut map: HashMap> = HashMap::new(); + for row in file_rows { + map.entry(row.external_license_id) + .or_default() + .push(LinkedFile { + name: row.filename, + sha1: hex::encode(&row.sha1), + }); + } + Ok(map) +} + +#[utoipa::path] +#[post("/search")] +async fn search( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + body: web::Json, +) -> Result>, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let rows = sqlx::query!( + r#" + SELECT + mel.id, + mel.title, + mel.status, + mel.link, + mel.exceptions, + mel.proof, + mel.flame_project_id, + mel.inserted_at, + mel.inserted_by, + mel.updated_at, + mel.updated_by + FROM moderation_external_licenses mel + WHERE ($1::text IS NULL OR mel.title ILIKE '%' || $1 || '%') + AND ($2::integer IS NULL OR mel.flame_project_id = $2) + ORDER BY mel.id + "#, + body.title, + body.flame_id, + ) + .fetch_all(&**pool) + .await?; + + let license_ids: Vec = rows.iter().map(|r| r.id).collect(); + let files_map = fetch_linked_files(&pool, &license_ids).await?; + + let results = rows + .into_iter() + .map(|row| { + let linked_files = + files_map.get(&row.id).cloned().unwrap_or_default(); + LicenseRow { + id: row.id, + title: row.title, + status: row.status, + link: row.link, + exceptions: row.exceptions, + proof: row.proof, + flame_project_id: row.flame_project_id, + inserted_at: row.inserted_at, + inserted_by: row.inserted_by, + updated_at: row.updated_at, + updated_by: row.updated_by, + } + .into_external_project(linked_files) + }) + .collect(); + + Ok(web::Json(results)) +} + +#[utoipa::path] +#[get("/by-sha1/{sha1}")] +async fn get_by_sha1( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(String,)>, +) -> Result, ApiError> { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let sha1 = path.into_inner().0; + + let row = sqlx::query!( + r#" + SELECT + mel.id, + mel.title, + mel.status, + mel.link, + mel.exceptions, + mel.proof, + mel.flame_project_id, + mel.inserted_at, + mel.inserted_by, + mel.updated_at, + mel.updated_by + FROM moderation_external_files mef + INNER JOIN moderation_external_licenses mel ON mel.id = mef.external_license_id + WHERE mef.sha1 = $1 + "#, + sha1.as_bytes().to_vec(), + ) + .fetch_optional(&**pool) + .await? + .ok_or(ApiError::NotFound)?; + + let files_map = fetch_linked_files(&pool, &[row.id]).await?; + let linked_files = files_map.get(&row.id).cloned().unwrap_or_default(); + + Ok(web::Json( + LicenseRow { + id: row.id, + title: row.title, + status: row.status, + link: row.link, + exceptions: row.exceptions, + proof: row.proof, + flame_project_id: row.flame_project_id, + inserted_at: row.inserted_at, + inserted_by: row.inserted_by, + updated_at: row.updated_at, + updated_by: row.updated_by, + } + .into_external_project(linked_files), + )) +} + +#[utoipa::path] +#[patch("/{id}")] +async fn update_license( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + path: web::Path<(i64,)>, + body: web::Json, +) -> Result, ApiError> { + let user = check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::PROJECT_READ, + ) + .await?; + + let id = path.into_inner().0; + + let result = sqlx::query!( + r#" + UPDATE moderation_external_licenses + SET title = COALESCE($2, title), + status = $3, + link = COALESCE($4, link), + exceptions = COALESCE($5, exceptions), + proof = COALESCE($6, proof), + flame_project_id = COALESCE($7, flame_project_id), + updated_at = $8, + updated_by = $9 + WHERE id = $1 + RETURNING id, title, status, link, exceptions, proof, flame_project_id, + inserted_at, inserted_by, updated_at, updated_by + "#, + id, + body.title, + body.status.as_str(), + body.link, + body.exceptions, + body.proof, + body.flame_project_id, + Utc::now(), + user.id.0 as i64, + ) + .fetch_optional(&**pool) + .await? + .ok_or(ApiError::NotFound)?; + + let files_map = fetch_linked_files(&pool, &[id]).await?; + let linked_files = files_map.get(&id).cloned().unwrap_or_default(); + + Ok(web::Json( + LicenseRow { + id: result.id, + title: result.title, + status: result.status, + link: result.link, + exceptions: result.exceptions, + proof: result.proof, + flame_project_id: result.flame_project_id, + inserted_at: result.inserted_at, + inserted_by: result.inserted_by, + updated_at: result.updated_at, + updated_by: result.updated_by, + } + .into_external_project(linked_files), + )) +} diff --git a/apps/labrinth/src/routes/internal/moderation/mod.rs b/apps/labrinth/src/routes/internal/moderation/mod.rs index 549b826465..c3b77bcbed 100644 --- a/apps/labrinth/src/routes/internal/moderation/mod.rs +++ b/apps/labrinth/src/routes/internal/moderation/mod.rs @@ -3,6 +3,7 @@ use crate::auth::get_user_from_headers; use crate::database; use crate::database::PgPool; use crate::database::models::DBModerationLock; +use crate::database::models::moderation_external_item; use crate::database::redis::RedisPool; use crate::models::ids::OrganizationId; use crate::models::projects::{Project, ProjectStatus}; @@ -20,6 +21,7 @@ use ownership::get_projects_ownership; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +mod external_license; mod ownership; mod tech_review; @@ -36,6 +38,10 @@ pub fn config(cfg: &mut utoipa_actix_web::service_config::ServiceConfig) { .service( utoipa_actix_web::scope("/tech-review") .configure(tech_review::config), + ) + .service( + utoipa_actix_web::scope("/external-license") + .configure(external_license::config), ); } @@ -412,7 +418,7 @@ async fn set_project_meta( session_queue: web::Data, judgements: web::Json>, ) -> Result<(), ApiError> { - check_is_moderator_from_headers( + let user = check_is_moderator_from_headers( &req, &**pool, &redis, @@ -423,14 +429,10 @@ async fn set_project_meta( let mut transaction = pool.begin().await?; - let mut ids = Vec::new(); - let mut titles = Vec::new(); - let mut statuses = Vec::new(); - let mut links = Vec::new(); - let mut proofs = Vec::new(); - let mut flame_ids = Vec::new(); - + let mut licenses = Vec::new(); let mut file_hashes = Vec::new(); + let mut file_filenames = Vec::new(); + let mut file_license_ids = Vec::new(); for (hash, judgement) in judgements.0 { let id = random_base62(8); @@ -456,41 +458,38 @@ async fn set_project_meta( } => (title, status, link, proof, None), }; - ids.push(id as i64); - titles.push(title); - statuses.push(status.as_str()); - links.push(link); - proofs.push(proof); - flame_ids.push(flame_id); + licenses.push(moderation_external_item::ExternalLicense { + id: id as i64, + title, + status: status.as_str().to_string(), + link, + proof, + flame_project_id: flame_id, + }); file_hashes.push(hash); + file_filenames.push(None); + file_license_ids.push(id as i64); } - sqlx::query( - " - INSERT INTO moderation_external_licenses (id, title, status, link, proof, flame_project_id) - SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::varchar[], $5::varchar[], $6::integer[]) - " + let user_id = database::models::ids::DBUserId::from(user.id); + + moderation_external_item::ExternalLicense::insert_many( + &mut transaction, + &licenses, + user_id, ) - .bind(&ids[..]) - .bind(&titles[..]) - .bind(&statuses[..]) - .bind(&links[..]) - .bind(&proofs[..]) - .bind(&flame_ids[..]) - .execute(&mut transaction) - .await?; + .await?; - sqlx::query( - " - INSERT INTO moderation_external_files (sha1, external_license_id) - SELECT * FROM UNNEST ($1::bytea[], $2::bigint[]) - ON CONFLICT (sha1) - DO NOTHING - ", + moderation_external_item::ExternalLicense::insert_files( + &mut transaction, + &file_hashes + .iter() + .map(|x| x.as_bytes().to_vec()) + .collect::>(), + &file_filenames, + &file_license_ids, + user_id, ) - .bind(&file_hashes[..]) - .bind(&ids[..]) - .execute(&mut transaction) .await?; transaction.commit().await?; diff --git a/packages/api-client/src/modules/index.ts b/packages/api-client/src/modules/index.ts index c1b9b13fef..7219d12e55 100644 --- a/packages/api-client/src/modules/index.ts +++ b/packages/api-client/src/modules/index.ts @@ -17,6 +17,7 @@ import { LabrinthAuthInternalModule } from './labrinth/auth/internal' import { LabrinthAuthV2Module } from './labrinth/auth/v2' import { LabrinthBillingInternalModule } from './labrinth/billing/internal' import { LabrinthCollectionsModule } from './labrinth/collections' +import { LabrinthExternalProjectsInternalModule } from './labrinth/external-projects/internal' import { LabrinthGlobalsInternalModule } from './labrinth/globals/internal' import { LabrinthLimitsV3Module } from './labrinth/limits/v3' import { LabrinthModerationInternalModule } from './labrinth/moderation/internal' @@ -75,6 +76,7 @@ export const MODULE_REGISTRY = { labrinth_auth_v2: LabrinthAuthV2Module, labrinth_billing_internal: LabrinthBillingInternalModule, labrinth_collections: LabrinthCollectionsModule, + labrinth_external_projects_internal: LabrinthExternalProjectsInternalModule, labrinth_globals_internal: LabrinthGlobalsInternalModule, labrinth_moderation_internal: LabrinthModerationInternalModule, labrinth_notifications_v2: LabrinthNotificationsV2Module, diff --git a/packages/api-client/src/modules/labrinth/external-projects/internal.ts b/packages/api-client/src/modules/labrinth/external-projects/internal.ts new file mode 100644 index 0000000000..4d0e278a02 --- /dev/null +++ b/packages/api-client/src/modules/labrinth/external-projects/internal.ts @@ -0,0 +1,50 @@ +import { AbstractModule } from '../../../core/abstract-module' +import type { Labrinth } from '../types' + +export class LabrinthExternalProjectsInternalModule extends AbstractModule { + public getModuleID(): string { + return 'labrinth_external_projects_internal' + } + + public async search( + data: Labrinth.ExternalProjects.Internal.SearchRequest, + ): Promise { + return this.client.request( + '/moderation/external-license/search', + { + api: 'labrinth', + version: 'internal', + method: 'POST', + body: data, + }, + ) + } + + public async getBySha1( + sha1: string, + ): Promise { + return this.client.request( + `/moderation/external-license/by-sha1/${sha1}`, + { + api: 'labrinth', + version: 'internal', + method: 'GET', + }, + ) + } + + public async update( + id: number, + data: Labrinth.ExternalProjects.Internal.UpdateLicenseRequest, + ): Promise { + return this.client.request( + `/moderation/external-license/${id}`, + { + api: 'labrinth', + version: 'internal', + method: 'PATCH', + body: data, + }, + ) + } +} diff --git a/packages/api-client/src/modules/labrinth/index.ts b/packages/api-client/src/modules/labrinth/index.ts index 123bf20684..883bac31d7 100644 --- a/packages/api-client/src/modules/labrinth/index.ts +++ b/packages/api-client/src/modules/labrinth/index.ts @@ -2,6 +2,7 @@ export * from './auth/internal' export * from './auth/v2' export * from './billing/internal' export * from './collections' +export * from './external-projects/internal' export * from './globals/internal' export * from './limits/v3' export * from './moderation/internal' diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 84f92bc84d..a1ba87e1ed 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -1462,6 +1462,52 @@ export namespace Labrinth { } } + export namespace ExternalProjects { + export namespace Internal { + export type ExternalLicenseStatus = + | 'yes' + | 'with-attribution-and-source' + | 'with-attribution' + | 'no' + | 'permanent-no' + | 'unidentified' + + export type LinkedFile = { + name: string | null + sha1: string + } + + export type ExternalProject = { + id: number + title: string | null + status: ExternalLicenseStatus + link: string | null + exceptions: string | null + proof: string | null + flame_project_id: number | null + inserted_at: string | null + inserted_by: number | null + updated_at: string | null + updated_by: number | null + linked_files: LinkedFile[] + } + + export type SearchRequest = { + title?: string + flame_id?: number + } + + export type UpdateLicenseRequest = { + title?: string + status: ExternalLicenseStatus + link?: string + exceptions?: string + proof?: string + flame_project_id?: number + } + } + } + export namespace TechReview { export namespace Internal { export type SearchProjectsRequest = { diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index bce13e0dc2..c5cb8747fe 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -26,6 +26,7 @@ import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component' import _BanIcon from './icons/ban.svg?component' import _BellIcon from './icons/bell.svg?component' import _BellRingIcon from './icons/bell-ring.svg?component' +import _BinaryIcon from './icons/binary.svg?component' import _BlendIcon from './icons/blend.svg?component' import _BlocksIcon from './icons/blocks.svg?component' import _BoldIcon from './icons/bold.svg?component' @@ -211,6 +212,7 @@ import _ShieldIcon from './icons/shield.svg?component' import _ShieldAlertIcon from './icons/shield-alert.svg?component' import _ShieldCheckIcon from './icons/shield-check.svg?component' import _SignalIcon from './icons/signal.svg?component' +import _SignatureIcon from './icons/signature.svg?component' import _SkullIcon from './icons/skull.svg?component' import _SlashIcon from './icons/slash.svg?component' import _SortAscIcon from './icons/sort-asc.svg?component' @@ -416,6 +418,7 @@ export const BadgeDollarSignIcon = _BadgeDollarSignIcon export const BanIcon = _BanIcon export const BellIcon = _BellIcon export const BellRingIcon = _BellRingIcon +export const BinaryIcon = _BinaryIcon export const BlendIcon = _BlendIcon export const BlocksIcon = _BlocksIcon export const BoldIcon = _BoldIcon @@ -601,6 +604,7 @@ export const ShieldIcon = _ShieldIcon export const ShieldAlertIcon = _ShieldAlertIcon export const ShieldCheckIcon = _ShieldCheckIcon export const SignalIcon = _SignalIcon +export const SignatureIcon = _SignatureIcon export const SkullIcon = _SkullIcon export const SlashIcon = _SlashIcon export const SortAscIcon = _SortAscIcon diff --git a/packages/assets/icons/binary.svg b/packages/assets/icons/binary.svg new file mode 100644 index 0000000000..702973f520 --- /dev/null +++ b/packages/assets/icons/binary.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/packages/assets/icons/signature.svg b/packages/assets/icons/signature.svg new file mode 100644 index 0000000000..298ef3587f --- /dev/null +++ b/packages/assets/icons/signature.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/ui/src/components/base/StyledInput.vue b/packages/ui/src/components/base/StyledInput.vue index 1d904571bd..355975fb1b 100644 --- a/packages/ui/src/components/base/StyledInput.vue +++ b/packages/ui/src/components/base/StyledInput.vue @@ -71,6 +71,9 @@ variant === 'outlined' ? 'bg-transparent border border-solid border-button-bg rounded-l-xl border-r-0' : 'bg-surface-4 border-none rounded-xl', + { + 'placeholder:text-sm': type === 'search', + }, ]" @input="onInput" @focus="isFocused = true" diff --git a/packages/ui/src/components/external_files/ExternalProjectLicenseStateTag.vue b/packages/ui/src/components/external_files/ExternalProjectLicenseStateTag.vue new file mode 100644 index 0000000000..2b5aea793c --- /dev/null +++ b/packages/ui/src/components/external_files/ExternalProjectLicenseStateTag.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/ui/src/components/external_files/ExternalProjectLookupCard.vue b/packages/ui/src/components/external_files/ExternalProjectLookupCard.vue new file mode 100644 index 0000000000..4bf6a34a92 --- /dev/null +++ b/packages/ui/src/components/external_files/ExternalProjectLookupCard.vue @@ -0,0 +1,117 @@ + + + diff --git a/packages/ui/src/components/external_files/ExternalProjectPermissionsCard.vue b/packages/ui/src/components/external_files/ExternalProjectPermissionsCard.vue new file mode 100644 index 0000000000..965dcfdda5 --- /dev/null +++ b/packages/ui/src/components/external_files/ExternalProjectPermissionsCard.vue @@ -0,0 +1,176 @@ + + diff --git a/packages/ui/src/components/external_files/index.ts b/packages/ui/src/components/external_files/index.ts new file mode 100644 index 0000000000..e0ec715662 --- /dev/null +++ b/packages/ui/src/components/external_files/index.ts @@ -0,0 +1,3 @@ +export { default as ExternalProjectLicenseStateTag } from './ExternalProjectLicenseStateTag.vue' +export { default as ExternalProjectLookupCard } from './ExternalProjectLookupCard.vue' +export type { ExternalLicenseStatus } from './types.ts' diff --git a/packages/ui/src/components/external_files/types.ts b/packages/ui/src/components/external_files/types.ts new file mode 100644 index 0000000000..331d41bf95 --- /dev/null +++ b/packages/ui/src/components/external_files/types.ts @@ -0,0 +1,7 @@ +export type ExternalLicenseStatus = + | 'yes' + | 'with-attribution-and-source' + | 'with-attribution' + | 'no' + | 'permanent-no' + | 'unidentified' diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 1f00fa63a7..5726399250 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -5,6 +5,7 @@ export * from './brand' export * from './changelog' export * from './chart' export * from './content' +export * from './external_files' export * from './modal' export * from './nav' export * from './page' diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index 218450499d..afc77f6a30 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -758,6 +758,24 @@ "creation-flow.title.set-up-server": { "defaultMessage": "Set up server" }, + "external-project-license-status.no": { + "defaultMessage": "No" + }, + "external-project-license-status.permanent-no": { + "defaultMessage": "Permanent no" + }, + "external-project-license-status.unidentified": { + "defaultMessage": "Unidentified" + }, + "external-project-license-status.with-attribution": { + "defaultMessage": "With attribution" + }, + "external-project-license-status.with-attribution-and-source": { + "defaultMessage": "With attribution and source" + }, + "external-project-license-status.yes": { + "defaultMessage": "Yes" + }, "files.conflict-modal.header": { "defaultMessage": "Extract summary" }, @@ -2789,6 +2807,9 @@ "project.settings.upload.title": { "defaultMessage": "Upload" }, + "project.settings.versions.permissions": { + "defaultMessage": "Permissions" + }, "project.settings.versions.title": { "defaultMessage": "Versions" }, diff --git a/packages/ui/src/utils/common-messages.ts b/packages/ui/src/utils/common-messages.ts index 1c773879da..ec9c953647 100644 --- a/packages/ui/src/utils/common-messages.ts +++ b/packages/ui/src/utils/common-messages.ts @@ -952,6 +952,10 @@ export const commonProjectSettingsMessages = defineMessages({ id: 'project.settings.versions.title', defaultMessage: 'Versions', }, + permissions: { + id: 'project.settings.versions.permissions', + defaultMessage: 'Permissions', + }, view: { id: 'project.settings.view.title', defaultMessage: 'View', @@ -1090,3 +1094,30 @@ export const paymentMethodMessages = defineMessages({ defaultMessage: 'Charities', }, }) + +export const externalProjectLicenseStatusMessages = defineMessages({ + yes: { + id: 'external-project-license-status.yes', + defaultMessage: 'Yes', + }, + 'with-attribution-and-source': { + id: 'external-project-license-status.with-attribution-and-source', + defaultMessage: 'With attribution and source', + }, + 'with-attribution': { + id: 'external-project-license-status.with-attribution', + defaultMessage: 'With attribution', + }, + no: { + id: 'external-project-license-status.no', + defaultMessage: 'No', + }, + 'permanent-no': { + id: 'external-project-license-status.permanent-no', + defaultMessage: 'Permanent no', + }, + unidentified: { + id: 'external-project-license-status.unidentified', + defaultMessage: 'Unidentified', + }, +})