fix: handle duplicate site binding conflicts
This commit is contained in:
@@ -70,6 +70,32 @@ describe('sites proxy settings', () => {
|
|||||||
expect(payload.globalWeight).toBe(1.5);
|
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 () => {
|
it('rejects invalid useSystemProxy flag', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -163,6 +189,43 @@ describe('sites proxy settings', () => {
|
|||||||
expect(payload.useSystemProxy).toBe(true);
|
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 () => {
|
it('rejects invalid custom headers json', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
+110
-16
@@ -1,4 +1,4 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { and, eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { detectSite } from '../../services/siteDetector.js';
|
import { detectSite } from '../../services/siteDetector.js';
|
||||||
@@ -76,6 +76,64 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
|
|||||||
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
|
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<unknown>();
|
||||||
|
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 = {
|
type SiteSubscriptionAggregate = {
|
||||||
activeCount: number;
|
activeCount: number;
|
||||||
totalUsedUsd: number;
|
totalUsedUsd: number;
|
||||||
@@ -267,6 +325,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const existingSites = await db.select().from(schema.sites).all();
|
const existingSites = await db.select().from(schema.sites).all();
|
||||||
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
||||||
|
const normalizedUrl = normalizeSiteUrl(url);
|
||||||
|
|
||||||
let detectedPlatform = platform;
|
let detectedPlatform = platform;
|
||||||
if (!detectedPlatform) {
|
if (!detectedPlatform) {
|
||||||
@@ -276,19 +335,32 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (!detectedPlatform) {
|
if (!detectedPlatform) {
|
||||||
return { error: 'Could not detect platform. Please specify manually.' };
|
return { error: 'Could not detect platform. Please specify manually.' };
|
||||||
}
|
}
|
||||||
const inserted = await db.insert(schema.sites).values({
|
const conflictingSite = findExistingSiteBinding(existingSites, detectedPlatform, normalizedUrl);
|
||||||
name,
|
if (conflictingSite) {
|
||||||
url: url.replace(/\/+$/, ''),
|
return sendSiteBindingConflict(reply, detectedPlatform, normalizedUrl);
|
||||||
platform: detectedPlatform,
|
}
|
||||||
proxyUrl: normalizedProxyUrl.proxyUrl,
|
|
||||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
let inserted;
|
||||||
customHeaders: normalizedCustomHeaders.customHeaders,
|
try {
|
||||||
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
inserted = await db.insert(schema.sites).values({
|
||||||
status: normalizedStatus ?? 'active',
|
name,
|
||||||
isPinned: normalizedPinned ?? false,
|
url: normalizedUrl,
|
||||||
sortOrder: normalizedSortOrder ?? (maxSortOrder + 1),
|
platform: detectedPlatform,
|
||||||
globalWeight: normalizedGlobalWeight ?? 1,
|
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||||
}).run();
|
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);
|
const siteId = Number(inserted.lastInsertRowid || 0);
|
||||||
if (siteId <= 0) {
|
if (siteId <= 0) {
|
||||||
return reply.code(500).send({ error: 'Create site failed' });
|
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.' });
|
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.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 (body.platform !== undefined) updates.platform = body.platform;
|
||||||
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
|
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
|
||||||
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
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.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
|
||||||
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
|
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
|
||||||
updates.updatedAt = new Date().toISOString();
|
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) {
|
if (body.status !== undefined && normalizedStatus) {
|
||||||
await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);
|
await applySiteStatusSideEffects(id, existingSite.name, normalizedStatus);
|
||||||
|
|||||||
Reference in New Issue
Block a user