-
-
-
- Music Copyright Checker
-
-
-
- Check if a song is safe to use in your videos without copyright issues.
- Avoid strikes and claim problems before uploading your content.
-
-
-
-
- Disclaimer
-
- This tool provides general information and is not a guarantee against copyright claims.
- Copyright policies may change over time, and different regions may have different rules.
-
-
-
-
-
- Song
- YouTube URL
-
-
-
- setSongArtist(e.target.value)}
- className="pixel-corners"
- />
- setSongTitle(e.target.value)}
- className="pixel-corners"
- />
-
-
-
-
- setYoutubeUrl(e.target.value)}
- className="pixel-corners"
- />
-
-
-
-
-
-
-
-
- {isLoading &&
}
-
- {!isLoading && result && (
-
- )}
-
- {!isLoading && !result && searchAttempted && (
-
-
-
No results found
-
Try a different search term or check the spelling
-
- )}
-
+ const [activeJobId, setActiveJobId] = useState
(null);
+ const [existingJob, setExistingJob] = useState(null);
+ const [limitMessage, setLimitMessage] = useState(false);
+ const navigate = useNavigate();
+
+ const showRecord = (record: LooneyHistoryRecord) => { if (record.result) navigate(`/gappa/check/${encodeURIComponent(record.jobId)}`); };
+
+ return
+
Looney Checks - Renderdragon
+
+
+
+
Looney Checks
Research music licensing signals before you publish. Check a Spotify track, catalog music, or an audio file you upload yourself.
+
+
+
+ 
Start a check
Choose the source you want Looney to inspect.
+ jobId && navigate(`/gappa/check/${encodeURIComponent(jobId)}`)} onExistingJob={setExistingJob} onLimitReached={() => setLimitMessage(true)} onCheckStart={() => setLimitMessage(false)} />
+
+
-
-
-
-
- );
+
+ {limitMessage && Two checks are already running. Wait for one to finish before starting another.
}
+
+
+
+ setExistingJob(null)} />
+ ;
};
-export default MusicCopyright;
\ No newline at end of file
+export default MusicCopyright;
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx
index 97b1a1b..9d75403 100644
--- a/src/pages/NotFound.tsx
+++ b/src/pages/NotFound.tsx
@@ -48,7 +48,7 @@ const NotFound = () => {
-
+
{
className="text-cow-purple mb-4 relative"
variants={itemVariants}
>
- {
const [selectedMoods, setSelectedMoods] = useState([]);
const [mobileMoodFilterOpen, setMobileMoodFilterOpen] = useState(false);
const [musicView, setMusicView] = useState<'community' | 'minecraft'>('community');
+ const [copyrightResource, setCopyrightResource] = useState(null);
const {
resources,
@@ -207,6 +209,10 @@ const ResourcesHub = () => {
}
};
+ const onCheckCopyright = useCallback((resource: Resource) => {
+ setCopyrightResource(resource);
+ }, []);
+
const renderContent = () => (
<>
{
onSortOrderChange={handleSortOrderChange}
isMobile={isMobile}
inputRef={inputRef}
- fontPreviewText={fontPreviewText}
- onFontPreviewTextChange={setFontPreviewText}
/>
{(selectedCategory === 'minecraft-icons' || selectedCategory === 'mcsounds') && (
@@ -336,7 +340,7 @@ const ResourcesHub = () => {
)}
{isMinecraftMusicView ? (
- {
onSelectResource={setSelectedResource}
onClearFilters={handleClearSearchWrapped}
hasCategoryResources={minecraftMusic.resources.length > 0}
- fontPreviewText={fontPreviewText}
- />
+ onCheckCopyright={onCheckCopyright}
+ />
) : (
- {
onSelectResource={setSelectedResource}
onClearFilters={handleClearSearch}
hasCategoryResources={hasCategoryResources}
- fontPreviewText={fontPreviewText}
- />
+ onCheckCopyright={onCheckCopyright}
+ />
)}
>
);
@@ -573,6 +577,11 @@ const ResourcesHub = () => {
onOpenChange={setAuthDialogOpen}
/>
+ setCopyrightResource(null)}
+ />
+
diff --git a/src/types/copyright.ts b/src/types/copyright.ts
deleted file mode 100644
index c2d81d9..0000000
--- a/src/types/copyright.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-
-export interface VideoInfo {
- title: string;
- channel: string;
- license: string;
- is_copyrighted: boolean;
- description: string;
- thumbnail: string | null;
- duration: string;
- view_count: number;
- upload_date: string;
- url: string;
-}
-
-export interface SpotifyTrackInfo {
- title: string;
- artist: string;
- album: string;
- release_date: string;
- spotify_url: string;
- thumbnail: string | null;
- is_copyrighted: boolean;
- copyright_text: string;
-}
-
-export interface ChannelInfo {
- title: string;
- description: string;
- subscribers: string;
- views: string;
- videos: string;
- watch_hours: number;
- created_at: string;
- profile_pic: string;
- banner_url: string | null;
-}
-
-export interface YouTubeSearchResult {
- title: string;
- video_id: string;
- published_at?: string;
-}
-
-export interface CopyrightResult {
- status: string;
- title: string;
- artist: string;
- confidence: number;
- riskAssessment: string;
- recommendedAction: string;
- platforms: {
- youtube: string;
- twitch: string;
- };
- sources: {
- contentId: string;
- pro: string;
- drm: string;
- publicDomain: string;
- royaltyFree: string;
- commercialDatabases: string;
- openSources: string;
- };
- sourceAnalysis: {
- commercialPresence: boolean;
- openSourcePresence: boolean;
- totalMatches: number;
- };
- processingTime: string;
- sourcesChecked: string[];
- sourceStats: {
- total: number;
- successful: number;
- coverage: number;
- };
- lastUpdated: string;
- apiVersion: string;
- imageUrl?: string;
- riskFactors?: {
- commercial: number;
- popularity: number;
- official: number;
- label: number;
- distribution: number;
- };
- totalRiskScore?: number;
- youtubeAnalysis?: {
- totalVideos: number;
- officialContent: number;
- userGeneratedContent: number;
- userContentRatio: number;
- assessment: string;
- };
- error?: string;
-}
diff --git a/src/types/looney.ts b/src/types/looney.ts
new file mode 100644
index 0000000..7bc18cb
--- /dev/null
+++ b/src/types/looney.ts
@@ -0,0 +1,45 @@
+export type LooneyValue = string | number | boolean | null | LooneyValue[] | { [key: string]: LooneyValue };
+
+export type LooneyJobStatus = 'queued' | 'running' | 'complete' | 'failed';
+
+export interface LooneyJob {
+ job_id: string;
+ status: LooneyJobStatus;
+ result?: LooneyResult;
+ error?: string;
+ detail?: string;
+ message?: string;
+ status_url?: string;
+}
+
+export interface LooneyHistoryRecord {
+ jobId: string;
+ sourceLabel: string;
+ sourceType: 'file' | 'spotify';
+ createdAt: string;
+ status: LooneyJobStatus;
+ result?: LooneyResult;
+ error?: string;
+ progress?: string;
+ sourceKey?: string;
+}
+
+export interface LooneyResult {
+ request?: {
+ track?: { [key: string]: LooneyValue };
+ credits?: LooneyValue;
+ [key: string]: LooneyValue | undefined;
+ };
+ research?: {
+ status?: string;
+ summary?: string;
+ matches?: LooneyValue;
+ sources?: LooneyValue;
+ usage_assessment?: LooneyValue;
+ official_licensing_contacts?: LooneyValue;
+ warnings?: LooneyValue;
+ [key: string]: LooneyValue | undefined;
+ };
+ ai_meta?: { [key: string]: LooneyValue };
+ [key: string]: LooneyValue | undefined;
+}
diff --git a/src/utils/copyrightChecker.ts b/src/utils/copyrightChecker.ts
deleted file mode 100644
index b03268d..0000000
--- a/src/utils/copyrightChecker.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-
-import { CopyrightResult } from '@/types/copyright';
-
-// YouTube URL pattern
-export const YOUTUBE_URL_PATTERN = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
-
-// Extract YouTube ID from URL
-export function extractYouTubeID(url: string): string | null {
- const match = url.match(YOUTUBE_URL_PATTERN);
- return match && match[1] ? match[1] : null;
-}
-
-// New function to check copyright status that MusicCopyright.tsx needs
-export async function checkCopyrightStatus(query: { artist: string; title: string } | { youtube_url: string }): Promise {
- try {
- // Use the correct API URL - this appears to be an external service
- const apiUrl = "https://ltazpjoqbhtqxqvrvnka.supabase.co/functions/v1/check";
- const apiKey = import.meta.env.VITE_GAPPA_API_KEY || "";
- const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || "";
-
- const body = query;
-
- const response = await fetch(apiUrl, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-API-Key': apiKey,
- 'Authorization': `Bearer ${anonKey}`
- },
- body: JSON.stringify(body)
- });
-
- if (!response.ok) {
- throw new Error(`API error: ${response.status} ${response.statusText}`);
- }
-
- const data: CopyrightResult = await response.json();
- return data;
- } catch (error) {
- console.error('Error checking copyright:', error);
- return {
- status: 'unknown' as const,
- title: 'Unknown',
- artist: 'Unknown',
- confidence: 0,
- riskAssessment: 'Unknown',
- recommendedAction: 'Unable to process request',
- platforms: {
- youtube: 'Unable to check',
- twitch: 'Unable to check'
- },
- sources: {
- contentId: 'Unable to check',
- pro: 'Unable to check',
- drm: 'Unable to check',
- publicDomain: 'Unable to check',
- royaltyFree: 'Unknown',
- commercialDatabases: 'Unknown',
- openSources: 'Unknown'
- },
- sourceAnalysis: {
- commercialPresence: false,
- openSourcePresence: false,
- totalMatches: 0
- },
- youtubeAnalysis: {
- totalVideos: 0,
- officialContent: 0,
- userGeneratedContent: 0,
- userContentRatio: 0,
- assessment: 'Unable to analyze'
- },
- riskFactors: {
- commercial: 0,
- popularity: 0,
- official: 0,
- label: 0,
- distribution: 0
- },
- totalRiskScore: 0,
- processingTime: 'N/A',
- sourcesChecked: [],
- sourceStats: {
- total: 0,
- successful: 0,
- coverage: 0
- },
- lastUpdated: new Date().toISOString(),
- apiVersion: '1.0',
- imageUrl: undefined,
- error: error instanceof Error ? error.message : 'Unknown error'
- };
- }
-}
diff --git a/src/utils/looneyChecker.ts b/src/utils/looneyChecker.ts
new file mode 100644
index 0000000..131fff6
--- /dev/null
+++ b/src/utils/looneyChecker.ts
@@ -0,0 +1,243 @@
+import { LooneyJob, LooneyResult } from '@/types/looney';
+import { loadLooneyHistory, MAX_RUNNING_LOONEY_AGE_MS, saveLooneyHistoryRecord, updateLooneyHistoryFromJob } from '@/utils/looneyHistory';
+import { supabase } from '@/integrations/supabase/client';
+
+export const MAX_LOONEY_FILE_BYTES = 50 * 1024 * 1024;
+const JOB_RECOVERY_INTERVAL_MS = 2000;
+const MAX_JOB_RECOVERY_ATTEMPTS = 150;
+const BROWSER_RATE_LIMIT_KEY = 'renderdragon-looney-browser-id';
+
+function createBrowserRateLimitId(): string {
+ const webCrypto = typeof globalThis.crypto !== 'undefined' ? globalThis.crypto : undefined;
+ if (typeof webCrypto?.randomUUID === 'function') return webCrypto.randomUUID();
+
+ if (typeof webCrypto?.getRandomValues === 'function') {
+ const bytes = webCrypto.getRandomValues(new Uint8Array(16));
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+ }
+
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
+}
+
+function getBrowserRateLimitId(): string {
+ const existing = localStorage.getItem(BROWSER_RATE_LIMIT_KEY);
+ if (existing) return existing;
+ const created = createBrowserRateLimitId();
+ localStorage.setItem(BROWSER_RATE_LIMIT_KEY, created);
+ return created;
+}
+
+async function getLooneyRequestHeaders(contentType?: string): Promise> {
+ const headers: Record = { 'X-Looney-Browser-ID': getBrowserRateLimitId() };
+ if (contentType) headers['Content-Type'] = contentType;
+ const { data } = await supabase.auth.getSession();
+ if (data.session?.access_token) headers.Authorization = `Bearer ${data.session.access_token}`;
+ return headers;
+}
+
+export async function refreshRunningLooneyChecks(): Promise {
+ const running = loadLooneyHistory().filter((record) => record.status === 'queued' || record.status === 'running');
+ await Promise.all(running.map(async (record) => {
+ const createdAt = Date.parse(record.createdAt);
+ if (!Number.isNaN(createdAt) && Date.now() - createdAt >= MAX_RUNNING_LOONEY_AGE_MS) {
+ saveLooneyHistoryRecord({ ...record, status: 'failed', error: 'This check expired after the Looney service timeout.' });
+ return;
+ }
+ try {
+ const job = await getLooneyJob(record.jobId);
+ saveLooneyHistoryRecord(updateLooneyHistoryFromJob(record, job));
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '';
+ if (/404|not found|unknown job|invalid job/i.test(message)) {
+ saveLooneyHistoryRecord({ ...record, status: 'failed', error: 'This Looney job is no longer available.' });
+ }
+ // A temporary network failure should not falsely free a running slot.
+ }
+ }));
+}
+
+export interface LooneyCheckInput {
+ fileUrl?: string;
+ spotifyUrl?: string;
+}
+
+export interface LooneyCheckCallbacks {
+ onJobCreated?: (job: LooneyJob) => void;
+ onJobUpdate?: (job: LooneyJob) => void;
+ onProgress?: (message: string) => void;
+}
+
+async function readJsonResponse(response: Response): Promise> {
+ return response.json().catch(() => ({}));
+}
+
+export async function startLooneyJob(
+ { fileUrl, spotifyUrl }: LooneyCheckInput,
+ signal?: AbortSignal,
+): Promise {
+ if (!fileUrl && !spotifyUrl) {
+ throw new Error('Choose an audio file or enter a Spotify track URL.');
+ }
+
+ const options: RequestInit = {
+ method: 'POST',
+ signal,
+ headers: await getLooneyRequestHeaders('application/json'),
+ body: JSON.stringify(fileUrl ? { file_url: fileUrl } : { spotify_url: spotifyUrl }),
+ };
+
+ const response = await fetch('/api/looney-check', options);
+ const data = await readJsonResponse(response);
+ if (!response.ok) {
+ const message = data.error || data.detail || data.message || data.title;
+ if (response.status === 429) {
+ const retryAfter = Number(data.retry_after_seconds || response.headers.get('Retry-After') || 0);
+ throw new Error(String(message || `Daily limit reached. Try again in ${Math.ceil(retryAfter / 3600)} hours.`));
+ }
+ throw new Error(String(message || `Looney could not process this track (${response.status}).`));
+ }
+
+ if (!data.job_id) {
+ throw new Error('Looney did not return a job ID.');
+ }
+
+ return data as unknown as LooneyJob;
+}
+
+export async function getLooneyJob(jobId: string, signal?: AbortSignal): Promise {
+ const response = await fetch(`/api/looney-check?job_id=${encodeURIComponent(jobId)}`, { signal });
+ const job = await readJsonResponse(response);
+ if (!response.ok) {
+ const message = job.error || job.detail || job.message;
+ throw new Error(String(message || `Unable to read the Looney job (${response.status}).`));
+ }
+ return job as unknown as LooneyJob;
+}
+
+async function waitForTerminalJob(
+ jobId: string,
+ signal?: AbortSignal,
+ onProgress?: (message: string) => void,
+): Promise {
+ for (let attempt = 0; attempt < MAX_JOB_RECOVERY_ATTEMPTS; attempt += 1) {
+ try {
+ const job = await getLooneyJob(jobId, signal);
+ if (job.status === 'complete' || job.status === 'failed' || job.result) return job;
+ onProgress?.(job.status === 'queued' ? 'Your check is queued...' : 'Looney is still researching...');
+ } catch (error) {
+ if (signal?.aborted) throw error;
+ onProgress?.('Connection interrupted. Reconnecting to Looney...');
+ }
+ await new Promise((resolve) => setTimeout(resolve, JOB_RECOVERY_INTERVAL_MS));
+ }
+
+ throw new Error('Looney took too long to finish this check.');
+}
+
+export async function streamLooneyJob(
+ jobId: string,
+ signal?: AbortSignal,
+ onProgress?: (message: string) => void,
+): Promise {
+ const response = await fetch(`/api/looney-check?job_id=${encodeURIComponent(jobId)}&stream=1`, { signal });
+ if (!response.ok) {
+ const error = await readJsonResponse(response);
+ const message = error.error || error.detail || error.message;
+ throw new Error(String(message || `Unable to stream the Looney job (${response.status}).`));
+ }
+
+ if (!response.body) return getLooneyJob(jobId, signal);
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let eventName = 'message';
+ let eventData: string[] = [];
+ let streamJob: LooneyJob | null = null;
+
+ const processEvent = () => {
+ if (eventData.length === 0) {
+ eventName = 'message';
+ return;
+ }
+ const rawData = eventData.join('\n');
+ let payload: Record;
+ try {
+ payload = JSON.parse(rawData) as Record;
+ } catch {
+ payload = { message: rawData };
+ }
+
+ const message = payload.message || payload.stage;
+ if (eventName === 'progress' && message) onProgress?.(String(message));
+
+ if (eventName === 'complete' || payload.status === 'complete') {
+ streamJob = { ...payload, job_id: String(payload.job_id || jobId), status: 'complete' } as LooneyJob;
+ } else if (eventName === 'failed' || payload.status === 'failed') {
+ streamJob = { ...payload, job_id: String(payload.job_id || jobId), status: 'failed' } as LooneyJob;
+ }
+
+ eventName = 'message';
+ eventData = [];
+ };
+
+ try {
+ while (!streamJob) {
+ const { value, done } = await reader.read();
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
+ const lines = buffer.split(/\r?\n/);
+ buffer = lines.pop() || '';
+
+ for (const line of lines) {
+ if (!line) {
+ processEvent();
+ } else if (line.startsWith('event:')) {
+ eventName = line.slice(6).trim();
+ } else if (line.startsWith('data:')) {
+ eventData.push(line.slice(5).trim());
+ }
+ }
+
+ if (done) {
+ if (buffer) eventData.push(buffer.startsWith('data:') ? buffer.slice(5).trim() : buffer);
+ processEvent();
+ break;
+ }
+ }
+ } catch (streamError) {
+ if (signal?.aborted) throw streamError;
+ return waitForTerminalJob(jobId, signal, onProgress);
+ } finally {
+ await reader.cancel().catch(() => undefined);
+ }
+
+ if (streamJob?.status === 'complete' && !streamJob.result) return waitForTerminalJob(jobId, signal, onProgress);
+ if (streamJob) return streamJob;
+ return waitForTerminalJob(jobId, signal, onProgress);
+}
+
+export async function checkWithLooney(
+ input: LooneyCheckInput,
+ signal?: AbortSignal,
+ callbacks?: LooneyCheckCallbacks,
+): Promise {
+ const data = await startLooneyJob(input, signal);
+ callbacks?.onJobCreated?.(data);
+ callbacks?.onJobUpdate?.(data);
+ const job = await streamLooneyJob(data.job_id, signal, callbacks?.onProgress);
+ callbacks?.onJobUpdate?.(job);
+
+ if (job.status === 'failed') {
+ const message = job.error || job.detail || job.message || 'Looney could not complete this check.';
+ throw new Error(String(message));
+ }
+
+ if (job.status !== 'complete') {
+ throw new Error('Looney closed the progress stream before returning a result.');
+ }
+
+ return (job.result || job) as LooneyResult;
+}
diff --git a/src/utils/looneyHistory.ts b/src/utils/looneyHistory.ts
new file mode 100644
index 0000000..31378ca
--- /dev/null
+++ b/src/utils/looneyHistory.ts
@@ -0,0 +1,75 @@
+import { LooneyHistoryRecord, LooneyJob } from '@/types/looney';
+
+const STORAGE_KEY = 'renderdragon-looney-history';
+const MAX_HISTORY_RECORDS = 12;
+const UPDATE_EVENT = 'looney-history-updated';
+
+export const MAX_RUNNING_LOONEY_CHECKS = 2;
+export const MAX_RUNNING_LOONEY_AGE_MS = 10 * 60 * 1000;
+
+const isFreshRunningRecord = (record: LooneyHistoryRecord) => {
+ if (record.status !== 'queued' && record.status !== 'running') return false;
+ const createdAt = Date.parse(record.createdAt);
+ return Number.isNaN(createdAt) || Date.now() - createdAt < MAX_RUNNING_LOONEY_AGE_MS;
+};
+
+export function findRunningLooneyCheck(sourceKey: string): LooneyHistoryRecord | undefined {
+ return loadLooneyHistory().find((record) => {
+ if (!isFreshRunningRecord(record)) return false;
+ const legacySpotifyKey = record.sourceType === 'spotify' ? `spotify:${(record.sourceLabel.match(/track\/([a-zA-Z0-9]+)/)?.[1] || record.sourceLabel).toLowerCase()}` : '';
+ return record.sourceKey === sourceKey || (!record.sourceKey && (sourceKey === `file:${record.sourceLabel.toLowerCase().trim()}` || sourceKey === legacySpotifyKey));
+ });
+}
+
+export function countRunningLooneyChecks(): number {
+ return loadLooneyHistory().filter(isFreshRunningRecord).length;
+}
+
+export function loadLooneyHistory(): LooneyHistoryRecord[] {
+ if (typeof window === 'undefined') return [];
+ try {
+ const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
+ return Array.isArray(stored) ? stored : [];
+ } catch {
+ return [];
+ }
+}
+
+export function saveLooneyHistoryRecord(record: LooneyHistoryRecord): void {
+ if (typeof window === 'undefined') return;
+ const records = loadLooneyHistory().filter((item) => item.jobId !== record.jobId);
+ records.unshift(record);
+
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(records.slice(0, MAX_HISTORY_RECORDS)));
+ window.dispatchEvent(new Event(UPDATE_EVENT));
+ } catch {
+ // A large result should not prevent the current check from completing.
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(records.slice(0, 5).map(({ result, ...item }) => item)));
+ window.dispatchEvent(new Event(UPDATE_EVENT));
+ } catch {
+ // Storage may be disabled or full; the active check can still continue.
+ }
+ }
+}
+
+export function updateLooneyHistoryFromJob(
+ current: LooneyHistoryRecord,
+ job: LooneyJob,
+): LooneyHistoryRecord {
+ return {
+ ...current,
+ status: job.status || current.status,
+ result: job.result || current.result,
+ error: job.error || job.detail || job.message || current.error,
+ };
+}
+
+export function clearLooneyHistory(): void {
+ if (typeof window === 'undefined') return;
+ localStorage.removeItem(STORAGE_KEY);
+ window.dispatchEvent(new Event(UPDATE_EVENT));
+}
+
+export { UPDATE_EVENT as looneyHistoryUpdateEvent };
diff --git a/src/utils/migrateResources.ts b/src/utils/migrateResources.ts
index ac0f179..564e74a 100644
--- a/src/utils/migrateResources.ts
+++ b/src/utils/migrateResources.ts
@@ -11,21 +11,18 @@ interface JsonResource {
preview_url?: string;
}
-interface JsonResourcesData {
- music: JsonResource[];
- sfx: JsonResource[];
- images: JsonResource[];
- animations: JsonResource[];
- fonts: JsonResource[];
- presets: JsonResource[];
-}
+const isJsonResource = (value: unknown): value is JsonResource => {
+ if (!value || typeof value !== "object") return false;
+ const resource = value as Record;
+ return typeof resource.id === "number" && typeof resource.title === "string";
+};
export const migrateJsonResourcesToSupabase = async () => {
try {
console.log("Starting migration of JSON resources to Supabase...");
// Fetch the JSON resources (new all-resources format with legacy fallback)
- let jsonData: JsonResourcesData | Resource[] | null = null;
+ let jsonData: unknown = null;
const allResponse = await fetch("/resources.all.json");
if (allResponse.ok) {
@@ -38,29 +35,42 @@ export const migrateJsonResourcesToSupabase = async () => {
jsonData = await legacyResponse.json();
}
- const normalizedData: Record = Array.isArray(
- jsonData,
- )
- ? jsonData.reduce(
+ if (!jsonData || typeof jsonData !== "object") {
+ throw new Error("Resource JSON is empty or malformed");
+ }
+
+ const sourceData = jsonData as Record | unknown[];
+ const normalizedData: Record = Array.isArray(sourceData)
+ ? sourceData.reduce(
(acc, resource) => {
- const resourceId = (resource as Resource).id;
- if (typeof resourceId !== "number") return acc;
- const category = (resource as Resource).category || "uncategorized";
+ if (!isJsonResource(resource)) throw new Error("Resource JSON contains a malformed resource");
+ const typedResource = resource as Resource;
+ const category = typedResource.category || "uncategorized";
if (!acc[category]) acc[category] = [];
acc[category].push({
- id: resourceId,
- title: (resource as Resource).title,
- credit: (resource as Resource).credit || undefined,
- filetype: (resource as Resource).filetype || undefined,
- software: (resource as Resource).software || undefined,
- description: (resource as Resource).description || undefined,
- preview_url: (resource as Resource).preview_url || undefined,
+ id: typedResource.id as number,
+ title: typedResource.title,
+ credit: typedResource.credit || undefined,
+ filetype: typedResource.filetype || undefined,
+ software: typedResource.software || undefined,
+ description: typedResource.description || undefined,
+ preview_url: typedResource.preview_url || undefined,
});
return acc;
},
{} as Record,
)
- : (jsonData as JsonResourcesData);
+ : Object.entries(sourceData).reduce>((acc, [category, resources]) => {
+ if (!Array.isArray(resources) || !resources.every(isJsonResource)) {
+ throw new Error(`Resource JSON category "${category}" is malformed`);
+ }
+ acc[category] = resources;
+ return acc;
+ }, {});
+
+ if (Object.values(normalizedData).every((resources) => resources.length === 0)) {
+ throw new Error("Resource JSON contains no resources");
+ }
console.log("Fetched JSON data:", normalizedData);
@@ -111,6 +121,9 @@ export const migrateJsonResourcesToSupabase = async () => {
console.log(
`Converted ${supabaseResources.length} resources for migration`,
);
+ if (supabaseResources.length === 0) {
+ throw new Error("Migration produced no resources; existing data was preserved");
+ }
// Clear existing resources (optional - comment out if you want to keep existing data)
console.log("Clearing existing resources...");
diff --git a/supabase/migrations/20260810000000_looney_check_rate_limits.sql b/supabase/migrations/20260810000000_looney_check_rate_limits.sql
new file mode 100644
index 0000000..95f5b0e
--- /dev/null
+++ b/supabase/migrations/20260810000000_looney_check_rate_limits.sql
@@ -0,0 +1,165 @@
+create table if not exists public.looney_check_rate_limits (
+ id bigint generated always as identity primary key,
+ bucket_type text not null check (bucket_type in ('browser', 'ip', 'account')),
+ bucket_hash text not null,
+ user_id uuid references auth.users(id) on delete cascade,
+ window_started_at timestamptz not null,
+ check_count integer not null default 0 check (check_count >= 0),
+ last_check_at timestamptz not null default now(),
+ unique (bucket_type, bucket_hash, window_started_at)
+);
+
+create index if not exists looney_check_rate_limits_user_idx
+ on public.looney_check_rate_limits (user_id, window_started_at);
+
+create extension if not exists pg_cron with schema extensions;
+
+do $$
+begin
+ if not exists (
+ select 1 from cron.job where jobname = 'looney-check-rate-limit-retention'
+ ) then
+ perform cron.schedule(
+ 'looney-check-rate-limit-retention',
+ '17 3 * * *',
+ $job$delete from public.looney_check_rate_limits where window_started_at < now() - interval '7 days'$job$
+ );
+ end if;
+end;
+$$;
+
+alter table public.looney_check_rate_limits enable row level security;
+
+revoke all on public.looney_check_rate_limits from anon, authenticated;
+
+create or replace function public.consume_looney_check_rate_limit(
+ p_buckets jsonb,
+ p_limit integer default 5,
+ p_consume boolean default true
+)
+returns table (
+ allowed boolean,
+ retry_after_seconds integer,
+ browser_count integer,
+ ip_count integer,
+ account_count integer
+)
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ bucket jsonb;
+ bucket_type_value text;
+ bucket_hash_value text;
+ current_window timestamptz := date_trunc('day', now() at time zone 'utc') at time zone 'utc';
+ bucket_count integer;
+ blocked boolean := false;
+ browser_total integer := 0;
+ ip_total integer := 0;
+ account_total integer := 0;
+begin
+ if p_limit is null or p_limit < 1 then
+ raise exception 'Rate-limit must be positive';
+ end if;
+
+ if jsonb_typeof(p_buckets) <> 'array' or jsonb_array_length(p_buckets) = 0 then
+ raise exception 'At least one rate-limit bucket is required';
+ end if;
+
+ for bucket in
+ select value
+ from (
+ select distinct on (value->>'type', value->>'hash') value
+ from jsonb_array_elements(p_buckets)
+ order by value->>'type', value->>'hash'
+ ) unique_buckets
+ loop
+ bucket_type_value := bucket->>'type';
+ bucket_hash_value := bucket->>'hash';
+ if bucket_type_value not in ('browser', 'ip', 'account') or bucket_hash_value is null or length(bucket_hash_value) < 16 then
+ raise exception 'Invalid rate-limit bucket';
+ end if;
+
+ insert into public.looney_check_rate_limits (bucket_type, bucket_hash, user_id, window_started_at)
+ values (bucket_type_value, bucket_hash_value, nullif(bucket->>'user_id', '')::uuid, current_window)
+ on conflict (bucket_type, bucket_hash, window_started_at) do nothing;
+
+ select check_count into bucket_count
+ from public.looney_check_rate_limits
+ where bucket_type = bucket_type_value
+ and bucket_hash = bucket_hash_value
+ and window_started_at = current_window
+ for update;
+
+ if bucket_type_value = 'browser' then browser_total := bucket_count; end if;
+ if bucket_type_value = 'ip' then ip_total := bucket_count; end if;
+ if bucket_type_value = 'account' then account_total := bucket_count; end if;
+ if bucket_count >= p_limit then blocked := true; end if;
+ end loop;
+
+ if not blocked and p_consume then
+ update public.looney_check_rate_limits as limits
+ set check_count = limits.check_count + 1, last_check_at = now()
+ from (
+ select distinct on (value->>'type', value->>'hash') value
+ from jsonb_array_elements(p_buckets)
+ order by value->>'type', value->>'hash'
+ ) unique_buckets
+ where limits.bucket_type = unique_buckets.value->>'type'
+ and limits.bucket_hash = unique_buckets.value->>'hash'
+ and limits.window_started_at = current_window;
+ browser_total := browser_total + case when exists (
+ select 1 from jsonb_array_elements(p_buckets) item where item->>'type' = 'browser'
+ ) then 1 else 0 end;
+ ip_total := ip_total + case when exists (
+ select 1 from jsonb_array_elements(p_buckets) item where item->>'type' = 'ip'
+ ) then 1 else 0 end;
+ account_total := account_total + case when exists (
+ select 1 from jsonb_array_elements(p_buckets) item where item->>'type' = 'account'
+ ) then 1 else 0 end;
+ end if;
+
+ return query select not blocked,
+ greatest(0, extract(epoch from ((current_window + interval '1 day') - now()))::integer),
+ browser_total, ip_total, account_total;
+end;
+$$;
+
+revoke all on function public.consume_looney_check_rate_limit(jsonb, integer, boolean) from public, anon, authenticated;
+grant execute on function public.consume_looney_check_rate_limit(jsonb, integer, boolean) to service_role;
+
+create or replace function public.release_looney_check_rate_limit(p_buckets jsonb)
+returns void
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ bucket jsonb;
+ current_window timestamptz := date_trunc('day', now() at time zone 'utc') at time zone 'utc';
+begin
+ if jsonb_typeof(p_buckets) <> 'array' or jsonb_array_length(p_buckets) = 0 then
+ raise exception 'At least one rate-limit bucket is required';
+ end if;
+
+ for bucket in
+ select value
+ from (
+ select distinct on (value->>'type', value->>'hash') value
+ from jsonb_array_elements(p_buckets)
+ order by value->>'type', value->>'hash'
+ ) unique_buckets
+ loop
+ update public.looney_check_rate_limits
+ set check_count = greatest(0, check_count - 1), last_check_at = now()
+ where bucket_type = bucket->>'type'
+ and bucket_hash = bucket->>'hash'
+ and window_started_at = current_window
+ and check_count > 0;
+ end loop;
+end;
+$$;
+
+revoke all on function public.release_looney_check_rate_limit(jsonb) from public, anon, authenticated;
+grant execute on function public.release_looney_check_rate_limit(jsonb) to service_role;
diff --git a/supabase/migrations/20260811000000_looney_check_rate_limit_release.sql b/supabase/migrations/20260811000000_looney_check_rate_limit_release.sql
new file mode 100644
index 0000000..7d50af8
--- /dev/null
+++ b/supabase/migrations/20260811000000_looney_check_rate_limit_release.sql
@@ -0,0 +1,34 @@
+create or replace function public.release_looney_check_rate_limit(p_buckets jsonb)
+returns void
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ bucket jsonb;
+ current_window timestamptz := date_trunc('day', now() at time zone 'utc') at time zone 'utc';
+begin
+ if jsonb_typeof(p_buckets) <> 'array' or jsonb_array_length(p_buckets) = 0 then
+ raise exception 'At least one rate-limit bucket is required';
+ end if;
+
+ for bucket in
+ select value
+ from (
+ select distinct on (value->>'type', value->>'hash') value
+ from jsonb_array_elements(p_buckets)
+ order by value->>'type', value->>'hash'
+ ) unique_buckets
+ loop
+ update public.looney_check_rate_limits
+ set check_count = greatest(0, check_count - 1), last_check_at = now()
+ where bucket_type = bucket->>'type'
+ and bucket_hash = bucket->>'hash'
+ and window_started_at = current_window
+ and check_count > 0;
+ end loop;
+end;
+$$;
+
+revoke all on function public.release_looney_check_rate_limit(jsonb) from public, anon, authenticated;
+grant execute on function public.release_looney_check_rate_limit(jsonb) to service_role;
diff --git a/vite.config.ts b/vite.config.ts
index 8babdc8..b14b4ed 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,12 +1,10 @@
-import { defineConfig, loadEnv } from "vite";
+import { defineConfig } from "vite";
import sitemap from 'vite-plugin-sitemap';
import react from "@vitejs/plugin-react-swc";
import path from "path";
// https://vitejs.dev/config/
-export default defineConfig(({ mode }) => {
- const env = loadEnv(mode, process.cwd(), '');
-
+export default defineConfig(() => {
return {
server: {
host: "::",
@@ -65,8 +63,5 @@ export default defineConfig(({ mode }) => {
},
},
},
- define: {
- 'process.env': env
- }
};
});