mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
fix(providers): bulk-add API keys no longer overwrite existing keys
Bulk-add named auto-generated keys by paste-line index, blind to existing connection names. The backend upserts apikey connections by exact name (connectionsRepo), so a colliding generated name silently replaced an existing key instead of inserting a new one. Add a collision-aware planner (src/shared/utils/bulkAdd.js) that gap-fills the smallest free "<base> <n>" against both existing connection names and names assigned earlier in the same batch, so a generated name is never reused and the backend always inserts. Applies to auto-named lines, custom name|apiKey lines, and Cloudflare name|apiKey|accountId lines. Wire the planner into AddApiKeyModal and pass existing connection names from the provider detail page. Add unit tests covering gap-fill, custom names, Cloudflare 3-part format, and robustness.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
// Bulk-add API-key planner.
|
||||
//
|
||||
// Background: the backend upserts apikey connections BY NAME
|
||||
// (src/lib/db/repos/connectionsRepo.js ~L144: existing = all.find(c =>
|
||||
// c.authType === "apikey" && c.name === data.name)). A colliding name
|
||||
// overwrites an existing key instead of inserting a new one. Bulk-add used to
|
||||
// derive "<base> <lineIndex>" from the paste position, blind to existing
|
||||
// names, so re-adding keys often silently replaced earlier ones.
|
||||
//
|
||||
// This planner gap-fills the smallest free "<base> <n>" against both existing
|
||||
// connection names and names already assigned earlier in the same batch, so a
|
||||
// generated name is never reused and the backend always inserts.
|
||||
//
|
||||
// ponytail: only numeric-suffix collision is handled. A user who manually
|
||||
// types an exact existing non-numbered custom name (no index) will still hit
|
||||
// the backend upsert — but bulk auto-naming always appends " <n>", so this
|
||||
// path is unreachable from the bulk modal. Upgrade path: a backend
|
||||
// "skip-if-exists" flag on POST /api/providers if single-add ever needs it.
|
||||
|
||||
/**
|
||||
* Parse one pipe-separated bulk line into { baseName, apiKey, providerSpecificData? }.
|
||||
* @param {string} line
|
||||
* @param {{isCloudflareAi?: boolean}} [opts]
|
||||
* @returns {{baseName: string, apiKey: string, providerSpecificData?: object}|null}
|
||||
*/
|
||||
function parseLine(line, opts = {}) {
|
||||
const { isCloudflareAi = false } = opts;
|
||||
const parts = line.split("|");
|
||||
|
||||
if (isCloudflareAi && parts.length >= 3) {
|
||||
// name|apiKey|accountId (apiKey may itself contain pipes)
|
||||
const baseName = parts[0].trim();
|
||||
const apiKey = parts.slice(1, -1).join("|").trim();
|
||||
const accountId = parts[parts.length - 1].trim();
|
||||
return {
|
||||
baseName: baseName || "Key",
|
||||
apiKey,
|
||||
providerSpecificData: { accountId },
|
||||
};
|
||||
}
|
||||
|
||||
if (parts.length >= 2) {
|
||||
// name|apiKey (apiKey may itself contain pipes)
|
||||
const baseName = parts[0].trim();
|
||||
const apiKey = parts.slice(1).join("|").trim();
|
||||
return { baseName: baseName || "Key", apiKey };
|
||||
}
|
||||
|
||||
// apiKey only — auto-named "Key N"
|
||||
const apiKey = parts[0].trim();
|
||||
return { baseName: "Key", apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan a bulk add: parse lines, assign collision-free "<base> <n>" names.
|
||||
*
|
||||
* @param {string[]} lines raw paste lines
|
||||
* @param {string[]|null|undefined} existingNames connection names already saved
|
||||
* @param {{isCloudflareAi?: boolean}} [opts]
|
||||
* @returns {{name: string, apiKey: string, skipped: boolean, providerSpecificData?: object}[]}
|
||||
*/
|
||||
export function planBulkAdd(lines, existingNames, opts = {}) {
|
||||
const { isCloudflareAi = false } = opts;
|
||||
|
||||
const safeExisting = Array.isArray(existingNames) ? existingNames : [];
|
||||
const used = new Set(safeExisting.map((n) => (typeof n === "string" ? n.toLowerCase() : "")));
|
||||
|
||||
const out = [];
|
||||
for (const raw of lines) {
|
||||
const line = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!line) continue;
|
||||
|
||||
const parsed = parseLine(line, { isCloudflareAi });
|
||||
if (!parsed || !parsed.apiKey) continue;
|
||||
|
||||
const base = parsed.baseName;
|
||||
|
||||
// Gap-fill from 1: smallest free "<base> <n>" not in `used`.
|
||||
// O(batch * existing) — fine for bulk add (tens to low hundreds of keys).
|
||||
let idx = 1;
|
||||
let name;
|
||||
for (;;) {
|
||||
name = `${base} ${idx}`;
|
||||
if (!used.has(name.toLowerCase())) break;
|
||||
idx += 1;
|
||||
}
|
||||
used.add(name.toLowerCase());
|
||||
|
||||
const entry = { name, apiKey: parsed.apiKey, skipped: false };
|
||||
if (parsed.providerSpecificData) entry.providerSpecificData = parsed.providerSpecificData;
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user