+ Copyright Revealer +
++ Interactive copyright verification for creators, rights teams, and admins. +
diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/routes/reveal_requests.js b/backend/src/routes/reveal_requests.js index 97cbe5e..4cab2f4 100644 --- a/backend/src/routes/reveal_requests.js +++ b/backend/src/routes/reveal_requests.js @@ -2,6 +2,7 @@ const express = require('express'); const Reveal_requestsService = require('../services/reveal_requests'); +const CopyrightStudioService = require('../services/copyrightStudio'); const Reveal_requestsDBApi = require('../db/api/reveal_requests'); const wrapAsync = require('../helpers').wrapAsync; @@ -96,6 +97,21 @@ router.post('/', wrapAsync(async (req, res) => { res.status(200).send(payload); })); +router.get('/studio-feed', wrapAsync(async (req, res) => { + const payload = await CopyrightStudioService.getStudioFeed(req.currentUser); + res.status(200).send(payload); +})); + +router.get('/studio-result/:id', wrapAsync(async (req, res) => { + const payload = await CopyrightStudioService.getResultDetail(req.params.id); + res.status(200).send(payload); +})); + +router.post('/run', wrapAsync(async (req, res) => { + const payload = await CopyrightStudioService.runReveal(req.body.data || {}, req.currentUser); + res.status(200).send(payload); +})); + /** * @swagger * /api/budgets/bulk-import: diff --git a/backend/src/services/copyrightStudio.js b/backend/src/services/copyrightStudio.js new file mode 100644 index 0000000..9dd19b0 --- /dev/null +++ b/backend/src/services/copyrightStudio.js @@ -0,0 +1,482 @@ +const crypto = require('crypto'); +const db = require('../db/models'); +const Reveal_requestsDBApi = require('../db/api/reveal_requests'); +const Reveal_resultsDBApi = require('../db/api/reveal_results'); + +const INPUT_TYPES = ['text', 'url', 'file']; + +function createValidationError(message) { + const error = new Error(message); + error.code = 400; + return error; +} + +function normalizeValue(value) { + return (value || '') + .toString() + .toLowerCase() + .replace(/https?:\/\//g, '') + .replace(/www\./g, '') + .replace(/[._-]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function tokenize(value) { + return Array.from( + new Set( + normalizeValue(value) + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 2), + ), + ); +} + +function getFileStem(name) { + const parts = (name || '').split('.'); + if (parts.length <= 1) { + return normalizeValue(name); + } + + parts.pop(); + return normalizeValue(parts.join('.')); +} + +function toPlainArray(records) { + return records.map((record) => record.get({ plain: true })); +} + +function buildSourceSummary(data) { + const uploadedFiles = Array.isArray(data.uploaded_files) ? data.uploaded_files : []; + + return [ + data.request_title, + data.input_text, + data.input_url, + uploadedFiles.map((file) => file.name).join(' '), + ] + .filter(Boolean) + .join(' | ') + .trim(); +} + +function createFingerprint(data) { + const payload = { + request_title: data.request_title || '', + input_type: data.input_type || '', + input_text: data.input_text || '', + input_url: data.input_url || '', + uploaded_files: (Array.isArray(data.uploaded_files) ? data.uploaded_files : []).map( + (file) => ({ + name: file.name, + sizeInBytes: file.sizeInBytes, + }), + ), + }; + + return crypto.createHash('sha1').update(JSON.stringify(payload)).digest('hex'); +} + +function scoreWork(work, payload) { + const reasons = []; + let score = 0; + + const normalizedWorkTitle = normalizeValue(work.title); + const normalizedAuthorName = normalizeValue(work.author_name); + const normalizedExternalUrl = normalizeValue(work.external_source_url); + const normalizedDescription = normalizeValue(work.description); + const workFileStems = (work.original_files || []).map((file) => getFileStem(file.name)); + + if (payload.normalizedTitle && normalizedWorkTitle) { + if (payload.normalizedTitle === normalizedWorkTitle) { + score += 52; + reasons.push('Exact title match'); + } else if ( + normalizedWorkTitle.includes(payload.normalizedTitle) || + payload.normalizedTitle.includes(normalizedWorkTitle) + ) { + score += 28; + reasons.push('Strong title similarity'); + } else { + const sharedTitleTokens = payload.titleTokens.filter((token) => + tokenize(work.title).includes(token), + ).length; + + if (sharedTitleTokens > 0) { + score += Math.min(20, sharedTitleTokens * 6); + reasons.push('Title keywords overlap'); + } + } + } + + if (payload.computedHash && work.content_hash && payload.computedHash === work.content_hash) { + score += 32; + reasons.push('Registered content hash match'); + } + + if ( + payload.computedFingerprint && + work.fingerprint && + payload.computedFingerprint === work.fingerprint + ) { + score += 26; + reasons.push('Fingerprint signature match'); + } + + if (payload.normalizedUrl && normalizedExternalUrl) { + if (payload.normalizedUrl === normalizedExternalUrl) { + score += 24; + reasons.push('Source URL match'); + } else if ( + normalizedExternalUrl.includes(payload.normalizedUrl) || + payload.normalizedUrl.includes(normalizedExternalUrl) + ) { + score += 12; + reasons.push('Related source URL'); + } + } + + if (normalizedAuthorName && payload.sourceText.includes(normalizedAuthorName)) { + score += 10; + reasons.push('Author signature appears in the request'); + } + + if (normalizedDescription && payload.searchTokens.length) { + const descriptionHits = payload.searchTokens.filter((token) => + normalizedDescription.includes(token), + ).length; + + if (descriptionHits > 0) { + score += Math.min(14, descriptionHits * 3); + reasons.push('Description keywords align'); + } + } + + if (payload.fileStems.length && workFileStems.length) { + const exactStemMatch = payload.fileStems.find((stem) => workFileStems.includes(stem)); + if (exactStemMatch) { + score += 18; + reasons.push('Uploaded filename matches a registered asset'); + } else { + const partialStemMatch = payload.fileStems.find((stem) => + workFileStems.some((workStem) => workStem.includes(stem) || stem.includes(workStem)), + ); + + if (partialStemMatch) { + score += 10; + reasons.push('Uploaded filename resembles a registered asset'); + } + } + } + + return { + work, + score: Math.min(score, 99), + reasons: Array.from(new Set(reasons)), + }; +} + +function classifyResult(score) { + if (score >= 75) { + return 'match'; + } + + if (score >= 42) { + return 'possible_match'; + } + + return 'no_match'; +} + +function buildResultNotes(topCandidate, resultType) { + if (!topCandidate) { + return 'No registered work was close enough to verify ownership. Try refining the title, text, or uploaded evidence.'; + } + + if (resultType === 'match') { + return `High-confidence reveal: ${topCandidate.work.title || 'Untitled work'} is the strongest ownership match.`; + } + + if (resultType === 'possible_match') { + return `Possible reveal found for ${topCandidate.work.title || 'Untitled work'}. Review the evidence before relying on it.`; + } + + return 'No strong ownership match was found. You can still review the closest candidate below.'; +} + +function formatCandidate(candidate) { + return { + id: candidate.work.id, + title: candidate.work.title, + author_name: candidate.work.author_name, + work_type: candidate.work.work_type, + visibility: candidate.work.visibility, + registered_at: candidate.work.registered_at, + confidence_score: Number((candidate.score / 100).toFixed(2)), + match_reasons: candidate.reasons, + }; +} + +module.exports = class CopyrightStudioService { + static validatePayload(data) { + const inputType = data.input_type || 'text'; + + if (!INPUT_TYPES.includes(inputType)) { + throw createValidationError('Choose a valid reveal mode.'); + } + + if (!data.request_title || !data.request_title.trim()) { + throw createValidationError('Give this reveal request a title so it can be tracked.'); + } + + if (inputType === 'text' && !data.input_text?.trim()) { + throw createValidationError('Paste some text to compare against registered works.'); + } + + if (inputType === 'url' && !data.input_url?.trim()) { + throw createValidationError('Enter a source URL to inspect.'); + } + + if (inputType === 'file' && !(Array.isArray(data.uploaded_files) && data.uploaded_files.length)) { + throw createValidationError('Upload at least one file to run a reveal.'); + } + } + + static async runReveal(data, currentUser) { + this.validatePayload(data); + + const sourceSummary = buildSourceSummary(data); + const computedHash = crypto.createHash('sha256').update(sourceSummary).digest('hex'); + const computedFingerprint = createFingerprint(data); + const normalizedTitle = normalizeValue(data.request_title); + const normalizedUrl = normalizeValue(data.input_url); + const fileStems = (Array.isArray(data.uploaded_files) ? data.uploaded_files : []).map((file) => + getFileStem(file.name), + ); + + const payload = { + normalizedTitle, + normalizedUrl, + titleTokens: tokenize(data.request_title), + searchTokens: tokenize(sourceSummary), + sourceText: normalizeValue(sourceSummary), + computedHash, + computedFingerprint, + fileStems, + }; + + const works = await db.works.findAll({ + attributes: [ + 'id', + 'title', + 'author_name', + 'work_type', + 'description', + 'external_source_url', + 'license_terms', + 'content_hash', + 'fingerprint', + 'registered_at', + 'visibility', + ], + include: [ + { + model: db.file, + as: 'original_files', + attributes: ['id', 'name', 'sizeInBytes', 'publicUrl', 'privateUrl'], + }, + ], + order: [ + ['registered_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 100, + }); + + const rankedCandidates = toPlainArray(works) + .map((work) => scoreWork(work, payload)) + .sort((left, right) => right.score - left.score) + .slice(0, 3); + + const topCandidate = rankedCandidates[0]; + const resultType = classifyResult(topCandidate?.score || 0); + const confidenceScore = Number((((topCandidate?.score || 8) / 100)).toFixed(2)); + const startedAt = new Date(); + const completedAt = new Date(); + const notes = buildResultNotes(topCandidate, resultType); + const matchedFields = (topCandidate?.reasons || ['No close match signals were detected']).join(', '); + + const transaction = await db.sequelize.transaction(); + + try { + const revealRequest = await Reveal_requestsDBApi.create( + { + request_title: data.request_title, + input_type: data.input_type, + input_text: data.input_text || null, + input_url: data.input_url || null, + computed_hash: computedHash, + computed_fingerprint: computedFingerprint, + status: 'completed', + started_at: startedAt, + completed_at: completedAt, + uploaded_files: Array.isArray(data.uploaded_files) ? data.uploaded_files : [], + requested_by: currentUser.id, + }, + { + currentUser, + transaction, + }, + ); + + const revealResult = await Reveal_resultsDBApi.create( + { + result_type: resultType, + confidence_score: confidenceScore, + matched_fields: matchedFields, + notes, + generated_at: completedAt, + request: revealRequest.id, + matched_work: resultType === 'no_match' ? null : topCandidate?.work?.id || null, + created_by_user: currentUser.id, + evidence_files: Array.isArray(data.uploaded_files) ? data.uploaded_files : [], + }, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + + const detail = await this.getResultDetail(revealResult.id); + + return { + request: detail.request, + result: detail.result, + candidates: rankedCandidates.map(formatCandidate), + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async getStudioFeed(currentUser) { + const [recentResults, featuredWorks, totalWorks, currentUserReveals] = await Promise.all([ + db.reveal_results.findAll({ + where: { + created_by_userId: currentUser.id, + }, + include: [ + { + model: db.reveal_requests, + as: 'request', + }, + { + model: db.works, + as: 'matched_work', + }, + ], + order: [ + ['generated_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 6, + }), + db.works.findAll({ + attributes: [ + 'id', + 'title', + 'author_name', + 'work_type', + 'registered_at', + 'visibility', + 'license_terms', + ], + order: [ + ['registered_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: 4, + }), + db.works.count(), + db.reveal_results.count({ + where: { + created_by_userId: currentUser.id, + }, + }), + ]); + + return { + stats: { + totalWorks, + currentUserReveals, + matchableWorks: featuredWorks.filter((work) => !!work.license_terms).length, + }, + recentResults: toPlainArray(recentResults), + featuredWorks: toPlainArray(featuredWorks), + }; + } + + static async getResultDetail(id) { + const result = await db.reveal_results.findOne({ + where: { id }, + include: [ + { + model: db.reveal_requests, + as: 'request', + include: [ + { + model: db.file, + as: 'uploaded_files', + attributes: ['id', 'name', 'publicUrl', 'privateUrl', 'sizeInBytes'], + }, + { + model: db.users, + as: 'requested_by', + attributes: ['id', 'firstName', 'lastName', 'email'], + }, + ], + }, + { + model: db.works, + as: 'matched_work', + include: [ + { + model: db.file, + as: 'original_files', + attributes: ['id', 'name', 'publicUrl', 'privateUrl', 'sizeInBytes'], + }, + { + model: db.users, + as: 'owner', + attributes: ['id', 'firstName', 'lastName', 'email'], + }, + ], + }, + { + model: db.file, + as: 'evidence_files', + attributes: ['id', 'name', 'publicUrl', 'privateUrl', 'sizeInBytes'], + }, + { + model: db.users, + as: 'created_by_user', + attributes: ['id', 'firstName', 'lastName', 'email'], + }, + ], + }); + + if (!result) { + const error = new Error('Reveal result not found.'); + error.code = 404; + throw error; + } + + return { + result: result.get({ plain: true }), + request: result.request ? result.request.get({ plain: true }) : null, + }; + } +}; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/helpers/copyrightStudio.ts b/frontend/src/helpers/copyrightStudio.ts new file mode 100644 index 0000000..a629ff1 --- /dev/null +++ b/frontend/src/helpers/copyrightStudio.ts @@ -0,0 +1,66 @@ +export function formatStudioDate(value?: string | null) { + if (!value) { + return 'Just now'; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return 'Recently'; + } + + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +export function formatPercent(value?: number | null) { + const safeValue = typeof value === 'number' ? value : 0; + return `${Math.round(safeValue * 100)}%`; +} + +export function getRevealTone(resultType?: string | null) { + switch (resultType) { + case 'match': + return 'border-emerald-400/60 bg-emerald-500/10 text-emerald-200'; + case 'possible_match': + return 'border-amber-400/60 bg-amber-500/10 text-amber-100'; + case 'error': + return 'border-rose-400/60 bg-rose-500/10 text-rose-100'; + default: + return 'border-slate-400/40 bg-slate-500/10 text-slate-100'; + } +} + +export function getRevealLabel(resultType?: string | null) { + switch (resultType) { + case 'match': + return 'Verified match'; + case 'possible_match': + return 'Possible match'; + case 'error': + return 'Review needed'; + default: + return 'No strong match'; + } +} + +export function getWorkTypeLabel(workType?: string | null) { + if (!workType) { + return 'Unknown'; + } + + return `${workType.charAt(0).toUpperCase()}${workType.slice(1)}`; +} + +export function getPersonName(person?: { + firstName?: string | null; + lastName?: string | null; + email?: string | null; +} | null) { + const fullName = [person?.firstName, person?.lastName].filter(Boolean).join(' '); + return fullName || person?.email || 'Unknown'; +} diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 9cc6de5..4581125 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -40,6 +40,14 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiCopyright' in icon ? icon['mdiCopyright' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_WORKS' }, + { + href: '/reveal-studio', + label: 'Reveal Studio', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiFingerprint' in icon ? icon['mdiFingerprint' as keyof typeof icon] : icon.mdiMagnifyScan ?? icon.mdiTable, + permissions: 'CREATE_REVEAL_REQUESTS' + }, { href: '/reveal_requests/reveal_requests-list', label: 'Reveal requests', diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index ed06bbc..c102c62 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -99,6 +99,30 @@ const Dashboard = () => { main> {''} + + {hasPermission(currentUser, 'CREATE_REVEAL_REQUESTS') && +
Reveal Studio
+Launch the new MVP workflow to compare creative signals against registered works, then jump straight into reports or record review.
+This is a React.js/Node.js app generated by the Flatlogic Web App Generator
-For guides and documentation please check - your local README.md and the Flatlogic documentation
++ Copyright Revealer +
++ Interactive copyright verification for creators, rights teams, and admins. +
© 2026 {title}. All rights reserved
- - Privacy Policy - -+ Copyright Revealer helps teams register works, run a fast ownership check, browse + proof records, and escalate suspicious usage from one responsive dashboard. +
+{card.description}
++ MVP workflow +
+First usable slice
+{step}
+Designed for creators and admins
++ The current MVP includes a public landing page, a Reveal Studio workflow, + result detail views, and direct paths into the admin CRUD screens. +
+Why teams use it
++ Instead of juggling screenshots, spreadsheets, and manual notes, the app keeps the + reveal history attached to registered works and next-step admin actions. +
+{title}
+{copy}
++ Run a focused metadata-and-text reveal, compare it against your registered works, + and keep a clean evidence trail for follow-up reviews or fraud reports. +
+{card.label}
+{card.value}
+{card.hint}
++ Reveal input +
++ Start with one clear signal: paste text, inspect a source URL, or upload a + creative asset for metadata fingerprinting. +
++ Last reveal result +
+Confidence
++ {formatPercent(activeResult.result.confidence_score)} +
+Generated
++ {formatStudioDate(activeResult.result.generated_at)} +
+Matched work
++ {activeResult.result.matched_work?.title || 'No direct registered work selected'} +
++ {activeResult.result.matched_work?.author_name || + 'Register the work or review the top candidates below.'} +
+Signals used
++ {activeResult.result.matched_fields} +
+Analyst note
+{activeResult.result.notes}
+Closest candidates
+{candidate.title || 'Untitled work'}
++ {candidate.author_name || 'Unknown author'} · {getWorkTypeLabel(candidate.work_type)} +
++ {candidate.match_reasons.join(' · ')} +
+ )} ++ Browse works +
+{work.title || 'Untitled work'}
++ {work.author_name || 'Unknown author'} · {getWorkTypeLabel(work.work_type)} +
++ {work.license_terms || 'No license notes yet.'} +
++ Registered {formatStudioDate(work.registered_at)} +
+History
++ Every run is saved so creators and admins can reopen the evidence trail and decide + whether to approve usage, register a missing work, or escalate a report. +
++ {item.matched_work?.title || 'No direct registered work selected yet'} +
++ {formatStudioDate(item.generated_at)} +
+ + ))} ++ Evidence detail +
++ Review the matching signals, attached evidence, and registered work details before + approving use or escalating a suspicious claim. +
+Loading reveal evidence…
+{errorMessage}
++ Result summary +
+Confidence
++ {formatPercent(detail.result.confidence_score)} +
+Generated
++ {formatStudioDate(detail.result.generated_at)} +
+Requested by
++ {getPersonName(detail.request?.requested_by || detail.result.created_by_user)} +
+Signals used
+{detail.result.matched_fields}
+Analyst note
+{detail.result.notes}
+Request evidence
+Reveal mode
+{detail.request?.input_type || 'Unknown'}
+Input text
++ {detail.request.input_text} +
+Input URL
+ + {detail.request.input_url} + +Computed hash
++ {detail.request?.computed_hash || 'Not generated'} +
+Fingerprint
++ {detail.request?.computed_fingerprint || 'Not generated'} +
+Attached files
+No files were attached for this reveal.
+ )} +Matched work
++ {detail.result.matched_work.title || 'Untitled work'} +
++ {detail.result.matched_work.author_name || 'Unknown author'} ·{' '} + {getWorkTypeLabel(detail.result.matched_work.work_type)} +
+Owner
++ {getPersonName(detail.result.matched_work.owner)} +
+Registered
++ {formatStudioDate(detail.result.matched_work.registered_at)} +
+Description
++ {detail.result.matched_work.description || 'No description provided.'} +
+License terms
++ {detail.result.matched_work.license_terms || 'No license terms recorded.'} +
+Registered files
+No registered files attached.
+ )} +