@c9up/ream 0.2.27 → 0.2.29
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 +1 -0
- package/dist/Codemods.d.ts +16 -0
- package/dist/Codemods.d.ts.map +1 -1
- package/dist/Codemods.js +182 -0
- package/dist/Codemods.js.map +1 -1
- package/dist/Ignitor.d.ts +11 -1
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +27 -21
- package/dist/Ignitor.js.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.d.ts.map +1 -1
- package/dist/bodyparser/BodyParserMiddleware.js +3 -0
- package/dist/bodyparser/BodyParserMiddleware.js.map +1 -1
- package/dist/bodyparser/qsParse.d.ts +10 -1
- package/dist/bodyparser/qsParse.d.ts.map +1 -1
- package/dist/bodyparser/qsParse.js +53 -15
- package/dist/bodyparser/qsParse.js.map +1 -1
- package/dist/codemods/source.d.ts +57 -0
- package/dist/codemods/source.d.ts.map +1 -0
- package/dist/codemods/source.js +282 -0
- package/dist/codemods/source.js.map +1 -0
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +3 -2
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/http/Exception.d.ts +14 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +10 -2
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/HttpContext.d.ts +11 -0
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +2 -1
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts +9 -3
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +39 -37
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts +42 -17
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +65 -29
- package/dist/http/Request.js.map +1 -1
- package/dist/http/SseStream.js +6 -7
- package/dist/http/SseStream.js.map +1 -1
- package/dist/http/permissions.d.ts +19 -0
- package/dist/http/permissions.d.ts.map +1 -0
- package/dist/http/permissions.js +28 -0
- package/dist/http/permissions.js.map +1 -0
- package/dist/http-client/HttpClient.d.ts +111 -0
- package/dist/http-client/HttpClient.d.ts.map +1 -0
- package/dist/http-client/HttpClient.js +386 -0
- package/dist/http-client/HttpClient.js.map +1 -0
- package/dist/http-client/addresses.d.ts +14 -0
- package/dist/http-client/addresses.d.ts.map +1 -0
- package/dist/http-client/addresses.js +57 -0
- package/dist/http-client/addresses.js.map +1 -0
- package/dist/http-client/fake.d.ts +50 -0
- package/dist/http-client/fake.d.ts.map +1 -0
- package/dist/http-client/fake.js +95 -0
- package/dist/http-client/fake.js.map +1 -0
- package/dist/http-client/index.d.ts +7 -0
- package/dist/http-client/index.d.ts.map +1 -0
- package/dist/http-client/index.js +7 -0
- package/dist/http-client/index.js.map +1 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts.map +1 -1
- package/dist/middleware/Pipeline.js +6 -3
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/native/generated.d.ts +108 -3
- package/dist/native/generated.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.d.ts +6 -0
- package/dist/rpc/RpcProvider.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.js +4 -1
- package/dist/rpc/RpcProvider.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts +27 -14
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +95 -38
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.d.ts.map +1 -1
- package/dist/scheduler/locks/RedisLockBackend.js +13 -2
- package/dist/scheduler/locks/RedisLockBackend.js.map +1 -1
- package/dist/security/CookieSigner.d.ts.map +1 -1
- package/dist/security/CookieSigner.js +17 -3
- package/dist/security/CookieSigner.js.map +1 -1
- package/dist/security/SignedUrl.d.ts.map +1 -1
- package/dist/security/SignedUrl.js +6 -1
- package/dist/security/SignedUrl.js.map +1 -1
- package/dist/server/Server.d.ts +75 -9
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +60 -9
- package/dist/server/Server.js.map +1 -1
- package/dist/server/nativeGrpc.d.ts +27 -0
- package/dist/server/nativeGrpc.d.ts.map +1 -0
- package/dist/server/nativeGrpc.js +47 -0
- package/dist/server/nativeGrpc.js.map +1 -0
- package/dist/server/nativeLimits.d.ts +49 -0
- package/dist/server/nativeLimits.d.ts.map +1 -0
- package/dist/server/nativeLimits.js +67 -0
- package/dist/server/nativeLimits.js.map +1 -0
- package/dist/session/Session.d.ts +45 -0
- package/dist/session/Session.d.ts.map +1 -1
- package/dist/session/Session.js +31 -2
- package/dist/session/Session.js.map +1 -1
- package/dist/session/SessionMiddleware.d.ts.map +1 -1
- package/dist/session/SessionMiddleware.js +52 -13
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/session/config.d.ts +5 -0
- package/dist/session/config.d.ts.map +1 -1
- package/dist/session/config.js +8 -0
- package/dist/session/config.js.map +1 -1
- package/dist/session/drivers/CookieDriver.d.ts +12 -3
- package/dist/session/drivers/CookieDriver.d.ts.map +1 -1
- package/dist/session/drivers/CookieDriver.js +20 -6
- package/dist/session/drivers/CookieDriver.js.map +1 -1
- package/dist/session/drivers/DatabaseDriver.d.ts +2 -0
- package/dist/session/drivers/DatabaseDriver.d.ts.map +1 -1
- package/dist/session/drivers/DatabaseDriver.js +6 -0
- package/dist/session/drivers/DatabaseDriver.js.map +1 -1
- package/dist/session/drivers/FileDriver.d.ts +8 -0
- package/dist/session/drivers/FileDriver.d.ts.map +1 -1
- package/dist/session/drivers/FileDriver.js +85 -11
- package/dist/session/drivers/FileDriver.js.map +1 -1
- package/dist/session/drivers/MemoryDriver.d.ts +2 -0
- package/dist/session/drivers/MemoryDriver.d.ts.map +1 -1
- package/dist/session/drivers/MemoryDriver.js +21 -5
- package/dist/session/drivers/MemoryDriver.js.map +1 -1
- package/dist/session/drivers/RedisDriver.d.ts +2 -0
- package/dist/session/drivers/RedisDriver.d.ts.map +1 -1
- package/dist/session/drivers/RedisDriver.js +6 -0
- package/dist/session/drivers/RedisDriver.js.map +1 -1
- package/dist/session/errors.d.ts +27 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +33 -0
- package/dist/session/errors.js.map +1 -1
- package/dist/session/templateTags.d.ts +14 -0
- package/dist/session/templateTags.d.ts.map +1 -1
- package/dist/session/templateTags.js +45 -17
- package/dist/session/templateTags.js.map +1 -1
- package/dist/storage/StaticMiddleware.d.ts.map +1 -1
- package/dist/storage/StaticMiddleware.js +29 -18
- package/dist/storage/StaticMiddleware.js.map +1 -1
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +11 -2
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Codemods.ts +265 -0
- package/src/Ignitor.ts +48 -20
- package/src/bodyparser/BodyParserMiddleware.ts +3 -0
- package/src/bodyparser/qsParse.ts +69 -14
- package/src/codemods/source.ts +303 -0
- package/src/graphql/GraphQLEngine.ts +3 -2
- package/src/http/Exception.ts +17 -2
- package/src/http/HttpContext.ts +14 -1
- package/src/http/RedirectBuilder.ts +48 -37
- package/src/http/Request.ts +87 -29
- package/src/http/SseStream.ts +6 -7
- package/src/http/permissions.ts +31 -0
- package/src/http-client/HttpClient.ts +505 -0
- package/src/http-client/addresses.ts +58 -0
- package/src/http-client/fake.ts +141 -0
- package/src/http-client/index.ts +21 -0
- package/src/index.ts +9 -1
- package/src/middleware/Pipeline.ts +7 -3
- package/src/native/generated.ts +123 -3
- package/src/rpc/RpcProvider.ts +10 -1
- package/src/rpc/RpcRouter.ts +126 -42
- package/src/scheduler/locks/RedisLockBackend.ts +12 -4
- package/src/security/CookieSigner.ts +17 -3
- package/src/security/SignedUrl.ts +6 -1
- package/src/server/Server.ts +103 -9
- package/src/server/nativeGrpc.ts +59 -0
- package/src/server/nativeLimits.ts +94 -0
- package/src/session/Session.ts +72 -2
- package/src/session/SessionMiddleware.ts +53 -14
- package/src/session/config.ts +7 -0
- package/src/session/drivers/CookieDriver.ts +21 -6
- package/src/session/drivers/DatabaseDriver.ts +10 -0
- package/src/session/drivers/FileDriver.ts +91 -11
- package/src/session/drivers/MemoryDriver.ts +20 -5
- package/src/session/drivers/RedisDriver.ts +7 -0
- package/src/session/errors.ts +48 -0
- package/src/session/templateTags.ts +57 -0
- package/src/storage/StaticMiddleware.ts +28 -18
|
@@ -16,6 +16,18 @@ export interface FileDriverOptions {
|
|
|
16
16
|
location: string
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** How long a writer waits for a session's lock before giving up. */
|
|
20
|
+
const LOCK_WAIT_MS = 2000
|
|
21
|
+
/**
|
|
22
|
+
* A lock older than this was left by a process that died holding it. Every
|
|
23
|
+
* operation under the lock is one small file read or write, far below it.
|
|
24
|
+
*/
|
|
25
|
+
const LOCK_STALE_MS = 10_000
|
|
26
|
+
|
|
27
|
+
function hasCode(error: unknown, code: string): boolean {
|
|
28
|
+
return error instanceof Error && Reflect.get(error, 'code') === code
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
export class FileDriver implements SessionDriver {
|
|
20
32
|
readonly #location: string
|
|
21
33
|
|
|
@@ -37,6 +49,17 @@ export class FileDriver implements SessionDriver {
|
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
async read(sessionId: string): Promise<Record<string, unknown> | null> {
|
|
52
|
+
return this.#read(sessionId, () => this.destroy(sessionId))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read, dropping an expired file through `drop`: `destroy()` from outside,
|
|
57
|
+
* a bare removal from inside the lock, which is already held there.
|
|
58
|
+
*/
|
|
59
|
+
async #read(
|
|
60
|
+
sessionId: string,
|
|
61
|
+
drop: () => Promise<void>,
|
|
62
|
+
): Promise<Record<string, unknown> | null> {
|
|
40
63
|
let raw: string
|
|
41
64
|
try {
|
|
42
65
|
raw = await fs.promises.readFile(this.#pathFor(sessionId), 'utf8')
|
|
@@ -48,7 +71,7 @@ export class FileDriver implements SessionDriver {
|
|
|
48
71
|
if (typeof parsed !== 'object' || parsed === null) return null
|
|
49
72
|
const entry = parsed as { expiresAt?: number; data?: Record<string, unknown> }
|
|
50
73
|
if (typeof entry.expiresAt === 'number' && entry.expiresAt < Date.now()) {
|
|
51
|
-
await
|
|
74
|
+
await drop()
|
|
52
75
|
return null
|
|
53
76
|
}
|
|
54
77
|
return entry.data ?? {}
|
|
@@ -60,6 +83,43 @@ export class FileDriver implements SessionDriver {
|
|
|
60
83
|
}
|
|
61
84
|
|
|
62
85
|
async write(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
|
|
86
|
+
await this.#locked(sessionId, () => this.#replace(sessionId, data, ttl))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Written only while the file is still there. A filesystem has no
|
|
91
|
+
* conditional write, so every change to a session's file — this, `write`,
|
|
92
|
+
* `destroy` and `touch` — runs under that session's lock: a `destroy` from a
|
|
93
|
+
* logout cannot land between the check and the write, in this process or in
|
|
94
|
+
* another one sharing the directory.
|
|
95
|
+
*/
|
|
96
|
+
async update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean> {
|
|
97
|
+
return this.#locked(sessionId, async () => {
|
|
98
|
+
if ((await this.#read(sessionId, () => this.#remove(sessionId))) === null) return false
|
|
99
|
+
await this.#replace(sessionId, data, ttl)
|
|
100
|
+
return true
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async destroy(sessionId: string): Promise<void> {
|
|
105
|
+
await this.#locked(sessionId, () => this.#remove(sessionId))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async #remove(sessionId: string): Promise<void> {
|
|
109
|
+
await fs.promises.rm(this.#pathFor(sessionId), { force: true })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async touch(sessionId: string, ttl: number): Promise<void> {
|
|
113
|
+
await this.#locked(sessionId, async () => {
|
|
114
|
+
const data = await this.#read(sessionId, () => this.#remove(sessionId))
|
|
115
|
+
// Nothing stored: there is no expiry to slide, and writing here would
|
|
116
|
+
// create the very session that was not there.
|
|
117
|
+
if (data === null || Object.keys(data).length === 0) return
|
|
118
|
+
await this.#replace(sessionId, data, ttl)
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async #replace(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<void> {
|
|
63
123
|
const file = this.#pathFor(sessionId)
|
|
64
124
|
const payload = JSON.stringify({ expiresAt: Date.now() + ttl * 1000, data })
|
|
65
125
|
// Written to a temporary neighbour then renamed: a crash mid-write would
|
|
@@ -70,15 +130,35 @@ export class FileDriver implements SessionDriver {
|
|
|
70
130
|
await fs.promises.rename(temp, file)
|
|
71
131
|
}
|
|
72
132
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Run `fn` holding the session's lock: a directory beside its file, which
|
|
135
|
+
* `mkdir` creates atomically or refuses — the one exclusive primitive every
|
|
136
|
+
* filesystem gives, across processes too.
|
|
137
|
+
*/
|
|
138
|
+
async #locked<T>(sessionId: string, fn: () => Promise<T>): Promise<T> {
|
|
139
|
+
const lock = `${this.#pathFor(sessionId)}.lock`
|
|
140
|
+
const deadline = Date.now() + LOCK_WAIT_MS
|
|
141
|
+
for (;;) {
|
|
142
|
+
try {
|
|
143
|
+
await fs.promises.mkdir(lock)
|
|
144
|
+
break
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!hasCode(error, 'EEXIST')) throw error
|
|
147
|
+
const held = await fs.promises.stat(lock).catch(() => null)
|
|
148
|
+
if (held !== null && Date.now() - held.mtimeMs > LOCK_STALE_MS) {
|
|
149
|
+
await fs.promises.rm(lock, { recursive: true, force: true })
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (Date.now() > deadline) {
|
|
153
|
+
throw new Error(`Session file is locked: ${lock}`)
|
|
154
|
+
}
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
return await fn()
|
|
160
|
+
} finally {
|
|
161
|
+
await fs.promises.rmdir(lock).catch(() => {})
|
|
162
|
+
}
|
|
83
163
|
}
|
|
84
164
|
}
|
|
@@ -9,8 +9,9 @@ const SWEEP_THRESHOLD = 1000
|
|
|
9
9
|
* The sweep alone only removes EXPIRED entries, so within the TTL window
|
|
10
10
|
* nothing bounded growth: anyone able to request a page could mint sessions
|
|
11
11
|
* until the process ran out of memory, without ever logging in. Past the cap
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
12
|
+
* the least recently used sessions are dropped (a write, an update or a
|
|
13
|
+
* touch moves a session to the back of the line) — a bounded store that
|
|
14
|
+
* forgets is strictly better than an unbounded one that dies.
|
|
14
15
|
*/
|
|
15
16
|
const DEFAULT_MAX_ENTRIES = 10_000
|
|
16
17
|
|
|
@@ -57,6 +58,15 @@ export class MemoryDriver implements SessionDriverWithTagging {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** Atomic by construction: nothing else runs between the check and the set. */
|
|
62
|
+
async update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean> {
|
|
63
|
+
const entry = this.#store.get(sessionId)
|
|
64
|
+
if (!entry || entry.expiresAt < Date.now()) return false
|
|
65
|
+
this.#store.delete(sessionId)
|
|
66
|
+
this.#store.set(sessionId, { data, expiresAt: Date.now() + ttl * 1000 })
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
async destroy(sessionId: string): Promise<void> {
|
|
61
71
|
this.#store.delete(sessionId)
|
|
62
72
|
// Drop the tags too: a destroyed session left in the index would be
|
|
@@ -69,9 +79,14 @@ export class MemoryDriver implements SessionDriverWithTagging {
|
|
|
69
79
|
|
|
70
80
|
async touch(sessionId: string, ttl: number): Promise<void> {
|
|
71
81
|
const entry = this.#store.get(sessionId)
|
|
72
|
-
if (entry)
|
|
73
|
-
|
|
74
|
-
|
|
82
|
+
if (!entry || entry.expiresAt < Date.now()) return
|
|
83
|
+
// Re-inserted, not updated in place: eviction drops the head of the map,
|
|
84
|
+
// so a session in use has to move to the tail. Updated in place, a
|
|
85
|
+
// signed-in user who keeps browsing stayed first in line, and ten
|
|
86
|
+
// thousand anonymous requests evicted them.
|
|
87
|
+
entry.expiresAt = Date.now() + ttl * 1000
|
|
88
|
+
this.#store.delete(sessionId)
|
|
89
|
+
this.#store.set(sessionId, entry)
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
async tag(sessionId: string, userId: string | number): Promise<void> {
|
|
@@ -142,6 +142,13 @@ export class RedisDriver implements SessionDriverWithTagging {
|
|
|
142
142
|
await client.set(this.#key(sessionId), JSON.stringify(data), 'EX', ttl)
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/** `XX` makes the existence check and the write one command. */
|
|
146
|
+
async update(sessionId: string, data: Record<string, unknown>, ttl: number): Promise<boolean> {
|
|
147
|
+
const client = await this.#client()
|
|
148
|
+
const reply = await client.set(this.#key(sessionId), JSON.stringify(data), 'EX', ttl, 'XX')
|
|
149
|
+
return reply !== null
|
|
150
|
+
}
|
|
151
|
+
|
|
145
152
|
async destroy(sessionId: string): Promise<void> {
|
|
146
153
|
const client = await this.#client()
|
|
147
154
|
await client.del(this.#key(sessionId))
|
package/src/session/errors.ts
CHANGED
|
@@ -25,6 +25,23 @@ export class E_SESSION_NOT_READY extends Exception {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A store with no `update()`: it cannot write a session back only while its
|
|
30
|
+
* entry still exists, so a request running across a logout could sign the
|
|
31
|
+
* user in again. Refused when the store is attached, not at the first race.
|
|
32
|
+
*/
|
|
33
|
+
export class E_SESSION_STORE_NOT_CONDITIONAL extends Exception {
|
|
34
|
+
static override status = 500
|
|
35
|
+
static override code = 'E_SESSION_STORE_NOT_CONDITIONAL'
|
|
36
|
+
|
|
37
|
+
constructor() {
|
|
38
|
+
super(
|
|
39
|
+
'This session store has no update(): it must write a session only over an entry that still exists, in one step. See "Writing a session store".',
|
|
40
|
+
{ status: 500, code: 'E_SESSION_STORE_NOT_CONDITIONAL' },
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
/** The configured store cannot tag sessions (cookie and file stores cannot). */
|
|
29
46
|
export class E_SESSION_TAGGING_NOT_SUPPORTED extends Exception {
|
|
30
47
|
static override status = 500
|
|
@@ -37,3 +54,34 @@ export class E_SESSION_TAGGING_NOT_SUPPORTED extends Exception {
|
|
|
37
54
|
)
|
|
38
55
|
}
|
|
39
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A session config without a store (AdonisJS raises "Missing store" for the
|
|
60
|
+
* same config). There is no implicit fallback: the memory store is per
|
|
61
|
+
* process and bounded — a restart, a second replica or enough anonymous
|
|
62
|
+
* traffic signs every user out — so it is only used when it is chosen.
|
|
63
|
+
*/
|
|
64
|
+
export class E_SESSION_STORE_NOT_CONFIGURED extends Exception {
|
|
65
|
+
static override status = 500
|
|
66
|
+
static override code = 'E_SESSION_STORE_NOT_CONFIGURED'
|
|
67
|
+
|
|
68
|
+
constructor() {
|
|
69
|
+
super(
|
|
70
|
+
"No session store is configured. Set `store` in config/session.ts (redis, database, file or cookie), or `store: 'memory'` for sessions kept in this process only (tests, a single development instance).",
|
|
71
|
+
{ status: 500, code: 'E_SESSION_STORE_NOT_CONFIGURED' },
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `cookie.httpOnly: false` — the session id would be readable by any script on the page. */
|
|
77
|
+
export class E_SESSION_COOKIE_NOT_HTTP_ONLY extends Exception {
|
|
78
|
+
static override status = 500
|
|
79
|
+
static override code = 'E_SESSION_COOKIE_NOT_HTTP_ONLY'
|
|
80
|
+
|
|
81
|
+
constructor() {
|
|
82
|
+
super(
|
|
83
|
+
'The session cookie must be httpOnly: a script able to read it turns any XSS into an account takeover. Remove `cookie.httpOnly: false` from the session config.',
|
|
84
|
+
{ status: 500, code: 'E_SESSION_COOKIE_NOT_HTTP_ONLY' },
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ReamError } from '../errors/ReamError.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The flash-message tags AdonisJS's session publishes to templates.
|
|
3
5
|
*
|
|
@@ -122,3 +124,58 @@ export function registerSessionTemplateTags(engine: TemplateTagEngine): void {
|
|
|
122
124
|
},
|
|
123
125
|
})
|
|
124
126
|
}
|
|
127
|
+
|
|
128
|
+
/** Loads the template engine package. Swappable so a test needs no engine. */
|
|
129
|
+
export type EnginePackageLoader = () => Promise<unknown>
|
|
130
|
+
|
|
131
|
+
const loadInker: EnginePackageLoader = () => {
|
|
132
|
+
// Built rather than written inline: inker is an OPTIONAL peer, and a literal
|
|
133
|
+
// `import('@c9up/inker')` makes a bundler resolve it at build time for an
|
|
134
|
+
// application that never installed it. Computed, it only runs behind the
|
|
135
|
+
// `usingInker` flag.
|
|
136
|
+
const specifier = '@c9up/inker'
|
|
137
|
+
return import(/* @vite-ignore */ specifier)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function unusable(detail: string): ReamError {
|
|
141
|
+
return new ReamError(
|
|
142
|
+
'E_SESSION_TEMPLATE_ENGINE_UNUSABLE',
|
|
143
|
+
`The template engine is installed but the session tags cannot be registered: ${detail}`,
|
|
144
|
+
{
|
|
145
|
+
hint: '`@c9up/inker` must default-export its plugin registrar (`inker.use(plugin)`), whose plugins receive an engine with `registerTag()`.',
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Publish the session tags into the template engine, the way AdonisJS's
|
|
152
|
+
* session does with Edge: through the engine package's MODULE SINGLETON
|
|
153
|
+
* (`inker.use(plugin)`), guarded by the host's `usingInker` flag — never by
|
|
154
|
+
* resolving a container binding. `use()` queues the plugin, and the engine
|
|
155
|
+
* runs it before its next render, whenever the engine was built.
|
|
156
|
+
*
|
|
157
|
+
* Returns whether the tags were handed over. A registrar or an engine of the
|
|
158
|
+
* wrong shape is an error, not a skip: a template using `@flashMessage`
|
|
159
|
+
* would otherwise render it as plain text with no warning.
|
|
160
|
+
*/
|
|
161
|
+
export async function installSessionTemplateTags(
|
|
162
|
+
usingInker: boolean,
|
|
163
|
+
load: EnginePackageLoader = loadInker,
|
|
164
|
+
): Promise<boolean> {
|
|
165
|
+
if (!usingInker) return false
|
|
166
|
+
const module: unknown = await load()
|
|
167
|
+
const registrar =
|
|
168
|
+
typeof module === 'object' && module !== null ? Reflect.get(module, 'default') : undefined
|
|
169
|
+
const use = Reflect.get(Object(registrar), 'use')
|
|
170
|
+
if (typeof use !== 'function') throw unusable('its default export has no use()')
|
|
171
|
+
use.call(registrar, (engine: unknown) => {
|
|
172
|
+
const register = Reflect.get(Object(engine), 'registerTag')
|
|
173
|
+
if (typeof register !== 'function') throw unusable('the engine has no registerTag()')
|
|
174
|
+
registerSessionTemplateTags({
|
|
175
|
+
registerTag: (tag) => {
|
|
176
|
+
register.call(engine, tag)
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
return true
|
|
181
|
+
}
|
|
@@ -150,8 +150,11 @@ export class StaticMiddleware {
|
|
|
150
150
|
const reqPath = ctx.request.path()
|
|
151
151
|
|
|
152
152
|
// A dotfile under `deny` is answered, not passed on: falling through would
|
|
153
|
-
// leak its existence through whatever the router answers next.
|
|
154
|
-
|
|
153
|
+
// leak its existence through whatever the router answers next. The check
|
|
154
|
+
// runs on the DECODED path — the name that would be opened — or `/%2eenv`
|
|
155
|
+
// reaches `.env`. A path that does not decode is checked raw; it is never
|
|
156
|
+
// served anyway.
|
|
157
|
+
if (this.#dotFiles === 'deny' && this.#hasDotSegment(decodePath(reqPath) ?? reqPath)) {
|
|
155
158
|
ctx.response.status(403).send('Forbidden')
|
|
156
159
|
return
|
|
157
160
|
}
|
|
@@ -335,25 +338,21 @@ export class StaticMiddleware {
|
|
|
335
338
|
reqPath = reqPath.slice(this.#prefix.length) || '/'
|
|
336
339
|
}
|
|
337
340
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// THROWS on a malformed sequence (a lone `%`), which would otherwise become
|
|
347
|
-
// a 500 on a request anyone can send.
|
|
348
|
-
let decoded: string
|
|
349
|
-
try {
|
|
350
|
-
decoded = decodeURIComponent(reqPath)
|
|
351
|
-
} catch {
|
|
352
|
-
return null
|
|
353
|
-
}
|
|
341
|
+
// Percent-decoding happens BEFORE any check and BEFORE resolution on
|
|
342
|
+
// purpose: the dot-file policy has to see the name that will be opened
|
|
343
|
+
// (`%2eenv` is `.env`), and `%2e%2e%2f` is `../`, which has to be
|
|
344
|
+
// normalised away by `path.resolve` like any other traversal, not carried
|
|
345
|
+
// through as an opaque segment. A malformed sequence (a lone `%`) is not
|
|
346
|
+
// servable.
|
|
347
|
+
const decoded = decodePath(reqPath)
|
|
348
|
+
if (decoded === null) return null
|
|
354
349
|
// A NUL truncates the path in the syscall layer of some runtimes; Node
|
|
355
350
|
// rejects it, but refusing here keeps the decision in one place.
|
|
356
351
|
if (decoded.includes('\0')) return null
|
|
352
|
+
if (this.#dotFiles !== 'allow' && this.#hasDotSegment(decoded)) return null
|
|
353
|
+
|
|
354
|
+
// The candidates, in upstream's order: the path itself, then the path with
|
|
355
|
+
// each fallback extension, then the index files for a directory request.
|
|
357
356
|
const relative = decoded.replace(/^\/+/, '')
|
|
358
357
|
const candidates: string[] = []
|
|
359
358
|
if (relative !== '') {
|
|
@@ -367,6 +366,8 @@ export class StaticMiddleware {
|
|
|
367
366
|
}
|
|
368
367
|
|
|
369
368
|
for (const candidate of candidates) {
|
|
369
|
+
// A configured index or extension can itself name a dot file.
|
|
370
|
+
if (this.#dotFiles !== 'allow' && this.#hasDotSegment(candidate)) continue
|
|
370
371
|
const opened = await this.#openInRoot(candidate)
|
|
371
372
|
if (opened) return opened
|
|
372
373
|
}
|
|
@@ -426,6 +427,15 @@ export class StaticMiddleware {
|
|
|
426
427
|
}
|
|
427
428
|
}
|
|
428
429
|
|
|
430
|
+
/** `decodeURIComponent` that answers `null` instead of throwing on a malformed sequence. */
|
|
431
|
+
function decodePath(reqPath: string): string | null {
|
|
432
|
+
try {
|
|
433
|
+
return decodeURIComponent(reqPath)
|
|
434
|
+
} catch {
|
|
435
|
+
return null
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
429
439
|
/**
|
|
430
440
|
* Static config with the defaults filled in — `@adonisjs/static`'s
|
|
431
441
|
* `defineConfig`, value for value.
|