@nuasite/cms 0.42.1 → 0.43.0-beta.1

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.
@@ -1,85 +0,0 @@
1
- import type { MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
2
-
3
- export interface ContemberStorageOptions {
4
- /** Base URL of the worker API, e.g. 'https://api.example.com' */
5
- apiBaseUrl: string
6
- /** Project slug used in the API path */
7
- projectSlug: string
8
- /** Session token for authentication (NUA_SITE_SESSION_TOKEN cookie value) */
9
- sessionToken?: string
10
- }
11
-
12
- /**
13
- * Media storage adapter that proxies to the Contember worker's CMS media endpoints.
14
- * Uses the existing /cms/:projectSlug/media/* API backed by R2 storage + Contember database.
15
- */
16
- export function createContemberStorageAdapter(options: ContemberStorageOptions): MediaStorageAdapter {
17
- const { apiBaseUrl, projectSlug, sessionToken } = options
18
- const base = `${apiBaseUrl.replace(/\/$/, '')}/cms/${projectSlug}/media`
19
-
20
- function headers(): Record<string, string> {
21
- const h: Record<string, string> = {}
22
- if (sessionToken) {
23
- h.Cookie = `NUA_SITE_SESSION_TOKEN=${sessionToken}`
24
- }
25
- return h
26
- }
27
-
28
- return {
29
- async list(opts) {
30
- const params = new URLSearchParams()
31
- if (opts?.limit) params.set('limit', String(opts.limit))
32
- if (opts?.cursor) params.set('cursor', opts.cursor)
33
-
34
- const url = `${base}/list${params.toString() ? `?${params}` : ''}`
35
- const res = await fetch(url, {
36
- method: 'GET',
37
- headers: headers(),
38
- credentials: 'include',
39
- })
40
-
41
- if (!res.ok) {
42
- throw new Error(`Failed to list media (${res.status}): ${await res.text()}`)
43
- }
44
-
45
- const data = await res.json()
46
- return { folders: [], ...data } as MediaListResult
47
- },
48
-
49
- async upload(file, filename, contentType) {
50
- // Build a FormData with the file buffer
51
- const blob = new Blob([new Uint8Array(file)], { type: contentType })
52
- const formData = new FormData()
53
- formData.append('file', blob, filename)
54
-
55
- const res = await fetch(`${base}/upload`, {
56
- method: 'POST',
57
- headers: headers(),
58
- body: formData,
59
- credentials: 'include',
60
- })
61
-
62
- if (!res.ok) {
63
- const text = await res.text().catch(() => '')
64
- return { success: false, error: `Upload failed (${res.status}): ${text}` }
65
- }
66
-
67
- return (await res.json()) as MediaUploadResult
68
- },
69
-
70
- async delete(id) {
71
- const res = await fetch(`${base}/${encodeURIComponent(id)}`, {
72
- method: 'DELETE',
73
- headers: headers(),
74
- credentials: 'include',
75
- })
76
-
77
- if (!res.ok) {
78
- const text = await res.text().catch(() => '')
79
- return { success: false, error: `Delete failed (${res.status}): ${text}` }
80
- }
81
-
82
- return { success: true }
83
- },
84
- }
85
- }
@@ -1,152 +0,0 @@
1
- import { randomUUID } from 'node:crypto'
2
- import fs from 'node:fs/promises'
3
- import path from 'node:path'
4
- import type { MediaFolderItem, MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
5
-
6
- export interface LocalStorageOptions {
7
- /** Directory to store media files (relative to project root or absolute). Default: 'public/uploads' */
8
- dir?: string
9
- /** URL prefix for serving files. Default: '/uploads' */
10
- urlPrefix?: string
11
- }
12
-
13
- export function createLocalStorageAdapter(options: LocalStorageOptions = {}): MediaStorageAdapter {
14
- const dir = path.resolve(options.dir ?? 'public/uploads')
15
- const urlPrefix = (options.urlPrefix ?? '/uploads').replace(/\/$/, '')
16
-
17
- return {
18
- staticFiles: { urlPrefix, dir },
19
-
20
- async list(opts) {
21
- const limit = opts?.limit ?? 50
22
- const offset = opts?.cursor ? parseInt(opts.cursor, 10) : 0
23
- const folder = opts?.folder ?? ''
24
-
25
- const targetDir = folder ? path.join(dir, folder) : dir
26
- await fs.mkdir(targetDir, { recursive: true })
27
-
28
- const entries = await fs.readdir(targetDir, { withFileTypes: true })
29
-
30
- // Collect subfolders
31
- const folders: MediaFolderItem[] = entries
32
- .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
33
- .map((e) => ({
34
- name: e.name,
35
- path: folder ? `${folder}/${e.name}` : e.name,
36
- }))
37
- .sort((a, b) => a.name.localeCompare(b.name))
38
-
39
- // Collect files
40
- const files = entries.filter((e) => e.isFile() && !e.name.startsWith('.'))
41
-
42
- // Get stats for sorting by mtime desc
43
- const withStats = await Promise.all(
44
- files.map(async (f) => {
45
- const filePath = path.join(targetDir, f.name)
46
- const stat = await fs.stat(filePath)
47
- return { name: f.name, stat }
48
- }),
49
- )
50
- withStats.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs)
51
-
52
- const slice = withStats.slice(offset, offset + limit)
53
- const hasMore = offset + limit < withStats.length
54
-
55
- const urlFolder = folder ? `/${folder}` : ''
56
- const items = slice.map((f) => {
57
- const ext = path.extname(f.name).toLowerCase()
58
- const contentType = mimeFromExt(ext)
59
- return {
60
- id: folder ? `${folder}/${f.name}` : f.name,
61
- url: `${urlPrefix}${urlFolder}/${f.name}`,
62
- filename: f.name,
63
- contentType,
64
- uploadedAt: f.stat.mtime.toISOString(),
65
- folder: folder || undefined,
66
- }
67
- })
68
-
69
- return {
70
- items,
71
- folders,
72
- hasMore,
73
- cursor: hasMore ? String(offset + limit) : undefined,
74
- } satisfies MediaListResult
75
- },
76
-
77
- async upload(file, filename, contentType, uploadOpts) {
78
- const folder = uploadOpts?.folder ?? ''
79
- const targetDir = folder ? path.join(dir, folder) : dir
80
- await fs.mkdir(targetDir, { recursive: true })
81
-
82
- const ext = getFileExtension(filename)
83
- const uuid = randomUUID()
84
- const newFilename = `${uuid}${ext ? `.${ext}` : ''}`
85
- const filePath = path.join(targetDir, newFilename)
86
-
87
- await fs.writeFile(filePath, file)
88
-
89
- const urlFolder = folder ? `/${folder}` : ''
90
- const id = folder ? `${folder}/${newFilename}` : newFilename
91
-
92
- return {
93
- success: true,
94
- url: `${urlPrefix}${urlFolder}/${newFilename}`,
95
- filename: newFilename,
96
- id,
97
- } satisfies MediaUploadResult
98
- },
99
-
100
- async delete(id) {
101
- // id may contain folder path — resolve safely within dir
102
- const safePath = id.split('/').map((s) => path.basename(s)).join('/')
103
- const filePath = path.join(dir, safePath)
104
- try {
105
- await fs.unlink(filePath)
106
- return { success: true }
107
- } catch (error) {
108
- const message = error instanceof Error ? error.message : String(error)
109
- return { success: false, error: message }
110
- }
111
- },
112
-
113
- async createFolder(folder) {
114
- const segments = folder.split('/').filter(Boolean)
115
- if (segments.some((s) => s === '..' || s === '.')) {
116
- return { success: false, error: 'Invalid folder name' }
117
- }
118
- try {
119
- await fs.mkdir(path.join(dir, ...segments), { recursive: true })
120
- return { success: true }
121
- } catch (error) {
122
- const message = error instanceof Error ? error.message : String(error)
123
- return { success: false, error: message }
124
- }
125
- },
126
- }
127
- }
128
-
129
- export function getFileExtension(filename: string): string {
130
- const parts = filename.split('.')
131
- const ext = parts.length > 1 ? (parts.pop()?.toLowerCase() ?? '') : ''
132
- // Only allow alphanumeric extensions to prevent injection
133
- return /^[a-z0-9]+$/.test(ext) ? ext : ''
134
- }
135
-
136
- export const MIME_BY_EXT: Record<string, string> = {
137
- '.jpg': 'image/jpeg',
138
- '.jpeg': 'image/jpeg',
139
- '.png': 'image/png',
140
- '.gif': 'image/gif',
141
- '.webp': 'image/webp',
142
- '.avif': 'image/avif',
143
- '.svg': 'image/svg+xml',
144
- '.ico': 'image/x-icon',
145
- '.mp4': 'video/mp4',
146
- '.webm': 'video/webm',
147
- '.pdf': 'application/pdf',
148
- }
149
-
150
- export function mimeFromExt(ext: string): string {
151
- return MIME_BY_EXT[ext] ?? 'application/octet-stream'
152
- }
@@ -1,81 +0,0 @@
1
- import type { Dirent } from 'node:fs'
2
- import fs from 'node:fs/promises'
3
- import path from 'node:path'
4
- import { getProjectRoot } from '../config'
5
- import { MIME_BY_EXT, mimeFromExt } from './local'
6
- import type { MediaItem } from './types'
7
-
8
- const IMAGE_EXTENSIONS = new Set(Object.entries(MIME_BY_EXT).filter(([, mime]) => mime.startsWith('image/')).map(([ext]) => ext))
9
-
10
- /**
11
- * Scan the project for image files in `public/` and `src/` directories,
12
- * excluding the media uploads directory to avoid duplicates.
13
- */
14
- export async function listProjectImages(options?: {
15
- excludeDir?: string
16
- }): Promise<MediaItem[]> {
17
- const root = getProjectRoot()
18
- const excludeDir = options?.excludeDir ? path.resolve(options.excludeDir) : null
19
-
20
- const scanDirs = [
21
- { dir: path.join(root, 'public'), urlPrefix: '' },
22
- { dir: path.join(root, 'src'), urlPrefix: null },
23
- ]
24
-
25
- const results = await Promise.all(
26
- scanDirs.map(({ dir, urlPrefix }) => {
27
- const items: MediaItem[] = []
28
- return scanDirectory(dir, dir, urlPrefix, excludeDir, items).then(() => items)
29
- }),
30
- )
31
-
32
- const items = results.flat()
33
- items.sort((a, b) => a.filename.localeCompare(b.filename))
34
- return items
35
- }
36
-
37
- async function scanDirectory(
38
- currentDir: string,
39
- baseDir: string,
40
- urlPrefix: string | null,
41
- excludeDir: string | null,
42
- items: MediaItem[],
43
- ): Promise<void> {
44
- if (excludeDir && path.resolve(currentDir) === excludeDir) return
45
-
46
- let entries: Dirent[]
47
- try {
48
- entries = await fs.readdir(currentDir, { withFileTypes: true }) as Dirent[]
49
- } catch {
50
- return
51
- }
52
-
53
- const subdirs: Promise<void>[] = []
54
-
55
- for (const entry of entries) {
56
- const name = String(entry.name)
57
- if (name.startsWith('.') || name === 'node_modules') continue
58
- const fullPath = path.join(currentDir, name)
59
-
60
- if (entry.isDirectory()) {
61
- subdirs.push(scanDirectory(fullPath, baseDir, urlPrefix, excludeDir, items))
62
- } else if (entry.isFile()) {
63
- const ext = path.extname(name).toLowerCase()
64
- if (!IMAGE_EXTENSIONS.has(ext)) continue
65
-
66
- const relativePath = path.relative(baseDir, fullPath).split(path.sep).join('/')
67
- const url = urlPrefix !== null
68
- ? `/${relativePath}`
69
- : `/${path.relative(getProjectRoot(), fullPath).split(path.sep).join('/')}`
70
-
71
- items.push({
72
- id: `project:${url}`,
73
- url,
74
- filename: name,
75
- contentType: mimeFromExt(ext),
76
- })
77
- }
78
- }
79
-
80
- await Promise.all(subdirs)
81
- }
package/src/media/s3.ts DELETED
@@ -1,154 +0,0 @@
1
- import { randomUUID } from 'node:crypto'
2
- import path from 'node:path'
3
- import { getFileExtension, mimeFromExt } from './local'
4
- import type { MediaFolderItem, MediaListResult, MediaStorageAdapter, MediaUploadResult } from './types'
5
-
6
- export interface S3StorageOptions {
7
- bucket: string
8
- region: string
9
- accessKeyId?: string
10
- secretAccessKey?: string
11
- endpoint?: string
12
- cdnPrefix?: string
13
- prefix?: string
14
- }
15
-
16
- // Dynamic import helper to avoid TS2307 for optional peer dependency
17
- const s3Module = '@aws-sdk/client-s3'
18
- async function loadS3(): Promise<any> {
19
- return import(/* @vite-ignore */ s3Module)
20
- }
21
-
22
- export function createS3StorageAdapter(options: S3StorageOptions): MediaStorageAdapter {
23
- const { bucket, region, accessKeyId, secretAccessKey, endpoint, cdnPrefix, prefix = 'uploads' } = options
24
-
25
- let s3Client: any = null
26
-
27
- async function getClient() {
28
- if (s3Client) return s3Client
29
- const { S3Client } = await loadS3()
30
- s3Client = new S3Client({
31
- region,
32
- ...(endpoint ? { endpoint } : {}),
33
- ...(accessKeyId && secretAccessKey
34
- ? { credentials: { accessKeyId, secretAccessKey } }
35
- : {}),
36
- })
37
- return s3Client
38
- }
39
-
40
- function getUrl(key: string): string {
41
- if (cdnPrefix) {
42
- return `${cdnPrefix.replace(/\/$/, '')}/${key}`
43
- }
44
- if (endpoint) {
45
- return `${endpoint.replace(/\/$/, '')}/${bucket}/${key}`
46
- }
47
- return `https://${bucket}.s3.${region}.amazonaws.com/${key}`
48
- }
49
-
50
- return {
51
- async list(opts) {
52
- const { ListObjectsV2Command } = await loadS3()
53
- const client = await getClient()
54
-
55
- const limit = opts?.limit ?? 50
56
- const folder = opts?.folder ?? ''
57
-
58
- // Build the S3 prefix: base prefix + subfolder
59
- const listPrefix = [prefix, folder].filter(Boolean).join('/')
60
- const delimiterPrefix = listPrefix ? `${listPrefix}/` : ''
61
-
62
- const command = new ListObjectsV2Command({
63
- Bucket: bucket,
64
- Prefix: delimiterPrefix,
65
- Delimiter: '/',
66
- MaxKeys: limit + 1,
67
- ...(opts?.cursor ? { ContinuationToken: opts.cursor } : {}),
68
- })
69
-
70
- const result = await client.send(command)
71
-
72
- // Extract subfolders from CommonPrefixes
73
- const folders: MediaFolderItem[] = (result.CommonPrefixes ?? []).map((cp: any) => {
74
- const fullPrefix = (cp.Prefix as string).replace(/\/$/, '')
75
- const name = fullPrefix.split('/').pop() ?? fullPrefix
76
- const relativePath = prefix ? fullPrefix.slice(prefix.length + 1) : fullPrefix
77
- return { name, path: relativePath }
78
- })
79
-
80
- // Extract files (exclude folder marker keys)
81
- const contents = (result.Contents ?? []).filter((obj: any) => {
82
- const key = obj.Key as string
83
- return key !== delimiterPrefix
84
- })
85
-
86
- const hasMore = contents.length > limit
87
- const items = contents.slice(0, limit).map((obj: any) => {
88
- const key = obj.Key as string
89
- const filename = key.split('/').pop() ?? key
90
- return {
91
- id: key,
92
- url: getUrl(key),
93
- filename,
94
- contentType: mimeFromExt(path.extname(key).toLowerCase()),
95
- uploadedAt: obj.LastModified?.toISOString(),
96
- folder: folder || undefined,
97
- }
98
- })
99
-
100
- return {
101
- items,
102
- folders,
103
- hasMore,
104
- cursor: hasMore ? result.NextContinuationToken : undefined,
105
- } satisfies MediaListResult
106
- },
107
-
108
- async upload(file, filename, contentType, uploadOpts) {
109
- const { PutObjectCommand } = await loadS3()
110
- const client = await getClient()
111
-
112
- const ext = getFileExtension(filename)
113
- const uuid = randomUUID()
114
- const newFilename = `${uuid}${ext ? `.${ext}` : ''}`
115
- const folder = uploadOpts?.folder ?? ''
116
- const keyParts = [prefix, folder, newFilename].filter(Boolean)
117
- const key = keyParts.join('/')
118
-
119
- const command = new PutObjectCommand({
120
- Bucket: bucket,
121
- Key: key,
122
- Body: file,
123
- ContentType: contentType,
124
- })
125
-
126
- await client.send(command)
127
-
128
- return {
129
- success: true,
130
- url: getUrl(key),
131
- filename: newFilename,
132
- id: key,
133
- } satisfies MediaUploadResult
134
- },
135
-
136
- async delete(id) {
137
- try {
138
- const { DeleteObjectCommand } = await loadS3()
139
- const client = await getClient()
140
-
141
- const command = new DeleteObjectCommand({
142
- Bucket: bucket,
143
- Key: id,
144
- })
145
-
146
- await client.send(command)
147
- return { success: true }
148
- } catch (error) {
149
- const message = error instanceof Error ? error.message : String(error)
150
- return { success: false, error: message }
151
- }
152
- },
153
- }
154
- }