mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 21:22:28 +03:00
* fix(api): bulk-add API keys no longer overwrite existing connections createProviderConnection upserts apikey connections BY NAME (same provider + auth_type "apikey" + same name updates the row in place, replacing its apiKey/priority/testStatus instead of inserting). The bulk-add route auto-names unnamed lines "Key 1", "Key 2", ... restarting from 1 on every request, blind to names already saved for the provider — so re-running a bulk paste against a provider that already had "Key 1" silently replaced it instead of adding a new connection alongside it. The same collision could also happen within one batch for two identical custom name|apiKey lines. Add resolveBulkNameCollisions (src/shared/utils/bulkApiKeyParser.ts): gap-fills the smallest free "<name> <n>" suffix against both existing connection names and names already assigned earlier in the same batch, so a name is never reused. Wire it into POST /api/providers/bulk before the create loop, fetching existing apikey connection names via the existing getProviderConnections db module (no raw SQL added to the route). Co-authored-by: asynx6 <sahrulbeni656@gmail.com> Inspired-by: https://github.com/decolua/9router/pull/2587 * chore(changelog): fragment for #7234 --------- Co-authored-by: asynx6 <sahrulbeni656@gmail.com>
168 lines
5.3 KiB
TypeScript
168 lines
5.3 KiB
TypeScript
/**
|
|
* Parses textarea input for bulk API key creation.
|
|
*
|
|
* Supported line formats (one per line):
|
|
* - `name|apiKey`
|
|
* - `apiKey` (auto-named as `Key N`)
|
|
* - `# comment` (skipped)
|
|
* - blank lines (skipped)
|
|
*
|
|
* `apiKey` may contain `|` — only the first `|` is treated as the separator.
|
|
*
|
|
* When `withAccountId` is enabled (Cloudflare Workers AI), each line carries a
|
|
* per-key account id in a 3-field shape:
|
|
* - `name|accountId|apiKey`
|
|
* Only the first two `|` are treated as separators, so an `apiKey` containing
|
|
* `|` stays intact. Lines missing the `accountId` or `apiKey` field are flagged
|
|
* as warnings and skipped.
|
|
*/
|
|
|
|
export interface BulkApiKeyEntry {
|
|
name: string;
|
|
apiKey: string;
|
|
lineNumber: number;
|
|
/** Per-key account id — only populated for providers parsed with `withAccountId` (Cloudflare). */
|
|
accountId?: string;
|
|
}
|
|
|
|
export interface BulkApiKeyParseResult {
|
|
entries: BulkApiKeyEntry[];
|
|
warnings: string[];
|
|
}
|
|
|
|
export interface ParseBulkApiKeysOptions {
|
|
/** Parse each line as the 3-field `name|accountId|apiKey` shape (Cloudflare Workers AI). */
|
|
withAccountId?: boolean;
|
|
}
|
|
|
|
const MAX_BULK_LINES = 200;
|
|
|
|
export function parseBulkApiKeys(
|
|
text: string,
|
|
options: ParseBulkApiKeysOptions = {}
|
|
): BulkApiKeyParseResult {
|
|
const lines = text.split(/\r?\n/);
|
|
const entries: BulkApiKeyEntry[] = [];
|
|
const warnings: string[] = [];
|
|
let autoIdx = 1;
|
|
|
|
if (lines.length > MAX_BULK_LINES) {
|
|
warnings.push(
|
|
`Input has ${lines.length} lines; only the first ${MAX_BULK_LINES} will be processed.`
|
|
);
|
|
}
|
|
|
|
const bound = Math.min(lines.length, MAX_BULK_LINES);
|
|
for (let i = 0; i < bound; i++) {
|
|
const raw = lines[i].trim();
|
|
if (!raw) continue;
|
|
if (raw.startsWith("#")) continue;
|
|
|
|
if (options.withAccountId) {
|
|
const firstPipe = raw.indexOf("|");
|
|
if (firstPipe === -1) {
|
|
warnings.push(`Line ${i + 1}: expected name|accountId|apiKey, skipped`);
|
|
continue;
|
|
}
|
|
const secondPipe = raw.indexOf("|", firstPipe + 1);
|
|
if (secondPipe === -1) {
|
|
warnings.push(`Line ${i + 1}: missing accountId or apiKey, skipped`);
|
|
continue;
|
|
}
|
|
const namePart = raw.slice(0, firstPipe).trim();
|
|
const accountId = raw.slice(firstPipe + 1, secondPipe).trim();
|
|
const apiKey = raw.slice(secondPipe + 1).trim();
|
|
const name = namePart || `Key ${autoIdx++}`;
|
|
|
|
if (!accountId) {
|
|
warnings.push(`Line ${i + 1}: empty accountId, skipped`);
|
|
continue;
|
|
}
|
|
if (!apiKey) {
|
|
warnings.push(`Line ${i + 1}: empty apiKey, skipped`);
|
|
continue;
|
|
}
|
|
|
|
entries.push({ name, accountId, apiKey, lineNumber: i + 1 });
|
|
continue;
|
|
}
|
|
|
|
const pipeIdx = raw.indexOf("|");
|
|
let name: string;
|
|
let apiKey: string;
|
|
if (pipeIdx === -1) {
|
|
name = `Key ${autoIdx++}`;
|
|
apiKey = raw;
|
|
} else {
|
|
const namePart = raw.slice(0, pipeIdx).trim();
|
|
apiKey = raw.slice(pipeIdx + 1).trim();
|
|
name = namePart || `Key ${autoIdx++}`;
|
|
}
|
|
|
|
if (!apiKey) {
|
|
warnings.push(`Line ${i + 1}: empty apiKey, skipped`);
|
|
continue;
|
|
}
|
|
|
|
entries.push({ name, apiKey, lineNumber: i + 1 });
|
|
}
|
|
|
|
return { entries, warnings };
|
|
}
|
|
|
|
export const BULK_API_KEY_MAX_LINES = MAX_BULK_LINES;
|
|
|
|
// Strips a trailing " <digits>" suffix so a colliding name's numeric index can be
|
|
// regenerated instead of stacking (e.g. "Key 1" -> "Key", not "Key 1 2").
|
|
function stripTrailingIndex(name: string): string {
|
|
const stripped = name.replace(/\s+\d+$/, "");
|
|
return stripped.length > 0 ? stripped : name;
|
|
}
|
|
|
|
/**
|
|
* Resolves name collisions across a batch of bulk-add entries.
|
|
*
|
|
* Background: `createProviderConnection` upserts apikey connections BY NAME
|
|
* (see `src/lib/db/providers.ts` — same provider + auth_type "apikey" + same
|
|
* `name` updates the existing row instead of inserting a new one, replacing
|
|
* its `apiKey`/`priority`/`testStatus`). `parseBulkApiKeys` auto-names
|
|
* unnamed lines "Key 1", "Key 2", ... per request, blind to names already
|
|
* saved for the provider — and a batch can also contain the same custom
|
|
* `name|apiKey` name twice. Either case previously reached the backend upsert
|
|
* path and silently overwrote (or self-collapsed) an existing connection
|
|
* instead of inserting a new one.
|
|
*
|
|
* This resolves every collision — against `existingNames` AND against names
|
|
* already assigned earlier in the same batch — by gap-filling the smallest
|
|
* free "<base> <n>" suffix, so a name is never reused and every entry reaches
|
|
* the backend as a genuine insert.
|
|
*/
|
|
export function resolveBulkNameCollisions<T extends { name: string }>(
|
|
entries: T[],
|
|
existingNames: readonly string[] | null | undefined
|
|
): T[] {
|
|
const used = new Set(
|
|
(Array.isArray(existingNames) ? existingNames : [])
|
|
.filter((n): n is string => typeof n === "string" && n.length > 0)
|
|
.map((n) => n.toLowerCase())
|
|
);
|
|
|
|
return entries.map((entry) => {
|
|
const lowerName = entry.name.toLowerCase();
|
|
if (!used.has(lowerName)) {
|
|
used.add(lowerName);
|
|
return entry;
|
|
}
|
|
|
|
const base = stripTrailingIndex(entry.name);
|
|
let idx = 1;
|
|
let candidate = `${base} ${idx}`;
|
|
while (used.has(candidate.toLowerCase())) {
|
|
idx += 1;
|
|
candidate = `${base} ${idx}`;
|
|
}
|
|
used.add(candidate.toLowerCase());
|
|
return { ...entry, name: candidate };
|
|
});
|
|
}
|