@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.
- package/LICENSE +23 -0
- package/README.md +44 -0
- package/dist/declarations/src/AzureStorage.d.ts +18 -0
- package/dist/declarations/src/AzureStorage.d.ts.map +1 -0
- package/dist/declarations/src/GcsStorage.d.ts +18 -0
- package/dist/declarations/src/GcsStorage.d.ts.map +1 -0
- package/dist/declarations/src/LocalStorage.d.ts +20 -0
- package/dist/declarations/src/LocalStorage.d.ts.map +1 -0
- package/dist/declarations/src/S3Storage.d.ts +20 -0
- package/dist/declarations/src/S3Storage.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +10 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +54 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/nixxie-cms-storage.cjs.d.ts +2 -0
- package/dist/nixxie-cms-storage.cjs.js +399 -0
- package/dist/nixxie-cms-storage.esm.js +391 -0
- package/package.json +39 -0
- package/src/AzureStorage.ts +104 -0
- package/src/GcsStorage.ts +92 -0
- package/src/LocalStorage.ts +99 -0
- package/src/S3Storage.ts +138 -0
- package/src/index.ts +39 -0
- package/src/types.ts +68 -0
|
@@ -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
|
+
}
|
package/src/S3Storage.ts
ADDED
|
@@ -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
|