@vouchington/media 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/dist/cleanup.d.mts +12 -0
- package/dist/cleanup.mjs +31 -0
- package/dist/complete-upload.d.mts +26 -0
- package/dist/complete-upload.mjs +51 -0
- package/dist/create-upload.d.mts +17 -0
- package/dist/create-upload.mjs +17 -0
- package/dist/errors.d.mts +5 -0
- package/dist/errors.mjs +8 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +8 -0
- package/dist/metadata.d.mts +15 -0
- package/dist/metadata.mjs +41 -0
- package/dist/s3.d.mts +33 -0
- package/dist/s3.mjs +65 -0
- package/dist/streams.d.mts +3 -0
- package/dist/streams.mjs +23 -0
- package/dist/types.d.mts +21 -0
- package/dist/types.mjs +1 -0
- package/dist/validation.d.mts +6 -0
- package/dist/validation.mjs +25 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonathan Ong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @vouchington/media
|
|
2
|
+
|
|
3
|
+
Schema-less direct media upload orchestration for Node.js. Applications provide identifiers,
|
|
4
|
+
object keys, persistence, authorization, lifecycle policy, queues, metadata extraction, and
|
|
5
|
+
failure reporting. The optional `@vouchington/media/s3` entrypoint supplies an S3 adapter with an
|
|
6
|
+
injected client and bucket.
|
|
7
|
+
|
|
8
|
+
Duplicate reuse/rejection is orchestrated directly. Replacement is an injected atomic operation so
|
|
9
|
+
an application can make the incoming record durable before retiring valid existing media.
|
|
10
|
+
|
|
11
|
+
This first release handles caller-uploaded objects only. Remote URL ingestion, HTTP fetching,
|
|
12
|
+
image transformation, and video processing are deliberately outside its scope. Compose metadata
|
|
13
|
+
processing with `@vouchington/image-resize` when Sharp-backed inspection is needed.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createMediaUpload } from '@vouchington/media'
|
|
17
|
+
import { createS3MediaStorage } from '@vouchington/media/s3'
|
|
18
|
+
|
|
19
|
+
const storage = createS3MediaStorage({ client, bucket: process.env.UPLOAD_BUCKET! })
|
|
20
|
+
const upload = await createMediaUpload(
|
|
21
|
+
{ contentType: request.headers.get('content-type'), contentLength: 42 },
|
|
22
|
+
{
|
|
23
|
+
policy: { acceptsContentType: (type) => type.startsWith('image/'), maxBytes: 50_000_000 },
|
|
24
|
+
createId: crypto.randomUUID,
|
|
25
|
+
createObjectKey: (id) => `pending/${id}`,
|
|
26
|
+
expiresInSeconds: 3_600,
|
|
27
|
+
presignUpload: ({ key, contentType, expiresInSeconds }) =>
|
|
28
|
+
storage.presignUpload({ key, contentType, expiresInSeconds }),
|
|
29
|
+
savePending: saveUpload,
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
No bucket name, CDN URL, MIME allowlist, size limit, expiry, database schema, authorization rule,
|
|
35
|
+
moderation decision, or queue policy is built in.
|
|
36
|
+
|
|
37
|
+
The declared upload length is a request and signing input; it does not independently
|
|
38
|
+
prove the stored object's final size. Applications that require that guarantee should verify object
|
|
39
|
+
metadata during completion.
|
|
40
|
+
|
|
41
|
+
The S3 adapter signs uploads with `If-None-Match: *` so an accepted object key cannot be
|
|
42
|
+
overwritten by reusing its URL. Upload clients must send that header, and bucket CORS policy must
|
|
43
|
+
allow it.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface CleanupAbandonedMediaDependencies<Record> {
|
|
2
|
+
deleteObjects(records: readonly Record[]): Promise<void>;
|
|
3
|
+
deleteRecords(records: readonly Record[]): Promise<void>;
|
|
4
|
+
findAbandoned(): Promise<readonly Record[]>;
|
|
5
|
+
onDatabaseError?: (error: unknown, records: readonly Record[]) => Promise<void>;
|
|
6
|
+
onStorageError?: (error: unknown, records: readonly Record[]) => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export interface CleanupAbandonedMediaResult {
|
|
9
|
+
deletedRecords: number;
|
|
10
|
+
storageDeleted: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function cleanupAbandonedMedia<Record>(dependencies: CleanupAbandonedMediaDependencies<Record>): Promise<CleanupAbandonedMediaResult>;
|
package/dist/cleanup.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export async function cleanupAbandonedMedia(dependencies) {
|
|
2
|
+
const records = await dependencies.findAbandoned();
|
|
3
|
+
if (records.length === 0)
|
|
4
|
+
return { deletedRecords: 0, storageDeleted: true };
|
|
5
|
+
let storageDeleted = true;
|
|
6
|
+
try {
|
|
7
|
+
await dependencies.deleteObjects(records);
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
storageDeleted = false;
|
|
11
|
+
try {
|
|
12
|
+
await dependencies.onStorageError?.(error, records);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// Continue with database cleanup even when storage reporting fails.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
await dependencies.deleteRecords(records);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
try {
|
|
23
|
+
await dependencies.onDatabaseError?.(error, records);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Preserve the database error; reporting is best effort.
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
return { deletedRecords: records.length, storageDeleted };
|
|
31
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { MediaBody } from './types.mts';
|
|
2
|
+
export type DuplicateMediaDecision = 'reject' | 'replace' | 'reuse';
|
|
3
|
+
export type PersistDigestResult<Record> = {
|
|
4
|
+
kind: 'saved';
|
|
5
|
+
record: Record;
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'conflict';
|
|
8
|
+
record: Record;
|
|
9
|
+
};
|
|
10
|
+
export interface CompleteMediaUploadDependencies<Record> {
|
|
11
|
+
authorize(record: Record): boolean | Promise<boolean>;
|
|
12
|
+
canComplete(record: Record): boolean;
|
|
13
|
+
claim(id: string): Promise<Record | null>;
|
|
14
|
+
deleteMedia(record: Record): Promise<void>;
|
|
15
|
+
enqueueMetadata(record: Record): Promise<void>;
|
|
16
|
+
findByDigest(digest: string): Promise<Record | null>;
|
|
17
|
+
getObjectKey(record: Record): string;
|
|
18
|
+
load(id: string): Promise<Record | null>;
|
|
19
|
+
markFailed(record: Record, error: unknown): Promise<void>;
|
|
20
|
+
onDuplicate(existing: Record, incoming: Record): DuplicateMediaDecision;
|
|
21
|
+
persistDigest(id: string, digest: string): Promise<PersistDigestResult<Record>>;
|
|
22
|
+
readObject(key: string): Promise<MediaBody>;
|
|
23
|
+
/** Atomically makes the incoming record durable before retiring the existing media. */
|
|
24
|
+
replaceDuplicate(existing: Record, incoming: Record, digest: string): Promise<Record>;
|
|
25
|
+
}
|
|
26
|
+
export declare function completeMediaUpload<Record>(id: string, dependencies: CompleteMediaUploadDependencies<Record>): Promise<Record>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { MediaError } from './errors.mjs';
|
|
2
|
+
import { hashMediaBody } from './streams.mjs';
|
|
3
|
+
export async function completeMediaUpload(id, dependencies) {
|
|
4
|
+
const loaded = await dependencies.load(id);
|
|
5
|
+
if (loaded === null)
|
|
6
|
+
throw new MediaError('MEDIA_NOT_FOUND', 'The media upload was not found');
|
|
7
|
+
if (!(await dependencies.authorize(loaded))) {
|
|
8
|
+
throw new MediaError('UNAUTHORIZED', 'The media upload is not authorized');
|
|
9
|
+
}
|
|
10
|
+
if (!dependencies.canComplete(loaded)) {
|
|
11
|
+
throw new MediaError('INVALID_STATE', 'The media upload cannot be completed in its state');
|
|
12
|
+
}
|
|
13
|
+
const incoming = await dependencies.claim(id);
|
|
14
|
+
if (incoming === null)
|
|
15
|
+
throw new MediaError('INVALID_STATE', 'The media upload was already claimed');
|
|
16
|
+
try {
|
|
17
|
+
const body = await dependencies.readObject(dependencies.getObjectKey(incoming));
|
|
18
|
+
const digest = await hashMediaBody(body);
|
|
19
|
+
const duplicate = await dependencies.findByDigest(digest);
|
|
20
|
+
if (duplicate !== null) {
|
|
21
|
+
return await resolveDuplicate(duplicate, incoming, digest, dependencies);
|
|
22
|
+
}
|
|
23
|
+
const result = await dependencies.persistDigest(id, digest);
|
|
24
|
+
if (result.kind === 'conflict') {
|
|
25
|
+
return await resolveDuplicate(result.record, incoming, digest, dependencies);
|
|
26
|
+
}
|
|
27
|
+
await dependencies.enqueueMetadata(result.record);
|
|
28
|
+
return result.record;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
try {
|
|
32
|
+
await dependencies.markFailed(incoming, error);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Preserve the workflow error; failure reporting is best effort.
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function resolveDuplicate(existing, incoming, digest, dependencies) {
|
|
41
|
+
const decision = dependencies.onDuplicate(existing, incoming);
|
|
42
|
+
if (decision === 'replace') {
|
|
43
|
+
const replacement = await dependencies.replaceDuplicate(existing, incoming, digest);
|
|
44
|
+
await dependencies.enqueueMetadata(replacement);
|
|
45
|
+
return replacement;
|
|
46
|
+
}
|
|
47
|
+
await dependencies.deleteMedia(incoming);
|
|
48
|
+
if (decision === 'reuse')
|
|
49
|
+
return existing;
|
|
50
|
+
throw new MediaError('DUPLICATE_MEDIA', 'The media duplicates an existing object');
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CreatedMediaUpload, MediaUploadPolicy, PendingMediaUpload } from './types.mts';
|
|
2
|
+
import { type MediaUploadInput } from './validation.mts';
|
|
3
|
+
export interface CreateMediaUploadDependencies<Record> {
|
|
4
|
+
createId(): string;
|
|
5
|
+
createObjectKey(id: string): string;
|
|
6
|
+
expiresInSeconds: number;
|
|
7
|
+
now?: () => Date;
|
|
8
|
+
policy: MediaUploadPolicy;
|
|
9
|
+
presignUpload(input: {
|
|
10
|
+
contentLength: number;
|
|
11
|
+
contentType: string;
|
|
12
|
+
expiresInSeconds: number;
|
|
13
|
+
key: string;
|
|
14
|
+
}): Promise<string>;
|
|
15
|
+
savePending(upload: PendingMediaUpload): Promise<Record>;
|
|
16
|
+
}
|
|
17
|
+
export declare function createMediaUpload<Record>(input: MediaUploadInput, dependencies: CreateMediaUploadDependencies<Record>): Promise<CreatedMediaUpload<Record>>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { MediaError } from './errors.mjs';
|
|
2
|
+
import { validateMediaUpload } from './validation.mjs';
|
|
3
|
+
export async function createMediaUpload(input, dependencies) {
|
|
4
|
+
const validated = validateMediaUpload(input, dependencies.policy);
|
|
5
|
+
const id = dependencies.createId();
|
|
6
|
+
const key = dependencies.createObjectKey(id);
|
|
7
|
+
const { expiresInSeconds } = dependencies;
|
|
8
|
+
if (!Number.isSafeInteger(expiresInSeconds) || expiresInSeconds <= 0) {
|
|
9
|
+
throw new MediaError('EXPIRY_INVALID', 'The upload expiry must be a positive safe integer');
|
|
10
|
+
}
|
|
11
|
+
const now = dependencies.now?.() ?? new Date();
|
|
12
|
+
const pending = { ...validated, id, key };
|
|
13
|
+
const uploadUrl = await dependencies.presignUpload({ ...pending, expiresInSeconds });
|
|
14
|
+
const record = await dependencies.savePending(pending);
|
|
15
|
+
const expiresAt = new Date(now.getTime() + expiresInSeconds * 1_000);
|
|
16
|
+
return { ...pending, expiresAt, record, uploadUrl };
|
|
17
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type MediaErrorCode = 'CONTENT_LENGTH_INVALID' | 'CONTENT_TYPE_INVALID' | 'DUPLICATE_MEDIA' | 'EXPIRY_INVALID' | 'INVALID_STATE' | 'MEDIA_NOT_FOUND' | 'POLICY_INVALID' | 'UNAUTHORIZED';
|
|
2
|
+
export declare class MediaError extends Error {
|
|
3
|
+
readonly code: MediaErrorCode;
|
|
4
|
+
constructor(code: MediaErrorCode, message: string, options?: ErrorOptions);
|
|
5
|
+
}
|
package/dist/errors.mjs
ADDED
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './cleanup.mts';
|
|
2
|
+
export * from './complete-upload.mts';
|
|
3
|
+
export * from './create-upload.mts';
|
|
4
|
+
export * from './errors.mts';
|
|
5
|
+
export * from './metadata.mts';
|
|
6
|
+
export * from './streams.mts';
|
|
7
|
+
export * from './types.mts';
|
|
8
|
+
export * from './validation.mts';
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './cleanup.mjs';
|
|
2
|
+
export * from './complete-upload.mjs';
|
|
3
|
+
export * from './create-upload.mjs';
|
|
4
|
+
export * from './errors.mjs';
|
|
5
|
+
export * from './metadata.mjs';
|
|
6
|
+
export * from './streams.mjs';
|
|
7
|
+
export * from './types.mjs';
|
|
8
|
+
export * from './validation.mjs';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MediaBody } from './types.mts';
|
|
2
|
+
export interface ProcessMediaMetadataDependencies<Record, Metadata> {
|
|
3
|
+
canProcess(record: Record): boolean;
|
|
4
|
+
extractMetadata(path: string): Promise<Metadata>;
|
|
5
|
+
finalize(record: Record, metadata: Metadata): Promise<Record | null>;
|
|
6
|
+
getObjectKey(record: Record): string;
|
|
7
|
+
load(id: string): Promise<Record | null>;
|
|
8
|
+
markFailed(record: Record, error: unknown): Promise<void>;
|
|
9
|
+
onFailed?: (record: Record, error: unknown) => Promise<void>;
|
|
10
|
+
onFinalized?: (record: Record) => Promise<void>;
|
|
11
|
+
onPostFinalizeError?: (error: unknown, record: Record) => Promise<void>;
|
|
12
|
+
readObject(key: string): Promise<MediaBody>;
|
|
13
|
+
validateMetadata?: (metadata: Metadata, record: Record) => void | Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export declare function processMediaMetadata<Record, Metadata>(id: string, dependencies: ProcessMediaMetadataDependencies<Record, Metadata>): Promise<Record | null>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { withTemporaryMediaFile } from './streams.mjs';
|
|
2
|
+
export async function processMediaMetadata(id, dependencies) {
|
|
3
|
+
const record = await dependencies.load(id);
|
|
4
|
+
if (record === null)
|
|
5
|
+
return null;
|
|
6
|
+
if (!dependencies.canProcess(record))
|
|
7
|
+
return null;
|
|
8
|
+
try {
|
|
9
|
+
const body = await dependencies.readObject(dependencies.getObjectKey(record));
|
|
10
|
+
const metadata = await withTemporaryMediaFile(body, (path) => dependencies.extractMetadata(path));
|
|
11
|
+
await dependencies.validateMetadata?.(metadata, record);
|
|
12
|
+
const finalized = await dependencies.finalize(record, metadata);
|
|
13
|
+
if (finalized === null)
|
|
14
|
+
return null;
|
|
15
|
+
await runPostFinalize(finalized, dependencies);
|
|
16
|
+
return finalized;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
try {
|
|
20
|
+
await dependencies.markFailed(record, error);
|
|
21
|
+
await dependencies.onFailed?.(record, error);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Preserve the processing error; failure reporting is best effort.
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function runPostFinalize(record, dependencies) {
|
|
30
|
+
try {
|
|
31
|
+
await dependencies.onFinalized?.(record);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
try {
|
|
35
|
+
await dependencies.onPostFinalizeError?.(error, record);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Completion is durable; post-finalize reporting must not undo it.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/s3.d.mts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { PutObjectCommand, type S3Client } from '@aws-sdk/client-s3';
|
|
2
|
+
import type { MediaBody } from './types.mts';
|
|
3
|
+
export interface S3MediaStorageOptions {
|
|
4
|
+
bucket: string;
|
|
5
|
+
client: S3Client;
|
|
6
|
+
sign?: (client: S3Client, command: PutObjectCommand, options: {
|
|
7
|
+
expiresIn: number;
|
|
8
|
+
}) => Promise<string>;
|
|
9
|
+
}
|
|
10
|
+
export interface S3MediaStorage {
|
|
11
|
+
deleteObject(key: string): Promise<void>;
|
|
12
|
+
deleteObjects(keys: readonly string[]): Promise<void>;
|
|
13
|
+
getObject(key: string): Promise<MediaBody>;
|
|
14
|
+
presignUpload(input: {
|
|
15
|
+
contentLength?: number;
|
|
16
|
+
contentType: string;
|
|
17
|
+
expiresInSeconds: number;
|
|
18
|
+
key: string;
|
|
19
|
+
}): Promise<string>;
|
|
20
|
+
}
|
|
21
|
+
export type S3MediaDeleteFailure = {
|
|
22
|
+
cause: unknown;
|
|
23
|
+
keys: readonly string[];
|
|
24
|
+
} | {
|
|
25
|
+
code?: string;
|
|
26
|
+
key?: string;
|
|
27
|
+
message?: string;
|
|
28
|
+
};
|
|
29
|
+
export declare class S3MediaDeleteError extends Error {
|
|
30
|
+
readonly failures: readonly S3MediaDeleteFailure[];
|
|
31
|
+
constructor(failures: readonly S3MediaDeleteFailure[]);
|
|
32
|
+
}
|
|
33
|
+
export declare function createS3MediaStorage(options: S3MediaStorageOptions): S3MediaStorage;
|
package/dist/s3.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { DeleteObjectCommand, DeleteObjectsCommand, GetObjectCommand, PutObjectCommand, } from '@aws-sdk/client-s3';
|
|
2
|
+
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
3
|
+
export class S3MediaDeleteError extends Error {
|
|
4
|
+
failures;
|
|
5
|
+
constructor(failures) {
|
|
6
|
+
super(`S3 failed to delete ${failures.length} media object(s)`);
|
|
7
|
+
this.name = 'S3MediaDeleteError';
|
|
8
|
+
this.failures = failures;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function createS3MediaStorage(options) {
|
|
12
|
+
const sign = options.sign ?? ((client, command, signOptions) => getSignedUrl(client, command, signOptions));
|
|
13
|
+
return {
|
|
14
|
+
async presignUpload(input) {
|
|
15
|
+
const command = new PutObjectCommand({
|
|
16
|
+
Bucket: options.bucket,
|
|
17
|
+
Key: input.key,
|
|
18
|
+
ContentLength: input.contentLength,
|
|
19
|
+
ContentType: input.contentType,
|
|
20
|
+
IfNoneMatch: '*',
|
|
21
|
+
});
|
|
22
|
+
return sign(options.client, command, { expiresIn: input.expiresInSeconds });
|
|
23
|
+
},
|
|
24
|
+
async getObject(key) {
|
|
25
|
+
const output = await options.client.send(new GetObjectCommand({ Bucket: options.bucket, Key: key }));
|
|
26
|
+
const body = output.Body;
|
|
27
|
+
const iterator = typeof body === 'object' && body !== null
|
|
28
|
+
? body[Symbol.asyncIterator]
|
|
29
|
+
: undefined;
|
|
30
|
+
if (typeof iterator !== 'function') {
|
|
31
|
+
throw new TypeError('S3 returned an unreadable media body');
|
|
32
|
+
}
|
|
33
|
+
return body;
|
|
34
|
+
},
|
|
35
|
+
async deleteObject(key) {
|
|
36
|
+
await options.client.send(new DeleteObjectCommand({ Bucket: options.bucket, Key: key }));
|
|
37
|
+
},
|
|
38
|
+
async deleteObjects(keys) {
|
|
39
|
+
const failures = [];
|
|
40
|
+
for (let index = 0; index < keys.length; index += 1_000) {
|
|
41
|
+
const batch = keys.slice(index, index + 1_000);
|
|
42
|
+
let output;
|
|
43
|
+
try {
|
|
44
|
+
output = await options.client.send(new DeleteObjectsCommand({
|
|
45
|
+
Bucket: options.bucket,
|
|
46
|
+
Delete: { Objects: batch.map((Key) => ({ Key })), Quiet: true },
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
failures.push({ cause, keys: batch });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
for (const failure of output.Errors ?? []) {
|
|
54
|
+
failures.push({
|
|
55
|
+
...(failure.Code === undefined ? {} : { code: failure.Code }),
|
|
56
|
+
...(failure.Key === undefined ? {} : { key: failure.Key }),
|
|
57
|
+
...(failure.Message === undefined ? {} : { message: failure.Message }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (failures.length > 0)
|
|
62
|
+
throw new S3MediaDeleteError(failures);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { MediaBody } from './types.mts';
|
|
2
|
+
export declare function hashMediaBody(body: MediaBody, algorithm?: string): Promise<string>;
|
|
3
|
+
export declare function withTemporaryMediaFile<Result>(body: MediaBody, useFile: (path: string) => Promise<Result>): Promise<Result>;
|
package/dist/streams.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createWriteStream } from 'node:fs';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { pipeline } from 'node:stream/promises';
|
|
7
|
+
export async function hashMediaBody(body, algorithm = 'sha256') {
|
|
8
|
+
const hash = createHash(algorithm);
|
|
9
|
+
for await (const chunk of body)
|
|
10
|
+
hash.update(chunk);
|
|
11
|
+
return hash.digest('hex');
|
|
12
|
+
}
|
|
13
|
+
export async function withTemporaryMediaFile(body, useFile) {
|
|
14
|
+
const directory = await mkdtemp(join(tmpdir(), 'vouchington-media-'));
|
|
15
|
+
const path = join(directory, 'media');
|
|
16
|
+
try {
|
|
17
|
+
await pipeline(body, createWriteStream(path, { flags: 'wx' }));
|
|
18
|
+
return await useFile(path);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
await rm(directory, { force: true, recursive: true });
|
|
22
|
+
}
|
|
23
|
+
}
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface MediaUploadPolicy {
|
|
2
|
+
acceptsContentType(contentType: string): boolean;
|
|
3
|
+
maxBytes: number;
|
|
4
|
+
minBytes?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ValidatedMediaUpload {
|
|
7
|
+
contentLength: number;
|
|
8
|
+
contentType: string;
|
|
9
|
+
}
|
|
10
|
+
export type MediaBody = AsyncIterable<Uint8Array>;
|
|
11
|
+
export interface PendingMediaUpload {
|
|
12
|
+
contentLength: number;
|
|
13
|
+
contentType: string;
|
|
14
|
+
id: string;
|
|
15
|
+
key: string;
|
|
16
|
+
}
|
|
17
|
+
export interface CreatedMediaUpload<Record> extends PendingMediaUpload {
|
|
18
|
+
expiresAt: Date;
|
|
19
|
+
record: Record;
|
|
20
|
+
uploadUrl: string;
|
|
21
|
+
}
|
package/dist/types.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { MediaUploadPolicy, ValidatedMediaUpload } from './types.mts';
|
|
2
|
+
export interface MediaUploadInput {
|
|
3
|
+
contentLength: number | null | undefined;
|
|
4
|
+
contentType: string | null | undefined;
|
|
5
|
+
}
|
|
6
|
+
export declare function validateMediaUpload(input: MediaUploadInput, policy: MediaUploadPolicy): ValidatedMediaUpload;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { MediaError } from './errors.mjs';
|
|
2
|
+
export function validateMediaUpload(input, policy) {
|
|
3
|
+
const contentType = input.contentType?.split(';', 1)[0]?.trim().toLowerCase();
|
|
4
|
+
if (contentType === undefined ||
|
|
5
|
+
!/^[a-z0-9!#$%&'*+.^_`|~-]+\/[a-z0-9!#$%&'*+.^_`|~-]+$/.test(contentType) ||
|
|
6
|
+
!policy.acceptsContentType(contentType)) {
|
|
7
|
+
throw new MediaError('CONTENT_TYPE_INVALID', 'The media content type is not accepted');
|
|
8
|
+
}
|
|
9
|
+
const contentLength = input.contentLength;
|
|
10
|
+
const minimum = policy.minBytes ?? 1;
|
|
11
|
+
if (!Number.isSafeInteger(minimum) ||
|
|
12
|
+
minimum < 0 ||
|
|
13
|
+
!Number.isSafeInteger(policy.maxBytes) ||
|
|
14
|
+
policy.maxBytes < minimum) {
|
|
15
|
+
throw new MediaError('POLICY_INVALID', 'Media size policy bounds must be safe integers');
|
|
16
|
+
}
|
|
17
|
+
if (contentLength === null ||
|
|
18
|
+
contentLength === undefined ||
|
|
19
|
+
!Number.isSafeInteger(contentLength) ||
|
|
20
|
+
contentLength < minimum ||
|
|
21
|
+
contentLength > policy.maxBytes) {
|
|
22
|
+
throw new MediaError('CONTENT_LENGTH_INVALID', 'The media content length is not accepted');
|
|
23
|
+
}
|
|
24
|
+
return { contentLength, contentType };
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vouchington/media",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Schema-less direct media upload orchestration and S3 storage primitives.",
|
|
5
|
+
"homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/media#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/vouchington/vouchington-platform/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Jonathan Ong",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/vouchington/vouchington-platform.git",
|
|
14
|
+
"directory": "packages/media"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "./dist/index.mjs",
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"import": "./dist/index.mjs",
|
|
28
|
+
"default": "./dist/index.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./s3": {
|
|
31
|
+
"types": "./dist/s3.d.mts",
|
|
32
|
+
"import": "./dist/s3.mjs",
|
|
33
|
+
"default": "./dist/s3.mjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc --project tsconfig.build.json",
|
|
41
|
+
"prepack": "pnpm run build"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@aws-sdk/client-s3": "^3.1121.0",
|
|
45
|
+
"@aws-sdk/s3-request-presigner": "^3.1121.0"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=24.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|