Files
edgeever/apps/api/src/object-storage.ts
T
tianma-if a853c92350 fix(docker): persist credential encryption secrets on the data volume
NAS GUI upgrades that drop container environment variables could leave
login working while AI and object-storage credentials could no longer be
encrypted. Persist the encryption root on /data, generate one when an
existing instance has no secret, and stop the add-service form from
looking fillable when encryption is still unavailable.

Fixes #370
2026-09-09 18:11:58 +08:00

193 lines
7.0 KiB
TypeScript

import type { ObjectStorageSettings } from "@edgeever/shared";
import { AppError } from "./app-error";
import { decryptSecret, encryptSecret } from "./secret-encryption";
import type { BlobStoreAdapter, DatabaseAdapter } from "./storage-contract";
import { createWorkerS3BlobStore, type WorkerS3Config } from "./worker-s3-blob-store";
export const BUILTIN_STORAGE_CONFIG_ID = "builtin";
export const S3_STORAGE_CONFIG_ID = "instance-s3";
const resolveCredentialEncryptionKey = (value: string | undefined) => {
const key = value?.trim();
return key || undefined;
};
const deriveObjectStorageCredentialKey = (value: string | undefined) => value
? `edgeever:object-storage:v1:${value}`
: undefined;
export type ObjectStorageCredentialEnvironment = {
EDGE_EVER_AUTH_PASSWORD?: string;
EDGE_EVER_AUTH_PASSWORD_HASH?: string;
EDGE_EVER_AUTH_PASSWORD_FALLBACK?: string;
/** Legacy decryption fallback for credentials saved before auth-derived keys. */
EDGE_EVER_STORAGE_ENCRYPTION_KEY?: string;
};
const resolveAuthDerivedObjectStorageEncryptionKeys = (
environment: ObjectStorageCredentialEnvironment,
) => [
deriveObjectStorageCredentialKey(resolveCredentialEncryptionKey(environment.EDGE_EVER_AUTH_PASSWORD)),
deriveObjectStorageCredentialKey(resolveCredentialEncryptionKey(environment.EDGE_EVER_AUTH_PASSWORD_HASH)),
].filter(Boolean) as string[];
export const resolveObjectStorageEncryptionKeys = (
environment: ObjectStorageCredentialEnvironment,
) => [
...resolveAuthDerivedObjectStorageEncryptionKeys(environment),
deriveObjectStorageCredentialKey(resolveCredentialEncryptionKey(environment.EDGE_EVER_AUTH_PASSWORD_FALLBACK)),
resolveCredentialEncryptionKey(environment.EDGE_EVER_STORAGE_ENCRYPTION_KEY),
].filter(Boolean) as string[];
export const resolvePrimaryObjectStorageEncryptionKey = (
environment: ObjectStorageCredentialEnvironment,
) => resolveAuthDerivedObjectStorageEncryptionKeys(environment)[0];
export type ObjectStorageConfigRow = {
id: string;
provider: "builtin" | "s3";
display_name: string;
endpoint: string | null;
region: string | null;
bucket: string | null;
access_key_id: string | null;
secret_access_key_encrypted: string | null;
force_path_style: number;
object_prefix: string;
is_active: number;
};
type ObjectStorageEnvironment = {
storage: { db: DatabaseAdapter; resources: BlobStoreAdapter };
EDGE_EVER_AUTH_PASSWORD?: string;
EDGE_EVER_AUTH_PASSWORD_HASH?: string;
EDGE_EVER_AUTH_PASSWORD_FALLBACK?: string;
EDGE_EVER_STORAGE_ENCRYPTION_KEY?: string;
EDGE_EVER_R2_BUCKET_NAME?: string;
};
const selectConfigSql = `SELECT id, provider, display_name, endpoint, region, bucket, access_key_id,
secret_access_key_encrypted, force_path_style, object_prefix, is_active FROM object_storage_configs`;
export const getActiveObjectStorageConfig = (db: DatabaseAdapter) =>
db.prepare(`${selectConfigSql} WHERE is_active = 1 LIMIT 1`).first<ObjectStorageConfigRow>();
export const getObjectStorageConfig = (db: DatabaseAdapter, id: string) =>
db.prepare(`${selectConfigSql} WHERE id = ?`).bind(id).first<ObjectStorageConfigRow>();
export const mapObjectStorageSettings = (
row: ObjectStorageConfigRow,
encryptionConfigured: boolean,
): ObjectStorageSettings => ({
provider: row.provider,
displayName: row.display_name,
endpoint: row.endpoint,
region: row.region,
bucket: row.bucket,
accessKeyId: row.access_key_id,
hasSecretAccessKey: Boolean(row.secret_access_key_encrypted),
forcePathStyle: Boolean(row.force_path_style),
objectPrefix: row.object_prefix,
encryptionConfigured,
});
const decryptObjectStorageCredential = async (
encryptedValue: string,
environment: ObjectStorageCredentialEnvironment,
) => {
const keys = resolveObjectStorageEncryptionKeys(environment);
for (const [index, key] of keys.entries()) {
try {
return { value: await decryptSecret(encryptedValue, key), needsMigration: index > 0 };
} catch {
// Try the next key so credentials encrypted by the legacy OSS key remain usable.
}
}
throw new AppError(
"object_storage_unavailable",
"The external object storage credential cannot be decrypted. Restore the instance authentication secret.",
503,
);
};
export const resolveStoredObjectStorageSecret = async (
db: DatabaseAdapter,
row: ObjectStorageConfigRow,
environment: ObjectStorageCredentialEnvironment,
) => {
if (!row.secret_access_key_encrypted) {
throw new AppError("object_storage_unavailable", "The external object storage credential is missing.", 503);
}
const resolved = await decryptObjectStorageCredential(row.secret_access_key_encrypted, environment);
const primaryKey = resolvePrimaryObjectStorageEncryptionKey(environment);
if (resolved.needsMigration && primaryKey) {
const migratedSecret = await encryptSecret(resolved.value, primaryKey);
await db.prepare(
`UPDATE object_storage_configs SET secret_access_key_encrypted = ? WHERE id = ?`,
).bind(migratedSecret, row.id).run();
row.secret_access_key_encrypted = migratedSecret;
}
return resolved.value;
};
const toWorkerS3Config = async (
db: DatabaseAdapter,
row: ObjectStorageConfigRow,
environment: ObjectStorageCredentialEnvironment,
): Promise<WorkerS3Config> => {
if (!row.endpoint || !row.region || !row.bucket || !row.access_key_id) {
throw new AppError("object_storage_unavailable", "The external object storage configuration is incomplete.", 503);
}
return {
endpoint: row.endpoint,
region: row.region,
bucket: row.bucket,
accessKeyId: row.access_key_id,
secretAccessKey: await resolveStoredObjectStorageSecret(db, row, environment),
forcePathStyle: Boolean(row.force_path_style),
objectPrefix: row.object_prefix,
};
};
export const resolveObjectStorage = async (env: ObjectStorageEnvironment, configId?: string | null) => {
const row = configId
? await getObjectStorageConfig(env.storage.db, configId)
: await getActiveObjectStorageConfig(env.storage.db);
if (!row && configId) {
throw new AppError("object_storage_unavailable", "The resource's object storage configuration no longer exists.", 503);
}
if (!row || row.provider === "builtin") {
return {
configId: BUILTIN_STORAGE_CONFIG_ID,
bucketName: env.EDGE_EVER_R2_BUCKET_NAME?.trim() || "edgeever-resources",
store: env.storage.resources,
};
}
return {
configId: row.id,
bucketName: row.bucket ?? "external-object-storage",
store: createWorkerS3BlobStore(await toWorkerS3Config(env.storage.db, row, env)),
};
};
export const deleteStoredObjects = async (
env: ObjectStorageEnvironment,
resources: Array<{ storage_config_id?: string | null; object_key: string }>,
) => {
const groups = new Map<string, string[]>();
for (const resource of resources) {
const id = resource.storage_config_id || BUILTIN_STORAGE_CONFIG_ID;
groups.set(id, [...(groups.get(id) ?? []), resource.object_key]);
}
for (const [configId, objectKeys] of groups) {
const { store } = await resolveObjectStorage(env, configId);
await store.delete(objectKeys);
}
};