diff --git a/apps/frontend/src/components/copyright-archives/components/ArchiveJsonForm.vue b/apps/frontend/src/components/copyright-archives/components/ArchiveJsonForm.vue new file mode 100644 index 00000000000..3405444613e --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/components/ArchiveJsonForm.vue @@ -0,0 +1,71 @@ + + + diff --git a/apps/frontend/src/components/copyright-archives/components/ArchiveModal.vue b/apps/frontend/src/components/copyright-archives/components/ArchiveModal.vue new file mode 100644 index 00000000000..0cf26444a95 --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/components/ArchiveModal.vue @@ -0,0 +1,43 @@ + + + diff --git a/apps/frontend/src/components/copyright-archives/components/BulkImportModal.vue b/apps/frontend/src/components/copyright-archives/components/BulkImportModal.vue new file mode 100644 index 00000000000..bacd2b0e0ba --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/components/BulkImportModal.vue @@ -0,0 +1,84 @@ + + + diff --git a/apps/frontend/src/components/copyright-archives/components/JsonEditor.vue b/apps/frontend/src/components/copyright-archives/components/JsonEditor.vue new file mode 100644 index 00000000000..aab0640dd3a --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/components/JsonEditor.vue @@ -0,0 +1,61 @@ + + + diff --git a/apps/frontend/src/components/copyright-archives/index.vue b/apps/frontend/src/components/copyright-archives/index.vue new file mode 100644 index 00000000000..3f0be70cef6 --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/index.vue @@ -0,0 +1,246 @@ + + + diff --git a/apps/frontend/src/components/copyright-archives/types.ts b/apps/frontend/src/components/copyright-archives/types.ts new file mode 100644 index 00000000000..fd1d9131916 --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/types.ts @@ -0,0 +1,27 @@ +import type { Labrinth } from '@modrinth/api-client' + +export type CopyrightProjectArchive = Labrinth.CopyrightArchives.Internal.Metadata & { + id: string + files?: Labrinth.CopyrightArchives.Internal.Create['files'] +} + +export type ArchiveInput = Labrinth.CopyrightArchives.Internal.Create & { id?: string } +export type ArchiveDraft = Omit & { + minecraftJson: string +} + +export function toDraft(archive: CopyrightProjectArchive): ArchiveDraft { + const { minecraft_metadata, ...rest } = archive + return { + ...rest, + minecraftJson: minecraft_metadata ? JSON.stringify(minecraft_metadata, null, '\t') : '', + } +} + +export function fromDraft(draft: ArchiveDraft): CopyrightProjectArchive { + const { minecraftJson, ...archive } = draft + return { + ...archive, + ...(minecraftJson.trim() ? { minecraft_metadata: JSON.parse(minecraftJson) } : {}), + } +} diff --git a/apps/frontend/src/components/copyright-archives/use-archive-editor.ts b/apps/frontend/src/components/copyright-archives/use-archive-editor.ts new file mode 100644 index 00000000000..30b63ea7f21 --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/use-archive-editor.ts @@ -0,0 +1,273 @@ +import type { Labrinth } from '@modrinth/api-client' +import { injectModrinthClient, injectNotificationManager } from '@modrinth/ui' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' +import { computed, ref, watch } from 'vue' + +import { + type ArchiveDraft, + type ArchiveInput, + type CopyrightProjectArchive, + fromDraft, + toDraft, +} from './types' +import { validateArchive } from './validation' + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) +} + +export function useArchiveEditor() { + const { addNotification } = injectNotificationManager() + const saved = ref([]) + const archives = ref([]) + const archiveJson = ref>({}) + const validationErrors = ref>({}) + const originalSnapshot = computed(() => + saved.value.map((archive) => JSON.stringify(fromDraft(archive), null, '\t')), + ) + const currentSnapshot = computed(() => + archives.value.map( + (archive) => archiveJson.value[archive.id] ?? JSON.stringify(fromDraft(archive), null, '\t'), + ), + ) + const hasChanges = computed( + () => JSON.stringify(originalSnapshot.value) !== JSON.stringify(currentSnapshot.value), + ) + + const client = injectModrinthClient() + const api = client.labrinth.copyright_archives_internal + const queryClient = useQueryClient() + const queryKey = ['copyright-archives', 'list'] as const + const { + data, + isFetching: loading, + error: queryError, + refetch: load, + } = useQuery({ + queryKey, + enabled: import.meta.client, + refetchOnWindowFocus: false, + queryFn: async () => { + const records: CopyrightProjectArchive[] = [] + const limit = 100 + for (let offset = 0; ; offset += limit) { + const page = await api.list({ limit, offset }) + records.push(...page) + if (page.length < limit) return records + } + }, + }) + const loadError = computed(() => queryError.value?.message ?? '') + watch( + queryError, + (error) => { + if (error) + addNotification({ + title: 'Could not load copyright archives', + text: error.message, + type: 'error', + }) + }, + { immediate: true }, + ) + const { mutateAsync: persist, isPending: saving } = useMutation({ + mutationFn: async (operation: () => Promise) => operation(), + retry: false, + onSuccess: () => { + queryClient.setQueryData(queryKey, clone(saved.value.map(fromDraft))) + void queryClient.invalidateQueries({ queryKey }) + }, + }) + const busy = computed(() => loading.value || saving.value || !!loadError.value) + watch( + data, + (records) => { + if (!records || hasChanges.value || saving.value) return + saved.value = records.map(toDraft) + reset() + }, + { immediate: true }, + ) + + function accept(records: CopyrightProjectArchive[]) { + for (const record of records) { + const index = saved.value.findIndex((archive) => archive.id === record.id) + if (index === -1) saved.value.push(toDraft(record)) + else saved.value[index] = toDraft(record) + } + } + + function reset() { + if (saving.value) return + archives.value = clone(saved.value) + archiveJson.value = Object.fromEntries( + archives.value.map((archive) => [archive.id, JSON.stringify(fromDraft(archive), null, '\t')]), + ) + validationErrors.value = {} + } + + async function save(): Promise { + if (busy.value || !hasChanges.value) return false + validationErrors.value = {} + const records: Labrinth.CopyrightArchives.Internal.Replace[] = [] + const originals = new Map(saved.value.map((archive) => [archive.id, fromDraft(archive)])) + for (const archive of archives.value) { + try { + const value: unknown = JSON.parse( + archiveJson.value[archive.id] ?? JSON.stringify(fromDraft(archive)), + ) + if (JSON.stringify(value) === JSON.stringify(originals.get(archive.id))) continue + const errors = validateArchive(value) + if (!errors.length && (value as ArchiveInput).id !== archive.id) + errors.push('The archive ID must remain unchanged.') + if (errors.length) validationErrors.value[archive.id] = errors.join(' ') + else { + const record = fromDraft( + toDraft(value as CopyrightProjectArchive), + ) as Labrinth.CopyrightArchives.Internal.Replace + if (JSON.stringify(record) !== JSON.stringify(originals.get(archive.id))) + records.push(record) + } + } catch { + validationErrors.value[archive.id] = 'Enter valid JSON for a single archive object.' + } + } + if (Object.keys(validationErrors.value).length) { + addNotification({ + title: 'Could not save copyright archives', + text: Object.entries(validationErrors.value) + .map(([id, message]) => `Archive ${id}: ${message}`) + .join('\n'), + type: 'error', + }) + return false + } + try { + await persist(async () => { + if (records.length) { + await api.replace(records) + accept(records) + } + const ids = new Set(archives.value.map((archive) => archive.id)) + const deleted = saved.value + .filter((archive) => !ids.has(archive.id)) + .map((archive) => archive.id) + if (deleted.length) { + await api.delete(deleted) + saved.value = saved.value.filter((archive) => ids.has(archive.id)) + } + }) + reset() + addNotification({ title: 'Copyright archives saved', type: 'success' }) + return true + } catch (error) { + addNotification({ + title: 'Could not save copyright archives', + text: error instanceof Error ? error.message : String(error), + type: 'error', + }) + return false + } + } + + async function upsert(entries: ArchiveInput[]): Promise { + if (busy.value) return false + const errors = entries.flatMap(validateArchive) + if (errors.length) { + addNotification({ + title: 'Invalid copyright archives', + text: errors.join(' '), + type: 'error', + }) + return false + } + const ids = new Set() + for (const entry of entries) { + if ( + entry.id && + (ids.has(entry.id) || !saved.value.some((archive) => archive.id === entry.id)) + ) { + addNotification({ + title: 'Invalid copyright archives', + text: `Unknown or duplicate archive ID "${entry.id}".`, + type: 'error', + }) + return false + } + if (entry.id) ids.add(entry.id) + } + function apply(records: CopyrightProjectArchive[]) { + accept(records) + for (const record of records) { + const archive = toDraft(record) + const index = archives.value.findIndex((entry) => entry.id === record.id) + if (index === -1) archives.value.push(archive) + else archives.value[index] = archive + archiveJson.value[record.id] = JSON.stringify(fromDraft(archive), null, '\t') + Reflect.deleteProperty(validationErrors.value, record.id) + } + } + const replacements = entries.filter( + (entry): entry is Labrinth.CopyrightArchives.Internal.Replace => !!entry.id, + ) + const additions = entries.filter((entry) => !entry.id) + let replacementsSaved = false + try { + await persist(async () => { + if (replacements.length) { + await api.replace(replacements) + replacementsSaved = true + apply(replacements) + } + if (additions.length) { + const newIds = await api.create(additions) + apply(additions.map((entry, index) => ({ ...entry, id: newIds[index] }))) + } + }) + addNotification({ title: 'Copyright archives saved', type: 'success' }) + return true + } catch (error) { + const outcome = + replacements.length && !replacementsSaved + ? `No archives were saved. Failed to replace ${replacements.length} existing archive(s). ${additions.length} new archive(s) were not attempted.` + : `${replacements.length} existing archive(s) replaced. Failed to create ${additions.length} new archive(s).` + addNotification({ + title: replacementsSaved + ? 'Copyright archives partially saved' + : 'Could not save copyright archives', + text: `${outcome}\n${error instanceof Error ? error.message : String(error)}`, + type: 'error', + }) + return false + } + } + + function remove(id: string) { + if (busy.value) return + archives.value = archives.value.filter((archive) => archive.id !== id) + archiveJson.value = Object.fromEntries( + Object.entries(archiveJson.value).filter(([archiveId]) => archiveId !== id), + ) + validationErrors.value = Object.fromEntries( + Object.entries(validationErrors.value).filter(([archiveId]) => archiveId !== id), + ) + } + + return { + archives, + archiveJson, + validationErrors, + loading, + saving, + busy, + loadError, + originalSnapshot, + currentSnapshot, + hasChanges, + load, + upsert, + remove, + save, + reset, + } +} diff --git a/apps/frontend/src/components/copyright-archives/validation.ts b/apps/frontend/src/components/copyright-archives/validation.ts new file mode 100644 index 00000000000..5e10ffbab13 --- /dev/null +++ b/apps/frontend/src/components/copyright-archives/validation.ts @@ -0,0 +1,130 @@ +import type { ArchiveInput } from './types' + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function validateArchive(value: unknown): string[] { + if (!isObject(value)) return ['Expected an archive object.'] + const errors: string[] = [] + function requiredString(object: Record, key: string, prefix = '') { + if (typeof object[key] !== 'string' || !object[key].trim()) { + errors.push(`${prefix}${key}: enter a non-empty string.`) + } + } + function allowedFields(object: Record, fields: string[], prefix = '') { + for (const key of Object.keys(object)) { + if (!fields.includes(key)) errors.push(`${prefix}${key}: unknown field.`) + } + } + allowedFields(value, [ + 'id', + 'name', + 'modrinth_metadata', + 'minecraft_metadata', + 'license', + 'license_terms', + 'files', + ]) + if (value.id !== undefined) requiredString(value, 'id') + requiredString(value, 'name') + requiredString(value, 'license') + if (value.modrinth_metadata != null) { + if (!isObject(value.modrinth_metadata)) errors.push('modrinth_metadata: expected an object.') + else { + allowedFields( + value.modrinth_metadata, + ['project_id', 'version_id', 'project_type'], + 'modrinth_metadata.', + ) + for (const key of ['project_id', 'version_id', 'project_type']) { + requiredString(value.modrinth_metadata, key, 'modrinth_metadata.') + } + } + } + if (value.minecraft_metadata != null) { + if (!isObject(value.minecraft_metadata)) errors.push('minecraft_metadata: expected an object.') + else { + allowedFields(value.minecraft_metadata, ['version'], 'minecraft_metadata.') + requiredString(value.minecraft_metadata, 'version', 'minecraft_metadata.') + } + } + if (value.license_terms != null) { + if (!isObject(value.license_terms)) errors.push('license_terms: expected an object.') + else { + const terms = value.license_terms + allowedFields( + terms, + [ + 'requires_source', + 'required_redistribution_license', + 'redistribution_allowed', + 'commercial_use_allowed', + 'attribution_required', + ], + 'license_terms.', + ) + if (terms.requires_source != null && typeof terms.requires_source !== 'string') { + errors.push('license_terms.requires_source: expected a string.') + } + for (const key of [ + 'redistribution_allowed', + 'commercial_use_allowed', + 'attribution_required', + ]) { + if (terms[key] != null && typeof terms[key] !== 'boolean') { + errors.push(`license_terms.${key}: expected true or false.`) + } + } + if ( + terms.required_redistribution_license != null && + (!Array.isArray(terms.required_redistribution_license) || + terms.required_redistribution_license.some( + (item) => typeof item !== 'string' || !item.trim(), + )) + ) + errors.push( + 'license_terms.required_redistribution_license: expected an array of license strings.', + ) + } + } + if (!Array.isArray(value.files)) + errors.push( + 'files: provide the complete file list to replace an archive; the API does not return it.', + ) + else + value.files.forEach((file: unknown, index: number) => { + const prefix = `files[${index}].` + if (!isObject(file)) { + errors.push(`files[${index}]: expected an object.`) + return + } + allowedFields(file, ['path', 'sha512'], prefix) + if (typeof file.path !== 'string') errors.push(`${prefix}path: expected a string.`) + if (typeof file.sha512 !== 'string' || !/^[a-f\d]{128}$/i.test(file.sha512)) { + errors.push(`${prefix}sha512: expected 128 hexadecimal characters.`) + } + }) + return errors +} + +export function parseImport(json: string, existingIds: Set): ArchiveInput[] { + const value: unknown = JSON.parse(json) + if (!Array.isArray(value) || !value.length) + throw new Error('Enter a non-empty JSON array of archives.') + const errors: string[] = [] + const ids = new Set() + value.forEach((entry: unknown, index: number) => { + for (const error of validateArchive(entry)) errors.push(`Archive ${index + 1}: ${error}`) + if (isObject(entry) && typeof entry.id === 'string') { + if (ids.has(entry.id)) errors.push(`Archive ${index + 1}: duplicate ID "${entry.id}".`) + if (!existingIds.has(entry.id)) + errors.push( + `Archive ${index + 1}: unknown ID "${entry.id}". Omit the ID to create an archive.`, + ) + ids.add(entry.id) + } + }) + if (errors.length) throw new Error(errors.join('\n')) + return value as ArchiveInput[] +} diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index ed2956a3e49..83883c64abe 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -484,6 +484,14 @@ to: '/admin/analytics/events', shown: isAdmin(auth.user), }, + { + id: 'copyright-archives', + label: 'Copyright archives', + icon: ChartIcon, + type: 'link', + to: '/admin/copyright-archives', + shown: isAdmin(auth.user), + }, { type: 'divider' }, { id: 'email-templates', diff --git a/apps/frontend/src/pages/admin.vue b/apps/frontend/src/pages/admin.vue index b0062a8901d..ede8f9df315 100644 --- a/apps/frontend/src/pages/admin.vue +++ b/apps/frontend/src/pages/admin.vue @@ -60,6 +60,12 @@ icon: ChartIcon, shown: admin, }, + { + link: '/admin/copyright-archives', + label: 'Copyright archives', + icon: FileSearchCornerIcon, + shown: admin, + }, { type: 'heading', label: 'Templates' }, { link: '/admin/emails', diff --git a/apps/frontend/src/pages/admin/analytics/events.vue b/apps/frontend/src/pages/admin/analytics/events.vue index 81b05716e0c..03102766439 100644 --- a/apps/frontend/src/pages/admin/analytics/events.vue +++ b/apps/frontend/src/pages/admin/analytics/events.vue @@ -150,6 +150,7 @@ :columns="columns" :data="sortedEvents" row-key="id" + table-min-width="56rem" >