g1t/apps/web/app/lib/secrets.server.ts
| 1 | import { redirect } from "react-router"; |
| 2 | |
| 3 | import type { Setting, SettingKind, SettingReader, SettingsOwner, User } from "@g1t/contracts"; |
| 4 | |
| 5 | import { actions, projects } from "./services.server"; |
| 6 | |
| 7 | export type SecretsData = { |
| 8 | rows: Setting[]; |
| 9 | /** For a workspace: its projects' slugs, to link rows to. */ |
| 10 | projects: string[]; |
| 11 | error: string | null; |
| 12 | }; |
| 13 | |
| 14 | /** A repository's or a workspace's secrets and variables, as one list. */ |
| 15 | export async function loadSecrets(owner: SettingsOwner, actor: User): Promise<SecretsData> { |
| 16 | const [rows, list] = await Promise.all([ |
| 17 | actions.settings(actor, owner, "all"), |
| 18 | "workspace" in owner ? projects.list(owner.workspace, actor) : Promise.resolve(null), |
| 19 | ]); |
| 20 | return { |
| 21 | rows: rows.ok ? rows.value : [], |
| 22 | projects: list?.ok ? list.value.map((project) => project.slug) : [], |
| 23 | error: rows.ok ? null : rows.error.message, |
| 24 | }; |
| 25 | } |
| 26 | |
| 27 | export type SecretsAction = { error?: string }; |
| 28 | |
| 29 | /** `KEY=value` lines, as a .env file has them; quotes around a value are dropped. */ |
| 30 | function parseDotenv(text: string): [string, string][] { |
| 31 | const pairs: [string, string][] = []; |
| 32 | for (const raw of text.split(/\r?\n/)) { |
| 33 | const line = raw.replace(/^\s*export\s+/, "").trim(); |
| 34 | if (!line || line.startsWith("#")) continue; |
| 35 | const at = line.indexOf("="); |
| 36 | if (at <= 0) continue; |
| 37 | let value = line.slice(at + 1).trim(); |
| 38 | if (/^(["']).*\1$/.test(value)) value = value.slice(1, -1); |
| 39 | pairs.push([line.slice(0, at).trim(), value]); |
| 40 | } |
| 41 | return pairs; |
| 42 | } |
| 43 | |
| 44 | /** |
| 45 | * Saves the side panel's form (one row, or many pasted as a .env file), or |
| 46 | * removes a row, then returns to the list. |
| 47 | */ |
| 48 | export async function actOnSecrets(owner: SettingsOwner, actor: User, form: FormData, page: string): Promise<SecretsAction> { |
| 49 | const intent = String(form.get("intent") ?? ""); |
| 50 | const id = String(form.get("id") ?? "") || undefined; |
| 51 | if (intent === "delete") { |
| 52 | const removed = await actions.deleteSetting(actor, owner, "all", String(form.get("name") ?? ""), id); |
| 53 | if (!removed.ok) return { error: removed.error.message }; |
| 54 | throw redirect(page); |
| 55 | } |
| 56 | const kind: SettingKind = form.get("type") === "config" ? "variable" : "secret"; |
| 57 | const environments = |
| 58 | form.get("scope") === "some" |
| 59 | ? [ |
| 60 | ...form.getAll("env").map(String), |
| 61 | ...String(form.get("envCustom") ?? "") |
| 62 | .split(",") |
| 63 | .map((name) => name.trim()) |
| 64 | .filter(Boolean), |
| 65 | ] |
| 66 | : []; |
| 67 | if (form.get("scope") === "some" && environments.length === 0) { |
| 68 | return { error: "Choose at least one environment, or All environments." }; |
| 69 | } |
| 70 | const availableTo = form.getAll("availableTo").map(String) as SettingReader[]; |
| 71 | if (availableTo.length === 0) return { error: "Choose who reads it: Workflows, Deployments, or both." }; |
| 72 | const linked = |
| 73 | "workspace" in owner && form.get("reach") === "some" ? form.getAll("project").map(String) : []; |
| 74 | const note = String(form.get("note") ?? ""); |
| 75 | const key = String(form.get("key") ?? "").trim(); |
| 76 | const value = String(form.get("value") ?? ""); |
| 77 | // A pasted .env file adds a row for each line. |
| 78 | const pasted = !id && key.includes("=") ? parseDotenv(key) : []; |
| 79 | const entries: [string, string | null][] = pasted.length > 0 ? pasted : [[key, value === "" && id ? null : value]]; |
| 80 | for (const [name, entryValue] of entries) { |
| 81 | if (!name) return { error: "Give it a key." }; |
| 82 | if (entryValue === "" && !id) return { error: `Give ${name} a value.` }; |
| 83 | const saved = await actions.setSetting(actor, owner, kind, name, entryValue, { |
| 84 | id, |
| 85 | availableTo, |
| 86 | environments, |
| 87 | projects: "workspace" in owner ? linked : undefined, |
| 88 | note, |
| 89 | }); |
| 90 | if (!saved.ok) return { error: pasted.length > 0 ? `${name}: ${saved.error.message}` : saved.error.message }; |
| 91 | } |
| 92 | throw redirect(page); |
| 93 | } |