@nixxie-cms/storage 1.0.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.
@@ -0,0 +1,99 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
3
+ import { dirname, join, posix, relative, resolve, sep } from 'node:path'
4
+ import type {
5
+ NixxiePutOptions,
6
+ NixxieSignedUrlOptions,
7
+ NixxieStorageService,
8
+ NixxieStoredFile,
9
+ } from '@nixxie-cms/core'
10
+ import type { LocalStorageConfig } from './types'
11
+
12
+ /**
13
+ * Filesystem-backed storage. Good for development and single-node deployments.
14
+ * `signedUrl()` falls back to the plain public URL since the local disk cannot sign.
15
+ */
16
+ export class LocalStorage implements NixxieStorageService {
17
+ private baseDir: string
18
+ private baseUrl: string
19
+
20
+ constructor(config: LocalStorageConfig) {
21
+ this.baseDir = config.baseDir ?? '.nixxie-storage'
22
+ this.baseUrl = (config.baseUrl ?? '/files').replace(/\/$/, '')
23
+ }
24
+
25
+ private path(key: string): string {
26
+ // Confine resolved paths to baseDir so keys like "../../etc/passwd" (or absolute paths)
27
+ // cannot escape the storage root.
28
+ const root = resolve(this.baseDir)
29
+ const full = resolve(root, key)
30
+ if (full !== root && !full.startsWith(root + sep)) {
31
+ throw new Error(`Invalid storage key (path traversal detected): ${key}`)
32
+ }
33
+ return full
34
+ }
35
+
36
+ async put(
37
+ key: string,
38
+ data: Buffer | Uint8Array | string,
39
+ options?: NixxiePutOptions
40
+ ): Promise<NixxieStoredFile> {
41
+ const filePath = this.path(key)
42
+ await mkdir(dirname(filePath), { recursive: true })
43
+ const buffer = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data)
44
+ await writeFile(filePath, buffer)
45
+ return {
46
+ key,
47
+ url: this.url(key),
48
+ size: buffer.byteLength,
49
+ contentType: options?.contentType,
50
+ }
51
+ }
52
+
53
+ async get(key: string): Promise<Buffer | undefined> {
54
+ const filePath = this.path(key)
55
+ if (!existsSync(filePath)) return undefined
56
+ return readFile(filePath)
57
+ }
58
+
59
+ async delete(key: string): Promise<void> {
60
+ await rm(this.path(key), { force: true })
61
+ }
62
+
63
+ async has(key: string): Promise<boolean> {
64
+ try {
65
+ await stat(this.path(key))
66
+ return true
67
+ } catch {
68
+ return false
69
+ }
70
+ }
71
+
72
+ url(key: string): string {
73
+ return `${this.baseUrl}/${key.split(sep).join('/')}`
74
+ }
75
+
76
+ async signedUrl(key: string, _options?: NixxieSignedUrlOptions): Promise<string> {
77
+ return this.url(key)
78
+ }
79
+
80
+ async list(prefix = ''): Promise<string[]> {
81
+ const root = this.baseDir
82
+ const out: string[] = []
83
+ const walk = async (dir: string) => {
84
+ let entries
85
+ try {
86
+ entries = await readdir(dir, { withFileTypes: true })
87
+ } catch {
88
+ return
89
+ }
90
+ for (const entry of entries) {
91
+ const full = join(dir, entry.name)
92
+ if (entry.isDirectory()) await walk(full)
93
+ else out.push(relative(root, full).split(sep).join(posix.sep))
94
+ }
95
+ }
96
+ await walk(root)
97
+ return out.filter(k => k.startsWith(prefix))
98
+ }
99
+ }
@@ -0,0 +1,138 @@
1
+ import type {
2
+ NixxiePutOptions,
3
+ NixxieSignedUrlOptions,
4
+ NixxieStorageService,
5
+ NixxieStoredFile,
6
+ } from '@nixxie-cms/core'
7
+ import type { S3StorageConfig } from './types'
8
+
9
+ type S3Module = typeof import('@aws-sdk/client-s3')
10
+ type PresignerModule = typeof import('@aws-sdk/s3-request-presigner')
11
+
12
+ function loadS3(): { s3: S3Module; presigner: PresignerModule } {
13
+ try {
14
+ return {
15
+ s3: require('@aws-sdk/client-s3') as S3Module,
16
+ presigner: require('@aws-sdk/s3-request-presigner') as PresignerModule,
17
+ }
18
+ } catch {
19
+ throw new Error(
20
+ '@aws-sdk/client-s3 and @aws-sdk/s3-request-presigner are required for the S3 driver. Run: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner'
21
+ )
22
+ }
23
+ }
24
+
25
+ /** AWS S3 (and S3-compatible: R2, MinIO, Spaces) storage backend. */
26
+ export class S3Storage implements NixxieStorageService {
27
+ private config: S3StorageConfig
28
+ private prefix: string
29
+ private client: InstanceType<S3Module['S3Client']>
30
+ private s3: S3Module
31
+ private presigner: PresignerModule
32
+
33
+ constructor(config: S3StorageConfig) {
34
+ this.config = config
35
+ this.prefix = config.prefix ? config.prefix.replace(/\/$/, '') + '/' : ''
36
+ const { s3, presigner } = loadS3()
37
+ this.s3 = s3
38
+ this.presigner = presigner
39
+ this.client = new s3.S3Client({
40
+ region: config.region,
41
+ endpoint: config.endpoint,
42
+ forcePathStyle: config.forcePathStyle,
43
+ credentials:
44
+ config.accessKeyId && config.secretAccessKey
45
+ ? { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey }
46
+ : undefined,
47
+ })
48
+ }
49
+
50
+ private k(key: string): string {
51
+ return `${this.prefix}${key}`
52
+ }
53
+
54
+ async put(
55
+ key: string,
56
+ data: Buffer | Uint8Array | string,
57
+ options?: NixxiePutOptions
58
+ ): Promise<NixxieStoredFile> {
59
+ const body = typeof data === 'string' ? Buffer.from(data) : Buffer.from(data)
60
+ await this.client.send(
61
+ new this.s3.PutObjectCommand({
62
+ Bucket: this.config.bucket,
63
+ Key: this.k(key),
64
+ Body: body,
65
+ ContentType: options?.contentType,
66
+ ACL: options?.public ? 'public-read' : undefined,
67
+ Metadata: options?.metadata,
68
+ })
69
+ )
70
+ return { key, url: this.url(key), size: body.byteLength, contentType: options?.contentType }
71
+ }
72
+
73
+ async get(key: string): Promise<Buffer | undefined> {
74
+ try {
75
+ const res = await this.client.send(
76
+ new this.s3.GetObjectCommand({ Bucket: this.config.bucket, Key: this.k(key) })
77
+ )
78
+ const bytes = await res.Body?.transformToByteArray()
79
+ return bytes ? Buffer.from(bytes) : undefined
80
+ } catch (err: any) {
81
+ if (err?.name === 'NoSuchKey' || err?.$metadata?.httpStatusCode === 404) return undefined
82
+ throw err
83
+ }
84
+ }
85
+
86
+ async delete(key: string): Promise<void> {
87
+ await this.client.send(
88
+ new this.s3.DeleteObjectCommand({ Bucket: this.config.bucket, Key: this.k(key) })
89
+ )
90
+ }
91
+
92
+ async has(key: string): Promise<boolean> {
93
+ try {
94
+ await this.client.send(
95
+ new this.s3.HeadObjectCommand({ Bucket: this.config.bucket, Key: this.k(key) })
96
+ )
97
+ return true
98
+ } catch {
99
+ return false
100
+ }
101
+ }
102
+
103
+ url(key: string): string {
104
+ if (this.config.publicBaseUrl)
105
+ return `${this.config.publicBaseUrl.replace(/\/$/, '')}/${this.k(key)}`
106
+ const host = this.config.endpoint
107
+ ? `${this.config.endpoint.replace(/\/$/, '')}/${this.config.bucket}`
108
+ : `https://${this.config.bucket}.s3.${this.config.region ?? 'us-east-1'}.amazonaws.com`
109
+ return `${host}/${this.k(key)}`
110
+ }
111
+
112
+ async signedUrl(key: string, options?: NixxieSignedUrlOptions): Promise<string> {
113
+ const command =
114
+ options?.operation === 'put'
115
+ ? new this.s3.PutObjectCommand({
116
+ Bucket: this.config.bucket,
117
+ Key: this.k(key),
118
+ ContentType: options.contentType,
119
+ })
120
+ : new this.s3.GetObjectCommand({ Bucket: this.config.bucket, Key: this.k(key) })
121
+ return this.presigner.getSignedUrl(this.client as any, command as any, {
122
+ expiresIn: options?.expiresIn ?? 900,
123
+ })
124
+ }
125
+
126
+ async list(prefix = ''): Promise<string[]> {
127
+ const res = await this.client.send(
128
+ new this.s3.ListObjectsV2Command({
129
+ Bucket: this.config.bucket,
130
+ Prefix: this.k(prefix),
131
+ })
132
+ )
133
+ return (res.Contents ?? [])
134
+ .map(o => o.Key ?? '')
135
+ .map(k => (this.prefix && k.startsWith(this.prefix) ? k.slice(this.prefix.length) : k))
136
+ .filter(Boolean)
137
+ }
138
+ }
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { StorageConfig } from './types'
2
+ import { AzureStorage } from './AzureStorage'
3
+ import { GcsStorage } from './GcsStorage'
4
+ import { LocalStorage } from './LocalStorage'
5
+ import { S3Storage } from './S3Storage'
6
+
7
+ export function createStorage(
8
+ config: StorageConfig
9
+ ): LocalStorage | S3Storage | GcsStorage | AzureStorage {
10
+ switch (config.driver) {
11
+ case 'local':
12
+ return new LocalStorage(config)
13
+ case 's3':
14
+ return new S3Storage(config)
15
+ case 'gcs':
16
+ return new GcsStorage(config)
17
+ case 'azure':
18
+ return new AzureStorage(config)
19
+ default: {
20
+ const exhaustive: never = config
21
+ throw new Error(`Unknown storage driver: ${(exhaustive as any).driver}`)
22
+ }
23
+ }
24
+ }
25
+
26
+ export { LocalStorage, S3Storage, GcsStorage, AzureStorage }
27
+ export type {
28
+ StorageConfig,
29
+ LocalStorageConfig,
30
+ S3StorageConfig,
31
+ GcsStorageConfig,
32
+ AzureStorageConfig,
33
+ } from './types'
34
+ export type {
35
+ NixxieStorageService,
36
+ NixxieStoredFile,
37
+ NixxiePutOptions,
38
+ NixxieSignedUrlOptions,
39
+ } from '@nixxie-cms/core'
package/src/types.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type {
2
+ NixxiePutOptions,
3
+ NixxieSignedUrlOptions,
4
+ NixxieStorageService,
5
+ NixxieStoredFile,
6
+ } from '@nixxie-cms/core'
7
+
8
+ export type { NixxiePutOptions, NixxieSignedUrlOptions, NixxieStorageService, NixxieStoredFile }
9
+
10
+ export type LocalStorageConfig = {
11
+ driver: 'local'
12
+ /** Directory on disk where files are written. Default: '.nixxie-storage' */
13
+ baseDir?: string
14
+ /** Public URL prefix that maps to `baseDir`, e.g. 'http://localhost:3000/files'. Default: '/files' */
15
+ baseUrl?: string
16
+ }
17
+
18
+ export type S3StorageConfig = {
19
+ driver: 's3'
20
+ /** Target bucket name. */
21
+ bucket: string
22
+ /** AWS region. */
23
+ region?: string
24
+ /** Access key id — falls back to the AWS SDK default credential chain when omitted. */
25
+ accessKeyId?: string
26
+ /** Secret access key. */
27
+ secretAccessKey?: string
28
+ /** Custom endpoint (for S3-compatible services like R2, MinIO, Spaces). */
29
+ endpoint?: string
30
+ /** Force path-style addressing (required by most S3-compatible services). */
31
+ forcePathStyle?: boolean
32
+ /** Key prefix applied to every object. */
33
+ prefix?: string
34
+ /** Public base URL used by `url()` when objects are public / served via CDN. */
35
+ publicBaseUrl?: string
36
+ }
37
+
38
+ export type GcsStorageConfig = {
39
+ driver: 'gcs'
40
+ /** Target bucket name. */
41
+ bucket: string
42
+ /** GCP project id. */
43
+ projectId?: string
44
+ /** Path to a service-account key file. */
45
+ keyFilename?: string
46
+ /** Key prefix applied to every object. */
47
+ prefix?: string
48
+ /** Public base URL used by `url()`. */
49
+ publicBaseUrl?: string
50
+ }
51
+
52
+ export type AzureStorageConfig = {
53
+ driver: 'azure'
54
+ /** Blob container name. */
55
+ container: string
56
+ /** Full connection string for the storage account. */
57
+ connectionString: string
58
+ /** Key prefix applied to every blob. */
59
+ prefix?: string
60
+ /** Public base URL used by `url()`. */
61
+ publicBaseUrl?: string
62
+ }
63
+
64
+ export type StorageConfig =
65
+ | LocalStorageConfig
66
+ | S3StorageConfig
67
+ | GcsStorageConfig
68
+ | AzureStorageConfig