-
-
Notifications
You must be signed in to change notification settings - Fork 602
Add WebUI and multi account manage #196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 3 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,3 +11,6 @@ tests/ | |
| *.md | ||
|
|
||
| .eslintcache | ||
|
|
||
| web/node_modules | ||
| web/dist | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| # Stage 1: Build frontend | ||
| FROM node:22-alpine AS web-builder | ||
| WORKDIR /app/web | ||
|
|
||
| COPY web/package.json ./ | ||
| RUN npm install | ||
|
|
||
| COPY web/ ./ | ||
| RUN npm run build | ||
|
|
||
| # Stage 2: Build backend | ||
| FROM oven/bun:1.2.19-alpine AS builder | ||
| WORKDIR /app | ||
|
|
||
| COPY package.json bun.lock ./ | ||
| RUN bun install --frozen-lockfile | ||
|
|
||
| COPY . . | ||
|
|
||
| # Stage 3: Runtime | ||
| FROM oven/bun:1.2.19-alpine | ||
| WORKDIR /app | ||
|
|
||
| COPY package.json bun.lock ./ | ||
| RUN bun install --frozen-lockfile --production --ignore-scripts --no-cache | ||
|
|
||
| COPY --from=builder /app/src ./src | ||
| COPY --from=builder /app/tsconfig.json ./ | ||
| COPY --from=web-builder /app/web/dist ./web/dist | ||
|
|
||
| EXPOSE 3000 4141 | ||
|
|
||
| VOLUME /root/.local/share/copilot-api | ||
|
|
||
| HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ | ||
| CMD wget --spider -q http://localhost:3000/api/config || exit 1 | ||
|
|
||
| ENTRYPOINT ["bun", "run", "./src/main.ts", "console"] | ||
| CMD ["--web-port", "3000", "--proxy-port", "4141"] |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,4 +4,5 @@ export default config({ | |
| prettier: { | ||
| plugins: ["prettier-plugin-packagejson"], | ||
| }, | ||
| ignores: ["web/**"], | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| import crypto from "node:crypto" | ||
| import fs from "node:fs/promises" | ||
| import path from "node:path" | ||
|
|
||
| import { PATHS } from "~/lib/paths" | ||
|
|
||
| export interface Account { | ||
| id: string | ||
| name: string | ||
| githubToken: string | ||
| accountType: string | ||
| apiKey: string | ||
| enabled: boolean | ||
| createdAt: string | ||
| } | ||
|
|
||
| export interface AccountStore { | ||
| accounts: Array<Account> | ||
| } | ||
|
|
||
| const STORE_PATH = path.join(PATHS.APP_DIR, "accounts.json") | ||
|
|
||
| function generateApiKey(): string { | ||
| return `cpa-${crypto.randomBytes(16).toString("hex")}` | ||
| } | ||
|
|
||
| async function readStore(): Promise<AccountStore> { | ||
| try { | ||
| const data = await fs.readFile(STORE_PATH) | ||
| return JSON.parse(data) as AccountStore | ||
| } catch { | ||
| return { accounts: [] } | ||
| } | ||
| } | ||
|
|
||
| async function writeStore(store: AccountStore): Promise<void> { | ||
| await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2)) | ||
| } | ||
|
|
||
| export async function getAccounts(): Promise<Array<Account>> { | ||
| const store = await readStore() | ||
| return store.accounts | ||
| } | ||
|
|
||
| export async function getAccount(id: string): Promise<Account | undefined> { | ||
| const store = await readStore() | ||
| return store.accounts.find((a) => a.id === id) | ||
| } | ||
|
|
||
| export async function getAccountByApiKey( | ||
| apiKey: string, | ||
| ): Promise<Account | undefined> { | ||
| const store = await readStore() | ||
| return store.accounts.find((a) => a.apiKey === apiKey) | ||
| } | ||
|
|
||
| export async function addAccount( | ||
| account: Omit<Account, "id" | "createdAt" | "apiKey">, | ||
| ): Promise<Account> { | ||
| const store = await readStore() | ||
| const newAccount: Account = { | ||
| ...account, | ||
| id: crypto.randomUUID(), | ||
| apiKey: generateApiKey(), | ||
| createdAt: new Date().toISOString(), | ||
| } | ||
| store.accounts.push(newAccount) | ||
| await writeStore(store) | ||
| return newAccount | ||
| } | ||
|
|
||
| export async function updateAccount( | ||
| id: string, | ||
| updates: Partial<Omit<Account, "id" | "createdAt" | "apiKey">>, | ||
| ): Promise<Account | undefined> { | ||
| const store = await readStore() | ||
| const index = store.accounts.findIndex((a) => a.id === id) | ||
| if (index === -1) return undefined | ||
| store.accounts[index] = { ...store.accounts[index], ...updates } | ||
| await writeStore(store) | ||
| return store.accounts[index] | ||
| } | ||
|
|
||
| export async function deleteAccount(id: string): Promise<boolean> { | ||
| const store = await readStore() | ||
| const index = store.accounts.findIndex((a) => a.id === id) | ||
| if (index === -1) return false | ||
| store.accounts.splice(index, 1) | ||
| await writeStore(store) | ||
| return true | ||
| } | ||
|
|
||
| export async function regenerateApiKey( | ||
| id: string, | ||
| ): Promise<Account | undefined> { | ||
| const store = await readStore() | ||
| const index = store.accounts.findIndex((a) => a.id === id) | ||
| if (index === -1) return undefined | ||
| store.accounts[index] = { ...store.accounts[index], apiKey: generateApiKey() } | ||
| await writeStore(store) | ||
| return store.accounts[index] | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,92 @@ | ||||||||||||||||||||||||||||
| import crypto from "node:crypto" | ||||||||||||||||||||||||||||
| import fs from "node:fs/promises" | ||||||||||||||||||||||||||||
| import path from "node:path" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| import { PATHS } from "~/lib/paths" | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| interface AdminCredentials { | ||||||||||||||||||||||||||||
| username: string | ||||||||||||||||||||||||||||
| passwordHash: string | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| interface AdminStore { | ||||||||||||||||||||||||||||
| credentials: AdminCredentials | null | ||||||||||||||||||||||||||||
| sessions: Array<{ token: string; createdAt: string }> | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const STORE_PATH = path.join(PATHS.APP_DIR, "admin.json") | ||||||||||||||||||||||||||||
| const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| async function readStore(): Promise<AdminStore> { | ||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||
| const data = await fs.readFile(STORE_PATH) | ||||||||||||||||||||||||||||
| return JSON.parse(data) as AdminStore | ||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||
| return { credentials: null, sessions: [] } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| async function writeStore(store: AdminStore): Promise<void> { | ||||||||||||||||||||||||||||
| await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2)) | ||||||||||||||||||||||||||||
|
Comment on lines
+29
to
+30
|
||||||||||||||||||||||||||||
| async function writeStore(store: AdminStore): Promise<void> { | |
| await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2)) | |
| async function ensureStoreDirSecure(): Promise<void> { | |
| const dir = path.dirname(STORE_PATH) | |
| await fs.mkdir(dir, { recursive: true, mode: 0o700 }) | |
| } | |
| async function writeStore(store: AdminStore): Promise<void> { | |
| await ensureStoreDirSecure() | |
| await fs.writeFile(STORE_PATH, JSON.stringify(store, null, 2), { | |
| mode: 0o600, | |
| }) | |
| await fs.chmod(STORE_PATH, 0o600) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
writeStorewritesaccounts.jsonwithout setting restrictive file permissions. Since this file contains long-lived secrets (GitHub tokens + API keys), it should be created/maintained with mode0o600similar toensureFile()insrc/lib/paths.ts. Consider writing with{ mode: 0o600 }and/or callingfs.chmodafter write when needed.