mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
add concept of secrets (#180)
* add @sourcebot/schemas package * migrate things to use the schemas package * Dockerfile support * add secret table to schema * Add concept of connection manager * Rename Config->Connection * Handle job failures * Add join table between repo and connection * nits * create first version of crypto package * add crypto package as deps to others * forgot to add package changes * add server action for adding and listing secrets, create test page for it * add secrets page to nav menu * add secret to config and support fetching it in backend * reset secret form on successful submission * add toast feedback for secrets form * add instructions for adding encryption key to dev instructions * add encryption key support in docker file * add delete secret button * fix nits from pr review --------- Co-authored-by: bkellam <bshizzle1234@gmail.com>
This commit is contained in:
parent
dd8ff6edb0
commit
31114a9d95
31 changed files with 699 additions and 31 deletions
|
|
@ -17,8 +17,10 @@ WORKDIR /app
|
||||||
COPY package.json yarn.lock* ./
|
COPY package.json yarn.lock* ./
|
||||||
COPY ./packages/db ./packages/db
|
COPY ./packages/db ./packages/db
|
||||||
COPY ./packages/schemas ./packages/schemas
|
COPY ./packages/schemas ./packages/schemas
|
||||||
|
COPY ./packages/crypto ./packages/crypto
|
||||||
RUN yarn workspace @sourcebot/db install --frozen-lockfile
|
RUN yarn workspace @sourcebot/db install --frozen-lockfile
|
||||||
RUN yarn workspace @sourcebot/schemas install --frozen-lockfile
|
RUN yarn workspace @sourcebot/schemas install --frozen-lockfile
|
||||||
|
RUN yarn workspace @sourcebot/crypto install --frozen-lockfile
|
||||||
|
|
||||||
# ------ Build Web ------
|
# ------ Build Web ------
|
||||||
FROM node-alpine AS web-builder
|
FROM node-alpine AS web-builder
|
||||||
|
|
@ -30,6 +32,7 @@ COPY ./packages/web ./packages/web
|
||||||
COPY --from=shared-libs-builder /app/node_modules ./node_modules
|
COPY --from=shared-libs-builder /app/node_modules ./node_modules
|
||||||
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
||||||
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
||||||
|
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
|
||||||
|
|
||||||
# Fixes arm64 timeouts
|
# Fixes arm64 timeouts
|
||||||
RUN yarn config set registry https://registry.npmjs.org/
|
RUN yarn config set registry https://registry.npmjs.org/
|
||||||
|
|
@ -60,6 +63,7 @@ COPY ./packages/backend ./packages/backend
|
||||||
COPY --from=shared-libs-builder /app/node_modules ./node_modules
|
COPY --from=shared-libs-builder /app/node_modules ./node_modules
|
||||||
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
||||||
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
||||||
|
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
|
||||||
RUN yarn workspace @sourcebot/backend install --frozen-lockfile
|
RUN yarn workspace @sourcebot/backend install --frozen-lockfile
|
||||||
RUN yarn workspace @sourcebot/backend build
|
RUN yarn workspace @sourcebot/backend build
|
||||||
|
|
||||||
|
|
@ -100,7 +104,7 @@ ENV POSTHOG_PAPIK=$POSTHOG_PAPIK
|
||||||
# ENV SOURCEBOT_TELEMETRY_DISABLED=1
|
# ENV SOURCEBOT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Configure dependencies
|
# Configure dependencies
|
||||||
RUN apk add --no-cache git ca-certificates bind-tools tini jansson wget supervisor uuidgen curl perl jq redis postgresql postgresql-contrib
|
RUN apk add --no-cache git ca-certificates bind-tools tini jansson wget supervisor uuidgen curl perl jq redis postgresql postgresql-contrib openssl
|
||||||
|
|
||||||
# Configure zoekt
|
# Configure zoekt
|
||||||
COPY vendor/zoekt/install-ctags-alpine.sh .
|
COPY vendor/zoekt/install-ctags-alpine.sh .
|
||||||
|
|
@ -129,6 +133,7 @@ COPY --from=backend-builder /app/packages/backend ./packages/backend
|
||||||
COPY --from=shared-libs-builder /app/node_modules ./node_modules
|
COPY --from=shared-libs-builder /app/node_modules ./node_modules
|
||||||
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
COPY --from=shared-libs-builder /app/packages/db ./packages/db
|
||||||
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
COPY --from=shared-libs-builder /app/packages/schemas ./packages/schemas
|
||||||
|
COPY --from=shared-libs-builder /app/packages/crypto ./packages/crypto
|
||||||
|
|
||||||
# Configure the database
|
# Configure the database
|
||||||
RUN mkdir -p /run/postgresql && \
|
RUN mkdir -p /run/postgresql && \
|
||||||
|
|
@ -143,6 +148,8 @@ RUN chmod +x ./entrypoint.sh
|
||||||
|
|
||||||
COPY default-config.json .
|
COPY default-config.json .
|
||||||
|
|
||||||
|
ENV SOURCEBOT_ENCRYPTION_KEY=""
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
|
||||||
10
README.md
10
README.md
|
|
@ -374,14 +374,20 @@ docker run <b>-v /path/to/my-repo:/repos/my-repo</b> /* additional args */ ghcr.
|
||||||
|
|
||||||
5. Create a `config.json` file at the repository root. See [Configuring Sourcebot](#configuring-sourcebot) for more information.
|
5. Create a `config.json` file at the repository root. See [Configuring Sourcebot](#configuring-sourcebot) for more information.
|
||||||
|
|
||||||
6. Start Sourcebot with the command:
|
6. Create `.env.local` files in the `packages/backend` and `packages/web` directories with the following contents:
|
||||||
|
```sh
|
||||||
|
# You can use https://acte.ltd/utils/randomkeygen to generate a key ("Encryption key 256")
|
||||||
|
SOURCEBOT_ENCRYPTION_KEY="32-byte-secret-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Start Sourcebot with the command:
|
||||||
```sh
|
```sh
|
||||||
yarn dev
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
A `.sourcebot` directory will be created and zoekt will begin to index the repositories found given `config.json`.
|
A `.sourcebot` directory will be created and zoekt will begin to index the repositories found given `config.json`.
|
||||||
|
|
||||||
7. Start searching at `http://localhost:3000`.
|
8. Start searching at `http://localhost:3000`.
|
||||||
|
|
||||||
## Telemetry
|
## Telemetry
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,22 @@ if [ ! -d "$DB_DATA_DIR" ]; then
|
||||||
su postgres -c "initdb -D $DB_DATA_DIR"
|
su postgres -c "initdb -D $DB_DATA_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -z "$SOURCEBOT_ENCRYPTION_KEY" ]; then
|
||||||
|
echo -e "\e[31m[Error] SOURCEBOT_ENCRYPTION_KEY is not set.\e[0m"
|
||||||
|
|
||||||
|
if [ -f "$DATA_CACHE_DIR/.secret" ]; then
|
||||||
|
echo -e "\e[34m[Info] Loading environment variables from $DATA_CACHE_DIR/.secret\e[0m"
|
||||||
|
else
|
||||||
|
echo -e "\e[34m[Info] Generating a new encryption key...\e[0m"
|
||||||
|
SOURCEBOT_ENCRYPTION_KEY=$(openssl rand -base64 24)
|
||||||
|
echo "SOURCEBOT_ENCRYPTION_KEY=\"$SOURCEBOT_ENCRYPTION_KEY\"" >> "$DATA_CACHE_DIR/.secret"
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. "$DATA_CACHE_DIR/.secret"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
# In order to detect if this is the first run, we create a `.installed` file in
|
# In order to detect if this is the first run, we create a `.installed` file in
|
||||||
# the cache directory.
|
# the cache directory.
|
||||||
FIRST_RUN_FILE="$DATA_CACHE_DIR/.installedv2"
|
FIRST_RUN_FILE="$DATA_CACHE_DIR/.installedv2"
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
"lowdb": "^7.0.1",
|
"lowdb": "^7.0.1",
|
||||||
"micromatch": "^4.0.8",
|
"micromatch": "^4.0.8",
|
||||||
"posthog-node": "^4.2.1",
|
"posthog-node": "^4.2.1",
|
||||||
|
"@sourcebot/crypto": "^0.1.0",
|
||||||
"@sourcebot/db": "^0.1.0",
|
"@sourcebot/db": "^0.1.0",
|
||||||
"@sourcebot/schemas": "^0.1.0",
|
"@sourcebot/schemas": "^0.1.0",
|
||||||
"simple-git": "^3.27.0",
|
"simple-git": "^3.27.0",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { getTokenFromConfig, marshalBool } from "./utils.js";
|
import { marshalBool } from "./utils.js";
|
||||||
import { getGitHubReposFromConfig } from "./github.js";
|
import { getGitHubReposFromConfig } from "./github.js";
|
||||||
|
|
||||||
interface IConnectionManager {
|
interface IConnectionManager {
|
||||||
|
|
@ -70,17 +70,13 @@ export class ConnectionManager implements IConnectionManager {
|
||||||
const repoData: RepoData[] = await (async () => {
|
const repoData: RepoData[] = await (async () => {
|
||||||
switch (config.type) {
|
switch (config.type) {
|
||||||
case 'github': {
|
case 'github': {
|
||||||
const token = config.token ? getTokenFromConfig(config.token, this.context) : undefined;
|
const gitHubRepos = await getGitHubReposFromConfig(config, orgId, this.db, abortController.signal);
|
||||||
const gitHubRepos = await getGitHubReposFromConfig(config, abortController.signal, this.context);
|
|
||||||
const hostUrl = config.url ?? 'https://github.com';
|
const hostUrl = config.url ?? 'https://github.com';
|
||||||
const hostname = config.url ? new URL(config.url).hostname : 'github.com';
|
const hostname = config.url ? new URL(config.url).hostname : 'github.com';
|
||||||
|
|
||||||
return gitHubRepos.map((repo) => {
|
return gitHubRepos.map((repo) => {
|
||||||
const repoName = `${hostname}/${repo.full_name}`;
|
const repoName = `${hostname}/${repo.full_name}`;
|
||||||
const cloneUrl = new URL(repo.clone_url!);
|
const cloneUrl = new URL(repo.clone_url!);
|
||||||
if (token) {
|
|
||||||
cloneUrl.username = token;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record: RepoData = {
|
const record: RepoData = {
|
||||||
external_id: repo.id.toString(),
|
external_id: repo.id.toString(),
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@ import micromatch from 'micromatch';
|
||||||
|
|
||||||
const logger = createLogger('Gitea');
|
const logger = createLogger('Gitea');
|
||||||
|
|
||||||
export const getGiteaReposFromConfig = async (config: GiteaConfig, ctx: AppContext) => {
|
export const getGiteaReposFromConfig = async (config: GiteaConfig, orgId: number, ctx: AppContext) => {
|
||||||
const token = config.token ? getTokenFromConfig(config.token, ctx) : undefined;
|
// TODO: pass in DB here to fetch secret properly
|
||||||
|
const token = config.token ? await getTokenFromConfig(config.token, orgId) : undefined;
|
||||||
|
|
||||||
const api = giteaApi(config.url ?? 'https://gitea.com', {
|
const api = giteaApi(config.url ?? 'https://gitea.com', {
|
||||||
token,
|
token,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { createLogger } from "./logger.js";
|
||||||
import { AppContext } from "./types.js";
|
import { AppContext } from "./types.js";
|
||||||
import { getTokenFromConfig, measure } from "./utils.js";
|
import { getTokenFromConfig, measure } from "./utils.js";
|
||||||
import micromatch from "micromatch";
|
import micromatch from "micromatch";
|
||||||
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
|
|
||||||
const logger = createLogger("GitHub");
|
const logger = createLogger("GitHub");
|
||||||
|
|
||||||
|
|
@ -25,8 +26,8 @@ export type OctokitRepository = {
|
||||||
size?: number,
|
size?: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, signal: AbortSignal, ctx: AppContext) => {
|
export const getGitHubReposFromConfig = async (config: GithubConnectionConfig, orgId: number, db: PrismaClient, signal: AbortSignal) => {
|
||||||
const token = config.token ? getTokenFromConfig(config.token, ctx) : undefined;
|
const token = config.token ? await getTokenFromConfig(config.token, orgId, db) : undefined;
|
||||||
|
|
||||||
const octokit = new Octokit({
|
const octokit = new Octokit({
|
||||||
auth: token,
|
auth: token,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ import { getTokenFromConfig, measure } from "./utils.js";
|
||||||
const logger = createLogger("GitLab");
|
const logger = createLogger("GitLab");
|
||||||
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
|
export const GITLAB_CLOUD_HOSTNAME = "gitlab.com";
|
||||||
|
|
||||||
export const getGitLabReposFromConfig = async (config: GitLabConfig, ctx: AppContext) => {
|
export const getGitLabReposFromConfig = async (config: GitLabConfig, orgId: number, ctx: AppContext) => {
|
||||||
const token = config.token ? getTokenFromConfig(config.token, ctx) : undefined;
|
// TODO: pass in DB here to fetch secret properly
|
||||||
|
const token = config.token ? await getTokenFromConfig(config.token, orgId) : undefined;
|
||||||
const api = new Gitlab({
|
const api = new Gitlab({
|
||||||
...(config.token ? {
|
...(config.token ? {
|
||||||
token,
|
token,
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,50 @@
|
||||||
import { ConnectionSyncStatus, PrismaClient, Repo, RepoIndexingStatus } from '@sourcebot/db';
|
import { ConnectionSyncStatus, PrismaClient, Repo, RepoIndexingStatus, RepoToConnection, Connection } from '@sourcebot/db';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { cloneRepository, fetchRepository } from "./git.js";
|
import { cloneRepository, fetchRepository } from "./git.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { captureEvent } from "./posthog.js";
|
import { captureEvent } from "./posthog.js";
|
||||||
import { AppContext } from "./types.js";
|
import { AppContext } from "./types.js";
|
||||||
import { getRepoPath, measure } from "./utils.js";
|
import { getRepoPath, getTokenFromConfig, measure } from "./utils.js";
|
||||||
import { indexGitRepository } from "./zoekt.js";
|
import { indexGitRepository } from "./zoekt.js";
|
||||||
import { DEFAULT_SETTINGS } from './constants.js';
|
import { DEFAULT_SETTINGS } from './constants.js';
|
||||||
import { Queue, Worker, Job } from 'bullmq';
|
import { Queue, Worker, Job } from 'bullmq';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { ConnectionManager } from './connectionManager.js';
|
import { ConnectionManager } from './connectionManager.js';
|
||||||
|
import { ConnectionConfig } from '@sourcebot/schemas/v3/connection.type';
|
||||||
|
|
||||||
const logger = createLogger('main');
|
const logger = createLogger('main');
|
||||||
|
|
||||||
const syncGitRepository = async (repo: Repo, ctx: AppContext) => {
|
type RepoWithConnections = Repo & { connections: (RepoToConnection & { connection: Connection})[] };
|
||||||
|
|
||||||
|
// TODO: do this better? ex: try using the tokens from all the connections
|
||||||
|
// We can no longer use repo.cloneUrl directly since it doesn't contain the token for security reasons. As a result, we need to
|
||||||
|
// fetch the token here using the connections from the repo. Multiple connections could be referencing this repo, and each
|
||||||
|
// may have their own token. This method will just pick the first connection that has a token (if one exists) and uses that. This
|
||||||
|
// may technically cause syncing to fail if that connection's token just so happens to not have access to the repo it's referrencing.
|
||||||
|
const getTokenForRepo = async (repo: RepoWithConnections, db: PrismaClient) => {
|
||||||
|
const repoConnections = repo.connections;
|
||||||
|
if (repoConnections.length === 0) {
|
||||||
|
logger.error(`Repo ${repo.id} has no connections`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let token: string | undefined;
|
||||||
|
for (const repoConnection of repoConnections) {
|
||||||
|
const connection = repoConnection.connection;
|
||||||
|
const config = connection.config as unknown as ConnectionConfig;
|
||||||
|
if (config.token) {
|
||||||
|
token = await getTokenFromConfig(config.token, connection.orgId, db);
|
||||||
|
if (token) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncGitRepository = async (repo: RepoWithConnections, ctx: AppContext, db: PrismaClient) => {
|
||||||
let fetchDuration_s: number | undefined = undefined;
|
let fetchDuration_s: number | undefined = undefined;
|
||||||
let cloneDuration_s: number | undefined = undefined;
|
let cloneDuration_s: number | undefined = undefined;
|
||||||
|
|
||||||
|
|
@ -35,7 +65,15 @@ const syncGitRepository = async (repo: Repo, ctx: AppContext) => {
|
||||||
} else {
|
} else {
|
||||||
logger.info(`Cloning ${repo.id}...`);
|
logger.info(`Cloning ${repo.id}...`);
|
||||||
|
|
||||||
const { durationMs } = await measure(() => cloneRepository(repo.cloneUrl, repoPath, metadata, ({ method, stage, progress }) => {
|
const token = await getTokenForRepo(repo, db);
|
||||||
|
let cloneUrl = repo.cloneUrl;
|
||||||
|
if (token) {
|
||||||
|
const url = new URL(cloneUrl);
|
||||||
|
url.username = token;
|
||||||
|
cloneUrl = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { durationMs } = await measure(() => cloneRepository(cloneUrl, repoPath, metadata, ({ method, stage, progress }) => {
|
||||||
logger.info(`git.${method} ${stage} stage ${progress}% complete for ${repo.id}`)
|
logger.info(`git.${method} ${stage} stage ${progress}% complete for ${repo.id}`)
|
||||||
}));
|
}));
|
||||||
cloneDuration_s = durationMs / 1000;
|
cloneDuration_s = durationMs / 1000;
|
||||||
|
|
@ -92,13 +130,13 @@ export const main = async (db: PrismaClient, context: AppContext) => {
|
||||||
|
|
||||||
const connectionManager = new ConnectionManager(db, DEFAULT_SETTINGS, redis, context);
|
const connectionManager = new ConnectionManager(db, DEFAULT_SETTINGS, redis, context);
|
||||||
setInterval(async () => {
|
setInterval(async () => {
|
||||||
const configs = await db.connection.findMany({
|
const connections = await db.connection.findMany({
|
||||||
where: {
|
where: {
|
||||||
syncStatus: ConnectionSyncStatus.SYNC_NEEDED,
|
syncStatus: ConnectionSyncStatus.SYNC_NEEDED,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
for (const config of configs) {
|
for (const connection of connections) {
|
||||||
await connectionManager.scheduleConnectionSync(config);
|
await connectionManager.scheduleConnectionSync(connection);
|
||||||
}
|
}
|
||||||
}, DEFAULT_SETTINGS.resyncConnectionPollingIntervalMs);
|
}, DEFAULT_SETTINGS.resyncConnectionPollingIntervalMs);
|
||||||
|
|
||||||
|
|
@ -111,13 +149,13 @@ export const main = async (db: PrismaClient, context: AppContext) => {
|
||||||
const numWorkers = numCores * DEFAULT_SETTINGS.indexConcurrencyMultiple;
|
const numWorkers = numCores * DEFAULT_SETTINGS.indexConcurrencyMultiple;
|
||||||
logger.info(`Detected ${numCores} cores. Setting repo index max concurrency to ${numWorkers}`);
|
logger.info(`Detected ${numCores} cores. Setting repo index max concurrency to ${numWorkers}`);
|
||||||
const worker = new Worker('indexQueue', async (job: Job) => {
|
const worker = new Worker('indexQueue', async (job: Job) => {
|
||||||
const repo = job.data as Repo;
|
const repo = job.data as RepoWithConnections;
|
||||||
|
|
||||||
let indexDuration_s: number | undefined;
|
let indexDuration_s: number | undefined;
|
||||||
let fetchDuration_s: number | undefined;
|
let fetchDuration_s: number | undefined;
|
||||||
let cloneDuration_s: number | undefined;
|
let cloneDuration_s: number | undefined;
|
||||||
|
|
||||||
const stats = await syncGitRepository(repo, context);
|
const stats = await syncGitRepository(repo, context, db);
|
||||||
indexDuration_s = stats.indexDuration_s;
|
indexDuration_s = stats.indexDuration_s;
|
||||||
fetchDuration_s = stats.fetchDuration_s;
|
fetchDuration_s = stats.fetchDuration_s;
|
||||||
cloneDuration_s = stats.cloneDuration_s;
|
cloneDuration_s = stats.cloneDuration_s;
|
||||||
|
|
@ -171,6 +209,13 @@ export const main = async (db: PrismaClient, context: AppContext) => {
|
||||||
{ indexedAt: { lt: thresholdDate } },
|
{ indexedAt: { lt: thresholdDate } },
|
||||||
{ repoIndexingStatus: RepoIndexingStatus.NEW }
|
{ repoIndexingStatus: RepoIndexingStatus.NEW }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
connections: {
|
||||||
|
include: {
|
||||||
|
connection: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
addReposToQueue(db, indexQueue, repos);
|
addReposToQueue(db, indexQueue, repos);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import { Logger } from "winston";
|
||||||
import { AppContext, Repository } from "./types.js";
|
import { AppContext, Repository } from "./types.js";
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import micromatch from "micromatch";
|
import micromatch from "micromatch";
|
||||||
import { Repo } from "@sourcebot/db";
|
import { PrismaClient, Repo } from "@sourcebot/db";
|
||||||
|
import { decrypt } from "@sourcebot/crypto";
|
||||||
|
import { Token } from "@sourcebot/schemas/v3/shared.type";
|
||||||
|
|
||||||
export const measure = async <T>(cb : () => Promise<T>) => {
|
export const measure = async <T>(cb : () => Promise<T>) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
@ -86,15 +88,39 @@ export const excludeReposByTopic = <T extends Repository>(repos: T[], excludedRe
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTokenFromConfig = (token: string | { env: string }, ctx: AppContext) => {
|
export const getTokenFromConfig = async (token: Token, orgId: number, db?: PrismaClient) => {
|
||||||
if (typeof token === 'string') {
|
if (typeof token === 'string') {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
const tokenValue = process.env[token.env];
|
if ('env' in token) {
|
||||||
if (!tokenValue) {
|
const tokenValue = process.env[token.env];
|
||||||
throw new Error(`The environment variable '${token.env}' was referenced in ${ctx.configPath}, but was not set.`);
|
if (!tokenValue) {
|
||||||
|
throw new Error(`The environment variable '${token.env}' was referenced in the config but was not set.`);
|
||||||
|
}
|
||||||
|
return tokenValue;
|
||||||
|
} else if ('secret' in token) {
|
||||||
|
if (!db) {
|
||||||
|
throw new Error(`Database connection required to retrieve secret`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretKey = token.secret;
|
||||||
|
const secret = await db.secret.findUnique({
|
||||||
|
where: {
|
||||||
|
orgId_key: {
|
||||||
|
key: secretKey,
|
||||||
|
orgId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error(`Secret with key ${secretKey} not found for org ${orgId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedSecret = decrypt(secret.iv, secret.encryptedValue);
|
||||||
|
return decryptedSecret;
|
||||||
}
|
}
|
||||||
return tokenValue;
|
throw new Error(`Invalid token configuration in config`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isRemotePath = (path: string) => {
|
export const isRemotePath = (path: string) => {
|
||||||
|
|
|
||||||
1
packages/crypto/.gitignore
vendored
Normal file
1
packages/crypto/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
.env.local
|
||||||
16
packages/crypto/package.json
Normal file
16
packages/crypto/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "@sourcebot/crypto",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"postinstall": "yarn build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.7.5",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
packages/crypto/src/environment.ts
Normal file
17
packages/crypto/src/environment.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
export const getEnv = (env: string | undefined, defaultValue?: string, required?: boolean) => {
|
||||||
|
if (required && !env && !defaultValue) {
|
||||||
|
throw new Error(`Missing required environment variable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return env ?? defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
dotenv.config({
|
||||||
|
path: './.env.local',
|
||||||
|
override: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// @note: You can use https://generate-random.org/encryption-key-generator to create a new 32 byte key
|
||||||
|
export const SOURCEBOT_ENCRYPTION_KEY = getEnv(process.env.SOURCEBOT_ENCRYPTION_KEY, undefined, true)!;
|
||||||
35
packages/crypto/src/index.ts
Normal file
35
packages/crypto/src/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { SOURCEBOT_ENCRYPTION_KEY } from './environment';
|
||||||
|
|
||||||
|
const algorithm = 'aes-256-cbc';
|
||||||
|
const ivLength = 16; // 16 bytes for CBC
|
||||||
|
|
||||||
|
const generateIV = (): Buffer => {
|
||||||
|
return crypto.randomBytes(ivLength);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function encrypt(text: string): { iv: string; encryptedData: string } {
|
||||||
|
const encryptionKey = Buffer.from(SOURCEBOT_ENCRYPTION_KEY, 'ascii');
|
||||||
|
|
||||||
|
const iv = generateIV();
|
||||||
|
const cipher = crypto.createCipheriv(algorithm, encryptionKey, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
|
||||||
|
return { iv: iv.toString('hex'), encryptedData: encrypted };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(iv: string, encryptedText: string): string {
|
||||||
|
const encryptionKey = Buffer.from(SOURCEBOT_ENCRYPTION_KEY, 'ascii');
|
||||||
|
|
||||||
|
const ivBuffer = Buffer.from(iv, 'hex');
|
||||||
|
const encryptedBuffer = Buffer.from(encryptedText, 'hex');
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(algorithm, encryptionKey, ivBuffer);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encryptedBuffer, undefined, 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
23
packages/crypto/tsconfig.json
Normal file
23
packages/crypto/tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES6",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"lib": ["ES6"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Secret" (
|
||||||
|
"orgId" INTEGER NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"encryptedValue" TEXT NOT NULL,
|
||||||
|
"iv" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Secret_pkey" PRIMARY KEY ("orgId","key")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Secret" ADD CONSTRAINT "Secret_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Org"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -88,6 +88,7 @@ model Org {
|
||||||
members UserToOrg[]
|
members UserToOrg[]
|
||||||
connections Connection[]
|
connections Connection[]
|
||||||
repos Repo[]
|
repos Repo[]
|
||||||
|
secrets Secret[]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OrgRole {
|
enum OrgRole {
|
||||||
|
|
@ -111,6 +112,19 @@ model UserToOrg {
|
||||||
@@id([orgId, userId])
|
@@id([orgId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Secret {
|
||||||
|
orgId Int
|
||||||
|
key String
|
||||||
|
encryptedValue String
|
||||||
|
iv String
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([orgId, key])
|
||||||
|
}
|
||||||
|
|
||||||
// @see : https://authjs.dev/concepts/database-models#user
|
// @see : https://authjs.dev/concepts/database-models#user
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,19 @@ const schema = {
|
||||||
"env"
|
"env"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"secret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the secret that contains the token."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"secret"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ export interface GithubConnectionConfig {
|
||||||
* The name of the environment variable that contains the token.
|
* The name of the environment variable that contains the token.
|
||||||
*/
|
*/
|
||||||
env: string;
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the secret that contains the token.
|
||||||
|
*/
|
||||||
|
secret: string;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* The URL of the GitHub host. Defaults to https://github.com
|
* The URL of the GitHub host. Defaults to https://github.com
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,19 @@ const schema = {
|
||||||
"env"
|
"env"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"secret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the secret that contains the token."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"secret"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ export interface GithubConnectionConfig {
|
||||||
* The name of the environment variable that contains the token.
|
* The name of the environment variable that contains the token.
|
||||||
*/
|
*/
|
||||||
env: string;
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the secret that contains the token.
|
||||||
|
*/
|
||||||
|
secret: string;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* The URL of the GitHub host. Defaults to https://github.com
|
* The URL of the GitHub host. Defaults to https://github.com
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,19 @@ const schema = {
|
||||||
"env"
|
"env"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"secret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the secret that contains the token."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"secret"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ export type Token =
|
||||||
* The name of the environment variable that contains the token.
|
* The name of the environment variable that contains the token.
|
||||||
*/
|
*/
|
||||||
env: string;
|
env: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* The name of the secret that contains the token.
|
||||||
|
*/
|
||||||
|
secret: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Shared {
|
export interface Shared {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
"@replit/codemirror-lang-svelte": "^6.0.0",
|
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||||
"@replit/codemirror-vim": "^6.2.1",
|
"@replit/codemirror-vim": "^6.2.1",
|
||||||
"@shopify/lang-jsonc": "^1.0.0",
|
"@shopify/lang-jsonc": "^1.0.0",
|
||||||
|
"@sourcebot/crypto": "^0.1.0",
|
||||||
"@sourcebot/schemas": "^0.1.0",
|
"@sourcebot/schemas": "^0.1.0",
|
||||||
"@sourcebot/db": "^0.1.0",
|
"@sourcebot/db": "^0.1.0",
|
||||||
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
|
"@ssddanbrown/codemirror-lang-twig": "^1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,144 @@ import { prisma } from "@/prisma";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
import { ErrorCode } from "./lib/errorCodes";
|
import { ErrorCode } from "./lib/errorCodes";
|
||||||
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
|
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
|
||||||
|
import { encrypt } from "@sourcebot/crypto"
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const createSecret = async (key: string, value: string): Promise<{ success: boolean } | ServiceError> => {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return notAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUser(session.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return unexpectedError("User not found");
|
||||||
|
}
|
||||||
|
const orgId = user.activeOrgId;
|
||||||
|
if (!orgId) {
|
||||||
|
return unexpectedError("User has no active org");
|
||||||
|
}
|
||||||
|
|
||||||
|
// @todo: refactor this into a shared function
|
||||||
|
const membership = await prisma.userToOrg.findUnique({
|
||||||
|
where: {
|
||||||
|
orgId_userId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
orgId,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!membership) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const encrypted = encrypt(value);
|
||||||
|
await prisma.secret.create({
|
||||||
|
data: {
|
||||||
|
orgId,
|
||||||
|
key,
|
||||||
|
encryptedValue: encrypted.encryptedData,
|
||||||
|
iv: encrypted.iv,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return unexpectedError(`Failed to create secret`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSecrets = async (): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return notAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUser(session.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return unexpectedError("User not found");
|
||||||
|
}
|
||||||
|
const orgId = user.activeOrgId;
|
||||||
|
if (!orgId) {
|
||||||
|
return unexpectedError("User has no active org");
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await prisma.userToOrg.findUnique({
|
||||||
|
where: {
|
||||||
|
orgId_userId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
orgId,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!membership) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const secrets = await prisma.secret.findMany({
|
||||||
|
where: {
|
||||||
|
orgId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
key: true,
|
||||||
|
createdAt: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return secrets.map((secret) => ({
|
||||||
|
key: secret.key,
|
||||||
|
createdAt: secret.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteSecret = async (key: string): Promise<{ success: boolean } | ServiceError> => {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return notAuthenticated();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUser(session.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return unexpectedError("User not found");
|
||||||
|
}
|
||||||
|
const orgId = user.activeOrgId;
|
||||||
|
if (!orgId) {
|
||||||
|
return unexpectedError("User has no active org");
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await prisma.userToOrg.findUnique({
|
||||||
|
where: {
|
||||||
|
orgId_userId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
orgId,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!membership) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.secret.delete({
|
||||||
|
where: {
|
||||||
|
orgId_key: {
|
||||||
|
orgId,
|
||||||
|
key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const createOrg = async (name: string): Promise<{ id: number } | ServiceError> => {
|
export const createOrg = async (name: string): Promise<{ id: number } | ServiceError> => {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,13 @@ export const NavigationMenu = async () => {
|
||||||
</NavigationMenuLink>
|
</NavigationMenuLink>
|
||||||
</Link>
|
</Link>
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
|
<NavigationMenuItem>
|
||||||
|
<Link href="/secrets" legacyBehavior passHref>
|
||||||
|
<NavigationMenuLink className={navigationMenuTriggerStyle()}>
|
||||||
|
Secrets
|
||||||
|
</NavigationMenuLink>
|
||||||
|
</Link>
|
||||||
|
</NavigationMenuItem>
|
||||||
</NavigationMenuList>
|
</NavigationMenuList>
|
||||||
</NavigationMenuBase>
|
</NavigationMenuBase>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
59
packages/web/src/app/secrets/columns.tsx
Normal file
59
packages/web/src/app/secrets/columns.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Column, ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { ArrowUpDown } from "lucide-react"
|
||||||
|
|
||||||
|
export type SecretColumnInfo = {
|
||||||
|
key: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const columns = (handleDelete: (key: string) => void): ColumnDef<SecretColumnInfo>[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: "key",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const secret = row.original;
|
||||||
|
return <div>{secret.key}</div>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createdAt",
|
||||||
|
header: ({ column }) => createSortHeader("Created At", column),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const secret = row.original;
|
||||||
|
return <div>{secret.createdAt}</div>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "delete",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const secret = row.original;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
handleDelete(secret.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSortHeader = (name: string, column: Column<SecretColumnInfo, unknown>) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
packages/web/src/app/secrets/page.tsx
Normal file
23
packages/web/src/app/secrets/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { NavigationMenu } from "../components/navigationMenu";
|
||||||
|
import { SecretsTable } from "./secretsTable";
|
||||||
|
import { getSecrets, createSecret } from "../../actions"
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface SecretsTableProps {
|
||||||
|
initialSecrets: { createdAt: Date; key: string; }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SecretsPage() {
|
||||||
|
const secrets = await getSecrets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col items-center">
|
||||||
|
<NavigationMenu />
|
||||||
|
{ !isServiceError(secrets) && (
|
||||||
|
<div className="max-w-[90%]">
|
||||||
|
<SecretsTable initialSecrets={secrets} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
147
packages/web/src/app/secrets/secretsTable.tsx
Normal file
147
packages/web/src/app/secrets/secretsTable.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
'use client';
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { getSecrets, createSecret } from "../../actions"
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { columns, SecretColumnInfo } from "./columns";
|
||||||
|
import { DataTable } from "@/components/ui/data-table";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import { deleteSecret } from "../../actions"
|
||||||
|
import { SecretsTableProps } from "./page";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
key: z.string().min(2).max(40),
|
||||||
|
value: z.string().min(2).max(40),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
||||||
|
const [secrets, setSecrets] = useState<{ createdAt: Date; key: string; }[]>(initialSecrets);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const fetchSecretKeys = async () => {
|
||||||
|
const keys = await getSecrets();
|
||||||
|
if ('keys' in keys) {
|
||||||
|
setSecrets(keys);
|
||||||
|
} else {
|
||||||
|
console.error(keys);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSecretKeys();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
key: "",
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCreateSecret = async (values: { key: string, value: string }) => {
|
||||||
|
const res = await createSecret(values.key, values.value);
|
||||||
|
if (isServiceError(res)) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Failed to create secret`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
description: `✅ Secret created successfully!`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = await getSecrets();
|
||||||
|
if (isServiceError(keys)) {
|
||||||
|
console.error("Failed to fetch secrets");
|
||||||
|
} else {
|
||||||
|
setSecrets(keys);
|
||||||
|
|
||||||
|
form.reset();
|
||||||
|
form.resetField("key");
|
||||||
|
form.resetField("value");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (key: string) => {
|
||||||
|
const res = await deleteSecret(key);
|
||||||
|
if (isServiceError(res)) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Failed to delete secret`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
description: `✅ Secret deleted successfully!`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = await getSecrets();
|
||||||
|
if ('keys' in keys) {
|
||||||
|
setSecrets(keys);
|
||||||
|
} else {
|
||||||
|
console.error(keys);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const keys = useMemo(() => {
|
||||||
|
return secrets.map((secret): SecretColumnInfo => {
|
||||||
|
return {
|
||||||
|
key: secret.key,
|
||||||
|
createdAt: secret.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
}).sort((a, b) => {
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
});
|
||||||
|
}, [secrets]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(handleCreateSecret)}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="key"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="value"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Value</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button className="mt-5" type="submit">Submit</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<DataTable
|
||||||
|
columns={columns(handleDelete)}
|
||||||
|
data={keys}
|
||||||
|
searchKey="key"
|
||||||
|
searchPlaceholder="Search secrets..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -98,6 +98,15 @@ export const fileSourceResponseSchema = z.object({
|
||||||
language: z.string(),
|
language: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const secretCreateRequestSchema = z.object({
|
||||||
|
key: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const secreteDeleteRequestSchema = z.object({
|
||||||
|
key: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L728
|
// @see : https://github.com/sourcebot-dev/zoekt/blob/3780e68cdb537d5a7ed2c84d9b3784f80c7c5d04/api.go#L728
|
||||||
const repoStatsSchema = z.object({
|
const repoStatsSchema = z.object({
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,19 @@
|
||||||
"env"
|
"env"
|
||||||
],
|
],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"secret": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name of the secret that contains the token."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"secret"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue