@stacksjs/defaults 0.74.51 → 0.74.52
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/ai/skills/stacks-database/SKILL.md +6 -1
- package/app/Controllers/ComingSoonController.ts +1 -1
- package/app/Controllers/QueryController.ts +1 -1
- package/app/Storefront/CartCookie.ts +28 -12
- package/app/Storefront/StorefrontDatabase.ts +73 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
- package/resources/layouts/storefront.stx +14 -18
- package/resources/views/cart.stx +48 -40
- package/resources/views/checkout/contact.stx +40 -30
- package/resources/views/checkout/payment.stx +45 -30
- package/resources/views/checkout/shipping.stx +45 -30
- package/resources/functions/storefront/cart-cookie.ts +0 -58
- /package/resources/components/Marketing/{Footer.stx → MarketingFooter.stx} +0 -0
- /package/resources/components/Marketing/{Header.stx → MarketingHeader.stx} +0 -0
- /package/resources/components/Marketing/{Hero.stx → MarketingHero.stx} +0 -0
|
@@ -259,13 +259,18 @@ Entity-centric API for single-table design:
|
|
|
259
259
|
migrationLocks: 'migration_locks',
|
|
260
260
|
queryLogging: {
|
|
261
261
|
// Defaults on outside production and off in production. Production also
|
|
262
|
-
// skips query hooks unless persistent history is explicitly enabled
|
|
262
|
+
// skips query hooks unless persistent history is explicitly enabled,
|
|
263
|
+
// except one onQueryError hook on PostgreSQL and MySQL that reports a
|
|
264
|
+
// pool broken by oven-sh/bun#42804.
|
|
263
265
|
enabled: env.DB_QUERY_LOGGING_ENABLED ?? !['production', 'prod'].includes(env.APP_ENV || ''),
|
|
264
266
|
captureAllTraces: false, // slow and failed queries always keep traces
|
|
265
267
|
// Bound values in query_logs.bindings, credentials stored as `<redacted>`
|
|
266
268
|
// (by name and shape, so not every secret); production keeps only each
|
|
267
269
|
// value's type unless this is enabled. Env takes true/false, 1/0, yes/no, on/off.
|
|
268
270
|
captureBindings: env.DB_QUERY_LOGGING_CAPTURE_BINDINGS ?? !['production', 'prod'].includes(env.APP_ENV || ''),
|
|
271
|
+
// More secret columns, on top of those names: 'code' in every table,
|
|
272
|
+
// 'gift_cards.code' in that one. Also taken out of a failed query's error.
|
|
273
|
+
sensitiveColumns: [],
|
|
269
274
|
slowThreshold: 100, // ms
|
|
270
275
|
retention: 7, // days
|
|
271
276
|
pruneFrequency: 24, // hours
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { config } from '@stacksjs/config'
|
|
2
2
|
import { db, mutationCount, sql, sqlDateTime } from '@stacksjs/database/runtime'
|
|
3
|
-
import { Controller } from '@stacksjs/server'
|
|
3
|
+
import { Controller } from '@stacksjs/server/controllers/base'
|
|
4
4
|
|
|
5
5
|
const DAY_MS = 86_400_000
|
|
6
6
|
/** `2026-09-10T11` - the stored timestamp truncated to its hour. */
|
|
@@ -60,6 +60,32 @@ function timingSafeEqual(a: string, b: string): boolean {
|
|
|
60
60
|
return diff === 0
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* The token a signed `<token>.<sig>` value carries, or null when the
|
|
65
|
+
* value is missing, unsigned or its signature does not match.
|
|
66
|
+
*
|
|
67
|
+
* The storefront pages read the cookie through this. They take no
|
|
68
|
+
* unsigned value: the actions accept one only to re-sign a cart that
|
|
69
|
+
* predates signing, and a page never writes the cookie.
|
|
70
|
+
*/
|
|
71
|
+
export function verifyCartCookie(value: string | null | undefined): string | null {
|
|
72
|
+
if (!value || typeof value !== 'string') return null
|
|
73
|
+
|
|
74
|
+
const dot = value.lastIndexOf('.')
|
|
75
|
+
if (dot < 0) return null
|
|
76
|
+
|
|
77
|
+
const token = value.slice(0, dot)
|
|
78
|
+
const presented = value.slice(dot + 1)
|
|
79
|
+
if (!token || presented.length !== SIG_CHARS) return null
|
|
80
|
+
|
|
81
|
+
const expected = createHmac('sha256', getKey())
|
|
82
|
+
.update(token)
|
|
83
|
+
.digest('base64url')
|
|
84
|
+
.slice(0, SIG_CHARS)
|
|
85
|
+
|
|
86
|
+
return timingSafeEqual(expected, presented) ? token : null
|
|
87
|
+
}
|
|
88
|
+
|
|
63
89
|
/**
|
|
64
90
|
* Pull a verified cart token out of the request, or null if the
|
|
65
91
|
* cookie is missing/malformed/forged. Legacy unsigned tokens
|
|
@@ -75,17 +101,7 @@ export function readCartCookie(request: any, cookieName: string): string | null
|
|
|
75
101
|
// returns no row for unknown tokens — same fallback path.
|
|
76
102
|
if (!raw.includes('.')) return raw
|
|
77
103
|
|
|
78
|
-
|
|
79
|
-
const token = raw.slice(0, dot)
|
|
80
|
-
const presented = raw.slice(dot + 1)
|
|
81
|
-
if (!token || presented.length !== SIG_CHARS) return null
|
|
82
|
-
|
|
83
|
-
const expected = createHmac('sha256', getKey())
|
|
84
|
-
.update(token)
|
|
85
|
-
.digest('base64url')
|
|
86
|
-
.slice(0, SIG_CHARS)
|
|
87
|
-
|
|
88
|
-
return timingSafeEqual(expected, presented) ? token : null
|
|
104
|
+
return verifyCartCookie(raw)
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
/**
|
|
@@ -101,7 +117,7 @@ export function writeCartCookie(
|
|
|
101
117
|
request.cookies?.set?.(cookieName, sign(token), opts)
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
export const cartCookie = { read: readCartCookie, write: writeCartCookie, sign }
|
|
120
|
+
export const cartCookie = { read: readCartCookie, write: writeCartCookie, sign, verify: verifyCartCookie }
|
|
105
121
|
export default cartCookie
|
|
106
122
|
|
|
107
123
|
// Re-export `SIG_BYTES` so tests can assert the byte budget without
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the storefront's server-rendered pages read the database.
|
|
3
|
+
*
|
|
4
|
+
* The cart, the three checkout steps and the storefront layout query
|
|
5
|
+
* SQLite directly from their `<script server>` blocks. Each used to open
|
|
6
|
+
* the file itself, and every way that could fail rendered an empty cart
|
|
7
|
+
* without a word in any log:
|
|
8
|
+
*
|
|
9
|
+
* - stx swallows an error thrown by a server script unless STX_DEBUG
|
|
10
|
+
* is set, and renders the page from the script's static values, so
|
|
11
|
+
* a missing column read as "Your cart is empty." No migration
|
|
12
|
+
* creates `carts.session_token`, which every one of these pages
|
|
13
|
+
* looks the cart up by.
|
|
14
|
+
* - The layout caught its own errors and said nothing, on purpose.
|
|
15
|
+
* - They opened the file read-only, and SQLite (as Bun 1.4.1 ships it)
|
|
16
|
+
* refuses a read-only connection to a WAL-mode database that has no
|
|
17
|
+
* `-shm` file beside it. The framework's query builder puts the
|
|
18
|
+
* database in WAL mode, so a page read worked only while a `-shm`
|
|
19
|
+
* file happened to exist.
|
|
20
|
+
* - Under a `DB_CONNECTION` other than sqlite they read whatever SQLite
|
|
21
|
+
* file `DB_DATABASE_PATH` or `database/stacks.sqlite` named, which
|
|
22
|
+
* is not the app's database.
|
|
23
|
+
*
|
|
24
|
+
* {@link readStorefront} is the one place those are handled: the page
|
|
25
|
+
* still degrades to its empty cart, and the server log says why.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Database } from 'bun:sqlite'
|
|
29
|
+
import { resolve } from 'node:path'
|
|
30
|
+
import process from 'node:process'
|
|
31
|
+
|
|
32
|
+
/** Each distinct warning once per process, so a broken schema is not one line per request. */
|
|
33
|
+
const warned = new Set<string>()
|
|
34
|
+
|
|
35
|
+
function warnOnce(message: string): void {
|
|
36
|
+
if (warned.has(message))
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
warned.add(message)
|
|
40
|
+
console.warn(message)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run `read` against the app's SQLite database and return what it returns,
|
|
45
|
+
* or `fallback` when the database cannot be read, with a warning in the
|
|
46
|
+
* server log naming `page` and the reason.
|
|
47
|
+
*
|
|
48
|
+
* Opened read-write without `create`, with `query_only` on: it can make the
|
|
49
|
+
* `-shm` file a WAL-mode database needs, cannot create a database that is
|
|
50
|
+
* not there, and cannot write to one that is. Closed before returning.
|
|
51
|
+
*/
|
|
52
|
+
export function readStorefront<T>(page: string, fallback: T, read: (db: Database) => T): T {
|
|
53
|
+
const connection = (process.env.DB_CONNECTION || 'sqlite').toLowerCase()
|
|
54
|
+
if (connection !== 'sqlite') {
|
|
55
|
+
warnOnce(`[storefront] ${page} shows no cart: it reads carts from SQLite, and DB_CONNECTION is ${connection}.`)
|
|
56
|
+
return fallback
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const file = resolve(process.cwd(), process.env.DB_DATABASE_PATH || 'database/stacks.sqlite')
|
|
60
|
+
let db: Database | undefined
|
|
61
|
+
try {
|
|
62
|
+
db = new Database(file, { readwrite: true, create: false })
|
|
63
|
+
db.run('PRAGMA query_only = ON')
|
|
64
|
+
return read(db)
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
warnOnce(`[storefront] ${page} shows no cart: reading ${file} failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
68
|
+
return fallback
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
db?.close()
|
|
72
|
+
}
|
|
73
|
+
}
|
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.74.
|
|
5
|
+
"version": "0.74.52",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/stacksjs/stacks.git",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@iconify-json/f7": "^1.2.2",
|
|
57
57
|
"@iconify-json/hugeicons": "^1.2.27",
|
|
58
|
-
"@stacksjs/mobile": "^0.74.
|
|
58
|
+
"@stacksjs/mobile": "^0.74.52",
|
|
59
59
|
"@stacksjs/sanitizer": "^0.2.113",
|
|
60
60
|
"ts-qr-codes": "^0.1.8"
|
|
61
61
|
}
|
|
@@ -19,27 +19,23 @@
|
|
|
19
19
|
* the request handler.
|
|
20
20
|
*
|
|
21
21
|
* The cart count is read from the storefront's session cookie so the
|
|
22
|
-
* badge is correct on first paint, before any client-side JS runs.
|
|
22
|
+
* badge is correct on first paint, before any client-side JS runs. A
|
|
23
|
+
* copy has to point the two requires below at the storefront helpers
|
|
24
|
+
* from wherever it sits.
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
|
-
const {
|
|
26
|
-
const
|
|
27
|
-
const path = require('path')
|
|
27
|
+
const { verifyCartCookie } = require('../../app/Storefront/CartCookie')
|
|
28
|
+
const { readStorefront } = require('../../app/Storefront/StorefrontDatabase')
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
cartCount = Number(cart.total_items) || 0
|
|
39
|
-
db.close()
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
catch { /* schema not migrated yet, or db missing — render an empty header */ }
|
|
30
|
+
// The same signed cookie the cart page reads. When the cart cannot be read
|
|
31
|
+
// the badge shows nothing, and the server log says why.
|
|
32
|
+
const token = verifyCartCookie(requestContext.cookie('stacks_cart'))
|
|
33
|
+
const cartCount = token
|
|
34
|
+
? readStorefront('layouts/storefront.stx', 0, (db: import('bun:sqlite').Database) => {
|
|
35
|
+
const row = db.query<{ total_items: number | string | null }, [string]>(`SELECT total_items FROM carts WHERE session_token = ?1 AND status = 'active' LIMIT 1`).get(token)
|
|
36
|
+
return Number(row?.total_items) || 0
|
|
37
|
+
})
|
|
38
|
+
: 0
|
|
43
39
|
</script>
|
|
44
40
|
|
|
45
41
|
<!DOCTYPE html>
|
package/resources/views/cart.stx
CHANGED
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
@section('title', 'Your cart')
|
|
4
4
|
|
|
5
5
|
<script server>
|
|
6
|
-
|
|
7
|
-
// The columns this query selects. bun:sqlite returns `any`, so nothing
|
|
8
|
-
// downstream of the map was checked either.
|
|
6
|
+
// The columns the line query selects.
|
|
9
7
|
interface CartItemRow {
|
|
10
8
|
id: number
|
|
11
9
|
quantity: number | string
|
|
@@ -17,46 +15,56 @@ interface CartItemRow {
|
|
|
17
15
|
weight_grams: number | null
|
|
18
16
|
}
|
|
19
17
|
|
|
18
|
+
// The column of `carts` this page reads. The query selects `*`.
|
|
19
|
+
interface CartRow {
|
|
20
|
+
id: number
|
|
21
|
+
}
|
|
22
|
+
|
|
20
23
|
interface StorefrontLineItem { lineTotal: number }
|
|
21
24
|
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
25
|
+
const { verifyCartCookie } = require('../../app/Storefront/CartCookie')
|
|
26
|
+
const { readStorefront } = require('../../app/Storefront/StorefrontDatabase')
|
|
27
|
+
|
|
28
|
+
// Signed by the storefront actions as the token, a dot and its HMAC. An
|
|
29
|
+
// unsigned or tampered cookie is no cart, and is never looked up.
|
|
30
|
+
const token = verifyCartCookie(requestContext.cookie('stacks_cart'))
|
|
31
|
+
|
|
32
|
+
const found = token
|
|
33
|
+
? readStorefront('views/cart.stx', { cart: null, items: [] }, (db: import('bun:sqlite').Database) => {
|
|
34
|
+
const row = db.query<CartRow, [string]>(`SELECT * FROM carts WHERE session_token = ?1 AND status = 'active' LIMIT 1`).get(token)
|
|
35
|
+
if (!row)
|
|
36
|
+
return { cart: null, items: [] }
|
|
37
|
+
|
|
38
|
+
const lines = db.query<CartItemRow, [number]>(`
|
|
39
|
+
SELECT ci.id, ci.quantity, ci.unit_price, ci.total_price, ci.product_name,
|
|
40
|
+
ci.product_image, ci.product_sku, p.weight_grams
|
|
41
|
+
FROM cart_items ci
|
|
42
|
+
LEFT JOIN products p ON p.slug = ci.product_sku
|
|
43
|
+
WHERE ci.cart_id = ?1
|
|
44
|
+
ORDER BY ci.id ASC
|
|
45
|
+
`).all(row.id).map((line: CartItemRow) => {
|
|
46
|
+
const qty = Number(line.quantity)
|
|
47
|
+
const price = Number(line.unit_price)
|
|
48
|
+
const lineTotal = Number(line.total_price)
|
|
49
|
+
return {
|
|
50
|
+
id: line.id,
|
|
51
|
+
slug: line.product_sku,
|
|
52
|
+
name: line.product_name,
|
|
53
|
+
weight: line.weight_grams ? `${line.weight_grams}g` : '',
|
|
54
|
+
qty,
|
|
55
|
+
priceLabel: `$${price.toFixed(2)}`,
|
|
56
|
+
lineTotal,
|
|
57
|
+
lineTotalLabel: `$${lineTotal.toFixed(2)}`,
|
|
58
|
+
image: line.product_image,
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
return { cart: row, items: lines }
|
|
57
63
|
})
|
|
58
|
-
}
|
|
59
|
-
|
|
64
|
+
: { cart: null, items: [] }
|
|
65
|
+
|
|
66
|
+
const cart = found.cart
|
|
67
|
+
const items = found.items
|
|
60
68
|
|
|
61
69
|
// Mirror the rule used by PlaceOrderAction so the price the shopper
|
|
62
70
|
// sees in their cart matches the total they're actually charged. Both
|
|
@@ -3,45 +3,55 @@
|
|
|
3
3
|
@section('title', 'Checkout — Contact')
|
|
4
4
|
|
|
5
5
|
<script server>
|
|
6
|
-
|
|
7
|
-
// The columns this query selects. bun:sqlite returns `any`, so nothing
|
|
8
|
-
// downstream of the map was checked either.
|
|
6
|
+
// The columns the line query selects.
|
|
9
7
|
interface CartSummaryRow {
|
|
10
8
|
product_name: string
|
|
11
9
|
quantity: number | string
|
|
12
10
|
total_price: number | string
|
|
13
11
|
}
|
|
14
12
|
|
|
13
|
+
// The columns of `carts` this page reads. The query selects `*`, and no
|
|
14
|
+
// migration creates any but `id` (see app/Storefront/StorefrontDatabase.ts).
|
|
15
|
+
interface CartRow {
|
|
16
|
+
id: number
|
|
17
|
+
email: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
interface StorefrontLineItem { lineTotal: number }
|
|
16
21
|
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
22
|
+
const { verifyCartCookie } = require('../../../app/Storefront/CartCookie')
|
|
23
|
+
const { readStorefront } = require('../../../app/Storefront/StorefrontDatabase')
|
|
24
|
+
|
|
25
|
+
// Signed by the storefront actions as the token, a dot and its HMAC. An
|
|
26
|
+
// unsigned or tampered cookie is no cart, and is never looked up.
|
|
27
|
+
const token = verifyCartCookie(requestContext.cookie('stacks_cart'))
|
|
28
|
+
|
|
29
|
+
const found = token
|
|
30
|
+
? readStorefront('views/checkout/contact.stx', { cart: null, items: [] }, (db: import('bun:sqlite').Database) => {
|
|
31
|
+
const row = db.query<CartRow, [string]>(`SELECT * FROM carts WHERE session_token = ?1 AND status = 'active' LIMIT 1`).get(token)
|
|
32
|
+
if (!row)
|
|
33
|
+
return { cart: null, items: [] }
|
|
34
|
+
|
|
35
|
+
const lines = db.query<CartSummaryRow, [number]>(`
|
|
36
|
+
SELECT product_name, quantity, total_price
|
|
37
|
+
FROM cart_items WHERE cart_id = ?1 ORDER BY id ASC
|
|
38
|
+
`).all(row.id).map((line: CartSummaryRow) => {
|
|
39
|
+
const qty = Number(line.quantity)
|
|
40
|
+
const lineTotal = Number(line.total_price)
|
|
41
|
+
return {
|
|
42
|
+
name: line.product_name,
|
|
43
|
+
qty,
|
|
44
|
+
lineTotal,
|
|
45
|
+
lineTotalLabel: `$${lineTotal.toFixed(2)}`,
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return { cart: row, items: lines }
|
|
42
50
|
})
|
|
43
|
-
}
|
|
44
|
-
|
|
51
|
+
: { cart: null, items: [] }
|
|
52
|
+
|
|
53
|
+
const cart = found.cart
|
|
54
|
+
const items = found.items
|
|
45
55
|
|
|
46
56
|
// Mirror the rule in PlaceOrderAction's _shipping helper so the running
|
|
47
57
|
// totals on every checkout step match what gets charged.
|
|
@@ -3,45 +3,60 @@
|
|
|
3
3
|
@section('title', 'Checkout — Payment')
|
|
4
4
|
|
|
5
5
|
<script server>
|
|
6
|
-
|
|
7
|
-
// The columns this query selects. bun:sqlite returns `any`, so nothing
|
|
8
|
-
// downstream of the map was checked either.
|
|
6
|
+
// The columns the line query selects.
|
|
9
7
|
interface CartSummaryRow {
|
|
10
8
|
product_name: string
|
|
11
9
|
quantity: number | string
|
|
12
10
|
total_price: number | string
|
|
13
11
|
}
|
|
14
12
|
|
|
13
|
+
// The columns of `carts` this page reads. The query selects `*`, and no
|
|
14
|
+
// migration creates any but `id` (see app/Storefront/StorefrontDatabase.ts).
|
|
15
|
+
interface CartRow {
|
|
16
|
+
id: number
|
|
17
|
+
email: string | null
|
|
18
|
+
shipping_name: string | null
|
|
19
|
+
shipping_address: string | null
|
|
20
|
+
shipping_city: string | null
|
|
21
|
+
shipping_state: string | null
|
|
22
|
+
shipping_zip: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
interface StorefrontLineItem { lineTotal: number }
|
|
16
26
|
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
27
|
+
const { verifyCartCookie } = require('../../../app/Storefront/CartCookie')
|
|
28
|
+
const { readStorefront } = require('../../../app/Storefront/StorefrontDatabase')
|
|
29
|
+
|
|
30
|
+
// Signed by the storefront actions as the token, a dot and its HMAC. An
|
|
31
|
+
// unsigned or tampered cookie is no cart, and is never looked up.
|
|
32
|
+
const token = verifyCartCookie(requestContext.cookie('stacks_cart'))
|
|
33
|
+
|
|
34
|
+
const found = token
|
|
35
|
+
? readStorefront('views/checkout/payment.stx', { cart: null, items: [] }, (db: import('bun:sqlite').Database) => {
|
|
36
|
+
const row = db.query<CartRow, [string]>(`SELECT * FROM carts WHERE session_token = ?1 AND status = 'active' LIMIT 1`).get(token)
|
|
37
|
+
if (!row)
|
|
38
|
+
return { cart: null, items: [] }
|
|
39
|
+
|
|
40
|
+
const lines = db.query<CartSummaryRow, [number]>(`
|
|
41
|
+
SELECT product_name, quantity, total_price
|
|
42
|
+
FROM cart_items WHERE cart_id = ?1 ORDER BY id ASC
|
|
43
|
+
`).all(row.id).map((line: CartSummaryRow) => {
|
|
44
|
+
const qty = Number(line.quantity)
|
|
45
|
+
const lineTotal = Number(line.total_price)
|
|
46
|
+
return {
|
|
47
|
+
name: line.product_name,
|
|
48
|
+
qty,
|
|
49
|
+
lineTotal,
|
|
50
|
+
lineTotalLabel: `$${lineTotal.toFixed(2)}`,
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return { cart: row, items: lines }
|
|
42
55
|
})
|
|
43
|
-
}
|
|
44
|
-
|
|
56
|
+
: { cart: null, items: [] }
|
|
57
|
+
|
|
58
|
+
const cart = found.cart
|
|
59
|
+
const items = found.items
|
|
45
60
|
|
|
46
61
|
const FREE_SHIPPING_THRESHOLD = Number(process.env.STOREFRONT_FREE_SHIPPING_THRESHOLD ?? 40) || 40
|
|
47
62
|
const FLAT_SHIPPING = Number(process.env.STOREFRONT_FLAT_SHIPPING ?? 5) || 5
|
|
@@ -3,45 +3,60 @@
|
|
|
3
3
|
@section('title', 'Checkout — Shipping')
|
|
4
4
|
|
|
5
5
|
<script server>
|
|
6
|
-
|
|
7
|
-
// The columns this query selects. bun:sqlite returns `any`, so nothing
|
|
8
|
-
// downstream of the map was checked either.
|
|
6
|
+
// The columns the line query selects.
|
|
9
7
|
interface CartSummaryRow {
|
|
10
8
|
product_name: string
|
|
11
9
|
quantity: number | string
|
|
12
10
|
total_price: number | string
|
|
13
11
|
}
|
|
14
12
|
|
|
13
|
+
// The columns of `carts` this page reads. The query selects `*`, and no
|
|
14
|
+
// migration creates any but `id` (see app/Storefront/StorefrontDatabase.ts).
|
|
15
|
+
interface CartRow {
|
|
16
|
+
id: number
|
|
17
|
+
email: string | null
|
|
18
|
+
shipping_name: string | null
|
|
19
|
+
shipping_address: string | null
|
|
20
|
+
shipping_city: string | null
|
|
21
|
+
shipping_state: string | null
|
|
22
|
+
shipping_zip: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
interface StorefrontLineItem { lineTotal: number }
|
|
16
26
|
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
27
|
+
const { verifyCartCookie } = require('../../../app/Storefront/CartCookie')
|
|
28
|
+
const { readStorefront } = require('../../../app/Storefront/StorefrontDatabase')
|
|
29
|
+
|
|
30
|
+
// Signed by the storefront actions as the token, a dot and its HMAC. An
|
|
31
|
+
// unsigned or tampered cookie is no cart, and is never looked up.
|
|
32
|
+
const token = verifyCartCookie(requestContext.cookie('stacks_cart'))
|
|
33
|
+
|
|
34
|
+
const found = token
|
|
35
|
+
? readStorefront('views/checkout/shipping.stx', { cart: null, items: [] }, (db: import('bun:sqlite').Database) => {
|
|
36
|
+
const row = db.query<CartRow, [string]>(`SELECT * FROM carts WHERE session_token = ?1 AND status = 'active' LIMIT 1`).get(token)
|
|
37
|
+
if (!row)
|
|
38
|
+
return { cart: null, items: [] }
|
|
39
|
+
|
|
40
|
+
const lines = db.query<CartSummaryRow, [number]>(`
|
|
41
|
+
SELECT product_name, quantity, total_price
|
|
42
|
+
FROM cart_items WHERE cart_id = ?1 ORDER BY id ASC
|
|
43
|
+
`).all(row.id).map((line: CartSummaryRow) => {
|
|
44
|
+
const qty = Number(line.quantity)
|
|
45
|
+
const lineTotal = Number(line.total_price)
|
|
46
|
+
return {
|
|
47
|
+
name: line.product_name,
|
|
48
|
+
qty,
|
|
49
|
+
lineTotal,
|
|
50
|
+
lineTotalLabel: `$${lineTotal.toFixed(2)}`,
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return { cart: row, items: lines }
|
|
42
55
|
})
|
|
43
|
-
}
|
|
44
|
-
|
|
56
|
+
: { cart: null, items: [] }
|
|
57
|
+
|
|
58
|
+
const cart = found.cart
|
|
59
|
+
const items = found.items
|
|
45
60
|
|
|
46
61
|
const FREE_SHIPPING_THRESHOLD = Number(process.env.STOREFRONT_FREE_SHIPPING_THRESHOLD ?? 40) || 40
|
|
47
62
|
const FLAT_SHIPPING = Number(process.env.STOREFRONT_FLAT_SHIPPING ?? 5) || 5
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Read the storefront cart token out of the request.
|
|
3
|
-
*
|
|
4
|
-
* The cart and the three checkout steps each opened with
|
|
5
|
-
*
|
|
6
|
-
* const token = (typeof requestContext !== 'undefined')
|
|
7
|
-
* ? requestContext.cookie('stacks_cart')
|
|
8
|
-
* : null
|
|
9
|
-
*
|
|
10
|
-
* and `requestContext` is not a binding stx has ever put in a server script's
|
|
11
|
-
* scope. It is the NAME OF A PARAMETER inside the template hydrator, and the
|
|
12
|
-
* page context a server assembles carries `params`, `request` and `method`.
|
|
13
|
-
* So the `typeof` guard was always false, `token` was always null, and every
|
|
14
|
-
* one of those pages rendered its empty-cart branch no matter what was in the
|
|
15
|
-
* cart. The guard is what hid it: without it the pages would have thrown on
|
|
16
|
-
* the first request instead of quietly showing nothing.
|
|
17
|
-
*
|
|
18
|
-
* `request` is really provided, so the cookie is read from its header.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/** The cookie the storefront stores its cart session token in. */
|
|
22
|
-
export const CART_COOKIE = 'stacks_cart'
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* The value of `name` in a request's Cookie header, or null.
|
|
26
|
-
*
|
|
27
|
-
* Values are percent-encoded on the way in, so they are decoded here. A
|
|
28
|
-
* malformed encoding yields the raw value rather than throwing, since a
|
|
29
|
-
* cookie is attacker-supplied and a render must not fail on one.
|
|
30
|
-
*/
|
|
31
|
-
export function cookieFromRequest(request: Request | undefined, name: string): string | null {
|
|
32
|
-
const header = request?.headers?.get('cookie')
|
|
33
|
-
if (!header)
|
|
34
|
-
return null
|
|
35
|
-
|
|
36
|
-
for (const part of header.split(';')) {
|
|
37
|
-
const index = part.indexOf('=')
|
|
38
|
-
if (index < 0)
|
|
39
|
-
continue
|
|
40
|
-
if (part.slice(0, index).trim() !== name)
|
|
41
|
-
continue
|
|
42
|
-
|
|
43
|
-
const raw = part.slice(index + 1).trim()
|
|
44
|
-
try {
|
|
45
|
-
return decodeURIComponent(raw)
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
return raw
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return null
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** The storefront cart token carried by this request, or null. */
|
|
56
|
-
export function cartTokenFromRequest(request: Request | undefined): string | null {
|
|
57
|
-
return cookieFromRequest(request, CART_COOKIE)
|
|
58
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|