@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.
Files changed (50) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/.turbo/turbo-test.log +16 -0
  3. package/dist/__tests__/git-storage.test.d.ts +2 -0
  4. package/dist/__tests__/git-storage.test.d.ts.map +1 -0
  5. package/dist/__tests__/git-storage.test.js +137 -0
  6. package/dist/__tests__/git-storage.test.js.map +1 -0
  7. package/dist/__tests__/registry.test.d.ts +2 -0
  8. package/dist/__tests__/registry.test.d.ts.map +1 -0
  9. package/dist/__tests__/registry.test.js +90 -0
  10. package/dist/__tests__/registry.test.js.map +1 -0
  11. package/dist/__tests__/validation.test.d.ts +2 -0
  12. package/dist/__tests__/validation.test.d.ts.map +1 -0
  13. package/dist/__tests__/validation.test.js +49 -0
  14. package/dist/__tests__/validation.test.js.map +1 -0
  15. package/dist/git-storage.d.ts +20 -0
  16. package/dist/git-storage.d.ts.map +1 -0
  17. package/dist/git-storage.js +87 -0
  18. package/dist/git-storage.js.map +1 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +4 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/interface.d.ts +34 -0
  24. package/dist/interface.d.ts.map +1 -0
  25. package/dist/interface.js +2 -0
  26. package/dist/interface.js.map +1 -0
  27. package/dist/registry.d.ts +42 -0
  28. package/dist/registry.d.ts.map +1 -0
  29. package/dist/registry.js +88 -0
  30. package/dist/registry.js.map +1 -0
  31. package/dist/types.d.ts +2 -0
  32. package/dist/types.d.ts.map +1 -0
  33. package/dist/types.js +2 -0
  34. package/dist/types.js.map +1 -0
  35. package/dist/validation.d.ts +12 -0
  36. package/dist/validation.d.ts.map +1 -0
  37. package/dist/validation.js +51 -0
  38. package/dist/validation.js.map +1 -0
  39. package/package.json +26 -0
  40. package/src/__tests__/git-storage.test.ts +160 -0
  41. package/src/__tests__/registry.test.ts +116 -0
  42. package/src/__tests__/validation.test.ts +58 -0
  43. package/src/git-storage.ts +109 -0
  44. package/src/index.ts +4 -0
  45. package/src/interface.ts +42 -0
  46. package/src/registry.ts +105 -0
  47. package/src/types.ts +1 -0
  48. package/src/validation.ts +70 -0
  49. package/tsconfig.json +8 -0
  50. package/vitest.config.ts +7 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * MediaRegistryManager reads/writes the media.json file in Git.
