@sourcepress/media 0.1.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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-test.log +16 -0
- package/dist/__tests__/git-storage.test.d.ts +2 -0
- package/dist/__tests__/git-storage.test.d.ts.map +1 -0
- package/dist/__tests__/git-storage.test.js +137 -0
- package/dist/__tests__/git-storage.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.js +90 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/validation.test.d.ts +2 -0
- package/dist/__tests__/validation.test.d.ts.map +1 -0
- package/dist/__tests__/validation.test.js +49 -0
- package/dist/__tests__/validation.test.js.map +1 -0
- package/dist/git-storage.d.ts +20 -0
- package/dist/git-storage.d.ts.map +1 -0
- package/dist/git-storage.js +87 -0
- package/dist/git-storage.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/interface.d.ts +34 -0
- package/dist/interface.d.ts.map +1 -0
- package/dist/interface.js +2 -0
- package/dist/interface.js.map +1 -0
- package/dist/registry.d.ts +42 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +88 -0
- package/dist/registry.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +12 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +51 -0
- package/dist/validation.js.map +1 -0
- package/package.json +26 -0
- package/src/__tests__/git-storage.test.ts +160 -0
- package/src/__tests__/registry.test.ts +116 -0
- package/src/__tests__/validation.test.ts +58 -0
- package/src/git-storage.ts +109 -0
- package/src/index.ts +4 -0
- package/src/interface.ts +42 -0
- package/src/registry.ts +105 -0
- package/src/types.ts +1 -0
- package/src/validation.ts +70 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +7 -0
package/src/interface.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { MediaRef, MediaUploadInput } from "@sourcepress/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pluggable media storage interface.
|
|
5
|
+
* Implementations: GitMediaStorage (default), R2MediaStorage, S3MediaStorage (future).
|
|
6
|
+
*/
|
|
7
|
+
export interface MediaStorage {
|
|
8
|
+
/**
|
|
9
|
+
* Upload a file and register it in the media registry.
|
|
10
|
+
* Returns the MediaRef with hash and metadata.
|
|
11
|
+
*/
|
|
12
|
+
upload(input: MediaUploadInput): Promise<MediaRef>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get file contents by path.
|
|
16
|
+
* Returns null if the file does not exist.
|
|
17
|
+
*/
|
|
18
|
+
get(path: string): Promise<Buffer | null>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Delete a file and remove it from the registry.
|
|
22
|
+
*/
|
|
23
|
+
delete(path: string): Promise<void>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* List all media entries, optionally filtered by path prefix.
|
|
27
|
+
*/
|
|
28
|
+
list(prefix?: string): Promise<MediaRef[]>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get metadata for a single file from the registry.
|
|
32
|
+
*/
|
|
33
|
+
getMeta(path: string): Promise<MediaRef | null>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Update metadata for an existing file (e.g., alt-text).
|
|
37
|
+
*/
|
|
38
|
+
updateMeta(
|
|
39
|
+
path: string,
|
|
40
|
+
updates: Partial<Pick<MediaRef, "alt" | "source" | "generated_by" | "prompt">>,
|
|
41
|
+
): Promise<MediaRef>;
|
|
42
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { MediaRef, MediaRegistry } from "@sourcepress/core";
|
|
2
|
+
import type { GitHubClient } from "@sourcepress/github";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* MediaRegistryManager reads/writes the media.json file in Git.
|
|
6
|
+
* This is the source of truth for all media metadata.
|
|
7
|
+
*/
|
|
8
|
+
export class MediaRegistryManager {
|
|
9
|
+
private github: GitHubClient;
|
|
10
|
+
private registryPath: string;
|
|
11
|
+
private cache: MediaRegistry | null = null;
|
|
12
|
+
private registrySha: string | null = null;
|
|
13
|
+
|
|
14
|
+
constructor(github: GitHubClient, registryPath: string) {
|
|
15
|
+
this.github = github;
|
|
16
|
+
this.registryPath = registryPath;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load the full registry from Git.
|
|
21
|
+
*/
|
|
22
|
+
async load(): Promise<MediaRegistry> {
|
|
23
|
+
if (this.cache) return this.cache;
|
|
24
|
+
|
|
25
|
+
const file = await this.github.getFile(this.registryPath);
|
|
26
|
+
if (!file) {
|
|
27
|
+
this.cache = {};
|
|
28
|
+
this.registrySha = null;
|
|
29
|
+
return this.cache;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.registrySha = file.sha;
|
|
33
|
+
try {
|
|
34
|
+
this.cache = JSON.parse(file.content) as MediaRegistry;
|
|
35
|
+
} catch {
|
|
36
|
+
console.error("Failed to parse media registry JSON, falling back to empty registry");
|
|
37
|
+
this.cache = {};
|
|
38
|
+
}
|
|
39
|
+
return this.cache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Save the registry back to Git.
|
|
44
|
+
*/
|
|
45
|
+
async save(message: string): Promise<void> {
|
|
46
|
+
const content = JSON.stringify(this.cache ?? {}, null, 2);
|
|
47
|
+
const result = await this.github.createOrUpdateFile(
|
|
48
|
+
this.registryPath,
|
|
49
|
+
content,
|
|
50
|
+
message,
|
|
51
|
+
undefined,
|
|
52
|
+
this.registrySha ?? undefined,
|
|
53
|
+
);
|
|
54
|
+
this.registrySha = result.sha;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Add or update an entry in the registry.
|
|
59
|
+
*/
|
|
60
|
+
async set(path: string, ref: MediaRef): Promise<void> {
|
|
61
|
+
await this.load();
|
|
62
|
+
if (!this.cache) this.cache = {};
|
|
63
|
+
this.cache[path] = ref;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get a single entry.
|
|
68
|
+
*/
|
|
69
|
+
async get(path: string): Promise<MediaRef | null> {
|
|
70
|
+
const registry = await this.load();
|
|
71
|
+
return registry[path] ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Remove an entry from the registry.
|
|
76
|
+
*/
|
|
77
|
+
async remove(path: string): Promise<void> {
|
|
78
|
+
await this.load();
|
|
79
|
+
if (this.cache) {
|
|
80
|
+
delete this.cache[path];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* List entries, optionally filtered by prefix.
|
|
86
|
+
*/
|
|
87
|
+
async list(prefix?: string): Promise<MediaRef[]> {
|
|
88
|
+
const registry = await this.load();
|
|
89
|
+
const entries = Object.entries(registry);
|
|
90
|
+
|
|
91
|
+
if (prefix) {
|
|
92
|
+
return entries.filter(([key]) => key.startsWith(prefix)).map(([, ref]) => ref);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return entries.map(([, ref]) => ref);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Invalidate the in-memory cache.
|
|
100
|
+
*/
|
|
101
|
+
invalidate(): void {
|
|
102
|
+
this.cache = null;
|
|
103
|
+
this.registrySha = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { MediaRef, MediaRegistry, MediaUploadInput, MediaConfig } from "@sourcepress/core";
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import type { MediaConfig } from "@sourcepress/core";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_ALLOWED_TYPES = [
|
|
5
|
+
"image/jpeg",
|
|
6
|
+
"image/png",
|
|
7
|
+
"image/webp",
|
|
8
|
+
"image/gif",
|
|
9
|
+
"image/svg+xml",
|
|
10
|
+
"application/pdf",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MAX_SIZE_MB = 10;
|
|
14
|
+
|
|
15
|
+
export interface ValidationResult {
|
|
16
|
+
valid: boolean;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function validateUpload(
|
|
21
|
+
contentType: string,
|
|
22
|
+
sizeBytes: number,
|
|
23
|
+
config?: Partial<MediaConfig>,
|
|
24
|
+
): ValidationResult {
|
|
25
|
+
const allowedTypes = config?.allowedTypes ?? DEFAULT_ALLOWED_TYPES;
|
|
26
|
+
const maxSizeMb = config?.maxSizeMb ?? DEFAULT_MAX_SIZE_MB;
|
|
27
|
+
|
|
28
|
+
if (!allowedTypes.includes(contentType)) {
|
|
29
|
+
return {
|
|
30
|
+
valid: false,
|
|
31
|
+
error: `Content type "${contentType}" is not allowed. Allowed: ${allowedTypes.join(", ")}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const maxBytes = maxSizeMb * 1024 * 1024;
|
|
36
|
+
if (sizeBytes > maxBytes) {
|
|
37
|
+
return {
|
|
38
|
+
valid: false,
|
|
39
|
+
error: `File size ${(sizeBytes / 1024 / 1024).toFixed(1)}MB exceeds maximum ${maxSizeMb}MB`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { valid: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sanitize a media path — no traversal, lowercase, forward slashes.
|
|
48
|
+
* Iterates until stable, then verifies no traversal sequences remain.
|
|
49
|
+
*/
|
|
50
|
+
export function sanitizePath(path: string): string {
|
|
51
|
+
let current = path.replace(/\\/g, "/").replace(/^\//, "").toLowerCase();
|
|
52
|
+
|
|
53
|
+
// Iteratively remove traversal sequences until stable
|
|
54
|
+
let prev: string;
|
|
55
|
+
do {
|
|
56
|
+
prev = current;
|
|
57
|
+
current = current
|
|
58
|
+
.replace(/\.{2,}/g, "")
|
|
59
|
+
.replace(/\/+/g, "/")
|
|
60
|
+
.replace(/^\//, "");
|
|
61
|
+
} while (current !== prev);
|
|
62
|
+
|
|
63
|
+
// Final check via posix.normalize
|
|
64
|
+
const normalized = posix.normalize(current);
|
|
65
|
+
if (normalized.includes("..") || normalized.startsWith("/")) {
|
|
66
|
+
throw new Error(`Path traversal detected in: ${path}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return normalized;
|
|
70
|
+
}
|
package/tsconfig.json
ADDED