From daa6a01ccf5eabf13f820fe0a7f47bfa7f0945d1 Mon Sep 17 00:00:00 2001 From: Cita Date: Sun, 22 Mar 2026 22:18:44 +0800 Subject: [PATCH] fix: handle duplicate site binding conflicts --- src/server/routes/api/sites.proxyUrl.test.ts | 63 ++++++++++ src/server/routes/api/sites.ts | 126 ++++++++++++++++--- 2 files changed, 173 insertions(+), 16 deletions(-) diff --git a/src/server/routes/api/sites.proxyUrl.test.ts b/src/server/routes/api/sites.proxyUrl.test.ts index ae38ffb..3fc54e4 100644 --- a/src/server/routes/api/sites.proxyUrl.test.ts +++ b/src/server/routes/api/sites.proxyUrl.test.ts @@ -70,6 +70,32 @@ describe('sites proxy settings', () => { expect(payload.globalWeight).toBe(1.5); }); + it('returns a conflict response when the same platform and url already exist', async () => { + const first = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'existing-site', + url: 'https://duplicate-site.example.com/', + platform: 'new-api', + }, + }); + expect(first.statusCode).toBe(200); + + const duplicate = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'duplicate-site', + url: 'https://duplicate-site.example.com', + platform: 'new-api', + }, + }); + + expect(duplicate.statusCode).toBe(409); + expect((duplicate.json() as { error?: string }).error).toContain('already exists'); + }); + it('rejects invalid useSystemProxy flag', async () => { const response = await app.inject({ method: 'POST', @@ -163,6 +189,43 @@ describe('sites proxy settings', () => { expect(payload.useSystemProxy).toBe(true); }); + it('returns a conflict response when updating a site to an existing platform and url', async () => { + const first = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'first-site', + url: 'https://first-site.example.com', + platform: 'new-api', + }, + }); + expect(first.statusCode).toBe(200); + + const second = await app.inject({ + method: 'POST', + url: '/api/sites', + payload: { + name: 'second-site', + url: 'https://second-site.example.com', + platform: 'new-api', + }, + }); + expect(second.statusCode).toBe(200); + + const { id } = second.json() as { id: number }; + const response = await app.inject({ + method: 'PUT', + url: `/api/sites/${id}`, + payload: { + url: 'https://first-site.example.com/', + platform: 'new-api', + }, + }); + + expect(response.statusCode).toBe(409); + expect((response.json() as { error?: string }).error).toContain('already exists'); + }); + it('rejects invalid custom headers json', async () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index 294379d..59aff01 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -1,4 +1,4 @@ -import { FastifyInstance } from 'fastify'; +import { FastifyInstance, FastifyReply } from 'fastify'; import { db, schema } from '../../db/index.js'; import { and, eq } from 'drizzle-orm'; import { detectSite } from '../../services/siteDetector.js'; @@ -76,6 +76,64 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): { return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') }; } +type ErrorLike = { + message?: string; + code?: string | number; + cause?: unknown; +}; + +function normalizeSiteUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +function getErrorChain(error: unknown): ErrorLike[] { + const chain: ErrorLike[] = []; + const seen = new Set(); + let current: unknown = error; + while (current && typeof current === 'object' && !seen.has(current)) { + seen.add(current); + chain.push(current as ErrorLike); + current = (current as ErrorLike).cause; + } + return chain; +} + +function isSitesPlatformUrlConflict(error: unknown): boolean { + return getErrorChain(error).some((entry) => { + const message = String(entry.message || ''); + const lowered = message.toLowerCase(); + const code = String(entry.code || ''); + const isSqliteSitesUnique = ( + (code === 'SQLITE_CONSTRAINT' || code === 'SQLITE_CONSTRAINT_UNIQUE') + && lowered.includes('unique constraint failed: sites.platform, sites.url') + ); + return (code === '23505' && lowered.includes('sites_platform_url_unique')) + || (code === 'ER_DUP_ENTRY' && lowered.includes('sites_platform_url_unique')) + || isSqliteSitesUnique + || (lowered.includes('duplicate key value violates unique constraint') && lowered.includes('sites_platform_url_unique')) + || (lowered.includes('duplicate entry') && lowered.includes('sites_platform_url_unique')); + }); +} + +function findExistingSiteBinding( + siteRows: Array<{ id: number; url: string; platform: string }>, + platform: string, + normalizedUrl: string, + excludedSiteId?: number, +) { + return siteRows.find((site) => ( + site.platform === platform + && site.url === normalizedUrl + && site.id !== excludedSiteId + )); +} + +function sendSiteBindingConflict(reply: FastifyReply, platform: string, normalizedUrl: string) { + return reply.code(409).send({ + error: `A ${platform} site with URL ${normalizedUrl} already exists.`, + }); +} + type SiteSubscriptionAggregate = { activeCount: number; totalUsedUsd: number; @@ -267,6 +325,7 @@ export async function sitesRoutes(app: FastifyInstance) { const existingSites = await db.select().from(schema.sites).all(); const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1); + const normalizedUrl = normalizeSiteUrl(url); let detectedPlatform = platform; if (!detectedPlatform) { @@ -276,19 +335,32 @@ export async function sitesRoutes(app: FastifyInstance) { if (!detectedPlatform) { return { error: 'Could not detect platform. Please specify manually.' }; } - const inserted = await db.insert(schema.sites).values({ - name, - url: url.replace(/\/+$/, ''), - platform: detectedPlatform, - proxyUrl: normalizedProxyUrl.proxyUrl, - useSystemProxy: normalizedUseSystemProxy ?? false, - customHeaders: normalizedCustomHeaders.customHeaders, - externalCheckinUrl: normalizedExternalCheckinUrl.url, - status: normalizedStatus ?? 'active', - isPinned: normalizedPinned ?? false, - sortOrder: normalizedSortOrder ?? (maxSortOrder + 1), - globalWeight: normalizedGlobalWeight ?? 1, - }).run(); + const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl); + if (conflictingSite) { + return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl); + } + + let inserted; + try { + inserted = await db.insert(schema.sites).values({ + name, + url: normalizedUrl, + platform: detectedPlatform, + proxyUrl: normalizedProxyUrl.proxyUrl, + useSystemProxy: normalizedUseSystemProxy ?? false, + customHeaders: normalizedCustomHeaders.customHeaders, + externalCheckinUrl: normalizedExternalCheckinUrl.url, + status: normalizedStatus ?? 'active', + isPinned: normalizedPinned ?? false, + sortOrder: normalizedSortOrder ?? (maxSortOrder + 1), + globalWeight: normalizedGlobalWeight ?? 1, + }).run(); + } catch (error) { + if (isSitesPlatformUrlConflict(error)) { + return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl); + } + throw error; + } const siteId = Number(inserted.lastInsertRowid || 0); if (siteId <= 0) { return reply.code(500).send({ error: 'Create site failed' }); @@ -360,8 +432,23 @@ export async function sitesRoutes(app: FastifyInstance) { return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' }); } + const nextUrl = body.url !== undefined ? normalizeSiteUrl(body.url) : existingSite.url; + const nextPlatform = body.platform !== undefined ? body.platform : existingSite.platform; + const siteIdentityChanged = nextUrl !== existingSite.url || nextPlatform !== existingSite.platform; + if (siteIdentityChanged) { + const siteRows = await db.select({ + id: schema.sites.id, + url: schema.sites.url, + platform: schema.sites.platform, + }).from(schema.sites).all(); + const conflictingSite = findExistingSiteBinding(siteRows, nextPlatform, nextUrl, id); + if (conflictingSite) { + return sendSiteBindingConflict(reply, nextPlatform, nextUrl); + } + } + if (body.name !== undefined) updates.name = body.name; - if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, ''); + if (body.url !== undefined) updates.url = nextUrl; if (body.platform !== undefined) updates.platform = body.platform; if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl; if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy; @@ -372,7 +459,14 @@ export async function sitesRoutes(app: FastifyInstance) { if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder; if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight; updates.updatedAt = new Date().toISOString(); - await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run(); + try { + await db.update(schema.sites).set(updates).where(eq(schema.sites.id, id)).run(); + } catch (error) { + if (isSitesPlatformUrlConflict(error)) { + return sendSiteBindingConflict(reply, nextPlatform, nextUrl); + } + throw error; + } if (body.status !== undefined && normalizedStatus) { await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);