feat: add storage system with local disk and S3 providers
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>
This commit is contained in:
@@ -14,3 +14,4 @@ export { llmCheck } from "./llm-check.js";
|
||||
export { logCheck } from "./log-check.js";
|
||||
export { portCheck } from "./port-check.js";
|
||||
export { secretsCheck } from "./secrets-check.js";
|
||||
export { storageCheck } from "./storage-check.js";
|
||||
|
||||
60
cli/src/checks/storage-check.ts
Normal file
60
cli/src/checks/storage-check.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import fs from "node:fs";
|
||||
import type { PaperclipConfig } from "../config/schema.js";
|
||||
import type { CheckResult } from "./index.js";
|
||||
import { resolveRuntimeLikePath } from "./path-resolver.js";
|
||||
|
||||
export function storageCheck(config: PaperclipConfig, configPath?: string): CheckResult {
|
||||
if (config.storage.provider === "local_disk") {
|
||||
const baseDir = resolveRuntimeLikePath(config.storage.localDisk.baseDir, configPath);
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
return {
|
||||
name: "Storage",
|
||||
status: "warn",
|
||||
message: `Local storage directory does not exist: ${baseDir}`,
|
||||
canRepair: true,
|
||||
repair: () => {
|
||||
fs.mkdirSync(baseDir, { recursive: true });
|
||||
},
|
||||
repairHint: "Run with --repair to create local storage directory",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
fs.accessSync(baseDir, fs.constants.W_OK);
|
||||
return {
|
||||
name: "Storage",
|
||||
status: "pass",
|
||||
message: `Local disk storage is writable: ${baseDir}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
name: "Storage",
|
||||
status: "fail",
|
||||
message: `Local storage directory is not writable: ${baseDir}`,
|
||||
canRepair: false,
|
||||
repairHint: "Check file permissions for storage.localDisk.baseDir",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const bucket = config.storage.s3.bucket.trim();
|
||||
const region = config.storage.s3.region.trim();
|
||||
if (!bucket || !region) {
|
||||
return {
|
||||
name: "Storage",
|
||||
status: "fail",
|
||||
message: "S3 storage requires non-empty bucket and region",
|
||||
canRepair: false,
|
||||
repairHint: "Run `paperclip configure --section storage`",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Storage",
|
||||
status: "warn",
|
||||
message: `S3 storage configured (bucket=${bucket}, region=${region}). Reachability check is skipped in doctor.`,
|
||||
canRepair: false,
|
||||
repairHint: "Verify credentials and endpoint in deployment environment",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { promptDatabase } from "../prompts/database.js";
|
||||
import { promptLlm } from "../prompts/llm.js";
|
||||
import { promptLogging } from "../prompts/logging.js";
|
||||
import { defaultSecretsConfig, promptSecrets } from "../prompts/secrets.js";
|
||||
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
||||
import { promptServer } from "../prompts/server.js";
|
||||
import {
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
@@ -14,13 +15,14 @@ import {
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
|
||||
type Section = "llm" | "database" | "logging" | "server" | "secrets";
|
||||
type Section = "llm" | "database" | "logging" | "server" | "storage" | "secrets";
|
||||
|
||||
const SECTION_LABELS: Record<Section, string> = {
|
||||
llm: "LLM Provider",
|
||||
database: "Database",
|
||||
logging: "Logging",
|
||||
server: "Server",
|
||||
storage: "Storage",
|
||||
secrets: "Secrets",
|
||||
};
|
||||
|
||||
@@ -45,6 +47,7 @@ function defaultConfig(): PaperclipConfig {
|
||||
port: 3100,
|
||||
serveUi: true,
|
||||
},
|
||||
storage: defaultStorageConfig(),
|
||||
secrets: defaultSecretsConfig(),
|
||||
};
|
||||
}
|
||||
@@ -123,6 +126,9 @@ export async function configure(opts: {
|
||||
case "server":
|
||||
config.server = await promptServer();
|
||||
break;
|
||||
case "storage":
|
||||
config.storage = await promptStorage(config.storage);
|
||||
break;
|
||||
case "secrets":
|
||||
config.secrets = await promptSecrets(config.secrets);
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
logCheck,
|
||||
portCheck,
|
||||
secretsCheck,
|
||||
storageCheck,
|
||||
type CheckResult,
|
||||
} from "../checks/index.js";
|
||||
|
||||
@@ -66,24 +67,30 @@ export async function doctor(opts: {
|
||||
printResult(secretsResult);
|
||||
await maybeRepair(secretsResult, opts);
|
||||
|
||||
// 4. Database check
|
||||
// 4. Storage check
|
||||
const storageResult = storageCheck(config, configPath);
|
||||
results.push(storageResult);
|
||||
printResult(storageResult);
|
||||
await maybeRepair(storageResult, opts);
|
||||
|
||||
// 5. Database check
|
||||
const dbResult = await databaseCheck(config, configPath);
|
||||
results.push(dbResult);
|
||||
printResult(dbResult);
|
||||
await maybeRepair(dbResult, opts);
|
||||
|
||||
// 5. LLM check
|
||||
// 6. LLM check
|
||||
const llmResult = await llmCheck(config);
|
||||
results.push(llmResult);
|
||||
printResult(llmResult);
|
||||
|
||||
// 6. Log directory check
|
||||
// 7. Log directory check
|
||||
const logResult = logCheck(config, configPath);
|
||||
results.push(logResult);
|
||||
printResult(logResult);
|
||||
await maybeRepair(logResult, opts);
|
||||
|
||||
// 7. Port check
|
||||
// 8. Port check
|
||||
const portResult = await portCheck(config);
|
||||
results.push(portResult);
|
||||
printResult(portResult);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../config/env.js";
|
||||
import {
|
||||
resolveDefaultSecretsKeyFilePath,
|
||||
resolveDefaultStorageDir,
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
|
||||
@@ -27,9 +28,13 @@ const DEFAULT_AGENT_JWT_ISSUER = "paperclip";
|
||||
const DEFAULT_AGENT_JWT_AUDIENCE = "paperclip-api";
|
||||
const DEFAULT_HEARTBEAT_SCHEDULER_INTERVAL_MS = "30000";
|
||||
const DEFAULT_SECRETS_PROVIDER = "local_encrypted";
|
||||
const DEFAULT_STORAGE_PROVIDER = "local_disk";
|
||||
function defaultSecretsKeyFilePath(): string {
|
||||
return resolveDefaultSecretsKeyFilePath(resolvePaperclipInstanceId());
|
||||
}
|
||||
function defaultStorageBaseDir(): string {
|
||||
return resolveDefaultStorageDir(resolvePaperclipInstanceId());
|
||||
}
|
||||
|
||||
export async function envCommand(opts: { config?: string }): Promise<void> {
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip env ")));
|
||||
@@ -127,6 +132,33 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE ??
|
||||
config?.secrets?.localEncrypted?.keyFilePath ??
|
||||
defaultSecretsKeyFilePath();
|
||||
const storageProvider =
|
||||
process.env.PAPERCLIP_STORAGE_PROVIDER ??
|
||||
config?.storage?.provider ??
|
||||
DEFAULT_STORAGE_PROVIDER;
|
||||
const storageLocalDir =
|
||||
process.env.PAPERCLIP_STORAGE_LOCAL_DIR ??
|
||||
config?.storage?.localDisk?.baseDir ??
|
||||
defaultStorageBaseDir();
|
||||
const storageS3Bucket =
|
||||
process.env.PAPERCLIP_STORAGE_S3_BUCKET ??
|
||||
config?.storage?.s3?.bucket ??
|
||||
"paperclip";
|
||||
const storageS3Region =
|
||||
process.env.PAPERCLIP_STORAGE_S3_REGION ??
|
||||
config?.storage?.s3?.region ??
|
||||
"us-east-1";
|
||||
const storageS3Endpoint =
|
||||
process.env.PAPERCLIP_STORAGE_S3_ENDPOINT ??
|
||||
config?.storage?.s3?.endpoint ??
|
||||
"";
|
||||
const storageS3Prefix =
|
||||
process.env.PAPERCLIP_STORAGE_S3_PREFIX ??
|
||||
config?.storage?.s3?.prefix ??
|
||||
"";
|
||||
const storageS3ForcePathStyle =
|
||||
process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE ??
|
||||
String(config?.storage?.s3?.forcePathStyle ?? false);
|
||||
|
||||
const rows: EnvVarRow[] = [
|
||||
{
|
||||
@@ -228,6 +260,83 @@ function collectDeploymentEnvRows(config: PaperclipConfig | null, configPath: st
|
||||
required: false,
|
||||
note: "Path to local encrypted secrets key file",
|
||||
},
|
||||
{
|
||||
key: "PAPERCLIP_STORAGE_PROVIDER",
|
||||
value: storageProvider,
|
||||
source: process.env.PAPERCLIP_STORAGE_PROVIDER
|
||||
? "env"
|
||||
: config?.storage?.provider
|
||||
? "config"
|
||||
: "default",
|
||||
required: false,
|
||||
note: "Storage provider (local_disk or s3)",
|
||||
},
|
||||
{
|
||||
key: "PAPERCLIP_STORAGE_LOCAL_DIR",
|
||||
value: storageLocalDir,
|
||||
source: process.env.PAPERCLIP_STORAGE_LOCAL_DIR
|
||||
? "env"
|
||||
: config?.storage?.localDisk?.baseDir
|
||||
? "config"
|
||||
: "default",
|
||||
required: false,
|
||||
note: "Local storage base directory for local_disk provider",
|
||||
},
|
||||
{
|
||||
key: "PAPERCLIP_STORAGE_S3_BUCKET",
|
||||
value: storageS3Bucket,
|
||||
source: process.env.PAPERCLIP_STORAGE_S3_BUCKET
|
||||
? "env"
|
||||
: config?.storage?.s3?.bucket
|
||||
? "config"
|
||||
: "default",
|
||||
required: false,
|
||||
note: "S3 bucket name for s3 provider",
|
||||
},
|
||||
{
|
||||
key: "PAPERCLIP_STORAGE_S3_REGION",
|
||||
value: storageS3Region,
|
||||
source: process.env.PAPERCLIP_STORAGE_S3_REGION
|
||||
? "env"
|
||||
: config?.storage?.s3?.region
|
||||
? "config"
|
||||
: "default",
|
||||
required: false,
|
||||
note: "S3 region for s3 provider",
|
||||
},
|
||||
{
|
||||
key: "PAPERCLIP_STORAGE_S3_ENDPOINT",
|
||||
value: storageS3Endpoint,
|
||||
source: process.env.PAPERCLIP_STORAGE_S3_ENDPOINT
|
||||
? "env"
|
||||
: config?.storage?.s3?.endpoint
|
||||
? "config"
|
||||
: "default",
|
||||
required: false,
|
||||
note: "Optional custom endpoint for S3-compatible providers",
|
||||
},
|
||||
{
|
||||
key: "PAPERCLIP_STORAGE_S3_PREFIX",
|
||||
value: storageS3Prefix,
|
||||
source: process.env.PAPERCLIP_STORAGE_S3_PREFIX
|
||||
? "env"
|
||||
: config?.storage?.s3?.prefix
|
||||
? "config"
|
||||
: "default",
|
||||
required: false,
|
||||
note: "Optional object key prefix",
|
||||
},
|
||||
{
|
||||
key: "PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE",
|
||||
value: storageS3ForcePathStyle,
|
||||
source: process.env.PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE
|
||||
? "env"
|
||||
: config?.storage?.s3?.forcePathStyle !== undefined
|
||||
? "config"
|
||||
: "default",
|
||||
required: false,
|
||||
note: "Set true for path-style access on compatible providers",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultConfigPath = resolveConfigPath();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { promptDatabase } from "../prompts/database.js";
|
||||
import { promptLlm } from "../prompts/llm.js";
|
||||
import { promptLogging } from "../prompts/logging.js";
|
||||
import { defaultSecretsConfig } from "../prompts/secrets.js";
|
||||
import { defaultStorageConfig, promptStorage } from "../prompts/storage.js";
|
||||
import { promptServer } from "../prompts/server.js";
|
||||
import { describeLocalInstancePaths, resolvePaperclipInstanceId } from "../config/home.js";
|
||||
|
||||
@@ -107,6 +108,10 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
||||
p.log.step(pc.bold("Server"));
|
||||
const server = await promptServer();
|
||||
|
||||
// Storage
|
||||
p.log.step(pc.bold("Storage"));
|
||||
const storage = await promptStorage(defaultStorageConfig());
|
||||
|
||||
// Secrets
|
||||
p.log.step(pc.bold("Secrets"));
|
||||
const secrets = defaultSecretsConfig();
|
||||
@@ -137,6 +142,7 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
||||
database,
|
||||
logging,
|
||||
server,
|
||||
storage,
|
||||
secrets,
|
||||
};
|
||||
|
||||
@@ -155,6 +161,7 @@ export async function onboard(opts: { config?: string }): Promise<void> {
|
||||
llm ? `LLM: ${llm.provider}` : "LLM: not configured",
|
||||
`Logging: ${logging.mode} → ${logging.logDir}`,
|
||||
`Server: port ${server.port}`,
|
||||
`Storage: ${storage.provider}`,
|
||||
`Secrets: ${secrets.provider} (strict mode ${secrets.strictMode ? "on" : "off"})`,
|
||||
`Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured`,
|
||||
].join("\n"),
|
||||
|
||||
@@ -45,6 +45,10 @@ export function resolveDefaultSecretsKeyFilePath(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "secrets", "master.key");
|
||||
}
|
||||
|
||||
export function resolveDefaultStorageDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "data", "storage");
|
||||
}
|
||||
|
||||
export function expandHomePrefix(value: string): string {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2));
|
||||
@@ -62,5 +66,6 @@ export function describeLocalInstancePaths(instanceId?: string) {
|
||||
embeddedPostgresDataDir: resolveDefaultEmbeddedPostgresDir(resolvedInstanceId),
|
||||
logDir: resolveDefaultLogsDir(resolvedInstanceId),
|
||||
secretsKeyFilePath: resolveDefaultSecretsKeyFilePath(resolvedInstanceId),
|
||||
storageDir: resolveDefaultStorageDir(resolvedInstanceId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ export {
|
||||
databaseConfigSchema,
|
||||
loggingConfigSchema,
|
||||
serverConfigSchema,
|
||||
storageConfigSchema,
|
||||
storageLocalDiskConfigSchema,
|
||||
storageS3ConfigSchema,
|
||||
secretsConfigSchema,
|
||||
secretsLocalEncryptedConfigSchema,
|
||||
type PaperclipConfig,
|
||||
@@ -12,6 +15,9 @@ export {
|
||||
type DatabaseConfig,
|
||||
type LoggingConfig,
|
||||
type ServerConfig,
|
||||
type StorageConfig,
|
||||
type StorageLocalDiskConfig,
|
||||
type StorageS3Config,
|
||||
type SecretsConfig,
|
||||
type SecretsLocalEncryptedConfig,
|
||||
type ConfigMeta,
|
||||
|
||||
@@ -48,7 +48,7 @@ program
|
||||
.command("configure")
|
||||
.description("Update configuration sections")
|
||||
.option("-c, --config <path>", "Path to config file")
|
||||
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, secrets)")
|
||||
.option("-s, --section <section>", "Section to configure (llm, database, logging, server, storage, secrets)")
|
||||
.action(configure);
|
||||
|
||||
program
|
||||
|
||||
146
cli/src/prompts/storage.ts
Normal file
146
cli/src/prompts/storage.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import type { StorageConfig } from "../config/schema.js";
|
||||
import { resolveDefaultStorageDir, resolvePaperclipInstanceId } from "../config/home.js";
|
||||
|
||||
function defaultStorageBaseDir(): string {
|
||||
return resolveDefaultStorageDir(resolvePaperclipInstanceId());
|
||||
}
|
||||
|
||||
export function defaultStorageConfig(): StorageConfig {
|
||||
return {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: defaultStorageBaseDir(),
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
endpoint: undefined,
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function promptStorage(current?: StorageConfig): Promise<StorageConfig> {
|
||||
const base = current ?? defaultStorageConfig();
|
||||
|
||||
const provider = await p.select({
|
||||
message: "Storage provider",
|
||||
options: [
|
||||
{
|
||||
value: "local_disk" as const,
|
||||
label: "Local disk (recommended)",
|
||||
hint: "best for single-user local deployments",
|
||||
},
|
||||
{
|
||||
value: "s3" as const,
|
||||
label: "S3 compatible",
|
||||
hint: "for cloud/object storage backends",
|
||||
},
|
||||
],
|
||||
initialValue: base.provider,
|
||||
});
|
||||
|
||||
if (p.isCancel(provider)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (provider === "local_disk") {
|
||||
const baseDir = await p.text({
|
||||
message: "Local storage base directory",
|
||||
defaultValue: base.localDisk.baseDir || defaultStorageBaseDir(),
|
||||
placeholder: defaultStorageBaseDir(),
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) return "Storage base directory is required";
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(baseDir)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: baseDir.trim(),
|
||||
},
|
||||
s3: base.s3,
|
||||
};
|
||||
}
|
||||
|
||||
const bucket = await p.text({
|
||||
message: "S3 bucket",
|
||||
defaultValue: base.s3.bucket || "paperclip",
|
||||
placeholder: "paperclip",
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) return "Bucket is required";
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(bucket)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const region = await p.text({
|
||||
message: "S3 region",
|
||||
defaultValue: base.s3.region || "us-east-1",
|
||||
placeholder: "us-east-1",
|
||||
validate: (value) => {
|
||||
if (!value || value.trim().length === 0) return "Region is required";
|
||||
},
|
||||
});
|
||||
|
||||
if (p.isCancel(region)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const endpoint = await p.text({
|
||||
message: "S3 endpoint (optional for compatible backends)",
|
||||
defaultValue: base.s3.endpoint ?? "",
|
||||
placeholder: "https://s3.amazonaws.com",
|
||||
});
|
||||
|
||||
if (p.isCancel(endpoint)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const prefix = await p.text({
|
||||
message: "Object key prefix (optional)",
|
||||
defaultValue: base.s3.prefix ?? "",
|
||||
placeholder: "paperclip/",
|
||||
});
|
||||
|
||||
if (p.isCancel(prefix)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const forcePathStyle = await p.confirm({
|
||||
message: "Use S3 path-style URLs?",
|
||||
initialValue: base.s3.forcePathStyle ?? false,
|
||||
});
|
||||
|
||||
if (p.isCancel(forcePathStyle)) {
|
||||
p.cancel("Setup cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "s3",
|
||||
localDisk: base.localDisk,
|
||||
s3: {
|
||||
bucket: bucket.trim(),
|
||||
region: region.trim(),
|
||||
endpoint: endpoint.trim() || undefined,
|
||||
prefix: prefix.trim(),
|
||||
forcePathStyle,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
206
doc/plans/storage-system-implementation.md
Normal file
206
doc/plans/storage-system-implementation.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# Storage System Implementation Plan (V1)
|
||||
|
||||
Status: Draft
|
||||
Owner: Backend + UI
|
||||
Date: 2026-02-20
|
||||
|
||||
## Goal
|
||||
|
||||
Add a single storage subsystem for Paperclip that supports:
|
||||
|
||||
- local disk storage for single-user local deployment
|
||||
- S3-compatible object storage for cloud deployment
|
||||
- a provider-agnostic interface for issue images and future file attachments
|
||||
|
||||
## V1 Scope
|
||||
|
||||
- First consumer: issue attachments/images.
|
||||
- Storage adapters: `local_disk` and `s3`.
|
||||
- Files are always company-scoped and access-controlled.
|
||||
- API serves attachment bytes through authenticated Paperclip endpoints.
|
||||
|
||||
## Out of Scope (This Draft)
|
||||
|
||||
- Public unauthenticated object URLs.
|
||||
- CDN/signed URL optimization.
|
||||
- Image transformations/thumbnails.
|
||||
- Malware scanning pipeline.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- Default local path is under instance root: `~/.paperclip/instances/<instanceId>/data/storage`.
|
||||
- Object bytes live in storage provider; metadata lives in Postgres.
|
||||
- `assets` is generic metadata table; `issue_attachments` links assets to issues/comments.
|
||||
- S3 credentials come from runtime environment/default AWS provider chain, not DB rows.
|
||||
- All object keys include company prefix to preserve hard tenancy boundaries.
|
||||
|
||||
## Phase 1: Shared Config + Provider Contract
|
||||
|
||||
### Checklist (Per File)
|
||||
|
||||
- [ ] `packages/shared/src/constants.ts`: add `STORAGE_PROVIDERS` and `StorageProvider` type.
|
||||
- [ ] `packages/shared/src/config-schema.ts`: add `storageConfigSchema` with:
|
||||
- provider: `local_disk | s3`
|
||||
- localDisk.baseDir
|
||||
- s3.bucket, s3.region, s3.endpoint?, s3.prefix?, s3.forcePathStyle?
|
||||
- [ ] `packages/shared/src/index.ts`: export new storage config/types.
|
||||
- [ ] `cli/src/config/schema.ts`: ensure re-export includes new storage schema/types.
|
||||
- [ ] `cli/src/commands/configure.ts`: add `storage` section support.
|
||||
- [ ] `cli/src/commands/onboard.ts`: initialize default storage config.
|
||||
- [ ] `cli/src/prompts/storage.ts`: new prompt flow for local disk vs s3 settings.
|
||||
- [ ] `cli/src/prompts/index` (if present) or direct imports: wire new storage prompt.
|
||||
- [ ] `server/src/config.ts`: load storage config and resolve home-aware local path.
|
||||
- [ ] `server/src/home-paths.ts`: add `resolveDefaultStorageDir()`.
|
||||
- [ ] `doc/CLI.md`: document `configure --section storage`.
|
||||
- [ ] `doc/DEVELOPING.md`: document default local storage path and overrides.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- `paperclip onboard` writes a valid `storage` config block by default.
|
||||
- `paperclip configure --section storage` can switch between local and s3 modes.
|
||||
- Server startup reads storage config without env-only hacks.
|
||||
|
||||
## Phase 2: Server Storage Subsystem + Providers
|
||||
|
||||
### Checklist (Per File)
|
||||
|
||||
- [ ] `server/src/storage/types.ts`: define provider + service interfaces.
|
||||
- [ ] `server/src/storage/service.ts`: provider-agnostic service (key generation, validation, stream APIs).
|
||||
- [ ] `server/src/storage/local-disk-provider.ts`: implement local disk provider with safe path resolution.
|
||||
- [ ] `server/src/storage/s3-provider.ts`: implement S3-compatible provider (`@aws-sdk/client-s3`).
|
||||
- [ ] `server/src/storage/provider-registry.ts`: provider lookup by configured id.
|
||||
- [ ] `server/src/storage/index.ts`: export storage factory helpers.
|
||||
- [ ] `server/src/services/index.ts`: export `storageService` factory.
|
||||
- [ ] `server/src/app.ts` or route wiring point: inject/use storage service where needed.
|
||||
- [ ] `server/package.json`: add AWS SDK dependency if not present.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- In `local_disk` mode, uploading + reading a file round-trips bytes on disk.
|
||||
- In `s3` mode, service can `put/get/delete` against S3-compatible endpoint.
|
||||
- Invalid provider config yields clear startup/config errors.
|
||||
|
||||
## Phase 3: Database Metadata Model
|
||||
|
||||
### Checklist (Per File)
|
||||
|
||||
- [ ] `packages/db/src/schema/assets.ts`: new generic asset metadata table.
|
||||
- [ ] `packages/db/src/schema/issue_attachments.ts`: issue-to-asset linking table.
|
||||
- [ ] `packages/db/src/schema/index.ts`: export new tables.
|
||||
- [ ] `packages/db/src/migrations/*`: generate migration for both tables and indexes.
|
||||
- [ ] `packages/shared/src/types/issue.ts` (or new asset types file): add `IssueAttachment` type.
|
||||
- [ ] `packages/shared/src/index.ts`: export new types.
|
||||
|
||||
### Suggested Columns
|
||||
|
||||
- `assets`:
|
||||
- `id`, `company_id`, `provider`, `object_key`
|
||||
- `content_type`, `byte_size`, `sha256`, `original_filename`
|
||||
- `created_by_agent_id`, `created_by_user_id`, timestamps
|
||||
- `issue_attachments`:
|
||||
- `id`, `company_id`, `issue_id`, `asset_id`, `issue_comment_id` (nullable), timestamps
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Migration applies cleanly on empty and existing local dev DB.
|
||||
- Metadata rows are company-scoped and indexed for issue lookup.
|
||||
|
||||
## Phase 4: Issue Attachment API
|
||||
|
||||
### Checklist (Per File)
|
||||
|
||||
- [ ] `packages/shared/src/validators/issue.ts`: add schemas for upload/list/delete attachment operations.
|
||||
- [ ] `server/src/services/issues.ts`: add attachment CRUD helpers with company checks.
|
||||
- [ ] `server/src/routes/issues.ts`: add endpoints:
|
||||
- `POST /companies/:companyId/issues/:issueId/attachments` (multipart)
|
||||
- `GET /issues/:issueId/attachments`
|
||||
- `GET /attachments/:attachmentId/content`
|
||||
- `DELETE /attachments/:attachmentId`
|
||||
- [ ] `server/src/routes/authz.ts`: reuse/enforce company access for attachment endpoints.
|
||||
- [ ] `server/src/services/activity-log.ts` usage callsites: log attachment add/remove mutations.
|
||||
- [ ] `server/src/app.ts`: ensure multipart parsing middleware is in place for upload route.
|
||||
|
||||
### API Behavior
|
||||
|
||||
- Enforce max size and image/content-type allowlist in V1.
|
||||
- Return consistent errors: `400/401/403/404/409/422/500`.
|
||||
- Stream bytes instead of buffering large payloads in memory.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Board and same-company agents can upload and read attachments per issue permissions.
|
||||
- Cross-company access is denied even with valid attachment id.
|
||||
- Activity log records attachment add/remove actions.
|
||||
|
||||
## Phase 5: UI Issue Attachment Integration
|
||||
|
||||
### Checklist (Per File)
|
||||
|
||||
- [ ] `ui/src/api/issues.ts`: add attachment API client methods.
|
||||
- [ ] `ui/src/api/client.ts`: support multipart upload helper (no JSON `Content-Type` for `FormData`).
|
||||
- [ ] `ui/src/lib/queryKeys.ts`: add issue attachment query keys.
|
||||
- [ ] `ui/src/pages/IssueDetail.tsx`: add upload UI + attachment list/query invalidation.
|
||||
- [ ] `ui/src/components/CommentThread.tsx`: optional comment image attach or display linked images.
|
||||
- [ ] `packages/shared/src/types/index.ts`: ensure attachment types are consumed cleanly in UI.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- User can upload an image from issue detail and see it listed immediately.
|
||||
- Uploaded image can be opened/rendered via authenticated API route.
|
||||
- Upload and fetch failures are visible to users (no silent errors).
|
||||
|
||||
## Phase 6: CLI Doctor + Operational Hardening
|
||||
|
||||
### Checklist (Per File)
|
||||
|
||||
- [ ] `cli/src/checks/storage-check.ts`: add storage check (local writable dir, optional S3 reachability check).
|
||||
- [ ] `cli/src/checks/index.ts`: export new storage check.
|
||||
- [ ] `cli/src/commands/doctor.ts`: include storage check in doctor sequence.
|
||||
- [ ] `doc/DATABASE.md` or `doc/DEVELOPING.md`: mention storage backend behavior by deployment mode.
|
||||
- [ ] `doc/SPEC-implementation.md`: add storage subsystem and issue-attachment endpoint contract.
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- `paperclip doctor` reports actionable storage status.
|
||||
- Local single-user install works without extra cloud credentials.
|
||||
- Cloud config supports S3-compatible endpoint without code changes.
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Server Integration Tests
|
||||
|
||||
- [ ] `server/src/__tests__/issue-attachments.auth.test.ts`: company boundary and permission tests.
|
||||
- [ ] `server/src/__tests__/issue-attachments.lifecycle.test.ts`: upload/list/read/delete flow.
|
||||
- [ ] `server/src/__tests__/storage-local-provider.test.ts`: local provider path safety and round-trip.
|
||||
- [ ] `server/src/__tests__/storage-s3-provider.test.ts`: s3 provider contract (mocked client).
|
||||
- [ ] `server/src/__tests__/activity-log.attachments.test.ts`: mutation logging assertions.
|
||||
|
||||
### CLI Tests
|
||||
|
||||
- [ ] `cli/src/__tests__/configure-storage.test.ts`: configure section writes valid config.
|
||||
- [ ] `cli/src/__tests__/doctor-storage-check.test.ts`: storage health output and repair behavior.
|
||||
|
||||
### UI Tests (if present in current stack)
|
||||
|
||||
- [ ] `ui/src/...`: issue detail upload and error handling tests.
|
||||
|
||||
## Verification Gate Before Merge
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
|
||||
If any command is skipped, document exactly what was skipped and why.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Phase 1 and Phase 2 (foundation, no user-visible breakage)
|
||||
2. Phase 3 (DB contract)
|
||||
3. Phase 4 (API)
|
||||
4. Phase 5 (UI consumer)
|
||||
5. Phase 6 (doctor/docs hardening)
|
||||
|
||||
26
packages/db/src/schema/assets.ts
Normal file
26
packages/db/src/schema/assets.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { pgTable, uuid, text, integer, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
|
||||
export const assets = pgTable(
|
||||
"assets",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
provider: text("provider").notNull(),
|
||||
objectKey: text("object_key").notNull(),
|
||||
contentType: text("content_type").notNull(),
|
||||
byteSize: integer("byte_size").notNull(),
|
||||
sha256: text("sha256").notNull(),
|
||||
originalFilename: text("original_filename"),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyCreatedIdx: index("assets_company_created_idx").on(table.companyId, table.createdAt),
|
||||
companyProviderIdx: index("assets_company_provider_idx").on(table.companyId, table.provider),
|
||||
companyObjectKeyUq: uniqueIndex("assets_company_object_key_uq").on(table.companyId, table.objectKey),
|
||||
}),
|
||||
);
|
||||
@@ -10,6 +10,8 @@ export { goals } from "./goals.js";
|
||||
export { issues } from "./issues.js";
|
||||
export { issueApprovals } from "./issue_approvals.js";
|
||||
export { issueComments } from "./issue_comments.js";
|
||||
export { assets } from "./assets.js";
|
||||
export { issueAttachments } from "./issue_attachments.js";
|
||||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
||||
export { costEvents } from "./cost_events.js";
|
||||
|
||||
23
packages/db/src/schema/issue_attachments.ts
Normal file
23
packages/db/src/schema/issue_attachments.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { pgTable, uuid, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { assets } from "./assets.js";
|
||||
import { issueComments } from "./issue_comments.js";
|
||||
|
||||
export const issueAttachments = pgTable(
|
||||
"issue_attachments",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
assetId: uuid("asset_id").notNull().references(() => assets.id, { onDelete: "cascade" }),
|
||||
issueCommentId: uuid("issue_comment_id").references(() => issueComments.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIssueIdx: index("issue_attachments_company_issue_idx").on(table.companyId, table.issueId),
|
||||
issueCommentIdx: index("issue_attachments_issue_comment_idx").on(table.issueCommentId),
|
||||
assetUq: uniqueIndex("issue_attachments_asset_uq").on(table.assetId),
|
||||
}),
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { SECRET_PROVIDERS } from "./constants.js";
|
||||
import { SECRET_PROVIDERS, STORAGE_PROVIDERS } from "./constants.js";
|
||||
|
||||
export const configMetaSchema = z.object({
|
||||
version: z.literal(1),
|
||||
@@ -29,6 +29,31 @@ export const serverConfigSchema = z.object({
|
||||
serveUi: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const storageLocalDiskConfigSchema = z.object({
|
||||
baseDir: z.string().default("~/.paperclip/instances/default/data/storage"),
|
||||
});
|
||||
|
||||
export const storageS3ConfigSchema = z.object({
|
||||
bucket: z.string().min(1).default("paperclip"),
|
||||
region: z.string().min(1).default("us-east-1"),
|
||||
endpoint: z.string().optional(),
|
||||
prefix: z.string().default(""),
|
||||
forcePathStyle: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const storageConfigSchema = z.object({
|
||||
provider: z.enum(STORAGE_PROVIDERS).default("local_disk"),
|
||||
localDisk: storageLocalDiskConfigSchema.default({
|
||||
baseDir: "~/.paperclip/instances/default/data/storage",
|
||||
}),
|
||||
s3: storageS3ConfigSchema.default({
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export const secretsLocalEncryptedConfigSchema = z.object({
|
||||
keyFilePath: z.string().default("~/.paperclip/instances/default/secrets/master.key"),
|
||||
});
|
||||
@@ -47,6 +72,18 @@ export const paperclipConfigSchema = z.object({
|
||||
database: databaseConfigSchema,
|
||||
logging: loggingConfigSchema,
|
||||
server: serverConfigSchema,
|
||||
storage: storageConfigSchema.default({
|
||||
provider: "local_disk",
|
||||
localDisk: {
|
||||
baseDir: "~/.paperclip/instances/default/data/storage",
|
||||
},
|
||||
s3: {
|
||||
bucket: "paperclip",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
forcePathStyle: false,
|
||||
},
|
||||
}),
|
||||
secrets: secretsConfigSchema.default({
|
||||
provider: "local_encrypted",
|
||||
strictMode: false,
|
||||
@@ -61,6 +98,9 @@ export type LlmConfig = z.infer<typeof llmConfigSchema>;
|
||||
export type DatabaseConfig = z.infer<typeof databaseConfigSchema>;
|
||||
export type LoggingConfig = z.infer<typeof loggingConfigSchema>;
|
||||
export type ServerConfig = z.infer<typeof serverConfigSchema>;
|
||||
export type StorageConfig = z.infer<typeof storageConfigSchema>;
|
||||
export type StorageLocalDiskConfig = z.infer<typeof storageLocalDiskConfigSchema>;
|
||||
export type StorageS3Config = z.infer<typeof storageS3ConfigSchema>;
|
||||
export type SecretsConfig = z.infer<typeof secretsConfigSchema>;
|
||||
export type SecretsLocalEncryptedConfig = z.infer<typeof secretsLocalEncryptedConfigSchema>;
|
||||
export type ConfigMeta = z.infer<typeof configMetaSchema>;
|
||||
|
||||
@@ -79,6 +79,9 @@ export const SECRET_PROVIDERS = [
|
||||
] as const;
|
||||
export type SecretProvider = (typeof SECRET_PROVIDERS)[number];
|
||||
|
||||
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
|
||||
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
|
||||
|
||||
export const HEARTBEAT_INVOCATION_SOURCES = [
|
||||
"timer",
|
||||
"assignment",
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
APPROVAL_TYPES,
|
||||
APPROVAL_STATUSES,
|
||||
SECRET_PROVIDERS,
|
||||
STORAGE_PROVIDERS,
|
||||
HEARTBEAT_INVOCATION_SOURCES,
|
||||
HEARTBEAT_RUN_STATUSES,
|
||||
WAKEUP_TRIGGER_DETAILS,
|
||||
@@ -28,6 +29,7 @@ export {
|
||||
type ApprovalType,
|
||||
type ApprovalStatus,
|
||||
type SecretProvider,
|
||||
type StorageProvider,
|
||||
type HeartbeatInvocationSource,
|
||||
type HeartbeatRunStatus,
|
||||
type WakeupTriggerDetail,
|
||||
@@ -44,6 +46,7 @@ export type {
|
||||
Project,
|
||||
Issue,
|
||||
IssueComment,
|
||||
IssueAttachment,
|
||||
Goal,
|
||||
Approval,
|
||||
ApprovalComment,
|
||||
@@ -94,11 +97,13 @@ export {
|
||||
checkoutIssueSchema,
|
||||
addIssueCommentSchema,
|
||||
linkIssueApprovalSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
type CreateIssue,
|
||||
type UpdateIssue,
|
||||
type CheckoutIssue,
|
||||
type AddIssueComment,
|
||||
type LinkIssueApproval,
|
||||
type CreateIssueAttachmentMetadata,
|
||||
createGoalSchema,
|
||||
updateGoalSchema,
|
||||
type CreateGoal,
|
||||
@@ -139,12 +144,18 @@ export {
|
||||
loggingConfigSchema,
|
||||
serverConfigSchema,
|
||||
secretsConfigSchema,
|
||||
storageConfigSchema,
|
||||
storageLocalDiskConfigSchema,
|
||||
storageS3ConfigSchema,
|
||||
secretsLocalEncryptedConfigSchema,
|
||||
type PaperclipConfig,
|
||||
type LlmConfig,
|
||||
type DatabaseConfig,
|
||||
type LoggingConfig,
|
||||
type ServerConfig,
|
||||
type StorageConfig,
|
||||
type StorageLocalDiskConfig,
|
||||
type StorageS3Config,
|
||||
type SecretsConfig,
|
||||
type SecretsLocalEncryptedConfig,
|
||||
type ConfigMeta,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
export type { Company } from "./company.js";
|
||||
export type { Agent, AgentPermissions, AgentKeyCreated, AgentConfigRevision } from "./agent.js";
|
||||
export type { Project } from "./project.js";
|
||||
export type { Issue, IssueComment, IssueAncestor } from "./issue.js";
|
||||
export type {
|
||||
Issue,
|
||||
IssueComment,
|
||||
IssueAncestor,
|
||||
IssueAncestorProject,
|
||||
IssueAncestorGoal,
|
||||
IssueAttachment,
|
||||
} from "./issue.js";
|
||||
export type { Goal } from "./goal.js";
|
||||
export type { Approval, ApprovalComment } from "./approval.js";
|
||||
export type {
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { IssuePriority, IssueStatus } from "../constants.js";
|
||||
|
||||
export interface IssueAncestorProject {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
goalId: string | null;
|
||||
}
|
||||
|
||||
export interface IssueAncestorGoal {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
level: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface IssueAncestor {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -9,6 +25,8 @@ export interface IssueAncestor {
|
||||
assigneeAgentId: string | null;
|
||||
projectId: string | null;
|
||||
goalId: string | null;
|
||||
project: IssueAncestorProject | null;
|
||||
goal: IssueAncestorGoal | null;
|
||||
}
|
||||
|
||||
export interface Issue {
|
||||
@@ -47,3 +65,22 @@ export interface IssueComment {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface IssueAttachment {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
issueCommentId: string | null;
|
||||
assetId: string;
|
||||
provider: string;
|
||||
objectKey: string;
|
||||
contentType: string;
|
||||
byteSize: number;
|
||||
sha256: string;
|
||||
originalFilename: string | null;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
contentPath: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface SidebarBadges {
|
||||
inbox: number;
|
||||
approvals: number;
|
||||
failedRuns: number;
|
||||
}
|
||||
|
||||
@@ -36,11 +36,13 @@ export {
|
||||
checkoutIssueSchema,
|
||||
addIssueCommentSchema,
|
||||
linkIssueApprovalSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
type CreateIssue,
|
||||
type UpdateIssue,
|
||||
type CheckoutIssue,
|
||||
type AddIssueComment,
|
||||
type LinkIssueApproval,
|
||||
type CreateIssueAttachmentMetadata,
|
||||
} from "./issue.js";
|
||||
|
||||
export {
|
||||
|
||||
@@ -42,3 +42,9 @@ export const linkIssueApprovalSchema = z.object({
|
||||
});
|
||||
|
||||
export type LinkIssueApproval = z.infer<typeof linkIssueApprovalSchema>;
|
||||
|
||||
export const createIssueAttachmentMetadataSchema = z.object({
|
||||
issueCommentId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateIssueAttachmentMetadata = z.infer<typeof createIssueAttachmentMetadataSchema>;
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
"@paperclip/adapter-utils": "workspace:*",
|
||||
"@paperclip/db": "workspace:*",
|
||||
"@paperclip/shared": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.888.0",
|
||||
"detect-port": "^2.1.0",
|
||||
"dotenv": "^17.0.1",
|
||||
"drizzle-orm": "^0.38.4",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^2.0.2",
|
||||
"pino": "^9.6.0",
|
||||
"pino-http": "^10.4.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
@@ -32,6 +34,7 @@
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"supertest": "^7.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import type { StorageService } from "./storage/types.js";
|
||||
import { httpLogger, errorHandler } from "./middleware/index.js";
|
||||
import { actorMiddleware } from "./middleware/auth.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
@@ -21,7 +22,7 @@ import { llmRoutes } from "./routes/llms.js";
|
||||
|
||||
type UiMode = "none" | "static" | "vite-dev";
|
||||
|
||||
export async function createApp(db: Db, opts: { uiMode: UiMode }) {
|
||||
export async function createApp(db: Db, opts: { uiMode: UiMode; storageService: StorageService }) {
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
@@ -35,7 +36,7 @@ export async function createApp(db: Db, opts: { uiMode: UiMode }) {
|
||||
api.use("/companies", companyRoutes(db));
|
||||
api.use(agentRoutes(db));
|
||||
api.use(projectRoutes(db));
|
||||
api.use(issueRoutes(db));
|
||||
api.use(issueRoutes(db, opts.storageService));
|
||||
api.use(goalRoutes(db));
|
||||
api.use(approvalRoutes(db));
|
||||
api.use(secretRoutes(db));
|
||||
|
||||
@@ -2,10 +2,11 @@ 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, type SecretProvider } from "@paperclip/shared";
|
||||
import { SECRET_PROVIDERS, STORAGE_PROVIDERS, type SecretProvider, type StorageProvider } from "@paperclip/shared";
|
||||
import {
|
||||
resolveDefaultEmbeddedPostgresDir,
|
||||
resolveDefaultSecretsKeyFilePath,
|
||||
resolveDefaultStorageDir,
|
||||
resolveHomeAwarePath,
|
||||
} from "./home-paths.js";
|
||||
|
||||
@@ -27,6 +28,13 @@ export interface Config {
|
||||
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;
|
||||
}
|
||||
@@ -41,6 +49,7 @@ export function loadConfig(): Config {
|
||||
? fileConfig?.database.connectionString
|
||||
: undefined;
|
||||
const fileSecrets = fileConfig?.secrets;
|
||||
const fileStorage = fileConfig?.storage;
|
||||
const strictModeFromEnv = process.env.PAPERCLIP_SECRETS_STRICT_MODE;
|
||||
const secretsStrictMode =
|
||||
strictModeFromEnv !== undefined
|
||||
@@ -55,6 +64,26 @@ export function loadConfig(): Config {
|
||||
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,
|
||||
@@ -76,6 +105,13 @@ export function loadConfig(): Config {
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -44,6 +44,10 @@ export function resolveDefaultSecretsKeyFilePath(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "secrets", "master.key");
|
||||
}
|
||||
|
||||
export function resolveDefaultStorageDir(): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "data", "storage");
|
||||
}
|
||||
|
||||
export function resolveHomeAwarePath(value: string): string {
|
||||
return path.resolve(expandHomePrefix(value));
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { loadConfig } from "./config.js";
|
||||
import { logger } from "./middleware/logger.js";
|
||||
import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
||||
import { heartbeatService } from "./services/index.js";
|
||||
import { createStorageServiceFromConfig } from "./storage/index.js";
|
||||
import { printStartupBanner } from "./startup-banner.js";
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
@@ -217,7 +218,8 @@ if (config.databaseUrl) {
|
||||
}
|
||||
|
||||
const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none";
|
||||
const app = await createApp(db as any, { uiMode });
|
||||
const storageService = createStorageServiceFromConfig(config);
|
||||
const app = await createApp(db as any, { uiMode, storageService });
|
||||
const server = createServer(app);
|
||||
const listenPort = await detectPort(config.port);
|
||||
|
||||
|
||||
@@ -1,29 +1,65 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import multer from "multer";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import {
|
||||
addIssueCommentSchema,
|
||||
createIssueAttachmentMetadataSchema,
|
||||
checkoutIssueSchema,
|
||||
createIssueSchema,
|
||||
linkIssueApprovalSchema,
|
||||
updateIssueSchema,
|
||||
} from "@paperclip/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
agentService,
|
||||
goalService,
|
||||
heartbeatService,
|
||||
issueApprovalService,
|
||||
issueService,
|
||||
logActivity,
|
||||
projectService,
|
||||
} from "../services/index.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
export function issueRoutes(db: Db) {
|
||||
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const router = Router();
|
||||
const svc = issueService(db);
|
||||
const heartbeat = heartbeatService(db);
|
||||
const agentsSvc = agentService(db);
|
||||
const projectsSvc = projectService(db);
|
||||
const goalsSvc = goalService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
|
||||
function withContentPath<T extends { id: string }>(attachment: T) {
|
||||
return {
|
||||
...attachment,
|
||||
contentPath: `/api/attachments/${attachment.id}/content`,
|
||||
};
|
||||
}
|
||||
|
||||
async function runSingleFileUpload(req: Request, res: Response) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upload.single("file")(req, res, (err: unknown) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCanManageIssueApprovalLinks(req: Request, res: Response, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -62,8 +98,12 @@ export function issueRoutes(db: Db) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const ancestors = await svc.getAncestors(issue.id);
|
||||
res.json({ ...issue, ancestors });
|
||||
const [ancestors, project, goal] = await Promise.all([
|
||||
svc.getAncestors(issue.id),
|
||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
||||
issue.goalId ? goalsSvc.getById(issue.goalId) : null,
|
||||
]);
|
||||
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null });
|
||||
});
|
||||
|
||||
router.get("/issues/:id/approvals", async (req, res) => {
|
||||
@@ -254,20 +294,17 @@ export function issueRoutes(db: Db) {
|
||||
|
||||
const assigneeChanged =
|
||||
req.body.assigneeAgentId !== undefined && req.body.assigneeAgentId !== existing.assigneeAgentId;
|
||||
const reopened =
|
||||
(existing.status === "done" || existing.status === "cancelled") &&
|
||||
issue.status !== "done" && issue.status !== "cancelled";
|
||||
|
||||
if ((assigneeChanged || reopened) && issue.assigneeAgentId) {
|
||||
if (assigneeChanged && issue.assigneeAgentId) {
|
||||
void heartbeat
|
||||
.wakeup(issue.assigneeAgentId, {
|
||||
source: reopened ? "automation" : "assignment",
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: reopened ? "issue_reopened" : "issue_assigned",
|
||||
reason: "issue_assigned",
|
||||
payload: { issueId: issue.id, mutation: "update" },
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: { issueId: issue.id, source: reopened ? "issue.reopen" : "issue.update" },
|
||||
contextSnapshot: { issueId: issue.id, source: "issue.update" },
|
||||
})
|
||||
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue update"));
|
||||
}
|
||||
@@ -518,5 +555,169 @@ export function issueRoutes(db: Db) {
|
||||
res.status(201).json(comment);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/attachments", async (req, res) => {
|
||||
const issueId = req.params.id as string;
|
||||
const issue = await svc.getById(issueId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const attachments = await svc.listAttachments(issueId);
|
||||
res.json(attachments.map(withContentPath));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/issues/:issueId/attachments", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const issueId = req.params.issueId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const issue = await svc.getById(issueId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
if (issue.companyId !== companyId) {
|
||||
res.status(422).json({ error: "Issue does not belong to company" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await runSingleFileUpload(req, res);
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file;
|
||||
if (!file) {
|
||||
res.status(400).json({ error: "Missing file field 'file'" });
|
||||
return;
|
||||
}
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
if (file.buffer.length <= 0) {
|
||||
res.status(422).json({ error: "Attachment is empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedMeta = createIssueAttachmentMetadataSchema.safeParse(req.body ?? {});
|
||||
if (!parsedMeta.success) {
|
||||
res.status(400).json({ error: "Invalid attachment metadata", details: parsedMeta.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const stored = await storage.putFile({
|
||||
companyId,
|
||||
namespace: `issues/${issueId}`,
|
||||
originalFilename: file.originalname || null,
|
||||
contentType,
|
||||
body: file.buffer,
|
||||
});
|
||||
|
||||
const attachment = await svc.createAttachment({
|
||||
issueId,
|
||||
issueCommentId: parsedMeta.data.issueCommentId ?? null,
|
||||
provider: stored.provider,
|
||||
objectKey: stored.objectKey,
|
||||
contentType: stored.contentType,
|
||||
byteSize: stored.byteSize,
|
||||
sha256: stored.sha256,
|
||||
originalFilename: stored.originalFilename,
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.attachment_added",
|
||||
entityType: "issue",
|
||||
entityId: issueId,
|
||||
details: {
|
||||
attachmentId: attachment.id,
|
||||
originalFilename: attachment.originalFilename,
|
||||
contentType: attachment.contentType,
|
||||
byteSize: attachment.byteSize,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json(withContentPath(attachment));
|
||||
});
|
||||
|
||||
router.get("/attachments/:attachmentId/content", async (req, res, next) => {
|
||||
const attachmentId = req.params.attachmentId as string;
|
||||
const attachment = await svc.getAttachmentById(attachmentId);
|
||||
if (!attachment) {
|
||||
res.status(404).json({ error: "Attachment not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, attachment.companyId);
|
||||
|
||||
const object = await storage.getObject(attachment.companyId, attachment.objectKey);
|
||||
res.setHeader("Content-Type", attachment.contentType || object.contentType || "application/octet-stream");
|
||||
res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0));
|
||||
res.setHeader("Cache-Control", "private, max-age=60");
|
||||
const filename = attachment.originalFilename ?? "attachment";
|
||||
res.setHeader("Content-Disposition", `inline; filename=\"${filename.replaceAll("\"", "")}\"`);
|
||||
|
||||
object.stream.on("error", (err) => {
|
||||
next(err);
|
||||
});
|
||||
object.stream.pipe(res);
|
||||
});
|
||||
|
||||
router.delete("/attachments/:attachmentId", async (req, res) => {
|
||||
const attachmentId = req.params.attachmentId as string;
|
||||
const attachment = await svc.getAttachmentById(attachmentId);
|
||||
if (!attachment) {
|
||||
res.status(404).json({ error: "Attachment not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, attachment.companyId);
|
||||
|
||||
try {
|
||||
await storage.deleteObject(attachment.companyId, attachment.objectKey);
|
||||
} catch (err) {
|
||||
logger.warn({ err, attachmentId }, "storage delete failed while removing attachment");
|
||||
}
|
||||
|
||||
const removed = await svc.removeAttachment(attachmentId);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Attachment not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: removed.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.attachment_removed",
|
||||
entityType: "issue",
|
||||
entityId: removed.issueId,
|
||||
details: {
|
||||
attachmentId: removed.id,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -13,3 +13,4 @@ export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js";
|
||||
export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js";
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { and, asc, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclip/db";
|
||||
import { agents, companies, issues, issueComments } from "@paperclip/db";
|
||||
import {
|
||||
agents,
|
||||
assets,
|
||||
companies,
|
||||
goals,
|
||||
issueAttachments,
|
||||
issueComments,
|
||||
issues,
|
||||
projects,
|
||||
} from "@paperclip/db";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
const ALL_ISSUE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done", "cancelled"];
|
||||
@@ -162,11 +171,26 @@ export function issueService(db: Db) {
|
||||
},
|
||||
|
||||
remove: (id: string) =>
|
||||
db
|
||||
.delete(issues)
|
||||
.where(eq(issues.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db.transaction(async (tx) => {
|
||||
const attachmentAssetIds = await tx
|
||||
.select({ assetId: issueAttachments.assetId })
|
||||
.from(issueAttachments)
|
||||
.where(eq(issueAttachments.issueId, id));
|
||||
|
||||
const removedIssue = await tx
|
||||
.delete(issues)
|
||||
.where(eq(issues.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (removedIssue && attachmentAssetIds.length > 0) {
|
||||
await tx
|
||||
.delete(assets)
|
||||
.where(inArray(assets.id, attachmentAssetIds.map((row) => row.assetId)));
|
||||
}
|
||||
|
||||
return removedIssue;
|
||||
}),
|
||||
|
||||
checkout: async (id: string, agentId: string, expectedStatuses: string[]) => {
|
||||
const issueCompany = await db
|
||||
@@ -275,6 +299,162 @@ export function issueService(db: Db) {
|
||||
.then((rows) => rows[0]);
|
||||
},
|
||||
|
||||
createAttachment: async (input: {
|
||||
issueId: string;
|
||||
issueCommentId?: string | null;
|
||||
provider: string;
|
||||
objectKey: string;
|
||||
contentType: string;
|
||||
byteSize: number;
|
||||
sha256: string;
|
||||
originalFilename?: string | null;
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
}) => {
|
||||
const issue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, input.issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!issue) throw notFound("Issue not found");
|
||||
|
||||
if (input.issueCommentId) {
|
||||
const comment = await db
|
||||
.select({ id: issueComments.id, companyId: issueComments.companyId, issueId: issueComments.issueId })
|
||||
.from(issueComments)
|
||||
.where(eq(issueComments.id, input.issueCommentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!comment) throw notFound("Issue comment not found");
|
||||
if (comment.companyId !== issue.companyId || comment.issueId !== issue.id) {
|
||||
throw unprocessable("Attachment comment must belong to same issue and company");
|
||||
}
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [asset] = await tx
|
||||
.insert(assets)
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
provider: input.provider,
|
||||
objectKey: input.objectKey,
|
||||
contentType: input.contentType,
|
||||
byteSize: input.byteSize,
|
||||
sha256: input.sha256,
|
||||
originalFilename: input.originalFilename ?? null,
|
||||
createdByAgentId: input.createdByAgentId ?? null,
|
||||
createdByUserId: input.createdByUserId ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const [attachment] = await tx
|
||||
.insert(issueAttachments)
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
issueId: issue.id,
|
||||
assetId: asset.id,
|
||||
issueCommentId: input.issueCommentId ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: attachment.id,
|
||||
companyId: attachment.companyId,
|
||||
issueId: attachment.issueId,
|
||||
issueCommentId: attachment.issueCommentId,
|
||||
assetId: attachment.assetId,
|
||||
provider: asset.provider,
|
||||
objectKey: asset.objectKey,
|
||||
contentType: asset.contentType,
|
||||
byteSize: asset.byteSize,
|
||||
sha256: asset.sha256,
|
||||
originalFilename: asset.originalFilename,
|
||||
createdByAgentId: asset.createdByAgentId,
|
||||
createdByUserId: asset.createdByUserId,
|
||||
createdAt: attachment.createdAt,
|
||||
updatedAt: attachment.updatedAt,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
listAttachments: async (issueId: string) =>
|
||||
db
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
companyId: issueAttachments.companyId,
|
||||
issueId: issueAttachments.issueId,
|
||||
issueCommentId: issueAttachments.issueCommentId,
|
||||
assetId: issueAttachments.assetId,
|
||||
provider: assets.provider,
|
||||
objectKey: assets.objectKey,
|
||||
contentType: assets.contentType,
|
||||
byteSize: assets.byteSize,
|
||||
sha256: assets.sha256,
|
||||
originalFilename: assets.originalFilename,
|
||||
createdByAgentId: assets.createdByAgentId,
|
||||
createdByUserId: assets.createdByUserId,
|
||||
createdAt: issueAttachments.createdAt,
|
||||
updatedAt: issueAttachments.updatedAt,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.where(eq(issueAttachments.issueId, issueId))
|
||||
.orderBy(desc(issueAttachments.createdAt)),
|
||||
|
||||
getAttachmentById: async (id: string) =>
|
||||
db
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
companyId: issueAttachments.companyId,
|
||||
issueId: issueAttachments.issueId,
|
||||
issueCommentId: issueAttachments.issueCommentId,
|
||||
assetId: issueAttachments.assetId,
|
||||
provider: assets.provider,
|
||||
objectKey: assets.objectKey,
|
||||
contentType: assets.contentType,
|
||||
byteSize: assets.byteSize,
|
||||
sha256: assets.sha256,
|
||||
originalFilename: assets.originalFilename,
|
||||
createdByAgentId: assets.createdByAgentId,
|
||||
createdByUserId: assets.createdByUserId,
|
||||
createdAt: issueAttachments.createdAt,
|
||||
updatedAt: issueAttachments.updatedAt,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.where(eq(issueAttachments.id, id))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
|
||||
removeAttachment: async (id: string) =>
|
||||
db.transaction(async (tx) => {
|
||||
const existing = await tx
|
||||
.select({
|
||||
id: issueAttachments.id,
|
||||
companyId: issueAttachments.companyId,
|
||||
issueId: issueAttachments.issueId,
|
||||
issueCommentId: issueAttachments.issueCommentId,
|
||||
assetId: issueAttachments.assetId,
|
||||
provider: assets.provider,
|
||||
objectKey: assets.objectKey,
|
||||
contentType: assets.contentType,
|
||||
byteSize: assets.byteSize,
|
||||
sha256: assets.sha256,
|
||||
originalFilename: assets.originalFilename,
|
||||
createdByAgentId: assets.createdByAgentId,
|
||||
createdByUserId: assets.createdByUserId,
|
||||
createdAt: issueAttachments.createdAt,
|
||||
updatedAt: issueAttachments.updatedAt,
|
||||
})
|
||||
.from(issueAttachments)
|
||||
.innerJoin(assets, eq(issueAttachments.assetId, assets.id))
|
||||
.where(eq(issueAttachments.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) return null;
|
||||
|
||||
await tx.delete(issueAttachments).where(eq(issueAttachments.id, id));
|
||||
await tx.delete(assets).where(eq(assets.id, existing.assetId));
|
||||
return existing;
|
||||
}),
|
||||
|
||||
findMentionedAgents: async (companyId: string, body: string) => {
|
||||
const re = /\B@([^\s@,!?.]+)/g;
|
||||
const tokens = new Set<string>();
|
||||
@@ -287,7 +467,7 @@ export function issueService(db: Db) {
|
||||
},
|
||||
|
||||
getAncestors: async (issueId: string) => {
|
||||
const ancestors: Array<{
|
||||
const raw: Array<{
|
||||
id: string; title: string; description: string | null;
|
||||
status: string; priority: string;
|
||||
assigneeAgentId: string | null; projectId: string | null; goalId: string | null;
|
||||
@@ -295,7 +475,7 @@ export function issueService(db: Db) {
|
||||
const visited = new Set<string>([issueId]);
|
||||
const start = await db.select().from(issues).where(eq(issues.id, issueId)).then(r => r[0] ?? null);
|
||||
let currentId = start?.parentId ?? null;
|
||||
while (currentId && !visited.has(currentId) && ancestors.length < 50) {
|
||||
while (currentId && !visited.has(currentId) && raw.length < 50) {
|
||||
visited.add(currentId);
|
||||
const parent = await db.select({
|
||||
id: issues.id, title: issues.title, description: issues.description,
|
||||
@@ -304,7 +484,7 @@ export function issueService(db: Db) {
|
||||
goalId: issues.goalId, parentId: issues.parentId,
|
||||
}).from(issues).where(eq(issues.id, currentId)).then(r => r[0] ?? null);
|
||||
if (!parent) break;
|
||||
ancestors.push({
|
||||
raw.push({
|
||||
id: parent.id, title: parent.title, description: parent.description ?? null,
|
||||
status: parent.status, priority: parent.priority,
|
||||
assigneeAgentId: parent.assigneeAgentId ?? null,
|
||||
@@ -312,7 +492,39 @@ export function issueService(db: Db) {
|
||||
});
|
||||
currentId = parent.parentId ?? null;
|
||||
}
|
||||
return ancestors;
|
||||
|
||||
// Batch-fetch referenced projects and goals
|
||||
const projectIds = [...new Set(raw.map(a => a.projectId).filter((id): id is string => id != null))];
|
||||
const goalIds = [...new Set(raw.map(a => a.goalId).filter((id): id is string => id != null))];
|
||||
|
||||
const projectMap = new Map<string, { id: string; name: string; description: string | null; status: string; goalId: string | null }>();
|
||||
const goalMap = new Map<string, { id: string; title: string; description: string | null; level: string; status: string }>();
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
const rows = await db.select({
|
||||
id: projects.id, name: projects.name, description: projects.description,
|
||||
status: projects.status, goalId: projects.goalId,
|
||||
}).from(projects).where(inArray(projects.id, projectIds));
|
||||
for (const r of rows) {
|
||||
projectMap.set(r.id, r);
|
||||
// Also collect goalIds from projects
|
||||
if (r.goalId && !goalIds.includes(r.goalId)) goalIds.push(r.goalId);
|
||||
}
|
||||
}
|
||||
|
||||
if (goalIds.length > 0) {
|
||||
const rows = await db.select({
|
||||
id: goals.id, title: goals.title, description: goals.description,
|
||||
level: goals.level, status: goals.status,
|
||||
}).from(goals).where(inArray(goals.id, goalIds));
|
||||
for (const r of rows) goalMap.set(r.id, r);
|
||||
}
|
||||
|
||||
return raw.map(a => ({
|
||||
...a,
|
||||
project: a.projectId ? projectMap.get(a.projectId) ?? null : null,
|
||||
goal: a.goalId ? goalMap.get(a.goalId) ?? null : null,
|
||||
}));
|
||||
},
|
||||
|
||||
staleCount: async (companyId: string, minutes = 60) => {
|
||||
|
||||
35
server/src/storage/index.ts
Normal file
35
server/src/storage/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { loadConfig, type Config } from "../config.js";
|
||||
import { createStorageProviderFromConfig } from "./provider-registry.js";
|
||||
import { createStorageService } from "./service.js";
|
||||
import type { StorageService } from "./types.js";
|
||||
|
||||
let cachedStorageService: StorageService | null = null;
|
||||
let cachedSignature: string | null = null;
|
||||
|
||||
function signatureForConfig(config: Config): string {
|
||||
return JSON.stringify({
|
||||
provider: config.storageProvider,
|
||||
localDisk: config.storageLocalDiskBaseDir,
|
||||
s3Bucket: config.storageS3Bucket,
|
||||
s3Region: config.storageS3Region,
|
||||
s3Endpoint: config.storageS3Endpoint,
|
||||
s3Prefix: config.storageS3Prefix,
|
||||
s3ForcePathStyle: config.storageS3ForcePathStyle,
|
||||
});
|
||||
}
|
||||
|
||||
export function createStorageServiceFromConfig(config: Config): StorageService {
|
||||
return createStorageService(createStorageProviderFromConfig(config));
|
||||
}
|
||||
|
||||
export function getStorageService(): StorageService {
|
||||
const config = loadConfig();
|
||||
const signature = signatureForConfig(config);
|
||||
if (!cachedStorageService || cachedSignature !== signature) {
|
||||
cachedStorageService = createStorageServiceFromConfig(config);
|
||||
cachedSignature = signature;
|
||||
}
|
||||
return cachedStorageService;
|
||||
}
|
||||
|
||||
export type { StorageService, PutFileResult } from "./types.js";
|
||||
89
server/src/storage/local-disk-provider.ts
Normal file
89
server/src/storage/local-disk-provider.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createReadStream, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { StorageProvider, GetObjectResult, HeadObjectResult } from "./types.js";
|
||||
import { notFound, badRequest } from "../errors.js";
|
||||
|
||||
function normalizeObjectKey(objectKey: string): string {
|
||||
const normalized = objectKey.replace(/\\/g, "/").trim();
|
||||
if (!normalized || normalized.startsWith("/")) {
|
||||
throw badRequest("Invalid object key");
|
||||
}
|
||||
|
||||
const parts = normalized.split("/").filter((part) => part.length > 0);
|
||||
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
|
||||
throw badRequest("Invalid object key");
|
||||
}
|
||||
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function resolveWithin(baseDir: string, objectKey: string): string {
|
||||
const normalizedKey = normalizeObjectKey(objectKey);
|
||||
const resolved = path.resolve(baseDir, normalizedKey);
|
||||
const base = path.resolve(baseDir);
|
||||
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
||||
throw badRequest("Invalid object key path");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
async function statOrNull(filePath: string) {
|
||||
try {
|
||||
return await fs.stat(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalDiskStorageProvider(baseDir: string): StorageProvider {
|
||||
const root = path.resolve(baseDir);
|
||||
|
||||
return {
|
||||
id: "local_disk",
|
||||
|
||||
async putObject(input) {
|
||||
const targetPath = resolveWithin(root, input.objectKey);
|
||||
const dir = path.dirname(targetPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const tempPath = `${targetPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
await fs.writeFile(tempPath, input.body);
|
||||
await fs.rename(tempPath, targetPath);
|
||||
},
|
||||
|
||||
async getObject(input): Promise<GetObjectResult> {
|
||||
const filePath = resolveWithin(root, input.objectKey);
|
||||
const stat = await statOrNull(filePath);
|
||||
if (!stat || !stat.isFile()) {
|
||||
throw notFound("Object not found");
|
||||
}
|
||||
return {
|
||||
stream: createReadStream(filePath),
|
||||
contentLength: stat.size,
|
||||
lastModified: stat.mtime,
|
||||
};
|
||||
},
|
||||
|
||||
async headObject(input): Promise<HeadObjectResult> {
|
||||
const filePath = resolveWithin(root, input.objectKey);
|
||||
const stat = await statOrNull(filePath);
|
||||
if (!stat || !stat.isFile()) {
|
||||
return { exists: false };
|
||||
}
|
||||
return {
|
||||
exists: true,
|
||||
contentLength: stat.size,
|
||||
lastModified: stat.mtime,
|
||||
};
|
||||
},
|
||||
|
||||
async deleteObject(input): Promise<void> {
|
||||
const filePath = resolveWithin(root, input.objectKey);
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
} catch {
|
||||
// idempotent delete
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
18
server/src/storage/provider-registry.ts
Normal file
18
server/src/storage/provider-registry.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Config } from "../config.js";
|
||||
import type { StorageProvider } from "./types.js";
|
||||
import { createLocalDiskStorageProvider } from "./local-disk-provider.js";
|
||||
import { createS3StorageProvider } from "./s3-provider.js";
|
||||
|
||||
export function createStorageProviderFromConfig(config: Config): StorageProvider {
|
||||
if (config.storageProvider === "local_disk") {
|
||||
return createLocalDiskStorageProvider(config.storageLocalDiskBaseDir);
|
||||
}
|
||||
|
||||
return createS3StorageProvider({
|
||||
bucket: config.storageS3Bucket,
|
||||
region: config.storageS3Region,
|
||||
endpoint: config.storageS3Endpoint,
|
||||
prefix: config.storageS3Prefix,
|
||||
forcePathStyle: config.storageS3ForcePathStyle,
|
||||
});
|
||||
}
|
||||
145
server/src/storage/s3-provider.ts
Normal file
145
server/src/storage/s3-provider.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
S3Client,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { Readable } from "node:stream";
|
||||
import type { StorageProvider, GetObjectResult, HeadObjectResult } from "./types.js";
|
||||
import { notFound, unprocessable } from "../errors.js";
|
||||
|
||||
interface S3ProviderConfig {
|
||||
bucket: string;
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
prefix?: string;
|
||||
forcePathStyle?: boolean;
|
||||
}
|
||||
|
||||
function normalizePrefix(prefix: string | undefined): string {
|
||||
if (!prefix) return "";
|
||||
return prefix
|
||||
.trim()
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function buildKey(prefix: string, objectKey: string): string {
|
||||
if (!prefix) return objectKey;
|
||||
return `${prefix}/${objectKey}`;
|
||||
}
|
||||
|
||||
async function toReadableStream(body: unknown): Promise<Readable> {
|
||||
if (!body) throw notFound("Object not found");
|
||||
if (body instanceof Readable) return body;
|
||||
|
||||
const candidate = body as {
|
||||
transformToWebStream?: () => ReadableStream<Uint8Array>;
|
||||
arrayBuffer?: () => Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
if (typeof candidate.transformToWebStream === "function") {
|
||||
return Readable.fromWeb(candidate.transformToWebStream() as globalThis.ReadableStream<any>);
|
||||
}
|
||||
|
||||
if (typeof candidate.arrayBuffer === "function") {
|
||||
const buffer = Buffer.from(await candidate.arrayBuffer());
|
||||
return Readable.from(buffer);
|
||||
}
|
||||
|
||||
throw unprocessable("Unsupported S3 body stream type");
|
||||
}
|
||||
|
||||
function toDate(value: Date | undefined): Date | undefined {
|
||||
return value instanceof Date ? value : undefined;
|
||||
}
|
||||
|
||||
export function createS3StorageProvider(config: S3ProviderConfig): StorageProvider {
|
||||
const bucket = config.bucket.trim();
|
||||
const region = config.region.trim();
|
||||
if (!bucket) throw unprocessable("S3 storage bucket is required");
|
||||
if (!region) throw unprocessable("S3 storage region is required");
|
||||
|
||||
const prefix = normalizePrefix(config.prefix);
|
||||
const client = new S3Client({
|
||||
region,
|
||||
endpoint: config.endpoint,
|
||||
forcePathStyle: Boolean(config.forcePathStyle),
|
||||
});
|
||||
|
||||
return {
|
||||
id: "s3",
|
||||
|
||||
async putObject(input) {
|
||||
const key = buildKey(prefix, input.objectKey);
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
Body: input.body,
|
||||
ContentType: input.contentType,
|
||||
ContentLength: input.contentLength,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async getObject(input): Promise<GetObjectResult> {
|
||||
const key = buildKey(prefix, input.objectKey);
|
||||
try {
|
||||
const output = await client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
stream: await toReadableStream(output.Body),
|
||||
contentType: output.ContentType,
|
||||
contentLength: output.ContentLength,
|
||||
etag: output.ETag,
|
||||
lastModified: toDate(output.LastModified),
|
||||
};
|
||||
} catch (err) {
|
||||
const code = (err as { name?: string }).name;
|
||||
if (code === "NoSuchKey" || code === "NotFound") throw notFound("Object not found");
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async headObject(input): Promise<HeadObjectResult> {
|
||||
const key = buildKey(prefix, input.objectKey);
|
||||
try {
|
||||
const output = await client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
contentType: output.ContentType,
|
||||
contentLength: output.ContentLength,
|
||||
etag: output.ETag,
|
||||
lastModified: toDate(output.LastModified),
|
||||
};
|
||||
} catch (err) {
|
||||
const code = (err as { name?: string }).name;
|
||||
if (code === "NoSuchKey" || code === "NotFound") return { exists: false };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteObject(input): Promise<void> {
|
||||
const key = buildKey(prefix, input.objectKey);
|
||||
await client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
131
server/src/storage/service.ts
Normal file
131
server/src/storage/service.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { StorageService, StorageProvider, PutFileInput, PutFileResult } from "./types.js";
|
||||
import { badRequest, forbidden, unprocessable } from "../errors.js";
|
||||
|
||||
const MAX_SEGMENT_LENGTH = 120;
|
||||
|
||||
function sanitizeSegment(value: string): string {
|
||||
const cleaned = value
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
||||
.replace(/_{2,}/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
if (!cleaned) return "file";
|
||||
return cleaned.slice(0, MAX_SEGMENT_LENGTH);
|
||||
}
|
||||
|
||||
function normalizeNamespace(namespace: string): string {
|
||||
const normalized = namespace
|
||||
.split("/")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0)
|
||||
.map((entry) => sanitizeSegment(entry));
|
||||
if (normalized.length === 0) return "misc";
|
||||
return normalized.join("/");
|
||||
}
|
||||
|
||||
function splitFilename(filename: string | null): { stem: string; ext: string } {
|
||||
if (!filename) return { stem: "file", ext: "" };
|
||||
const base = path.basename(filename).trim();
|
||||
if (!base) return { stem: "file", ext: "" };
|
||||
|
||||
const extRaw = path.extname(base);
|
||||
const stemRaw = extRaw ? base.slice(0, base.length - extRaw.length) : base;
|
||||
const stem = sanitizeSegment(stemRaw);
|
||||
const ext = extRaw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9.]/g, "")
|
||||
.slice(0, 16);
|
||||
return {
|
||||
stem,
|
||||
ext,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureCompanyPrefix(companyId: string, objectKey: string): void {
|
||||
const expectedPrefix = `${companyId}/`;
|
||||
if (!objectKey.startsWith(expectedPrefix)) {
|
||||
throw forbidden("Object does not belong to company");
|
||||
}
|
||||
if (objectKey.includes("..")) {
|
||||
throw badRequest("Invalid object key");
|
||||
}
|
||||
}
|
||||
|
||||
function hashBuffer(input: Buffer): string {
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
function buildObjectKey(companyId: string, namespace: string, originalFilename: string | null): string {
|
||||
const ns = normalizeNamespace(namespace);
|
||||
const now = new Date();
|
||||
const year = String(now.getUTCFullYear());
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getUTCDate()).padStart(2, "0");
|
||||
const { stem, ext } = splitFilename(originalFilename);
|
||||
const suffix = randomUUID();
|
||||
const filename = `${suffix}-${stem}${ext}`;
|
||||
return `${companyId}/${ns}/${year}/${month}/${day}/${filename}`;
|
||||
}
|
||||
|
||||
function assertPutFileInput(input: PutFileInput): void {
|
||||
if (!input.companyId || input.companyId.trim().length === 0) {
|
||||
throw unprocessable("companyId is required");
|
||||
}
|
||||
if (!input.namespace || input.namespace.trim().length === 0) {
|
||||
throw unprocessable("namespace is required");
|
||||
}
|
||||
if (!input.contentType || input.contentType.trim().length === 0) {
|
||||
throw unprocessable("contentType is required");
|
||||
}
|
||||
if (!(input.body instanceof Buffer)) {
|
||||
throw unprocessable("body must be a Buffer");
|
||||
}
|
||||
if (input.body.length <= 0) {
|
||||
throw unprocessable("File is empty");
|
||||
}
|
||||
}
|
||||
|
||||
export function createStorageService(provider: StorageProvider): StorageService {
|
||||
return {
|
||||
provider: provider.id,
|
||||
|
||||
async putFile(input: PutFileInput): Promise<PutFileResult> {
|
||||
assertPutFileInput(input);
|
||||
const objectKey = buildObjectKey(input.companyId, input.namespace, input.originalFilename);
|
||||
const byteSize = input.body.length;
|
||||
const contentType = input.contentType.trim().toLowerCase();
|
||||
await provider.putObject({
|
||||
objectKey,
|
||||
body: input.body,
|
||||
contentType,
|
||||
contentLength: byteSize,
|
||||
});
|
||||
|
||||
return {
|
||||
provider: provider.id,
|
||||
objectKey,
|
||||
contentType,
|
||||
byteSize,
|
||||
sha256: hashBuffer(input.body),
|
||||
originalFilename: input.originalFilename,
|
||||
};
|
||||
},
|
||||
|
||||
async getObject(companyId: string, objectKey: string) {
|
||||
ensureCompanyPrefix(companyId, objectKey);
|
||||
return provider.getObject({ objectKey });
|
||||
},
|
||||
|
||||
async headObject(companyId: string, objectKey: string) {
|
||||
ensureCompanyPrefix(companyId, objectKey);
|
||||
return provider.headObject({ objectKey });
|
||||
},
|
||||
|
||||
async deleteObject(companyId: string, objectKey: string) {
|
||||
ensureCompanyPrefix(companyId, objectKey);
|
||||
await provider.deleteObject({ objectKey });
|
||||
},
|
||||
};
|
||||
}
|
||||
62
server/src/storage/types.ts
Normal file
62
server/src/storage/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { StorageProvider as StorageProviderId } from "@paperclip/shared";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
export interface PutObjectInput {
|
||||
objectKey: string;
|
||||
body: Buffer;
|
||||
contentType: string;
|
||||
contentLength: number;
|
||||
}
|
||||
|
||||
export interface GetObjectInput {
|
||||
objectKey: string;
|
||||
}
|
||||
|
||||
export interface GetObjectResult {
|
||||
stream: Readable;
|
||||
contentType?: string;
|
||||
contentLength?: number;
|
||||
etag?: string;
|
||||
lastModified?: Date;
|
||||
}
|
||||
|
||||
export interface HeadObjectResult {
|
||||
exists: boolean;
|
||||
contentType?: string;
|
||||
contentLength?: number;
|
||||
etag?: string;
|
||||
lastModified?: Date;
|
||||
}
|
||||
|
||||
export interface StorageProvider {
|
||||
id: StorageProviderId;
|
||||
putObject(input: PutObjectInput): Promise<void>;
|
||||
getObject(input: GetObjectInput): Promise<GetObjectResult>;
|
||||
headObject(input: GetObjectInput): Promise<HeadObjectResult>;
|
||||
deleteObject(input: GetObjectInput): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PutFileInput {
|
||||
companyId: string;
|
||||
namespace: string;
|
||||
originalFilename: string | null;
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
}
|
||||
|
||||
export interface PutFileResult {
|
||||
provider: StorageProviderId;
|
||||
objectKey: string;
|
||||
contentType: string;
|
||||
byteSize: number;
|
||||
sha256: string;
|
||||
originalFilename: string | null;
|
||||
}
|
||||
|
||||
export interface StorageService {
|
||||
provider: StorageProviderId;
|
||||
putFile(input: PutFileInput): Promise<PutFileResult>;
|
||||
getObject(companyId: string, objectKey: string): Promise<GetObjectResult>;
|
||||
headObject(companyId: string, objectKey: string): Promise<HeadObjectResult>;
|
||||
deleteObject(companyId: string, objectKey: string): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user