3
+ * This is the source of truth for all media metadata.
4
+ */
5
+ export class MediaRegistryManager {
6
+ github;
7
+ registryPath;
8
+ cache = null;
9
+ registrySha = null;
10
+ constructor(github, registryPath) {
11
+ this.github = github;
12
+ this.registryPath = registryPath;
13
+ }
14
+ /**
15
+ * Load the full registry from Git.
16
+ */
17
+ async load() {
18
+ if (this.cache)
19
+ return this.cache;
20
+ const file = await this.github.getFile(this.registryPath);
21
+ if (!file) {
22
+ this.cache = {};
23
+ this.registrySha = null;
24
+ return this.cache;
25
+ }
26
+ this.registrySha = file.sha;
27
+ try {
28
+ this.cache = JSON.parse(file.content);
29
+ }
30
+ catch {
31
+ console.error("Failed to parse media registry JSON, falling back to empty registry");
32
+ this.cache = {};
33
+ }
34
+ return this.cache;
35
+ }
36
+ /**
37
+ * Save the registry back to Git.
38
+ */
39
+ async save(message) {
40
+ const content = JSON.stringify(this.cache ?? {}, null, 2);
41
+ const result = await this.github.createOrUpdateFile(this.registryPath, content, message, undefined, this.registrySha ?? undefined);
42
+ this.registrySha = result.sha;
43
+ }
44
+ /**
45
+ * Add or update an entry in the registry.
46
+ */
47
+ async set(path, ref) {
48
+ await this.load();
49
+ if (!this.cache)
50
+ this.cache = {};
51
+ this.cache[path] = ref;
52
+ }
53
+ /**
54
+ * Get a single entry.
55
+ */
56
+ async get(path) {
57
+ const registry = await this.load();
58
+ return registry[path] ?? null;
59
+ }
60
+ /**
61
+ * Remove an entry from the registry.
62
+ */
63
+ async remove(path) {
64
+ await this.load();
65
+ if (this.cache) {
66
+ delete this.cache[path];
67
+ }
68
+ }
69
+ /**
70
+ * List entries, optionally filtered by prefix.
71
+ */
72
+ async list(prefix) {
73
+ const registry = await this.load();
74
+ const entries = Object.entries(registry);
75
+ if (prefix) {
76
+ return entries.filter(([key]) => key.startsWith(prefix)).map(([, ref]) => ref);
77
+ }
78
+ return entries.map(([, ref]) => ref);
79
+ }
80
+ /**
81
+ * Invalidate the in-memory cache.
82
+ */
83
+ invalidate() {
84
+ this.cache = null;
85
+ this.registrySha = null;
86
+ }
87
+ }
88
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export type { MediaRef, MediaRegistry, MediaUploadInput, MediaConfig } from "@sourcepress/core";
2
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,EAAE,aAAa,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,12 @@
1
+ import type { MediaConfig } from "@sourcepress/core";
2
+ export interface ValidationResult {
3
+ valid: boolean;
4
+ error?: string;
5
+ }
6
+ export declare function validateUpload(contentType: string, sizeBytes: number, config?: Partial<MediaConfig>): ValidationResult;
7
+ /**
8
+ * Sanitize a media path — no traversal, lowercase, forward slashes.
9
+ * Iterates until stable, then verifies no traversal sequences remain.
10
+ */
11
+ export declare function sanitizePath(path: string): string;
12
+ //# sourceMappingURL=validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.d.ts","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAarD,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,cAAc,CAC7B,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,GAC3B,gBAAgB,CAoBlB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAoBjD"}
@@ -0,0 +1,51 @@
1
+ import { posix } from "node:path";
2
+ const DEFAULT_ALLOWED_TYPES = [
3
+ "image/jpeg",
4
+ "image/png",
5
+ "image/webp",
6
+ "image/gif",
7
+ "image/svg+xml",
8
+ "application/pdf",
9
+ ];
10
+ const DEFAULT_MAX_SIZE_MB = 10;
11
+ export function validateUpload(contentType, sizeBytes, config) {
12
+ const allowedTypes = config?.allowedTypes ?? DEFAULT_ALLOWED_TYPES;
13
+ const maxSizeMb = config?.maxSizeMb ?? DEFAULT_MAX_SIZE_MB;
14
+ if (!allowedTypes.includes(contentType)) {
15
+ return {
16
+ valid: false,
17
+ error: `Content type "${contentType}" is not allowed. Allowed: ${allowedTypes.join(", ")}`,
18
+ };
19
+ }
20
+ const maxBytes = maxSizeMb * 1024 * 1024;
21
+ if (sizeBytes > maxBytes) {
22
+ return {
23
+ valid: false,
24
+ error: `File size ${(sizeBytes / 1024 / 1024).toFixed(1)}MB exceeds maximum ${maxSizeMb}MB`,
25
+ };
26
+ }
27
+ return { valid: true };
28
+ }
29
+ /**
30
+ * Sanitize a media path — no traversal, lowercase, forward slashes.
31
+ * Iterates until stable, then verifies no traversal sequences remain.
32
+ */
33
+ export function sanitizePath(path) {
34
+ let current = path.replace(/\\/g, "/").replace(/^\//, "").toLowerCase();
35
+ // Iteratively remove traversal sequences until stable
36
+ let prev;
37
+ do {
38
+ prev = current;
39
+ current = current
40
+ .replace(/\.{2,}/g, "")
41
+ .replace(/\/+/g, "/")
42
+ .replace(/^\//, "");
43
+ } while (current !== prev);
44
+ // Final check via posix.normalize
45
+ const normalized = posix.normalize(current);
46
+ if (normalized.includes("..") || normalized.startsWith("/")) {
47
+ throw new Error(`Path traversal detected in: ${path}`);
48
+ }
49
+ return normalized;
50
+ }
51
+ //# sourceMappingURL=validation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validation.js","sourceRoot":"","sources":["../src/validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,WAAW,CAAC;AAGlC,MAAM,qBAAqB,GAAG;IAC7B,YAAY;IACZ,WAAW;IACX,YAAY;IACZ,WAAW;IACX,eAAe;IACf,iBAAiB;CACjB,CAAC;AAEF,MAAM,mBAAmB,GAAG,EAAE,CAAC;AAO/B,MAAM,UAAU,cAAc,CAC7B,WAAmB,EACnB,SAAiB,EACjB,MAA6B;IAE7B,MAAM,YAAY,GAAG,MAAM,EAAE,YAAY,IAAI,qBAAqB,CAAC;IACnE,MAAM,SAAS,GAAG,MAAM,EAAE,SAAS,IAAI,mBAAmB,CAAC;IAE3D,IAAI,CAAC,YAAY,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC;QACzC,OAAO;YACN,KAAK,EAAE,KAAK;YACZ,KAAK,EAAE,iBAAiB,WAAW,8BAA8B,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE;SAC1F,CAAC;IACH,CAAC;IAED,MAAM,QAAQ,GAAG,SAAS,GAAG,IAAI,GAAG,IAAI,CAAC;IACzC,IAAI,SAAS,GAAG,QAAQ,EAAE,CAAC;QAC1B,OAAO;YACN,KAAK,EAAE,KAAK;YACZ,KAAK,EAAE,aAAa,CAAC,SAAS,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,sBAAsB,SAAS,IAAI;SAC3F,CAAC;IACH,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;AACxB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,IAAY;IACxC,IAAI,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,WAAW,EAAE,CAAC;IAExE,sDAAsD;IACtD,IAAI,IAAY,CAAC;IACjB,GAAG,CAAC;QACH,IAAI,GAAG,OAAO,CAAC;QACf,OAAO,GAAG,OAAO;aACf,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC;aACtB,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC;aACpB,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACtB,CAAC,QAAQ,OAAO,KAAK,IAAI,EAAE;IAE3B,kCAAkC;IAClC,MAAM,UAAU,GAAG,KAAK,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IAC5C,IAAI,UAAU,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC;QAC7D,MAAM,IAAI,KAAK,CAAC,+BAA+B,IAAI,EAAE,CAAC,CAAC;IACxD,CAAC;IAED,OAAO,UAAU,CAAC;AACnB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@sourcepress/media",
3
+ "version": "0.1.0",
4
+ "publishConfig": { "access": "public" },
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "import": "./dist/index.js"
10
+ }
11
+ },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "test": "vitest run",
15
+ "typecheck": "tsc --noEmit",
16
+ "clean": "rm -rf dist"
17
+ },
18
+ "dependencies": {
19
+ "@sourcepress/core": "workspace:*",
20
+ "@sourcepress/github": "workspace:*"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^5.7.0",
24
+ "vitest": "^3.0.0"
25
+ }
26
+ }
@@ -0,0 +1,160 @@
1
+ import type { MediaRef } from "@sourcepress/core";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { GitMediaStorage } from "../git-storage.js";
4
+
5
+ // Mock crypto.subtle for hash computation
6
+ vi.stubGlobal("crypto", {
7
+ subtle: {
8
+ digest: vi.fn().mockResolvedValue(new Uint8Array(32).buffer),
9
+ },
10
+ });
11
+
12
+ function createMockGitHub() {
13
+ return {
14
+ getFile: vi.fn().mockResolvedValue(null),
15
+ createOrUpdateFile: vi.fn().mockResolvedValue({ sha: "file-sha", commit_sha: "commit-sha" }),
16
+ owner: "test",
17
+ repo: "test",
18
+ branch: "main",
19
+ };
20
+ }
21
+
22
+ const defaultConfig = {
23
+ storage: "git" as const,
24
+ path: "assets",
25
+ registry: "media.json",
26
+ };
27
+
28
+ describe("GitMediaStorage", () => {
29
+ it("uploads a file and registers it", async () => {
30
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
31
+ const github = createMockGitHub() as any;
32
+ const storage = new GitMediaStorage(github, defaultConfig);
33
+
34
+ const result = await storage.upload({
35
+ file: Buffer.from("fake-image-data"),
36
+ path: "hero.webp",
37
+ content_type: "image/webp",
38
+ source: "uploaded",
39
+ uploaded_by: "test-user",
40
+ alt: "Hero image",
41
+ });
42
+
43
+ expect(result.path).toBe("assets/hero.webp");
44
+ expect(result.content_type).toBe("image/webp");
45
+ expect(result.source).toBe("uploaded");
46
+ expect(result.uploaded_by).toBe("test-user");
47
+ expect(result.alt).toBe("Hero image");
48
+
49
+ // Should have uploaded the file
50
+ expect(github.createOrUpdateFile).toHaveBeenCalledTimes(2); // file + registry
51
+ });
52
+
53
+ it("rejects disallowed content type", async () => {
54
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
55
+ const github = createMockGitHub() as any;
56
+ const storage = new GitMediaStorage(github, defaultConfig);
57
+
58
+ await expect(
59
+ storage.upload({
60
+ file: Buffer.from("data"),
61
+ path: "malware.exe",
62
+ content_type: "application/x-executable",
63
+ source: "uploaded",
64
+ uploaded_by: "test-user",
65
+ }),
66
+ ).rejects.toThrow("not allowed");
67
+ });
68
+
69
+ it("rejects file exceeding max size", async () => {
70
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
71
+ const github = createMockGitHub() as any;
72
+ const storage = new GitMediaStorage(github, {
73
+ ...defaultConfig,
74
+ maxSizeMb: 1,
75
+ });
76
+
77
+ const largeBuffer = Buffer.alloc(2 * 1024 * 1024); // 2MB
78
+ await expect(
79
+ storage.upload({
80
+ file: largeBuffer,
81
+ path: "large.png",
82
+ content_type: "image/png",
83
+ source: "uploaded",
84
+ uploaded_by: "test-user",
85
+ }),
86
+ ).rejects.toThrow("exceeds maximum");
87
+ });
88
+
89
+ it("sanitizes upload paths", async () => {
90
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
91
+ const github = createMockGitHub() as any;
92
+ const storage = new GitMediaStorage(github, defaultConfig);
93
+
94
+ const result = await storage.upload({
95
+ file: Buffer.from("data"),
96
+ path: "../../../HERO.WebP",
97
+ content_type: "image/webp",
98
+ source: "uploaded",
99
+ uploaded_by: "test-user",
100
+ });
101
+
102
+ expect(result.path).toBe("assets/hero.webp");
103
+ });
104
+
105
+ it("lists media entries", async () => {
106
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
107
+ const github = createMockGitHub() as any;
108
+ // Pre-populate registry via getFile mock
109
+ const registry = {
110
+ "assets/a.webp": {
111
+ path: "assets/a.webp",
112
+ content_type: "image/webp",
113
+ size_bytes: 1000,
114
+ hash: "aaa",
115
+ source: "uploaded",
116
+ uploaded_at: "2026-04-04",
117
+ uploaded_by: "test",
118
+ },
119
+ };
120
+ github.getFile.mockResolvedValueOnce({
121
+ path: "media.json",
122
+ sha: "reg-sha",
123
+ content: JSON.stringify(registry),
124
+ encoding: "utf-8",
125
+ });
126
+
127
+ const storage = new GitMediaStorage(github, defaultConfig);
128
+ const list = await storage.list();
129
+ expect(list).toHaveLength(1);
130
+ expect(list[0].path).toBe("assets/a.webp");
131
+ });
132
+
133
+ it("updates metadata for existing file", async () => {
134
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
135
+ const github = createMockGitHub() as any;
136
+ const existingRef: MediaRef = {
137
+ path: "assets/hero.webp",
138
+ content_type: "image/webp",
139
+ size_bytes: 5000,
140
+ hash: "abc",
141
+ source: "uploaded",
142
+ uploaded_at: "2026-04-04",
143
+ uploaded_by: "test",
144
+ };
145
+ github.getFile.mockResolvedValueOnce({
146
+ path: "media.json",
147
+ sha: "reg-sha",
148
+ content: JSON.stringify({ "assets/hero.webp": existingRef }),
149
+ encoding: "utf-8",
150
+ });
151
+
152
+ const storage = new GitMediaStorage(github, defaultConfig);
153
+ const updated = await storage.updateMeta("assets/hero.webp", {
154
+ alt: "Updated alt text",
155
+ });
156
+
157
+ expect(updated.alt).toBe("Updated alt text");
158
+ expect(updated.path).toBe("assets/hero.webp");
159
+ });
160
+ });
@@ -0,0 +1,116 @@
1
+ import type { MediaRef } from "@sourcepress/core";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { MediaRegistryManager } from "../registry.js";
4
+
5
+ // Mock GitHubClient
6
+ function createMockGitHub(existingRegistry?: Record<string, MediaRef>) {
7
+ const content = existingRegistry ? JSON.stringify(existingRegistry) : null;
8
+ return {
9
+ getFile: vi
10
+ .fn()
11
+ .mockResolvedValue(
12
+ content ? { path: "media.json", sha: "abc123", content, encoding: "utf-8" as const } : null,
13
+ ),
14
+ createOrUpdateFile: vi.fn().mockResolvedValue({ sha: "new-sha", commit_sha: "commit-sha" }),
15
+ owner: "test",
16
+ repo: "test",
17
+ branch: "main",
18
+ };
19
+ }
20
+
21
+ const sampleRef: MediaRef = {
22
+ path: "assets/hero.webp",
23
+ content_type: "image/webp",
24
+ size_bytes: 50000,
25
+ hash: "abc123def456",
26
+ source: "uploaded",
27
+ uploaded_at: "2026-04-04T10:00:00Z",
28
+ uploaded_by: "test-user",
29
+ };
30
+
31
+ describe("MediaRegistryManager", () => {
32
+ it("loads empty registry when file does not exist", async () => {
33
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
34
+ const github = createMockGitHub() as any;
35
+ const manager = new MediaRegistryManager(github, "media.json");
36
+
37
+ const registry = await manager.load();
38
+ expect(registry).toEqual({});
39
+ });
40
+
41
+ it("loads existing registry from Git", async () => {
42
+ const existing = { "assets/hero.webp": sampleRef };
43
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
44
+ const github = createMockGitHub(existing) as any;
45
+ const manager = new MediaRegistryManager(github, "media.json");
46
+
47
+ const registry = await manager.load();
48
+ expect(registry["assets/hero.webp"]).toEqual(sampleRef);
49
+ });
50
+
51
+ it("sets and retrieves an entry", async () => {
52
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
53
+ const github = createMockGitHub() as any;
54
+ const manager = new MediaRegistryManager(github, "media.json");
55
+
56
+ await manager.set("assets/new.png", sampleRef);
57
+ const result = await manager.get("assets/new.png");
58
+ expect(result).toEqual(sampleRef);
59
+ });
60
+
61
+ it("removes an entry", async () => {
62
+ const existing = { "assets/hero.webp": sampleRef };
63
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
64
+ const github = createMockGitHub(existing) as any;
65
+ const manager = new MediaRegistryManager(github, "media.json");
66
+
67
+ await manager.remove("assets/hero.webp");
68
+ const result = await manager.get("assets/hero.webp");
69
+ expect(result).toBeNull();
70
+ });
71
+
72
+ it("lists entries with prefix filter", async () => {
73
+ const existing = {
74
+ "assets/cases/hero.webp": { ...sampleRef, path: "assets/cases/hero.webp" },
75
+ "assets/team/anna.jpg": { ...sampleRef, path: "assets/team/anna.jpg" },
76
+ "assets/cases/detail.png": { ...sampleRef, path: "assets/cases/detail.png" },
77
+ };
78
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
79
+ const github = createMockGitHub(existing) as any;
80
+ const manager = new MediaRegistryManager(github, "media.json");
81
+
82
+ const casesOnly = await manager.list("assets/cases/");
83
+ expect(casesOnly).toHaveLength(2);
84
+ });
85
+
86
+ it("saves registry to Git", async () => {
87
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
88
+ const github = createMockGitHub() as any;
89
+ const manager = new MediaRegistryManager(github, "media.json");
90
+
91
+ await manager.set("assets/hero.webp", sampleRef);
92
+ await manager.save("media: add hero image");
93
+
94
+ expect(github.createOrUpdateFile).toHaveBeenCalledWith(
95
+ "media.json",
96
+ expect.stringContaining("assets/hero.webp"),
97
+ "media: add hero image",
98
+ undefined,
99
+ undefined,
100
+ );
101
+ });
102
+
103
+ it("invalidates cache", async () => {
104
+ const existing = { "assets/hero.webp": sampleRef };
105
+ // biome-ignore lint/suspicious/noExplicitAny: mock GitHubClient
106
+ const github = createMockGitHub(existing) as any;
107
+ const manager = new MediaRegistryManager(github, "media.json");
108
+
109
+ await manager.load();
110
+ manager.invalidate();
111
+
112
+ // Should call getFile again after invalidation
113
+ await manager.load();
114
+ expect(github.getFile).toHaveBeenCalledTimes(2);
115
+ });
116
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { sanitizePath, validateUpload } from "../validation.js";
3
+
4
+ describe("validateUpload", () => {
5
+ it("accepts valid image upload", () => {
6
+ const result = validateUpload("image/webp", 500_000);
7
+ expect(result.valid).toBe(true);
8
+ expect(result.error).toBeUndefined();
9
+ });
10
+
11
+ it("rejects disallowed content type", () => {
12
+ const result = validateUpload("application/zip", 1000);
13
+ expect(result.valid).toBe(false);
14
+ expect(result.error).toContain("not allowed");
15
+ });
16
+
17
+ it("rejects file exceeding max size", () => {
18
+ const result = validateUpload("image/png", 20 * 1024 * 1024);
19
+ expect(result.valid).toBe(false);
20
+ expect(result.error).toContain("exceeds maximum");
21
+ });
22
+
23
+ it("uses custom allowed types from config", () => {
24
+ const result = validateUpload("application/zip", 1000, {
25
+ allowedTypes: ["application/zip"],
26
+ });
27
+ expect(result.valid).toBe(true);
28
+ });
29
+
30
+ it("uses custom max size from config", () => {
31
+ const result = validateUpload("image/png", 50 * 1024 * 1024, {
32
+ maxSizeMb: 100,
33
+ });
34
+ expect(result.valid).toBe(true);
35
+ });
36
+ });
37
+
38
+ describe("sanitizePath", () => {
39
+ it("normalizes backslashes to forward slashes", () => {
40
+ expect(sanitizePath("assets\\images\\hero.png")).toBe("assets/images/hero.png");
41
+ });
42
+
43
+ it("removes directory traversal attempts", () => {
44
+ expect(sanitizePath("../../../etc/passwd")).toBe("etc/passwd");
45
+ });
46
+
47
+ it("collapses multiple slashes", () => {
48
+ expect(sanitizePath("assets///images//hero.png")).toBe("assets/images/hero.png");
49
+ });
50
+
51
+ it("lowercases the path", () => {
52
+ expect(sanitizePath("Assets/HERO.PNG")).toBe("assets/hero.png");
53
+ });
54
+
55
+ it("removes leading slash", () => {
56
+ expect(sanitizePath("/assets/hero.png")).toBe("assets/hero.png");
57
+ });
58
+ });
@@ -0,0 +1,109 @@
1
+ import type { MediaConfig, MediaRef, MediaUploadInput } from "@sourcepress/core";
2
+ import type { GitHubClient } from "@sourcepress/github";
3
+ import type { MediaStorage } from "./interface.js";
4
+ import { MediaRegistryManager } from "./registry.js";
5
+ import { sanitizePath, validateUpload } from "./validation.js";
6
+
7
+ /**
8
+ * Compute a simple hash from a Buffer using Web Crypto API.
9
+ */
10
+ async function computeHash(data: Buffer): Promise<string> {
11
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
12
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
13
+ return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
14
+ }
15
+
16
+ /**
17
+ * GitMediaStorage stores media files directly in the Git repository.
18
+ * Best for small sites with < 50 images.
19
+ */
20
+ export class GitMediaStorage implements MediaStorage {
21
+ private github: GitHubClient;
22
+ private registry: MediaRegistryManager;
23
+ private config: MediaConfig;
24
+
25
+ constructor(github: GitHubClient, config: MediaConfig) {
26
+ this.github = github;
27
+ this.config = config;
28
+ this.registry = new MediaRegistryManager(github, config.registry);
29
+ }
30
+
31
+ async upload(input: MediaUploadInput): Promise<MediaRef> {
32
+ const sanitized = sanitizePath(input.path);
33
+ const fullPath = `${this.config.path}/${sanitized}`;
34
+
35
+ // Validate
36
+ const validation = validateUpload(input.content_type, input.file.length, this.config);
37
+ if (!validation.valid) {
38
+ throw new Error(validation.error);
39
+ }
40
+
41
+ // Compute hash
42
+ const hash = await computeHash(input.file);
43
+
44
+ // Upload file to Git — pass as latin1 string so createOrUpdateFile's
45
+ // internal btoa() produces valid base64 (no double-encoding).
46
+ const content = input.file.toString("latin1");
47
+ await this.github.createOrUpdateFile(fullPath, content, `media: upload ${sanitized}`);
48
+
49
+ // Create MediaRef
50
+ const ref: MediaRef = {
51
+ path: fullPath,
52
+ content_type: input.content_type,
53
+ size_bytes: input.file.length,
54
+ hash,
55
+ alt: input.alt,
56
+ source: input.source,
57
+ generated_by: input.generated_by,
58
+ prompt: input.prompt,
59
+ uploaded_at: new Date().toISOString(),
60
+ uploaded_by: input.uploaded_by,
61
+ };
62
+
63
+ // Register in media.json
64
+ await this.registry.set(fullPath, ref);
65
+ await this.registry.save(`media: register ${sanitized}`);
66
+
67
+ return ref;
68
+ }
69
+
70
+ async get(path: string): Promise<Buffer | null> {
71
+ const file = await this.github.getFile(path);
72
+ if (!file) return null;
73
+ return Buffer.from(file.content, "base64");
74
+ }
75
+
76
+ async delete(path: string): Promise<void> {
77
+ // Remove from registry
78
+ await this.registry.remove(path);
79
+ await this.registry.save(`media: delete ${path}`);
80
+
81
+ // Note: Git doesn't truly "delete" — we remove from registry.
82
+ // The file remains in Git history. For actual file deletion,
83
+ // a future version could use the GitHub Contents API DELETE.
84
+ }
85
+
86
+ async list(prefix?: string): Promise<MediaRef[]> {
87
+ return this.registry.list(prefix);
88
+ }
89
+
90
+ async getMeta(path: string): Promise<MediaRef | null> {
91
+ return this.registry.get(path);
92
+ }
93
+
94
+ async updateMeta(
95
+ path: string,
96
+ updates: Partial<Pick<MediaRef, "alt" | "source" | "generated_by" | "prompt">>,
97
+ ): Promise<MediaRef> {
98
+ const existing = await this.registry.get(path);
99
+ if (!existing) {
100
+ throw new Error(`Media not found: ${path}`);
101
+ }
102
+
103
+ const updated: MediaRef = { ...existing, ...updates };
104
+ await this.registry.set(path, updated);
105
+ await this.registry.save(`media: update metadata for ${path}`);
106
+
107
+ return updated;
108
+ }
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type { MediaStorage } from "./interface.js";
2
+ export { GitMediaStorage } from "./git-storage.js";
3
+ export { MediaRegistryManager } from "./registry.js";
4
+ export { validateUpload, sanitizePath } from "./validation.js";