@iskra-bun/web-kit 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/CHANGELOG.md +7 -0
- package/README.md +31 -0
- package/dist/chunk-POXNRNTC.js +51 -0
- package/dist/chunk-POXNRNTC.js.map +1 -0
- package/dist/index.d.ts +966 -0
- package/dist/index.js +2824 -0
- package/dist/index.js.map +1 -0
- package/dist/mailgun-Z46GZJNI.js +83 -0
- package/dist/mailgun-Z46GZJNI.js.map +1 -0
- package/dist/s3-7IG4ESFW.js +171 -0
- package/dist/s3-7IG4ESFW.js.map +1 -0
- package/dist/sendgrid-UK2GSBEF.js +43 -0
- package/dist/sendgrid-UK2GSBEF.js.map +1 -0
- package/dist/smtp-WJDLYKD5.js +50 -0
- package/dist/smtp-WJDLYKD5.js.map +1 -0
- package/package.json +74 -0
- package/src/driver.ts +55 -0
- package/src/errors.ts +66 -0
- package/src/features/api-key.ts +243 -0
- package/src/features/auth/better-auth-config.ts +160 -0
- package/src/features/auth/index.ts +229 -0
- package/src/features/auth/schema.ts +174 -0
- package/src/features/auth/types.ts +114 -0
- package/src/features/cache.ts +144 -0
- package/src/features/cors.ts +33 -0
- package/src/features/csrf.ts +94 -0
- package/src/features/db.ts +90 -0
- package/src/features/email/index.ts +103 -0
- package/src/features/email/providers/mailgun.ts +99 -0
- package/src/features/email/providers/sendgrid.ts +42 -0
- package/src/features/email/providers/smtp.ts +51 -0
- package/src/features/error-handler.ts +147 -0
- package/src/features/health.ts +94 -0
- package/src/features/json-schema-validation.ts +186 -0
- package/src/features/logger.ts +70 -0
- package/src/features/openapi.ts +107 -0
- package/src/features/permissions.ts +128 -0
- package/src/features/rate-limit.ts +173 -0
- package/src/features/request-id.ts +45 -0
- package/src/features/session.ts +322 -0
- package/src/features/storage/adapters/local.ts +133 -0
- package/src/features/storage/adapters/s3.ts +193 -0
- package/src/features/storage/base.ts +112 -0
- package/src/features/storage/index.ts +53 -0
- package/src/features/tracing.ts +49 -0
- package/src/features/upload/helper.ts +85 -0
- package/src/features/upload/index.ts +140 -0
- package/src/features/validation.ts +105 -0
- package/src/index.ts +29 -0
- package/src/kernel.ts +257 -0
- package/src/responses.ts +37 -0
- package/src/router.ts +31 -0
- package/src/server.ts +135 -0
- package/src/types.ts +272 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Feature, RequestIdConfig } from "../types";
|
|
2
|
+
import type { Kernel } from "../kernel";
|
|
3
|
+
import type { Context, Next } from "hono";
|
|
4
|
+
|
|
5
|
+
// Extend Hono's context with requestId
|
|
6
|
+
declare module "hono" {
|
|
7
|
+
interface ContextVariableMap {
|
|
8
|
+
requestId: string;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class RequestIdFeature implements Feature {
|
|
13
|
+
name = "request-id";
|
|
14
|
+
|
|
15
|
+
private config: Required<RequestIdConfig>;
|
|
16
|
+
|
|
17
|
+
constructor(config: RequestIdConfig = {}) {
|
|
18
|
+
this.config = {
|
|
19
|
+
headerName: config.headerName || "X-Request-ID",
|
|
20
|
+
generator: config.generator || this.defaultGenerator,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async initialize(kernel: Kernel): Promise<void> {
|
|
25
|
+
const app = kernel.getApp();
|
|
26
|
+
|
|
27
|
+
app.use("*", async (c: Context, next: Next) => {
|
|
28
|
+
let requestId = c.req.header(this.config.headerName);
|
|
29
|
+
|
|
30
|
+
if (!requestId) {
|
|
31
|
+
requestId = this.config.generator();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
c.set("requestId", requestId);
|
|
35
|
+
await next();
|
|
36
|
+
c.res.headers.set(this.config.headerName, requestId);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
console.log("✅ Request ID feature initialized");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private defaultGenerator(): string {
|
|
43
|
+
return crypto.randomUUID();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { Feature, SessionConfig } from "../types";
|
|
2
|
+
import type { Kernel } from "../kernel";
|
|
3
|
+
import type { Context, Next } from "hono";
|
|
4
|
+
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
5
|
+
import { createHmac } from "crypto";
|
|
6
|
+
import { sql, eq } from "drizzle-orm";
|
|
7
|
+
import { pgTable, text as pgText, bigint as pgBigint } from "drizzle-orm/pg-core";
|
|
8
|
+
import { mysqlTable, varchar as myVarchar, text as myText, bigint as myBigint } from "drizzle-orm/mysql-core";
|
|
9
|
+
import { sqliteTable, text as sqliteText, integer as sqliteInteger } from "drizzle-orm/sqlite-core";
|
|
10
|
+
|
|
11
|
+
// ─── Session Store Interface ─────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
interface SessionStore {
|
|
14
|
+
get(id: string): Promise<Record<string, any> | null>;
|
|
15
|
+
set(id: string, data: Record<string, any>, ttl: number): Promise<void>;
|
|
16
|
+
destroy(id: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ─── Memory Store ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
class MemorySessionStore implements SessionStore {
|
|
22
|
+
private store = new Map<string, { data: Record<string, any>; expiresAt: number }>();
|
|
23
|
+
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
24
|
+
|
|
25
|
+
constructor() {
|
|
26
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 300_000);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async get(id: string) {
|
|
30
|
+
const entry = this.store.get(id);
|
|
31
|
+
if (!entry) return null;
|
|
32
|
+
if (Date.now() > entry.expiresAt) {
|
|
33
|
+
this.store.delete(id);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return entry.data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async set(id: string, data: Record<string, any>, ttl: number) {
|
|
40
|
+
this.store.set(id, { data, expiresAt: Date.now() + ttl * 1000 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async destroy(id: string) {
|
|
44
|
+
this.store.delete(id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private cleanup() {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
for (const [key, entry] of this.store.entries()) {
|
|
50
|
+
if (now > entry.expiresAt) this.store.delete(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
dispose() {
|
|
55
|
+
clearInterval(this.cleanupInterval);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Cache Store ─────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
class CacheSessionStore implements SessionStore {
|
|
62
|
+
constructor(private cache: any) { }
|
|
63
|
+
|
|
64
|
+
async get(id: string) {
|
|
65
|
+
const data = await this.cache.get(`session:${id}`);
|
|
66
|
+
return data || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async set(id: string, data: Record<string, any>, ttl: number) {
|
|
70
|
+
await this.cache.set(`session:${id}`, data, ttl);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async destroy(id: string) {
|
|
74
|
+
await this.cache.delete(`session:${id}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── DB Store ────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
type SessionDialect = "postgres" | "mysql" | "sqlite";
|
|
81
|
+
|
|
82
|
+
// One table definition per dialect (column types differ). The Drizzle query
|
|
83
|
+
// builder parameterizes every value, so user data in a session can never break
|
|
84
|
+
// out of a SQL string.
|
|
85
|
+
const sessionTables = {
|
|
86
|
+
postgres: pgTable("sessions", {
|
|
87
|
+
id: pgText("id").primaryKey(),
|
|
88
|
+
data: pgText("data").notNull(),
|
|
89
|
+
expiresAt: pgBigint("expires_at", { mode: "number" }).notNull(),
|
|
90
|
+
}),
|
|
91
|
+
mysql: mysqlTable("sessions", {
|
|
92
|
+
id: myVarchar("id", { length: 255 }).primaryKey(),
|
|
93
|
+
data: myText("data").notNull(),
|
|
94
|
+
expiresAt: myBigint("expires_at", { mode: "number" }).notNull(),
|
|
95
|
+
}),
|
|
96
|
+
sqlite: sqliteTable("sessions", {
|
|
97
|
+
id: sqliteText("id").primaryKey(),
|
|
98
|
+
data: sqliteText("data").notNull(),
|
|
99
|
+
expiresAt: sqliteInteger("expires_at").notNull(),
|
|
100
|
+
}),
|
|
101
|
+
} as const;
|
|
102
|
+
|
|
103
|
+
const createTableDdl: Record<SessionDialect, string> = {
|
|
104
|
+
postgres: "CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at BIGINT NOT NULL)",
|
|
105
|
+
mysql: "CREATE TABLE IF NOT EXISTS sessions (id VARCHAR(255) PRIMARY KEY, data TEXT NOT NULL, expires_at BIGINT NOT NULL)",
|
|
106
|
+
sqlite: "CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL)",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
class DbSessionStore implements SessionStore {
|
|
110
|
+
private initialized = false;
|
|
111
|
+
private dialect: SessionDialect;
|
|
112
|
+
private table: (typeof sessionTables)[SessionDialect];
|
|
113
|
+
|
|
114
|
+
constructor(private db: any, dialect: SessionDialect = "sqlite") {
|
|
115
|
+
this.dialect = sessionTables[dialect] ? dialect : "sqlite";
|
|
116
|
+
this.table = sessionTables[this.dialect];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async ensureTable() {
|
|
120
|
+
if (this.initialized) return;
|
|
121
|
+
try {
|
|
122
|
+
const ddl = sql.raw(createTableDdl[this.dialect]);
|
|
123
|
+
// sqlite drivers expose run(); postgres/mysql expose execute().
|
|
124
|
+
if (this.dialect === "sqlite") {
|
|
125
|
+
await this.db.run(ddl);
|
|
126
|
+
} else {
|
|
127
|
+
await this.db.execute(ddl);
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error("[session] Failed to ensure sessions table:", err);
|
|
131
|
+
}
|
|
132
|
+
this.initialized = true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async get(id: string) {
|
|
136
|
+
await this.ensureTable();
|
|
137
|
+
try {
|
|
138
|
+
const rows = await this.db
|
|
139
|
+
.select()
|
|
140
|
+
.from(this.table)
|
|
141
|
+
.where(eq(this.table.id, id))
|
|
142
|
+
.limit(1);
|
|
143
|
+
|
|
144
|
+
const row = rows?.[0];
|
|
145
|
+
if (!row) return null;
|
|
146
|
+
|
|
147
|
+
if (Date.now() > Number(row.expiresAt)) {
|
|
148
|
+
await this.destroy(id);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
return JSON.parse(row.data);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error("[session] Failed to read session:", err);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async set(id: string, data: Record<string, any>, ttl: number) {
|
|
159
|
+
await this.ensureTable();
|
|
160
|
+
const row = { id, data: JSON.stringify(data), expiresAt: Date.now() + ttl * 1000 };
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
// Portable upsert: delete-then-insert works identically across all
|
|
164
|
+
// three dialects without per-dialect ON CONFLICT / ON DUPLICATE syntax.
|
|
165
|
+
await this.db.delete(this.table).where(eq(this.table.id, id));
|
|
166
|
+
await this.db.insert(this.table).values(row);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error("[session] Failed to write session:", err);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async destroy(id: string) {
|
|
173
|
+
try {
|
|
174
|
+
await this.db.delete(this.table).where(eq(this.table.id, id));
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error("[session] Failed to destroy session:", err);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── Cookie Signing ──────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
function signValue(value: string, secret: string): string {
|
|
184
|
+
const signature = createHmac("sha256", secret).update(value).digest("base64url");
|
|
185
|
+
return `${value}.${signature}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function verifySignedValue(signed: string, secret: string): string | null {
|
|
189
|
+
const lastDot = signed.lastIndexOf(".");
|
|
190
|
+
if (lastDot === -1) return null;
|
|
191
|
+
|
|
192
|
+
const value = signed.substring(0, lastDot);
|
|
193
|
+
const signature = signed.substring(lastDot + 1);
|
|
194
|
+
const expected = createHmac("sha256", secret).update(value).digest("base64url");
|
|
195
|
+
|
|
196
|
+
if (signature !== expected) return null;
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── Session Feature ─────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
declare module "hono" {
|
|
203
|
+
interface ContextVariableMap {
|
|
204
|
+
session: Record<string, any>;
|
|
205
|
+
sessionId: string;
|
|
206
|
+
destroySession: () => Promise<void>;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export class SessionFeature implements Feature {
|
|
211
|
+
name = "session";
|
|
212
|
+
dependencies?: string[];
|
|
213
|
+
|
|
214
|
+
private store?: SessionStore;
|
|
215
|
+
private ttl: number;
|
|
216
|
+
private cookieName: string;
|
|
217
|
+
|
|
218
|
+
constructor(private config: SessionConfig) {
|
|
219
|
+
this.ttl = config.ttl || 86400; // 24 hours default
|
|
220
|
+
this.cookieName = config.cookieName || "sid";
|
|
221
|
+
|
|
222
|
+
// Set dependencies based on store type
|
|
223
|
+
if (config.store === "cache") {
|
|
224
|
+
this.dependencies = ["cache"];
|
|
225
|
+
} else if (config.store === "db") {
|
|
226
|
+
this.dependencies = ["db"];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async initialize(kernel: Kernel): Promise<void> {
|
|
231
|
+
console.log(`⚙️ Initializing Session: store=${this.config.store}`);
|
|
232
|
+
|
|
233
|
+
switch (this.config.store) {
|
|
234
|
+
case "memory":
|
|
235
|
+
this.store = new MemorySessionStore();
|
|
236
|
+
break;
|
|
237
|
+
case "cache": {
|
|
238
|
+
const cacheFeature = kernel.getFeature("cache") as any;
|
|
239
|
+
if (!cacheFeature?.client) {
|
|
240
|
+
console.warn("⚠️ Cache feature not available, falling back to memory session store");
|
|
241
|
+
this.store = new MemorySessionStore();
|
|
242
|
+
} else {
|
|
243
|
+
this.store = new CacheSessionStore(cacheFeature.client);
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case "db": {
|
|
248
|
+
const dbFeature = kernel.getFeature("db") as any;
|
|
249
|
+
if (!dbFeature?.db) {
|
|
250
|
+
console.warn("⚠️ DB feature not available, falling back to memory session store");
|
|
251
|
+
this.store = new MemorySessionStore();
|
|
252
|
+
} else {
|
|
253
|
+
this.store = new DbSessionStore(dbFeature.db, dbFeature.adapter);
|
|
254
|
+
}
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const app = kernel.getApp();
|
|
260
|
+
app.use("*", async (c: Context, next: Next) => {
|
|
261
|
+
const signedCookie = getCookie(c, this.cookieName);
|
|
262
|
+
let sessionId: string | null = null;
|
|
263
|
+
let session: Record<string, any> = {};
|
|
264
|
+
|
|
265
|
+
if (signedCookie) {
|
|
266
|
+
sessionId = verifySignedValue(signedCookie, this.config.secret);
|
|
267
|
+
if (sessionId) {
|
|
268
|
+
const data = await this.store!.get(sessionId);
|
|
269
|
+
if (data) {
|
|
270
|
+
session = data;
|
|
271
|
+
} else {
|
|
272
|
+
sessionId = null; // Session expired or not found
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!sessionId) {
|
|
278
|
+
sessionId = crypto.randomUUID();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let destroyed = false;
|
|
282
|
+
|
|
283
|
+
c.set("session", session);
|
|
284
|
+
c.set("sessionId", sessionId);
|
|
285
|
+
c.set("destroySession", async () => {
|
|
286
|
+
destroyed = true;
|
|
287
|
+
await this.store!.destroy(sessionId!);
|
|
288
|
+
deleteCookie(c, this.cookieName);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await next();
|
|
292
|
+
|
|
293
|
+
// A destroyed session must not be re-persisted (otherwise the save
|
|
294
|
+
// block below would immediately recreate it and override deleteCookie).
|
|
295
|
+
if (destroyed) return;
|
|
296
|
+
|
|
297
|
+
// Save session after response
|
|
298
|
+
const currentSession = c.get("session");
|
|
299
|
+
if (currentSession && Object.keys(currentSession).length > 0) {
|
|
300
|
+
await this.store!.set(sessionId, currentSession, this.ttl);
|
|
301
|
+
const signed = signValue(sessionId, this.config.secret);
|
|
302
|
+
const opts = this.config.cookieOptions || {};
|
|
303
|
+
setCookie(c, this.cookieName, signed, {
|
|
304
|
+
httpOnly: true,
|
|
305
|
+
sameSite: opts.sameSite || "Lax",
|
|
306
|
+
secure: opts.secure ?? false,
|
|
307
|
+
domain: opts.domain,
|
|
308
|
+
path: opts.path || "/",
|
|
309
|
+
maxAge: this.ttl,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
console.log("✅ Session initialized");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async shutdown(): Promise<void> {
|
|
318
|
+
if (this.store instanceof MemorySessionStore) {
|
|
319
|
+
(this.store as any).dispose?.();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { BaseStorageAdapter, type PutOptions, type StorageConfig, type StorageFile } from "../base";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
export class LocalStorageAdapter extends BaseStorageAdapter {
|
|
7
|
+
private basePath: string;
|
|
8
|
+
|
|
9
|
+
constructor(config: StorageConfig) {
|
|
10
|
+
super();
|
|
11
|
+
this.basePath = config.basePath || "./storage";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async connect(): Promise<void> {
|
|
15
|
+
await fs.mkdir(this.basePath, { recursive: true });
|
|
16
|
+
this.connected = true;
|
|
17
|
+
console.log(`✅ Local storage connected at: ${this.basePath}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async disconnect(): Promise<void> {
|
|
21
|
+
this.connected = false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async put(filePath: string, data: Uint8Array | Buffer, options?: PutOptions): Promise<StorageFile> {
|
|
25
|
+
this.ensureConnected();
|
|
26
|
+
|
|
27
|
+
const sanitizedPath = this.sanitizePath(filePath);
|
|
28
|
+
const fullPath = path.join(this.basePath, sanitizedPath);
|
|
29
|
+
|
|
30
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
31
|
+
await fs.writeFile(fullPath, data);
|
|
32
|
+
|
|
33
|
+
const stat = await fs.stat(fullPath);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
name: path.basename(sanitizedPath),
|
|
37
|
+
path: sanitizedPath,
|
|
38
|
+
size: stat.size,
|
|
39
|
+
mimeType: options?.contentType || this.getMimeType(sanitizedPath),
|
|
40
|
+
lastModified: stat.mtime,
|
|
41
|
+
url: await this.url(sanitizedPath),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async get(filePath: string): Promise<Uint8Array | null> {
|
|
46
|
+
this.ensureConnected();
|
|
47
|
+
const sanitizedPath = this.sanitizePath(filePath);
|
|
48
|
+
const fullPath = path.join(this.basePath, sanitizedPath);
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
return await fs.readFile(fullPath);
|
|
52
|
+
} catch (error: any) {
|
|
53
|
+
if (error.code === 'ENOENT') return null;
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async delete(filePath: string): Promise<void> {
|
|
59
|
+
this.ensureConnected();
|
|
60
|
+
const sanitizedPath = this.sanitizePath(filePath);
|
|
61
|
+
const fullPath = path.join(this.basePath, sanitizedPath);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await fs.unlink(fullPath);
|
|
65
|
+
} catch (error: any) {
|
|
66
|
+
if (error.code !== 'ENOENT') throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async exists(filePath: string): Promise<boolean> {
|
|
71
|
+
this.ensureConnected();
|
|
72
|
+
const sanitizedPath = this.sanitizePath(filePath);
|
|
73
|
+
const fullPath = path.join(this.basePath, sanitizedPath);
|
|
74
|
+
try {
|
|
75
|
+
await fs.access(fullPath);
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async isDirectory(filePath: string): Promise<boolean> {
|
|
83
|
+
this.ensureConnected();
|
|
84
|
+
const sanitizedPath = this.sanitizePath(filePath);
|
|
85
|
+
const fullPath = path.join(this.basePath, sanitizedPath);
|
|
86
|
+
try {
|
|
87
|
+
const stat = await fs.stat(fullPath);
|
|
88
|
+
return stat.isDirectory();
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async list(prefix?: string): Promise<StorageFile[]> {
|
|
95
|
+
this.ensureConnected();
|
|
96
|
+
const searchPath = prefix ? path.join(this.basePath, this.sanitizePath(prefix)) : this.basePath;
|
|
97
|
+
const files: StorageFile[] = [];
|
|
98
|
+
|
|
99
|
+
// Simple recursive walk
|
|
100
|
+
const walk = async (dir: string) => {
|
|
101
|
+
try {
|
|
102
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const fullPath = path.join(dir, entry.name);
|
|
105
|
+
if (entry.isDirectory()) {
|
|
106
|
+
await walk(fullPath);
|
|
107
|
+
} else {
|
|
108
|
+
const relativePath = path.relative(this.basePath, fullPath);
|
|
109
|
+
const stat = await fs.stat(fullPath);
|
|
110
|
+
files.push({
|
|
111
|
+
name: entry.name,
|
|
112
|
+
path: this.sanitizePath(relativePath),
|
|
113
|
+
size: stat.size,
|
|
114
|
+
mimeType: this.getMimeType(entry.name),
|
|
115
|
+
lastModified: stat.mtime,
|
|
116
|
+
url: await this.url(this.sanitizePath(relativePath))
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch (e: any) {
|
|
121
|
+
if (e.code !== 'ENOENT') throw e;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
await walk(searchPath);
|
|
126
|
+
return files;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async url(filePath: string, _expiresIn?: number): Promise<string> {
|
|
130
|
+
const sanitizedPath = this.sanitizePath(filePath);
|
|
131
|
+
return `/storage/${sanitizedPath}`;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { BaseStorageAdapter } from "../base";
|
|
2
|
+
import type { StorageConfig, StorageFile, PutOptions } from "../base";
|
|
3
|
+
import {
|
|
4
|
+
S3Client,
|
|
5
|
+
PutObjectCommand,
|
|
6
|
+
GetObjectCommand,
|
|
7
|
+
DeleteObjectCommand,
|
|
8
|
+
HeadObjectCommand,
|
|
9
|
+
HeadBucketCommand,
|
|
10
|
+
ListObjectsV2Command,
|
|
11
|
+
CopyObjectCommand,
|
|
12
|
+
} from "@aws-sdk/client-s3";
|
|
13
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
14
|
+
|
|
15
|
+
export class S3StorageAdapter extends BaseStorageAdapter {
|
|
16
|
+
private client: S3Client;
|
|
17
|
+
private bucket: string;
|
|
18
|
+
|
|
19
|
+
constructor(private config: StorageConfig) {
|
|
20
|
+
super();
|
|
21
|
+
const conn = config.connection || {};
|
|
22
|
+
|
|
23
|
+
this.bucket = conn.bucket || "iskra-storage";
|
|
24
|
+
|
|
25
|
+
this.client = new S3Client({
|
|
26
|
+
endpoint: conn.endpoint,
|
|
27
|
+
region: conn.region || "us-east-1",
|
|
28
|
+
credentials: conn.accessKey && conn.secretKey
|
|
29
|
+
? { accessKeyId: conn.accessKey, secretAccessKey: conn.secretKey }
|
|
30
|
+
: undefined,
|
|
31
|
+
forcePathStyle: !!conn.endpoint, // true for MinIO
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async connect(): Promise<void> {
|
|
36
|
+
try {
|
|
37
|
+
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
|
38
|
+
this.connected = true;
|
|
39
|
+
} catch (err: any) {
|
|
40
|
+
throw new Error(`Failed to connect to S3 bucket "${this.bucket}": ${err.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async disconnect(): Promise<void> {
|
|
45
|
+
this.client.destroy();
|
|
46
|
+
this.connected = false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async put(path: string, data: Uint8Array | Buffer | ReadableStream, options?: PutOptions): Promise<StorageFile> {
|
|
50
|
+
this.ensureConnected();
|
|
51
|
+
const key = this.sanitizePath(path);
|
|
52
|
+
|
|
53
|
+
let body: Uint8Array | Buffer;
|
|
54
|
+
if (data instanceof ReadableStream) {
|
|
55
|
+
const response = new Response(data);
|
|
56
|
+
body = new Uint8Array(await response.arrayBuffer());
|
|
57
|
+
} else {
|
|
58
|
+
body = data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await this.client.send(new PutObjectCommand({
|
|
62
|
+
Bucket: this.bucket,
|
|
63
|
+
Key: key,
|
|
64
|
+
Body: body,
|
|
65
|
+
ContentType: options?.contentType || this.getMimeType(key),
|
|
66
|
+
Metadata: options?.metadata,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: key.split("/").pop() || key,
|
|
71
|
+
path: key,
|
|
72
|
+
size: body.length,
|
|
73
|
+
mimeType: options?.contentType || this.getMimeType(key),
|
|
74
|
+
lastModified: new Date(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async get(path: string): Promise<Uint8Array | null> {
|
|
79
|
+
this.ensureConnected();
|
|
80
|
+
const key = this.sanitizePath(path);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const response = await this.client.send(new GetObjectCommand({
|
|
84
|
+
Bucket: this.bucket,
|
|
85
|
+
Key: key,
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
if (!response.Body) return null;
|
|
89
|
+
return new Uint8Array(await response.Body.transformToByteArray());
|
|
90
|
+
} catch (err: any) {
|
|
91
|
+
if (err.name === "NoSuchKey" || err.$metadata?.httpStatusCode === 404) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async delete(path: string): Promise<void> {
|
|
99
|
+
this.ensureConnected();
|
|
100
|
+
const key = this.sanitizePath(path);
|
|
101
|
+
|
|
102
|
+
await this.client.send(new DeleteObjectCommand({
|
|
103
|
+
Bucket: this.bucket,
|
|
104
|
+
Key: key,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async exists(path: string): Promise<boolean> {
|
|
109
|
+
this.ensureConnected();
|
|
110
|
+
const key = this.sanitizePath(path);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await this.client.send(new HeadObjectCommand({
|
|
114
|
+
Bucket: this.bucket,
|
|
115
|
+
Key: key,
|
|
116
|
+
}));
|
|
117
|
+
return true;
|
|
118
|
+
} catch (err: any) {
|
|
119
|
+
if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async list(prefix?: string): Promise<StorageFile[]> {
|
|
127
|
+
this.ensureConnected();
|
|
128
|
+
const files: StorageFile[] = [];
|
|
129
|
+
let continuationToken: string | undefined;
|
|
130
|
+
|
|
131
|
+
do {
|
|
132
|
+
const response = await this.client.send(new ListObjectsV2Command({
|
|
133
|
+
Bucket: this.bucket,
|
|
134
|
+
Prefix: prefix ? this.sanitizePath(prefix) : undefined,
|
|
135
|
+
ContinuationToken: continuationToken,
|
|
136
|
+
}));
|
|
137
|
+
|
|
138
|
+
if (response.Contents) {
|
|
139
|
+
for (const obj of response.Contents) {
|
|
140
|
+
if (!obj.Key) continue;
|
|
141
|
+
files.push({
|
|
142
|
+
name: obj.Key.split("/").pop() || obj.Key,
|
|
143
|
+
path: obj.Key,
|
|
144
|
+
size: obj.Size || 0,
|
|
145
|
+
mimeType: this.getMimeType(obj.Key),
|
|
146
|
+
lastModified: obj.LastModified,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
|
152
|
+
} while (continuationToken);
|
|
153
|
+
|
|
154
|
+
return files;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async url(path: string, expiresIn: number = 3600): Promise<string> {
|
|
158
|
+
this.ensureConnected();
|
|
159
|
+
const key = this.sanitizePath(path);
|
|
160
|
+
|
|
161
|
+
const command = new GetObjectCommand({
|
|
162
|
+
Bucket: this.bucket,
|
|
163
|
+
Key: key,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return await getSignedUrl(this.client, command, { expiresIn });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async copy(from: string, to: string): Promise<void> {
|
|
170
|
+
this.ensureConnected();
|
|
171
|
+
const sourceKey = this.sanitizePath(from);
|
|
172
|
+
const destKey = this.sanitizePath(to);
|
|
173
|
+
|
|
174
|
+
await this.client.send(new CopyObjectCommand({
|
|
175
|
+
Bucket: this.bucket,
|
|
176
|
+
CopySource: `${this.bucket}/${sourceKey}`,
|
|
177
|
+
Key: destKey,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async isDirectory(path: string): Promise<boolean> {
|
|
182
|
+
this.ensureConnected();
|
|
183
|
+
const prefix = this.sanitizePath(path).replace(/\/?$/, "/");
|
|
184
|
+
|
|
185
|
+
const response = await this.client.send(new ListObjectsV2Command({
|
|
186
|
+
Bucket: this.bucket,
|
|
187
|
+
Prefix: prefix,
|
|
188
|
+
MaxKeys: 1,
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
return (response.Contents?.length || 0) > 0;
|
|
192
|
+
}
|
|
193
|
+
}
|