Introduces a provider-agnostic storage subsystem for file attachments. Includes local disk and S3 backends, asset/attachment DB schemas, issue attachment CRUD routes with multer upload, CLI configure/doctor/env integration, and enriched issue ancestors with project/goal resolution. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
119 lines
4.7 KiB
TypeScript
119 lines
4.7 KiB
TypeScript
import { readConfigFile } from "./config-file.js";
|
|
import { existsSync } from "node:fs";
|
|
import { config as loadDotenv } from "dotenv";
|
|
import { resolvePaperclipEnvPath } from "./paths.js";
|
|
import { SECRET_PROVIDERS, STORAGE_PROVIDERS, type SecretProvider, type StorageProvider } from "@paperclip/shared";
|
|
import {
|
|
resolveDefaultEmbeddedPostgresDir,
|
|
resolveDefaultSecretsKeyFilePath,
|
|
resolveDefaultStorageDir,
|
|
resolveHomeAwarePath,
|
|
} from "./home-paths.js";
|
|
|
|
const PAPERCLIP_ENV_FILE_PATH = resolvePaperclipEnvPath();
|
|
if (existsSync(PAPERCLIP_ENV_FILE_PATH)) {
|
|
loadDotenv({ path: PAPERCLIP_ENV_FILE_PATH, override: false, quiet: true });
|
|
}
|
|
|
|
type DatabaseMode = "embedded-postgres" | "postgres";
|
|
|
|
export interface Config {
|
|
port: number;
|
|
databaseMode: DatabaseMode;
|
|
databaseUrl: string | undefined;
|
|
embeddedPostgresDataDir: string;
|
|
embeddedPostgresPort: number;
|
|
serveUi: boolean;
|
|
uiDevMiddleware: boolean;
|
|
secretsProvider: SecretProvider;
|
|
secretsStrictMode: boolean;
|
|
secretsMasterKeyFilePath: string;
|
|
storageProvider: StorageProvider;
|
|
storageLocalDiskBaseDir: string;
|
|
storageS3Bucket: string;
|
|
storageS3Region: string;
|
|
storageS3Endpoint: string | undefined;
|
|
storageS3Prefix: string;
|
|
storageS3ForcePathStyle: boolean;
|
|
heartbeatSchedulerEnabled: boolean;
|
|
heartbeatSchedulerIntervalMs: number;
|
|
}
|
|
|
|
export function loadConfig(): Config {
|
|
const fileConfig = readConfigFile();
|
|
const fileDatabaseMode =
|
|
(fileConfig?.database.mode === "postgres" ? "postgres" : "embedded-postgres") as DatabaseMode;
|
|
|
|
const fileDbUrl =
|
|
fileDatabaseMode === "postgres"
|
|
? fileConfig?.database.connectionString
|
|
: undefined;
|
|
const fileSecrets = fileConfig?.secrets;
|
|
const fileStorage = fileConfig?.storage;
|
|
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
|
|
const secretsStrictMode =
|
|
strictModeFromEnv !== undefined
|
|
? strictModeFromEnv === "true"
|
|
: (fileSecrets?.strictMode ?? false);
|
|
|
|
const providerFromEnvRaw = process.env.PAPERCLIP_SECRETS_PROVIDER;
|
|
const providerFromEnv =
|
|
providerFromEnvRaw && SECRET_PROVIDERS.includes(providerFromEnvRaw as SecretProvider)
|
|
? (providerFromEnvRaw as SecretProvider)
|
|
: null;
|
|
const providerFromFile = fileSecrets?.provider;
|
|
const secretsProvider: SecretProvider = providerFromEnv ?? providerFromFile ?? "local_encrypted";
|
|
|
|
const storageProviderFromEnvRaw = process.env.PAPERCLIP_STORAGE_PROVIDER;
|
|
const storageProviderFromEnv =
|
|
storageProviderFromEnvRaw && STORAGE_PROVIDERS.includes(storageProviderFromEnvRaw as StorageProvider)
|
|
? (storageProviderFromEnvRaw as StorageProvider)
|
|
: null;
|
|
const storageProvider: StorageProvider = storageProviderFromEnv ?? fileStorage?.provider ?? "local_disk";
|
|
const storageLocalDiskBaseDir = resolveHomeAwarePath(
|
|
process.env.PAPERCLIP_STORAGE_LOCAL_DIR ??
|
|
fileStorage?.localDisk?.baseDir ??
|
|
resolveDefaultStorageDir(),
|
|
);
|
|
const storageS3Bucket = process.env.PAPERCLIP_STORAGE_S3_BUCKET ?? fileStorage?.s3?.bucket ?? "paperclip";
|
|
const storageS3Region = process.env.PAPERCLIP_STORAGE_S3_REGION ?? fileStorage?.s3?.region ?? "us-east-1";
|
|
const storageS3Endpoint = process.env.PAPERCLIP_STORAGE_S3_ENDPOINT ?? fileStorage?.s3?.endpoint ?? undefined;
|
|
const storageS3Prefix = process.env.PAPERCLIP_STORAGE_S3_PREFIX ?? fileStorage?.s3?.prefix ?? "";
|
|
const storageS3ForcePathStyle =
|
|
process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE !== undefined
|
|
? process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE === "true"
|
|
: (fileStorage?.s3?.forcePathStyle ?? false);
|
|
|
|
return {
|
|
port: Number(process.env.PORT) || fileConfig?.server.port || 3100,
|
|
databaseMode: fileDatabaseMode,
|
|
databaseUrl: process.env.DATABASE_URL ?? fileDbUrl,
|
|
embeddedPostgresDataDir: resolveHomeAwarePath(
|
|
fileConfig?.database.embeddedPostgresDataDir ?? resolveDefaultEmbeddedPostgresDir(),
|
|
),
|
|
embeddedPostgresPort: fileConfig?.database.embeddedPostgresPort ?? 54329,
|
|
serveUi:
|
|
process.env.SERVE_UI !== undefined
|
|
? process.env.SERVE_UI === "true"
|
|
: fileConfig?.server.serveUi ?? true,
|
|
uiDevMiddleware: process.env.PAPERCLIP_UI_DEV_MIDDLEWARE === "true",
|
|
secretsProvider,
|
|
secretsStrictMode,
|
|
secretsMasterKeyFilePath:
|
|
resolveHomeAwarePath(
|
|
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ??
|
|
fileSecrets?.localEncrypted.keyFilePath ??
|
|
resolveDefaultSecretsKeyFilePath(),
|
|
),
|
|
storageProvider,
|
|
storageLocalDiskBaseDir,
|
|
storageS3Bucket,
|
|
storageS3Region,
|
|
storageS3Endpoint,
|
|
storageS3Prefix,
|
|
storageS3ForcePathStyle,
|
|
heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false",
|
|
heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000),
|
|
};
|
|
}
|