@stravigor/core 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/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type Context from '../http/context.ts'
|
|
2
|
+
import { clearCookie } from '../http/cookie.ts'
|
|
3
|
+
import { randomHex } from '../helpers/crypto.ts'
|
|
4
|
+
import { extractUserId } from '../helpers/identity.ts'
|
|
5
|
+
import SessionManager from './session_manager.ts'
|
|
6
|
+
|
|
7
|
+
const FLASH_KEY = '_flash'
|
|
8
|
+
const FLASH_OLD_KEY = '_flash_old'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Unified server-side session backed by a database row and an HTTP-only cookie.
|
|
12
|
+
*
|
|
13
|
+
* Serves both anonymous visitors and authenticated users. Stores arbitrary
|
|
14
|
+
* key-value data in a JSONB column and supports flash data (available only
|
|
15
|
+
* on the next request).
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* // Read / write data (anonymous or authenticated)
|
|
19
|
+
* const session = ctx.get<Session>('session')
|
|
20
|
+
* session.set('cart', [item])
|
|
21
|
+
* session.flash('success', 'Item added!')
|
|
22
|
+
*
|
|
23
|
+
* // Login
|
|
24
|
+
* session.authenticate(user)
|
|
25
|
+
* await session.regenerate()
|
|
26
|
+
*
|
|
27
|
+
* // Logout
|
|
28
|
+
* return Session.destroy(ctx, ctx.redirect('/login'))
|
|
29
|
+
*/
|
|
30
|
+
export default class Session {
|
|
31
|
+
private _id: string
|
|
32
|
+
private _userId: string | null
|
|
33
|
+
private _csrfToken: string
|
|
34
|
+
private _data: Record<string, unknown>
|
|
35
|
+
private _dirty = false
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
id: string,
|
|
39
|
+
userId: string | null,
|
|
40
|
+
csrfToken: string,
|
|
41
|
+
data: Record<string, unknown>,
|
|
42
|
+
readonly ipAddress: string | null,
|
|
43
|
+
readonly userAgent: string | null,
|
|
44
|
+
readonly lastActivity: Date,
|
|
45
|
+
readonly createdAt: Date
|
|
46
|
+
) {
|
|
47
|
+
this._id = id
|
|
48
|
+
this._userId = userId
|
|
49
|
+
this._csrfToken = csrfToken
|
|
50
|
+
this._data = data
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Getters
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
get id(): string {
|
|
58
|
+
return this._id
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get userId(): string | null {
|
|
62
|
+
return this._userId
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get csrfToken(): string {
|
|
66
|
+
return this._csrfToken
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get isAuthenticated(): boolean {
|
|
70
|
+
return this._userId !== null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get isDirty(): boolean {
|
|
74
|
+
return this._dirty
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Data bag
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** Get a value from the session data. */
|
|
82
|
+
get<T = unknown>(key: string, defaultValue?: T): T {
|
|
83
|
+
return (this._data[key] as T) ?? (defaultValue as T)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Set a persistent session value. */
|
|
87
|
+
set(key: string, value: unknown): void {
|
|
88
|
+
this._data[key] = value
|
|
89
|
+
this._dirty = true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Check whether a key exists in the session data. */
|
|
93
|
+
has(key: string): boolean {
|
|
94
|
+
return key in this._data && !key.startsWith('_flash')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Remove a key from the session data. */
|
|
98
|
+
forget(key: string): void {
|
|
99
|
+
delete this._data[key]
|
|
100
|
+
this._dirty = true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Remove all session data (keeps the session row alive). */
|
|
104
|
+
flush(): void {
|
|
105
|
+
this._data = {}
|
|
106
|
+
this._dirty = true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Return all user-facing session data (excludes flash internals). */
|
|
110
|
+
all(): Record<string, unknown> {
|
|
111
|
+
const result: Record<string, unknown> = {}
|
|
112
|
+
for (const [key, value] of Object.entries(this._data)) {
|
|
113
|
+
if (!key.startsWith('_flash')) {
|
|
114
|
+
result[key] = value
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return result
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Flash data
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/** Set flash data that will be available only on the next request. */
|
|
125
|
+
flash(key: string, value: unknown): void {
|
|
126
|
+
const bag = (this._data[FLASH_KEY] ?? {}) as Record<string, unknown>
|
|
127
|
+
bag[key] = value
|
|
128
|
+
this._data[FLASH_KEY] = bag
|
|
129
|
+
this._dirty = true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Get flash data set by the previous request. */
|
|
133
|
+
getFlash<T = unknown>(key: string): T | undefined {
|
|
134
|
+
const old = this._data[FLASH_OLD_KEY] as Record<string, unknown> | undefined
|
|
135
|
+
return old?.[key] as T | undefined
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Check if there is flash data for the given key (from previous request). */
|
|
139
|
+
hasFlash(key: string): boolean {
|
|
140
|
+
const old = this._data[FLASH_OLD_KEY] as Record<string, unknown> | undefined
|
|
141
|
+
return old !== undefined && key in old
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Rotate flash data for the next request cycle. Called once per request
|
|
146
|
+
* by the session middleware before the handler runs.
|
|
147
|
+
*
|
|
148
|
+
* Moves `_flash` → `_flash_old` (readable this request), then clears `_flash`.
|
|
149
|
+
* Only marks dirty if there was actually flash data to rotate.
|
|
150
|
+
*/
|
|
151
|
+
ageFlash(): void {
|
|
152
|
+
const current = this._data[FLASH_KEY]
|
|
153
|
+
const old = this._data[FLASH_OLD_KEY]
|
|
154
|
+
|
|
155
|
+
if (current !== undefined || old !== undefined) {
|
|
156
|
+
this._data[FLASH_OLD_KEY] = current ?? {}
|
|
157
|
+
delete this._data[FLASH_KEY]
|
|
158
|
+
this._dirty = true
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Authentication
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/** Associate this session with a user (login). */
|
|
167
|
+
authenticate(user: unknown): void {
|
|
168
|
+
this._userId = extractUserId(user)
|
|
169
|
+
this._dirty = true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Disassociate the user from this session. */
|
|
173
|
+
clearUser(): void {
|
|
174
|
+
this._userId = null
|
|
175
|
+
this._dirty = true
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Lifecycle
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
/** Whether this session has exceeded its configured lifetime. */
|
|
183
|
+
isExpired(): boolean {
|
|
184
|
+
const lifetimeMs = SessionManager.config.lifetime * 60_000
|
|
185
|
+
return Date.now() - this.lastActivity.getTime() > lifetimeMs
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Update the last_activity timestamp to keep the session alive. */
|
|
189
|
+
async touch(): Promise<void> {
|
|
190
|
+
await SessionManager.db.sql`
|
|
191
|
+
UPDATE "_strav_sessions"
|
|
192
|
+
SET "last_activity" = NOW()
|
|
193
|
+
WHERE "id" = ${this._id}
|
|
194
|
+
`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Regenerate the session ID and CSRF token. Use after login to
|
|
199
|
+
* prevent session fixation attacks.
|
|
200
|
+
*/
|
|
201
|
+
async regenerate(): Promise<void> {
|
|
202
|
+
const oldId = this._id
|
|
203
|
+
this._id = crypto.randomUUID()
|
|
204
|
+
this._csrfToken = randomHex(32)
|
|
205
|
+
this._dirty = true
|
|
206
|
+
|
|
207
|
+
await this.save()
|
|
208
|
+
await SessionManager.db.sql`
|
|
209
|
+
DELETE FROM "_strav_sessions" WHERE "id" = ${oldId}
|
|
210
|
+
`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Persist session data to the database. Uses an upsert so both new
|
|
215
|
+
* and existing sessions go through the same code path.
|
|
216
|
+
* No-op if the session has not been modified.
|
|
217
|
+
*/
|
|
218
|
+
async save(): Promise<void> {
|
|
219
|
+
if (!this._dirty) return
|
|
220
|
+
|
|
221
|
+
const dataToSave = { ...this._data }
|
|
222
|
+
delete dataToSave[FLASH_OLD_KEY]
|
|
223
|
+
|
|
224
|
+
await SessionManager.db.sql`
|
|
225
|
+
INSERT INTO "_strav_sessions"
|
|
226
|
+
("id", "user_id", "csrf_token", "data", "ip_address", "user_agent", "last_activity")
|
|
227
|
+
VALUES
|
|
228
|
+
(${this._id}, ${this._userId}, ${this._csrfToken},
|
|
229
|
+
${JSON.stringify(dataToSave)}::jsonb, ${this.ipAddress}, ${this.userAgent}, NOW())
|
|
230
|
+
ON CONFLICT ("id") DO UPDATE SET
|
|
231
|
+
"user_id" = EXCLUDED."user_id",
|
|
232
|
+
"csrf_token" = EXCLUDED."csrf_token",
|
|
233
|
+
"data" = EXCLUDED."data",
|
|
234
|
+
"last_activity" = NOW()
|
|
235
|
+
`
|
|
236
|
+
this._dirty = false
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// Static API
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
/** Create a new anonymous session (not yet persisted — call save() or let the middleware handle it). */
|
|
244
|
+
static create(ctx: Context): Session {
|
|
245
|
+
const id = crypto.randomUUID()
|
|
246
|
+
const csrfToken = randomHex(32)
|
|
247
|
+
const ipAddress = ctx.header('x-forwarded-for') ?? null
|
|
248
|
+
const userAgent = ctx.header('user-agent') ?? null
|
|
249
|
+
const now = new Date()
|
|
250
|
+
|
|
251
|
+
const session = new Session(id, null, csrfToken, {}, ipAddress, userAgent, now, now)
|
|
252
|
+
session._dirty = true
|
|
253
|
+
return session
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Look up a session by ID. Returns null if not found. */
|
|
257
|
+
static async find(id: string): Promise<Session | null> {
|
|
258
|
+
const rows = await SessionManager.db.sql`
|
|
259
|
+
SELECT * FROM "_strav_sessions" WHERE "id" = ${id} LIMIT 1
|
|
260
|
+
`
|
|
261
|
+
if (rows.length === 0) return null
|
|
262
|
+
return Session.hydrate(rows[0] as Record<string, unknown>)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Read the session cookie from the request and look up the session. */
|
|
266
|
+
static async fromRequest(ctx: Context): Promise<Session | null> {
|
|
267
|
+
const sessionId = ctx.cookie(SessionManager.config.cookie)
|
|
268
|
+
if (!sessionId) return null
|
|
269
|
+
return Session.find(sessionId)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Delete the session from the database and clear the cookie on the response. */
|
|
273
|
+
static async destroy(ctx: Context, response: Response): Promise<Response> {
|
|
274
|
+
const cfg = SessionManager.config
|
|
275
|
+
const sessionId = ctx.cookie(cfg.cookie)
|
|
276
|
+
|
|
277
|
+
if (sessionId) {
|
|
278
|
+
await SessionManager.db.sql`
|
|
279
|
+
DELETE FROM "_strav_sessions" WHERE "id" = ${sessionId}
|
|
280
|
+
`
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return clearCookie(response, cfg.cookie, { path: '/' })
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Internal
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
private static hydrate(row: Record<string, unknown>): Session {
|
|
291
|
+
const rawData = row.data
|
|
292
|
+
const data: Record<string, unknown> =
|
|
293
|
+
typeof rawData === 'string'
|
|
294
|
+
? JSON.parse(rawData)
|
|
295
|
+
: ((rawData as Record<string, unknown>) ?? {})
|
|
296
|
+
|
|
297
|
+
return new Session(
|
|
298
|
+
row.id as string,
|
|
299
|
+
(row.user_id as string) ?? null,
|
|
300
|
+
row.csrf_token as string,
|
|
301
|
+
data,
|
|
302
|
+
(row.ip_address as string) ?? null,
|
|
303
|
+
(row.user_agent as string) ?? null,
|
|
304
|
+
row.last_activity as Date,
|
|
305
|
+
row.created_at as Date
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { inject } from '../core/inject.ts'
|
|
2
|
+
import { ConfigurationError } from '../exceptions/errors.ts'
|
|
3
|
+
import Configuration from '../config/configuration.ts'
|
|
4
|
+
import Database from '../database/database.ts'
|
|
5
|
+
|
|
6
|
+
export interface SessionConfig {
|
|
7
|
+
cookie: string
|
|
8
|
+
lifetime: number
|
|
9
|
+
httpOnly: boolean
|
|
10
|
+
secure: boolean
|
|
11
|
+
sameSite: 'strict' | 'lax' | 'none'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Central session configuration hub.
|
|
16
|
+
*
|
|
17
|
+
* Resolved once via the DI container — stores the database reference
|
|
18
|
+
* and parsed config for Session and the session middleware.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* app.singleton(SessionManager)
|
|
22
|
+
* app.resolve(SessionManager)
|
|
23
|
+
* await SessionManager.ensureTable()
|
|
24
|
+
*/
|
|
25
|
+
@inject
|
|
26
|
+
export default class SessionManager {
|
|
27
|
+
private static _db: Database
|
|
28
|
+
private static _config: SessionConfig
|
|
29
|
+
|
|
30
|
+
constructor(db: Database, config: Configuration) {
|
|
31
|
+
SessionManager._db = db
|
|
32
|
+
SessionManager._config = {
|
|
33
|
+
cookie: 'strav_session',
|
|
34
|
+
lifetime: 120,
|
|
35
|
+
httpOnly: true,
|
|
36
|
+
secure: true,
|
|
37
|
+
sameSite: 'lax',
|
|
38
|
+
...(config.get('session', {}) as object),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static get db(): Database {
|
|
43
|
+
if (!SessionManager._db) {
|
|
44
|
+
throw new ConfigurationError('SessionManager not configured. Resolve it through the container first.')
|
|
45
|
+
}
|
|
46
|
+
return SessionManager._db
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static get config(): SessionConfig {
|
|
50
|
+
return SessionManager._config
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Create the sessions table if it does not exist. */
|
|
54
|
+
static async ensureTable(): Promise<void> {
|
|
55
|
+
await SessionManager.db.sql`
|
|
56
|
+
CREATE TABLE IF NOT EXISTS "_strav_sessions" (
|
|
57
|
+
"id" UUID PRIMARY KEY,
|
|
58
|
+
"user_id" VARCHAR(255),
|
|
59
|
+
"csrf_token" VARCHAR(64) NOT NULL,
|
|
60
|
+
"data" JSONB NOT NULL DEFAULT '{}',
|
|
61
|
+
"ip_address" VARCHAR(45),
|
|
62
|
+
"user_agent" TEXT,
|
|
63
|
+
"last_activity" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
64
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
65
|
+
)
|
|
66
|
+
`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Delete all expired sessions. Call periodically for housekeeping. */
|
|
70
|
+
static async gc(): Promise<number> {
|
|
71
|
+
const lifetimeMs = SessionManager.config.lifetime * 60_000
|
|
72
|
+
const cutoff = new Date(Date.now() - lifetimeMs)
|
|
73
|
+
|
|
74
|
+
const rows = await SessionManager.db.sql`
|
|
75
|
+
DELETE FROM "_strav_sessions"
|
|
76
|
+
WHERE "last_activity" < ${cutoff}
|
|
77
|
+
RETURNING "id"
|
|
78
|
+
`
|
|
79
|
+
return rows.length
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { default as Storage } from './storage.ts'
|
|
2
|
+
export { Upload, FileTooLargeError, InvalidFileTypeError } from './upload.ts'
|
|
3
|
+
export { default as StorageManager } from './storage_manager.ts'
|
|
4
|
+
export { default as LocalDriver } from './local_driver.ts'
|
|
5
|
+
export { default as S3Driver } from './s3_driver.ts'
|
|
6
|
+
export type {
|
|
7
|
+
StorageDriver,
|
|
8
|
+
StorageConfig,
|
|
9
|
+
FileStats,
|
|
10
|
+
LocalDriverConfig,
|
|
11
|
+
S3DriverConfig,
|
|
12
|
+
} from './types.ts'
|
|
13
|
+
export type { UploadResult } from './upload.ts'
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { join, extname, resolve } from 'node:path'
|
|
2
|
+
import { unlink } from 'node:fs/promises'
|
|
3
|
+
import { randomHex } from '../helpers/crypto.ts'
|
|
4
|
+
import type { StorageDriver, LocalDriverConfig } from './types.ts'
|
|
5
|
+
|
|
6
|
+
export default class LocalDriver implements StorageDriver {
|
|
7
|
+
private root: string
|
|
8
|
+
private baseUrl: string
|
|
9
|
+
|
|
10
|
+
constructor(config: LocalDriverConfig) {
|
|
11
|
+
this.root = resolve(config.root)
|
|
12
|
+
this.baseUrl = config.baseUrl
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async put(directory: string, file: File, name?: string): Promise<string> {
|
|
16
|
+
const ext = extname(file.name)
|
|
17
|
+
const filename = name ?? `${randomHex(8)}${ext}`
|
|
18
|
+
const relativePath = join(directory, filename)
|
|
19
|
+
const fullPath = join(this.root, relativePath)
|
|
20
|
+
|
|
21
|
+
await Bun.write(fullPath, file)
|
|
22
|
+
|
|
23
|
+
return relativePath
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async get(path: string): Promise<Blob | null> {
|
|
27
|
+
const file = Bun.file(join(this.root, path))
|
|
28
|
+
if (!(await file.exists())) return null
|
|
29
|
+
return file
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async exists(path: string): Promise<boolean> {
|
|
33
|
+
return Bun.file(join(this.root, path)).exists()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async delete(path: string): Promise<void> {
|
|
37
|
+
const fullPath = join(this.root, path)
|
|
38
|
+
if (await Bun.file(fullPath).exists()) {
|
|
39
|
+
await unlink(fullPath)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
url(path: string): string {
|
|
44
|
+
return `${this.baseUrl}/${path}`
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { S3Client } from 'bun'
|
|
2
|
+
import { extname } from 'node:path'
|
|
3
|
+
import { randomHex } from '../helpers/crypto.ts'
|
|
4
|
+
import type { StorageDriver, S3DriverConfig } from './types.ts'
|
|
5
|
+
|
|
6
|
+
export default class S3Driver implements StorageDriver {
|
|
7
|
+
private client: S3Client
|
|
8
|
+
private cdnUrl?: string
|
|
9
|
+
|
|
10
|
+
constructor(config: S3DriverConfig) {
|
|
11
|
+
this.cdnUrl = config.baseUrl ?? undefined
|
|
12
|
+
|
|
13
|
+
this.client = new S3Client({
|
|
14
|
+
accessKeyId: config.accessKeyId,
|
|
15
|
+
secretAccessKey: config.secretAccessKey,
|
|
16
|
+
region: config.region,
|
|
17
|
+
endpoint: config.endpoint ?? undefined,
|
|
18
|
+
bucket: config.bucket,
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async put(directory: string, file: File, name?: string): Promise<string> {
|
|
23
|
+
const ext = extname(file.name)
|
|
24
|
+
const filename = name ?? `${randomHex(8)}${ext}`
|
|
25
|
+
const key = `${directory}/${filename}`
|
|
26
|
+
|
|
27
|
+
const s3File = this.client.file(key)
|
|
28
|
+
await s3File.write(file)
|
|
29
|
+
|
|
30
|
+
return key
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async get(path: string): Promise<Blob | null> {
|
|
34
|
+
const s3File = this.client.file(path)
|
|
35
|
+
if (!(await s3File.exists())) return null
|
|
36
|
+
return new Blob([await s3File.arrayBuffer()])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async exists(path: string): Promise<boolean> {
|
|
40
|
+
return this.client.file(path).exists()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async delete(path: string): Promise<void> {
|
|
44
|
+
await this.client.file(path).delete()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
url(path: string, expiresIn = 3600): string {
|
|
48
|
+
if (this.cdnUrl) return `${this.cdnUrl}/${path}`
|
|
49
|
+
return this.client.file(path).presign({ expiresIn })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import StorageManager from './storage_manager.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Static file storage API.
|
|
5
|
+
*
|
|
6
|
+
* Delegates all operations to the configured driver (local or S3).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const path = await Storage.put('avatars', avatarFile)
|
|
10
|
+
* const url = Storage.url(path)
|
|
11
|
+
* await Storage.delete(path)
|
|
12
|
+
*/
|
|
13
|
+
export default class Storage {
|
|
14
|
+
/** Store a file with a random filename. */
|
|
15
|
+
static put(directory: string, file: File): Promise<string> {
|
|
16
|
+
return StorageManager.driver.put(directory, file)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Store a file with a custom filename. */
|
|
20
|
+
static putAs(directory: string, file: File, name: string): Promise<string> {
|
|
21
|
+
return StorageManager.driver.put(directory, file, name)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Retrieve file content, or null if not found. */
|
|
25
|
+
static get(path: string): Promise<Blob | null> {
|
|
26
|
+
return StorageManager.driver.get(path)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Check if a file exists. */
|
|
30
|
+
static exists(path: string): Promise<boolean> {
|
|
31
|
+
return StorageManager.driver.exists(path)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Delete a file. */
|
|
35
|
+
static delete(path: string): Promise<void> {
|
|
36
|
+
return StorageManager.driver.delete(path)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Generate a URL for the file. */
|
|
40
|
+
static url(path: string, expiresIn?: number): string {
|
|
41
|
+
return StorageManager.driver.url(path, expiresIn)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { inject } from '../core/inject.ts'
|
|
2
|
+
import Configuration from '../config/configuration.ts'
|
|
3
|
+
import { ConfigurationError } from '../exceptions/errors.ts'
|
|
4
|
+
import LocalDriver from './local_driver.ts'
|
|
5
|
+
import S3Driver from './s3_driver.ts'
|
|
6
|
+
import type { StorageDriver, StorageConfig } from './types.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Central storage configuration hub.
|
|
10
|
+
*
|
|
11
|
+
* Resolved once via the DI container — reads the storage config
|
|
12
|
+
* and initializes the appropriate driver.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* app.singleton(StorageManager)
|
|
16
|
+
* app.resolve(StorageManager)
|
|
17
|
+
*/
|
|
18
|
+
@inject
|
|
19
|
+
export default class StorageManager {
|
|
20
|
+
private static _driver: StorageDriver
|
|
21
|
+
private static _config: StorageConfig
|
|
22
|
+
|
|
23
|
+
constructor(config: Configuration) {
|
|
24
|
+
const driverName = config.get('storage.default', 'local') as string
|
|
25
|
+
|
|
26
|
+
StorageManager._config = {
|
|
27
|
+
default: driverName as 'local' | 's3',
|
|
28
|
+
local: {
|
|
29
|
+
root: 'storage',
|
|
30
|
+
baseUrl: '/storage',
|
|
31
|
+
...(config.get('storage.local', {}) as object),
|
|
32
|
+
},
|
|
33
|
+
s3: {
|
|
34
|
+
bucket: '',
|
|
35
|
+
region: 'us-east-1',
|
|
36
|
+
accessKeyId: '',
|
|
37
|
+
secretAccessKey: '',
|
|
38
|
+
...(config.get('storage.s3', {}) as object),
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (driverName === 's3') {
|
|
43
|
+
StorageManager._driver = new S3Driver(StorageManager._config.s3)
|
|
44
|
+
} else {
|
|
45
|
+
StorageManager._driver = new LocalDriver(StorageManager._config.local)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
static get driver(): StorageDriver {
|
|
50
|
+
if (!StorageManager._driver) {
|
|
51
|
+
throw new ConfigurationError('StorageManager not configured. Resolve it through the container first.')
|
|
52
|
+
}
|
|
53
|
+
return StorageManager._driver
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static get config(): StorageConfig {
|
|
57
|
+
return StorageManager._config
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface StorageDriver {
|
|
2
|
+
/** Store a file and return its relative path/key. */
|
|
3
|
+
put(directory: string, file: File, name?: string): Promise<string>
|
|
4
|
+
|
|
5
|
+
/** Retrieve file content, or null if not found. */
|
|
6
|
+
get(path: string): Promise<Blob | null>
|
|
7
|
+
|
|
8
|
+
/** Check if a file exists. */
|
|
9
|
+
exists(path: string): Promise<boolean>
|
|
10
|
+
|
|
11
|
+
/** Delete a file. */
|
|
12
|
+
delete(path: string): Promise<void>
|
|
13
|
+
|
|
14
|
+
/** Generate a URL for the file. */
|
|
15
|
+
url(path: string, expiresIn?: number): string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface FileStats {
|
|
19
|
+
size: number
|
|
20
|
+
lastModified: Date
|
|
21
|
+
contentType?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LocalDriverConfig {
|
|
25
|
+
root: string
|
|
26
|
+
baseUrl: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface S3DriverConfig {
|
|
30
|
+
bucket: string
|
|
31
|
+
region: string
|
|
32
|
+
endpoint?: string | null
|
|
33
|
+
accessKeyId: string
|
|
34
|
+
secretAccessKey: string
|
|
35
|
+
baseUrl?: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface StorageConfig {
|
|
39
|
+
default: 'local' | 's3'
|
|
40
|
+
local: LocalDriverConfig
|
|
41
|
+
s3: S3DriverConfig
|
|
42
|
+
}
|