diff --git a/package-lock.json b/package-lock.json index 01e7261..7664f42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16733,11 +16733,132 @@ "version": "0.0.1", "license": "MIT", "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/client-s3": "^3.859.0", "@aws-sdk/lib-storage": "^3.859.0", "@aws-sdk/s3-request-presigner": "^3.864.0", + "@smithy/signature-v4": "^4.2.4", "dotenv": "^17.2.1" } + }, + "packages/storage/node_modules/@smithy/is-array-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-3.0.0.tgz", + "integrity": "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/storage/node_modules/@smithy/protocol-http": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-4.1.8.tgz", + "integrity": "sha512-hmgIAVyxw1LySOwkgMIUN0kjN8TG9Nc85LJeEmEE/cNEe2rkHDUWhnJf2gxcSRFLWsyqWsrZGw40ROjUogg+Iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/storage/node_modules/@smithy/signature-v4": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-4.2.4.tgz", + "integrity": "sha512-5JWeMQYg81TgU4cG+OexAWdvDTs5JDdbEZx+Qr1iPbvo91QFGzjy0IkXAKaXUHqmKUJgSHK0ZxnCkgZpzkeNTA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^3.0.0", + "@smithy/protocol-http": "^4.1.8", + "@smithy/types": "^3.7.2", + "@smithy/util-hex-encoding": "^3.0.0", + "@smithy/util-middleware": "^3.0.11", + "@smithy/util-uri-escape": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/storage/node_modules/@smithy/types": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-3.7.2.tgz", + "integrity": "sha512-bNwBYYmN8Eh9RyjS1p2gW6MIhSO2rl7X9QeLM8iTdcGRP+eDiIWDt66c9IysCc22gefKszZv+ubV9qZc7hdESg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/storage/node_modules/@smithy/util-buffer-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-3.0.0.tgz", + "integrity": "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/storage/node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/storage/node_modules/@smithy/util-middleware": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-3.0.11.tgz", + "integrity": "sha512-dWpyc1e1R6VoXrwLoLDd57U1z6CwNSdkM69Ie4+6uYh2GC7Vg51Qtan7ITzczuVpqezdDTKJGJB95fFvvjU/ow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/storage/node_modules/@smithy/util-uri-escape": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-3.0.0.tgz", + "integrity": "sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "packages/storage/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } } } } diff --git a/packages/storage/package.json b/packages/storage/package.json index 65fa8b3..fbed68a 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -63,9 +63,11 @@ }, "license": "MIT", "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/client-s3": "^3.859.0", "@aws-sdk/lib-storage": "^3.859.0", "@aws-sdk/s3-request-presigner": "^3.864.0", + "@smithy/signature-v4": "^4.2.4", "dotenv": "^17.2.1" } } diff --git a/packages/storage/src/lib/bucket/update.ts b/packages/storage/src/lib/bucket/update.ts new file mode 100644 index 0000000..5ccd704 --- /dev/null +++ b/packages/storage/src/lib/bucket/update.ts @@ -0,0 +1,118 @@ +import { TigrisHeaders } from '@shared/headers'; +import { createStorageClient } from '../http-client'; +import type { TigrisStorageConfig, TigrisStorageResponse } from '../types'; + +export type UpdateBucketOptions = { + // access and sharing settings + access?: 'public' | 'private'; + allowObjectAcl?: boolean; + disableDirectoryListing?: boolean; + + // storage settings + // consistency?: 'strict' | 'default'; + regions?: string | string[]; + cacheControl?: string; + + // data management settings + // TODO: Data Migration, TTL Config, Objects Lifecycle + + // custom domain + customDomain?: string; + + // cors settings + // TODO: Additional Headers, CORS Rules + + // notification settings + // TODO: enableNotifications?: boolean; + + // deletion settings + enableDeleteProtection?: boolean; + + config?: Omit; +}; + +export type UpdateBucketResponse = { + bucket: string; + updated: boolean; +}; + +export async function updateBucket( + bucketName: string, + options?: UpdateBucketOptions +): Promise> { + const { data: client, error } = createStorageClient(options?.config); + + if (error || !client) { + return { error }; + } + + const body: Record = {}; + const headers: Record = {}; + + // access and sharing settings + if (options?.access !== undefined) { + headers[TigrisHeaders.ACL] = + options.access === 'public' ? 'public-read' : 'private'; + } + + if (options?.allowObjectAcl !== undefined) { + body.acl_settings = { allow_object_acl: options.allowObjectAcl }; + } + + if (options?.disableDirectoryListing !== undefined) { + headers[TigrisHeaders.ACL_LIST_OBJECTS] = + options.disableDirectoryListing === true ? 'false' : 'true'; + } + + // storage settings + /*if (options?.consistency !== undefined) { + body.consistent = options.consistency === 'strict' ? true : false; + }*/ + + if (options?.regions !== undefined) { + body.object_regions = Array.isArray(options.regions) + ? options.regions.join(',') + : options.regions; + } + + if (options?.cacheControl !== undefined) { + body.cache_control = options.cacheControl; + } + + // custom domain + if (options?.customDomain !== undefined) { + body.website = { domain_name: options.customDomain }; + } + + // deletion settings + if (options?.enableDeleteProtection !== undefined) { + body.protection = { protected: options.enableDeleteProtection }; + } + + const response = await client.request< + Record, + { status: 'success' | 'error'; message?: string } + >({ + method: 'PATCH', + path: `/${bucketName}`, + body, + headers, + }); + + if (response.error) { + return { error: response.error }; + } + + if (response.data.status === 'error') { + return { + error: new Error(response.data.message ?? 'Failed to update bucket'), + }; + } + + return { + data: { + bucket: bucketName, + updated: true, + }, + }; +} diff --git a/packages/storage/src/lib/http-client.ts b/packages/storage/src/lib/http-client.ts new file mode 100644 index 0000000..98e9f0c --- /dev/null +++ b/packages/storage/src/lib/http-client.ts @@ -0,0 +1,36 @@ +import { createTigrisHttpClient, type TigrisHttpClient } from '@shared/index'; +import { config } from './config'; +import type { TigrisStorageConfig, TigrisStorageResponse } from './types'; + +function getStorageEndpoint(options?: TigrisStorageConfig): string { + return options?.endpoint ?? config.endpoint ?? 'https://t3.storage.dev'; +} + +export function createStorageClient( + options?: TigrisStorageConfig +): TigrisStorageResponse { + const sessionToken = options?.sessionToken ?? config.sessionToken; + const organizationId = options?.organizationId ?? config.organizationId; + const accessKeyId = options?.accessKeyId ?? config.accessKeyId; + const secretAccessKey = options?.secretAccessKey ?? config.secretAccessKey; + + // Allow either session token, authorization, or credentials + const hasCredentials = accessKeyId && secretAccessKey; + if (!sessionToken && !hasCredentials) { + return { + error: new Error('Session token or credentials are required'), + }; + } + + if (sessionToken && (!organizationId || organizationId === '')) { + return { error: new Error('Organization ID is required') }; + } + + return createTigrisHttpClient({ + baseUrl: getStorageEndpoint(options), + sessionToken, + organizationId, + accessKeyId, + secretAccessKey, + }); +} diff --git a/packages/storage/src/server.ts b/packages/storage/src/server.ts index 3974e87..e27edb1 100644 --- a/packages/storage/src/server.ts +++ b/packages/storage/src/server.ts @@ -25,6 +25,11 @@ export { type ListBucketSnapshotsOptions, type ListBucketSnapshotsResponse, } from './lib/bucket/snapshot'; +export { + updateBucket, + type UpdateBucketOptions, + type UpdateBucketResponse, +} from './lib/bucket/update'; export { get, type GetOptions, type GetResponse } from './lib/object/get'; export { head, type HeadOptions, type HeadResponse } from './lib/object/head'; export { diff --git a/shared/headers.ts b/shared/headers.ts index d92d39c..70945a9 100644 --- a/shared/headers.ts +++ b/shared/headers.ts @@ -1,4 +1,7 @@ export enum TigrisHeaders { + ACL = 'X-Amz-Acl', + ACL_LIST_OBJECTS = 'X-Amz-Acl-Public-List-Objects-Enabled', + AUTHORIZATION = 'authorization', SESSION_TOKEN = 'x-amz-security-token', NAMESPACE = 'X-Tigris-Namespace', STORAGE_CLASS = 'X-Amz-Storage-Class', diff --git a/shared/http-client.ts b/shared/http-client.ts index 0979479..3d77d76 100644 --- a/shared/http-client.ts +++ b/shared/http-client.ts @@ -1,3 +1,5 @@ +import { SignatureV4 } from '@smithy/signature-v4'; +import { Sha256 } from '@aws-crypto/sha256-js'; import { TigrisHeaders } from './headers'; import type { TigrisResponse } from './types'; @@ -35,16 +37,74 @@ export interface CreateHttpClientOptions { baseUrl: string; sessionToken?: string; organizationId?: string; + accessKeyId?: string; + secretAccessKey?: string; } const cachedHttpClients = new Map(); +/** + * Generate AWS Signature V4 headers for a request + */ +async function generateSignatureHeaders( + method: string, + url: URL, + headers: Record, + body: string | undefined, + accessKeyId: string, + secretAccessKey: string +): Promise> { + const signer = new SignatureV4({ + credentials: { + accessKeyId, + secretAccessKey, + }, + region: 'auto', + service: 's3', + sha256: Sha256, + }); + + const request = { + method, + protocol: url.protocol, + hostname: url.hostname, + port: url.port ? parseInt(url.port) : undefined, + path: url.pathname + url.search, + headers: { + ...headers, + host: url.host, + }, + body, + }; + + const signedRequest = await signer.sign(request); + return signedRequest.headers as Record; +} + export function createTigrisHttpClient( options: CreateHttpClientOptions ): TigrisResponse { - const { baseUrl, sessionToken, organizationId } = options; + const { + baseUrl, + sessionToken, + organizationId, + accessKeyId, + secretAccessKey, + } = options; + + let key = `${baseUrl}`; + + if (organizationId) { + key = `${key}-${organizationId}`; + } - const key = `${baseUrl}-${sessionToken}-${organizationId}`; + if (sessionToken) { + key = `${key}-${sessionToken}`; + } + + if (accessKeyId) { + key = `${key}-${accessKeyId}`; + } const cachedClient = cachedHttpClients.get(key); if (cachedClient !== undefined) { @@ -63,17 +123,37 @@ export function createTigrisHttpClient( }); } - const headers: Record = { + let headers: Record = { 'Content-Type': 'application/json', ...req.headers, }; - if (organizationId) { - headers[TigrisHeaders.NAMESPACE] = organizationId; + // Prepare body for signing + let bodyString: string | undefined; + if (req.body && req.method !== 'GET' && req.method !== 'HEAD') { + bodyString = JSON.stringify(req.body); } - if (sessionToken) { - headers[TigrisHeaders.SESSION_TOKEN] = sessionToken; + // Use credentials-based auth with signature if available + if (accessKeyId && secretAccessKey && !sessionToken) { + const signedHeaders = await generateSignatureHeaders( + req.method, + url, + headers, + bodyString, + accessKeyId, + secretAccessKey + ); + headers = signedHeaders; + } else { + // Use session token or pre-generated authorization + if (sessionToken) { + headers[TigrisHeaders.SESSION_TOKEN] = sessionToken; + } + + if (organizationId) { + headers[TigrisHeaders.NAMESPACE] = organizationId; + } } const fetchOptions: RequestInit = { @@ -81,8 +161,8 @@ export function createTigrisHttpClient( headers, }; - if (req.body && req.method !== 'GET' && req.method !== 'HEAD') { - fetchOptions.body = JSON.stringify(req.body); + if (bodyString) { + fetchOptions.body = bodyString; } const response = await fetch(url.toString(), fetchOptions);