@merijnslegers/admin 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +231 -2
- package/package.json +40 -5
- package/src/resize.d.ts +33 -0
- package/src/resize.js +113 -0
- package/src/rules.d.ts +72 -0
- package/src/rules.js +209 -0
- package/src/server.d.ts +54 -0
- package/src/server.js +213 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Merijn Slegers
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,232 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @merijnslegers/admin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The admin login of the software factory (F-0011), and image resizing in the
|
|
4
|
+
browser (F-0008). Each person signs in to `/admin` with their own email address
|
|
5
|
+
and password through Supabase Auth. Who may enter is an allow list the project
|
|
6
|
+
passes in, checked on every request.
|
|
7
|
+
|
|
8
|
+
The package holds the login, the access rules and the headers. The screens, the
|
|
9
|
+
components, the navigation and every sentence a user reads stay in the project,
|
|
10
|
+
in its own look and language.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pnpm add @merijnslegers/admin @supabase/ssr @supabase/supabase-js
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Import | What it holds | Where it runs |
|
|
17
|
+
| ----------------------------- | ------------------------------------------------------------------------ | ------------- |
|
|
18
|
+
| `@merijnslegers/admin/server` | `createAdmin`: the hook and the functions for the login screens | server only |
|
|
19
|
+
| `@merijnslegers/admin/rules` | The pure rules: allow list, return path, Origin check, headers, password | anywhere |
|
|
20
|
+
| `@merijnslegers/admin/resize` | `resizeImage` and `resizeFormFiles` | browser |
|
|
21
|
+
|
|
22
|
+
## Connecting a project
|
|
23
|
+
|
|
24
|
+
**The settings.** Four variables, declared in `src/env.ts` and read at runtime
|
|
25
|
+
(F-0005). The package reads none of them itself.
|
|
26
|
+
|
|
27
|
+
| Variable | What it is |
|
|
28
|
+
| --------------------------- | ----------------------------------------------------------------------- |
|
|
29
|
+
| `SUPABASE_URL` | The project's Supabase address (F-0009) |
|
|
30
|
+
| `SUPABASE_ANON_KEY` | Only for logging in; it reads or writes no table |
|
|
31
|
+
| `SUPABASE_SERVICE_ROLE_KEY` | Already there for the database (F-0004); here only to create a new user |
|
|
32
|
+
| `ADMIN_EMAILS` | Who may enter: addresses separated by commas, spaces or new lines |
|
|
33
|
+
|
|
34
|
+
Without one of them nobody gets in, the build still works, and
|
|
35
|
+
`missingSettings()` names what is missing so the login screen can show it.
|
|
36
|
+
A project whose variables are named differently passes `settingNames`.
|
|
37
|
+
|
|
38
|
+
**One module**, `src/lib/server/admin.ts`:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createAdmin } from '@merijnslegers/admin/server';
|
|
42
|
+
import {
|
|
43
|
+
ADMIN_EMAILS,
|
|
44
|
+
SUPABASE_ANON_KEY,
|
|
45
|
+
SUPABASE_SERVICE_ROLE_KEY,
|
|
46
|
+
SUPABASE_URL
|
|
47
|
+
} from '$app/env/private';
|
|
48
|
+
|
|
49
|
+
export const admin = createAdmin({
|
|
50
|
+
supabaseUrl: SUPABASE_URL,
|
|
51
|
+
anonKey: SUPABASE_ANON_KEY,
|
|
52
|
+
serviceRoleKey: SUPABASE_SERVICE_ROLE_KEY,
|
|
53
|
+
allowList: ADMIN_EMAILS,
|
|
54
|
+
// Optional: a project in Dutch names its screens in Dutch.
|
|
55
|
+
paths: { login: '/admin/inloggen', password: '/admin/wachtwoord' },
|
|
56
|
+
// Optional: the hook's 401 and 403 in the project's own words.
|
|
57
|
+
responses: {
|
|
58
|
+
unauthorized: 'U bent niet ingelogd.',
|
|
59
|
+
forbidden: 'Dit verzoek kwam niet van onze eigen site.'
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**The hook**, in `src/hooks.server.ts`. With other hooks, combine them with
|
|
65
|
+
`sequence`.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { admin } from '#lib/server/admin.js';
|
|
69
|
+
|
|
70
|
+
export const handle = admin.handle;
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For everything under the admin path it:
|
|
74
|
+
|
|
75
|
+
- lets the login and password screens through without a session;
|
|
76
|
+
- sends a page, a link from a mail or a form without a session to the login
|
|
77
|
+
screen, and back to where it was afterwards (only within the admin path);
|
|
78
|
+
- answers any other request without a session with a 401;
|
|
79
|
+
- answers a request that can change something with a 403 when it does not come
|
|
80
|
+
from the site's own origin, even when SvelteKit's own check is off for a
|
|
81
|
+
webhook;
|
|
82
|
+
- gives that 401 or 403 the text from `responses` as plain text, or an empty
|
|
83
|
+
body without it, so the package chooses no words of its own;
|
|
84
|
+
- puts `ADMIN_HEADERS` on every response: no framing, `noindex`, `no-store`,
|
|
85
|
+
a referrer that stays on the site.
|
|
86
|
+
|
|
87
|
+
Someone signed in is in `event.locals.admin` (`{ email }`). Declare it in
|
|
88
|
+
`src/app.d.ts`:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
declare global {
|
|
92
|
+
namespace App {
|
|
93
|
+
interface Locals {
|
|
94
|
+
admin?: import('@merijnslegers/admin/server').Admin;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A webhook belongs under `/api`, never under the admin path.
|
|
101
|
+
|
|
102
|
+
**Remote functions** are called at `/_app/remote/...`, outside the admin path,
|
|
103
|
+
so the hook does not guard them. An admin remote function checks
|
|
104
|
+
`admin.currentAdmin(cookies)` itself, first thing.
|
|
105
|
+
|
|
106
|
+
**The screens.** The login and password screens use `+page@.svelte`, so they do
|
|
107
|
+
not hang under the admin layout, which belongs to someone signed in. Their
|
|
108
|
+
actions call the package and turn its outcome into the project's own words:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// src/routes/admin/inloggen/+page.server.ts
|
|
112
|
+
import { fail, redirect } from '@sveltejs/kit';
|
|
113
|
+
import { safeReturnPath } from '@merijnslegers/admin/rules';
|
|
114
|
+
import { admin } from '#lib/server/admin.js';
|
|
115
|
+
|
|
116
|
+
const TEXT = {
|
|
117
|
+
invalid_credentials: 'Dit e-mailadres en wachtwoord horen niet bij elkaar.',
|
|
118
|
+
rate_limited: 'Te veel pogingen. Wacht een paar minuten.',
|
|
119
|
+
missing_setting: 'Dit e-mailadres en wachtwoord horen niet bij elkaar.'
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const load = async ({ cookies, url }) => {
|
|
123
|
+
const to = safeReturnPath(url.searchParams.get('to'), admin.paths);
|
|
124
|
+
if (await admin.currentAdmin(cookies)) redirect(303, to);
|
|
125
|
+
return { to, missing: admin.missingSettings() };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const actions = {
|
|
129
|
+
signIn: async ({ request, cookies }) => {
|
|
130
|
+
const data = await request.formData();
|
|
131
|
+
const email = String(data.get('email') ?? '');
|
|
132
|
+
const outcome = await admin.signIn(cookies, email, String(data.get('password') ?? ''));
|
|
133
|
+
if (outcome !== 'ok')
|
|
134
|
+
return fail(outcome === 'rate_limited' ? 429 : 400, { email, error: TEXT[outcome] });
|
|
135
|
+
redirect(303, safeReturnPath(String(data.get('to') ?? ''), admin.paths));
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
| Function | Outcomes |
|
|
141
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
142
|
+
| `signIn(cookies, email, password)` | `ok`, `invalid_credentials`, `rate_limited`, `missing_setting` |
|
|
143
|
+
| `sendPasswordLink(cookies, email, origin)` | `ok`, `invalid_email`, `rate_limited`, `send_failed` |
|
|
144
|
+
| `setPassword(cookies, input)` | `ok`, `password_too_short`, `passwords_differ`, `link_invalid`, `not_signed_in`, `weak_password`, `save_failed`, `missing_setting` |
|
|
145
|
+
| `signOut(cookies)` | nothing |
|
|
146
|
+
|
|
147
|
+
Some outcomes say nothing on purpose:
|
|
148
|
+
|
|
149
|
+
- An address that is not on the list gets `invalid_credentials`, just like a
|
|
150
|
+
wrong password, and Supabase is not called.
|
|
151
|
+
- `sendPasswordLink` answers `ok` for every valid address, and only one on the
|
|
152
|
+
list gets a mail.
|
|
153
|
+
|
|
154
|
+
So no screen gives away who has access.
|
|
155
|
+
|
|
156
|
+
**A password link** works for a first password and a forgotten one alike:
|
|
157
|
+
|
|
158
|
+
- `sendPasswordLink` creates the user if they do not exist yet. You never create
|
|
159
|
+
one by hand.
|
|
160
|
+
- On the password screen, `readPasswordLink(url)` reads the link in `load` and
|
|
161
|
+
puts it in the form.
|
|
162
|
+
- `setPassword` uses the link only once the password is saved, because mail
|
|
163
|
+
programs and virus scanners open links in advance.
|
|
164
|
+
- Both kinds of link work: `token_hash` (your own mail template, any browser)
|
|
165
|
+
and `code` (Supabase's default template, only in the browser that asked for
|
|
166
|
+
it).
|
|
167
|
+
- Someone signed in sets a new password on the same screen, without a link.
|
|
168
|
+
- A password is at least `PASSWORD_MIN_LENGTH` (10) characters.
|
|
169
|
+
|
|
170
|
+
## Settings in Supabase
|
|
171
|
+
|
|
172
|
+
Under Authentication:
|
|
173
|
+
|
|
174
|
+
- **URL Configuration, Redirect URLs:** the password screen on the production
|
|
175
|
+
domain, such as `https://www.<domain>/admin/password`, and on the previews.
|
|
176
|
+
- Mind `www`: the link is built from the address the person is on.
|
|
177
|
+
- Supabase replaces an address that is not listed by the Site URL, and then
|
|
178
|
+
the link does not work.
|
|
179
|
+
- **"Allow new users to sign up"** may be off. The package creates users with
|
|
180
|
+
the `service_role` key, which does not look at it.
|
|
181
|
+
- **Emails, SMTP Settings:** your own mail server, through Resend
|
|
182
|
+
(`smtp.resend.com`, port 465, user `resend`, an API key of its own as
|
|
183
|
+
password). Without it, Supabase only mails members of its own organisation, a
|
|
184
|
+
few per hour, and you cannot change the templates.
|
|
185
|
+
- **Emails, template "Reset password":** the link as
|
|
186
|
+
`{{ .RedirectTo }}?token_hash={{ .TokenHash }}` instead of
|
|
187
|
+
`{{ .ConfirmationURL }}`. It then goes straight to your own screen and is used
|
|
188
|
+
only on saving.
|
|
189
|
+
|
|
190
|
+
## Resizing images
|
|
191
|
+
|
|
192
|
+
In the browser, before the upload (F-0008). The project sets the width and the
|
|
193
|
+
format per kind of image, in one place:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { resizeFormFiles, resizeImage } from '@merijnslegers/admin/resize';
|
|
197
|
+
|
|
198
|
+
const SCREENSHOT = { maxWidth: 2000 }; // webp, for the site
|
|
199
|
+
const MAIL_PHOTO = { maxWidth: 1200, format: 'mail' } as const; // JPEG, PNG kept
|
|
200
|
+
|
|
201
|
+
await resizeFormFiles(formData, 'images', SCREENSHOT);
|
|
202
|
+
const photo = await resizeImage(file, MAIL_PHOTO);
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- **`webp`** (the default) for images on the site.
|
|
206
|
+
- **`mail`** for images in an email. Many mail clients (Outlook) do not show
|
|
207
|
+
webp, so the image becomes JPEG, and a PNG stays PNG because it often holds
|
|
208
|
+
transparency.
|
|
209
|
+
- A phone photo stays upright (its EXIF orientation).
|
|
210
|
+
- An image that is already narrow enough and under 1 MB is left alone.
|
|
211
|
+
- SVG and GIF are never drawn again.
|
|
212
|
+
- When the browser cannot do it, or the result is not smaller, the original
|
|
213
|
+
goes and the server decides. With `mail`, a webp or AVIF is always turned
|
|
214
|
+
into JPEG, even when that is larger.
|
|
215
|
+
- A browser without `OffscreenCanvas` draws on an ordinary canvas instead.
|
|
216
|
+
|
|
217
|
+
## Tests
|
|
218
|
+
|
|
219
|
+
The rules and the resize plan are pure; `npm test` at the repo root runs their
|
|
220
|
+
tests. A project's own tests use the same rules without Supabase, for instance
|
|
221
|
+
for its own screen names:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
// F-0011
|
|
225
|
+
import { adminPaths, safeReturnPath } from '@merijnslegers/admin/rules';
|
|
226
|
+
|
|
227
|
+
const paths = adminPaths({ login: '/admin/inloggen', password: '/admin/wachtwoord' });
|
|
228
|
+
|
|
229
|
+
it('never sends you back to the login screen', () => {
|
|
230
|
+
expect(safeReturnPath('/admin/inloggen', paths)).toBe('/admin');
|
|
231
|
+
});
|
|
232
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@merijnslegers/admin",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "The admin login of the software factory: Supabase Auth with an allow list, the access rules and headers, and image resizing in the browser.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
"./rules": {
|
|
9
|
+
"types": "./src/rules.d.ts",
|
|
10
|
+
"default": "./src/rules.js"
|
|
11
|
+
},
|
|
12
|
+
"./server": {
|
|
13
|
+
"types": "./src/server.d.ts",
|
|
14
|
+
"default": "./src/server.js"
|
|
15
|
+
},
|
|
16
|
+
"./resize": {
|
|
17
|
+
"types": "./src/resize.d.ts",
|
|
18
|
+
"default": "./src/resize.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=22.17"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@supabase/ssr": "^0.12.7",
|
|
30
|
+
"@supabase/supabase-js": "^2.118.0",
|
|
31
|
+
"@sveltejs/kit": "^3.0.0"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/Merijnslegers90/software-factory.git",
|
|
36
|
+
"directory": "packages/admin"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/resize.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** `webp` for images on the site; `mail` (JPEG, PNG kept) for images in an email. */
|
|
2
|
+
export type ImageFormat = 'webp' | 'mail';
|
|
3
|
+
|
|
4
|
+
export interface ResizeOptions {
|
|
5
|
+
/** The maximum width for this kind of image, in pixels. */
|
|
6
|
+
maxWidth: number;
|
|
7
|
+
format?: ImageFormat;
|
|
8
|
+
/** 0 to 1, for webp and JPEG. 0.85 by default. */
|
|
9
|
+
quality?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ResizePlan {
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
type: 'image/webp' | 'image/jpeg' | 'image/png';
|
|
16
|
+
extension: 'webp' | 'jpg' | 'png';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const SKIP_BELOW_BYTES: number;
|
|
20
|
+
export function keepsAsIs(type: string): boolean;
|
|
21
|
+
export function mustConvert(type: string, format: ImageFormat): boolean;
|
|
22
|
+
export function targetType(type: string, format: ImageFormat): ResizePlan['type'];
|
|
23
|
+
export function resizePlan(
|
|
24
|
+
image: { type: string; size: number; width: number; height: number },
|
|
25
|
+
options: { maxWidth: number; format?: ImageFormat }
|
|
26
|
+
): ResizePlan | null;
|
|
27
|
+
export function renamed(name: string, extension: string): string;
|
|
28
|
+
export function resizeImage(file: File, options: ResizeOptions): Promise<File>;
|
|
29
|
+
export function resizeFormFiles(
|
|
30
|
+
formData: FormData,
|
|
31
|
+
field: string,
|
|
32
|
+
options: ResizeOptions
|
|
33
|
+
): Promise<void>;
|
package/src/resize.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Resizes images in the browser before they are uploaded (F-0008). A function
|
|
2
|
+
// on Vercel accepts about 4.5 MB per request, and a photo from a phone is a few
|
|
3
|
+
// megabytes. The plan (what to do with which file) is pure and tested; the
|
|
4
|
+
// drawing itself needs a browser.
|
|
5
|
+
|
|
6
|
+
/** Leave an image alone that is already narrow enough and smaller than this: compressing it again only makes it worse. */
|
|
7
|
+
export const SKIP_BELOW_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
/** SVG is not drawn on a canvas; a GIF would lose its animation. */
|
|
10
|
+
export function keepsAsIs(type) {
|
|
11
|
+
return !/^image\//.test(type) || type === 'image/gif' || type === 'image/svg+xml';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The type a resized image gets. `webp` for the site. `mail` for an image in an
|
|
16
|
+
* email: many mail clients (Outlook) do not show webp, so it becomes JPEG, and
|
|
17
|
+
* a PNG stays PNG because it often holds transparency (a logo) that turns
|
|
18
|
+
* black in a JPEG.
|
|
19
|
+
*/
|
|
20
|
+
export function targetType(type, format) {
|
|
21
|
+
if (format === 'mail') return type === 'image/png' ? 'image/png' : 'image/jpeg';
|
|
22
|
+
return 'image/webp';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether the original may not go as it is, whatever its size: a webp or AVIF
|
|
27
|
+
* in a mail would not show in many mail clients.
|
|
28
|
+
*/
|
|
29
|
+
export function mustConvert(type, format) {
|
|
30
|
+
return format === 'mail' && type !== 'image/jpeg' && type !== 'image/png';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const EXTENSIONS = { 'image/webp': 'webp', 'image/jpeg': 'jpg', 'image/png': 'png' };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What to do with an image of this type, size and width: null to keep the
|
|
37
|
+
* original, or the size and type to draw it at.
|
|
38
|
+
*/
|
|
39
|
+
export function resizePlan({ type, size, width, height }, { maxWidth, format = 'webp' }) {
|
|
40
|
+
if (keepsAsIs(type)) return null;
|
|
41
|
+
if (width <= maxWidth && size <= SKIP_BELOW_BYTES && !mustConvert(type, format)) return null;
|
|
42
|
+
const scale = Math.min(1, maxWidth / width);
|
|
43
|
+
const target = targetType(type, format);
|
|
44
|
+
return {
|
|
45
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
46
|
+
height: Math.max(1, Math.round(height * scale)),
|
|
47
|
+
type: target,
|
|
48
|
+
extension: EXTENSIONS[target]
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The file name with the extension of the new type; the server derives the storage path from it. */
|
|
53
|
+
export function renamed(name, extension) {
|
|
54
|
+
return `${String(name).replace(/\.[^.]+$/, '') || 'image'}.${extension}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resizes one image. When it cannot (a format the browser cannot unpack, no
|
|
59
|
+
* canvas) or the result is not smaller, the original goes and the server
|
|
60
|
+
* decides.
|
|
61
|
+
*/
|
|
62
|
+
export async function resizeImage(file, { maxWidth, format = 'webp', quality = 0.85 }) {
|
|
63
|
+
if (keepsAsIs(file.type)) return file;
|
|
64
|
+
let bitmap;
|
|
65
|
+
try {
|
|
66
|
+
// from-image: otherwise an upright phone photo comes back lying down,
|
|
67
|
+
// because the rotation is only in its EXIF data.
|
|
68
|
+
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
|
|
69
|
+
const plan = resizePlan(
|
|
70
|
+
{ type: file.type, size: file.size, width: bitmap.width, height: bitmap.height },
|
|
71
|
+
{ maxWidth, format }
|
|
72
|
+
);
|
|
73
|
+
if (!plan) return file;
|
|
74
|
+
|
|
75
|
+
const blob = await draw(bitmap, plan, plan.type === 'image/png' ? undefined : quality);
|
|
76
|
+
// A browser that cannot write the type (webp in an older Safari) quietly
|
|
77
|
+
// gives a PNG instead; then the original is the better choice.
|
|
78
|
+
if (!blob || blob.type !== plan.type) return file;
|
|
79
|
+
if (blob.size >= file.size && !mustConvert(file.type, format)) return file;
|
|
80
|
+
return new File([blob], renamed(file.name, plan.extension), { type: plan.type });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error('Resizing failed, the original is used:', err);
|
|
83
|
+
return file;
|
|
84
|
+
} finally {
|
|
85
|
+
bitmap?.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function draw(bitmap, plan, quality) {
|
|
90
|
+
if (typeof OffscreenCanvas !== 'undefined') {
|
|
91
|
+
const canvas = new OffscreenCanvas(plan.width, plan.height);
|
|
92
|
+
const context = canvas.getContext('2d');
|
|
93
|
+
if (!context) return null;
|
|
94
|
+
context.drawImage(bitmap, 0, 0, plan.width, plan.height);
|
|
95
|
+
return canvas.convertToBlob({ type: plan.type, quality });
|
|
96
|
+
}
|
|
97
|
+
const canvas = document.createElement('canvas');
|
|
98
|
+
canvas.width = plan.width;
|
|
99
|
+
canvas.height = plan.height;
|
|
100
|
+
const context = canvas.getContext('2d');
|
|
101
|
+
if (!context) return null;
|
|
102
|
+
context.drawImage(bitmap, 0, 0, plan.width, plan.height);
|
|
103
|
+
return new Promise((done) => canvas.toBlob(done, plan.type, quality));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Replaces the files in one field of a FormData by resized versions. */
|
|
107
|
+
export async function resizeFormFiles(formData, field, options) {
|
|
108
|
+
const files = formData.getAll(field).filter((v) => v instanceof File && v.size > 0);
|
|
109
|
+
formData.delete(field);
|
|
110
|
+
for (const file of await Promise.all(files.map((f) => resizeImage(f, options)))) {
|
|
111
|
+
formData.append(field, file);
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/rules.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface AdminPaths {
|
|
2
|
+
/** The admin path, `/admin` by default. */
|
|
3
|
+
base: string;
|
|
4
|
+
/** The login screen, `<base>/login` by default. */
|
|
5
|
+
login: string;
|
|
6
|
+
/** The password screen, `<base>/password` by default. */
|
|
7
|
+
password: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type AdminDecision =
|
|
11
|
+
| { kind: 'pass' }
|
|
12
|
+
| { kind: 'redirect'; location: string }
|
|
13
|
+
| { kind: 'unauthorized' }
|
|
14
|
+
| { kind: 'forbidden' };
|
|
15
|
+
|
|
16
|
+
/** The project's own words for the hook's 401 and 403. A missing one is an empty body. */
|
|
17
|
+
export interface AdminResponses {
|
|
18
|
+
unauthorized?: string;
|
|
19
|
+
forbidden?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SettingNames {
|
|
23
|
+
supabaseUrl: string;
|
|
24
|
+
anonKey: string;
|
|
25
|
+
serviceRoleKey: string;
|
|
26
|
+
allowList: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const PASSWORD_MIN_LENGTH: number;
|
|
30
|
+
export const ADMIN_HEADERS: Readonly<Record<string, string>>;
|
|
31
|
+
|
|
32
|
+
export function adminPaths(paths?: Partial<AdminPaths>): AdminPaths;
|
|
33
|
+
export function decodePath(pathname: string): string | null;
|
|
34
|
+
export function isAdminPath(pathname: string, paths: AdminPaths): boolean;
|
|
35
|
+
export function isOpenPath(pathname: string, paths: AdminPaths): boolean;
|
|
36
|
+
export function normaliseEmail(email: string | null | undefined): string;
|
|
37
|
+
export function parseAllowList(list: string | null | undefined): Set<string>;
|
|
38
|
+
export function mayManage(email: string | null | undefined, allowList: Set<string>): boolean;
|
|
39
|
+
export function passwordProblem(
|
|
40
|
+
password: string,
|
|
41
|
+
repeat: string
|
|
42
|
+
): 'password_too_short' | 'passwords_differ' | null;
|
|
43
|
+
export function safeReturnPath(to: string | null | undefined, paths: AdminPaths): string;
|
|
44
|
+
export function loginAddress(from: URL, paths: AdminPaths): string;
|
|
45
|
+
export function fromOtherSite(method: string, origin: string | null, ownOrigin: string): boolean;
|
|
46
|
+
export function adminDecision(request: {
|
|
47
|
+
url: URL;
|
|
48
|
+
method: string;
|
|
49
|
+
contentType: string | null;
|
|
50
|
+
origin: string | null;
|
|
51
|
+
signedIn: boolean;
|
|
52
|
+
paths: AdminPaths;
|
|
53
|
+
}): AdminDecision;
|
|
54
|
+
export function refusal(kind: 'unauthorized' | 'forbidden', responses?: AdminResponses): Response;
|
|
55
|
+
export function missingSettings(
|
|
56
|
+
settings: {
|
|
57
|
+
supabaseUrl: string;
|
|
58
|
+
anonKey: string;
|
|
59
|
+
allowList: Set<string>;
|
|
60
|
+
},
|
|
61
|
+
names: SettingNames
|
|
62
|
+
): string[];
|
|
63
|
+
export function signInOutcome(
|
|
64
|
+
error: { status?: number; code?: string } | null
|
|
65
|
+
): 'ok' | 'rate_limited' | 'invalid_credentials';
|
|
66
|
+
export function linkOutcome(
|
|
67
|
+
error: { status?: number; code?: string } | null
|
|
68
|
+
): 'ok' | 'rate_limited' | 'send_failed';
|
|
69
|
+
export function passwordOutcome(
|
|
70
|
+
error: { status?: number; code?: string } | null
|
|
71
|
+
): 'ok' | 'weak_password' | 'save_failed';
|
|
72
|
+
export function readPasswordLink(url: URL): { tokenHash: string; code: string; linkError: boolean };
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// The rules around the admin login (F-0011). Pure, so they are tested without
|
|
2
|
+
// Supabase or SvelteKit (F-0006). The calls to Supabase are in server.js.
|
|
3
|
+
|
|
4
|
+
/** Longer than the six characters Supabase asks by default. A short sentence or a password manager makes it easily. */
|
|
5
|
+
export const PASSWORD_MIN_LENGTH = 10;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every response under the admin path gets these headers. Not in a frame, so
|
|
9
|
+
* no other site can lay our buttons under a page of its own; not in a search
|
|
10
|
+
* engine; not in a cache; and no admin address (with an email or an invoice in
|
|
11
|
+
* it) as referrer to another service.
|
|
12
|
+
*/
|
|
13
|
+
export const ADMIN_HEADERS = Object.freeze({
|
|
14
|
+
'Content-Security-Policy': "frame-ancestors 'none'",
|
|
15
|
+
'X-Frame-Options': 'DENY',
|
|
16
|
+
'X-Robots-Tag': 'noindex, nofollow',
|
|
17
|
+
'Referrer-Policy': 'same-origin',
|
|
18
|
+
'X-Content-Type-Options': 'nosniff',
|
|
19
|
+
'Cache-Control': 'private, no-store'
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** The admin path and its two screens that open without a session. */
|
|
23
|
+
export function adminPaths({ base = '/admin', login, password } = {}) {
|
|
24
|
+
const root = base.replace(/\/+$/, '') || '/admin';
|
|
25
|
+
return {
|
|
26
|
+
base: root,
|
|
27
|
+
login: login ?? `${root}/login`,
|
|
28
|
+
password: password ?? `${root}/password`
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The path as SvelteKit decodes it to pick a route. Comparing the raw path
|
|
34
|
+
* would let `/%61dmin/orders` reach the route `/admin/orders` past the hook.
|
|
35
|
+
* Null when it cannot be decoded.
|
|
36
|
+
*/
|
|
37
|
+
export function decodePath(pathname) {
|
|
38
|
+
try {
|
|
39
|
+
return pathname.split('%25').map(decodeURI).join('%25');
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether a path is the admin path or below it, after decoding. `/administration`
|
|
47
|
+
* is not. A path that cannot be decoded counts as admin, so it stays closed.
|
|
48
|
+
*/
|
|
49
|
+
export function isAdminPath(pathname, paths) {
|
|
50
|
+
const path = decodePath(pathname);
|
|
51
|
+
return path === null || path === paths.base || path.startsWith(paths.base + '/');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The screens under the admin path you must reach without a session, after decoding. */
|
|
55
|
+
export function isOpenPath(pathname, paths) {
|
|
56
|
+
const path = decodePath(pathname);
|
|
57
|
+
return path !== null && (path === paths.login || path === paths.password);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** An email address as we compare it: without spaces, in lower case. */
|
|
61
|
+
export function normaliseEmail(email) {
|
|
62
|
+
return String(email ?? '')
|
|
63
|
+
.trim()
|
|
64
|
+
.toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The allow list from `ADMIN_EMAILS`: addresses separated by commas,
|
|
69
|
+
* semicolons, spaces or new lines. An entry without `@` is a typo and does not
|
|
70
|
+
* count.
|
|
71
|
+
*/
|
|
72
|
+
export function parseAllowList(list) {
|
|
73
|
+
return new Set(
|
|
74
|
+
String(list ?? '')
|
|
75
|
+
.split(/[\s,;]+/)
|
|
76
|
+
.map(normaliseEmail)
|
|
77
|
+
.filter((email) => email.includes('@'))
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether this address may manage. Without an address, never. */
|
|
82
|
+
export function mayManage(email, allowList) {
|
|
83
|
+
return !!email && allowList.has(normaliseEmail(email));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What is wrong with a new password, or null when it is fine. */
|
|
87
|
+
export function passwordProblem(password, repeat) {
|
|
88
|
+
if (String(password ?? '').length < PASSWORD_MIN_LENGTH) return 'password_too_short';
|
|
89
|
+
if (password !== repeat) return 'passwords_differ';
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Where to go after logging in. The address comes from a link, so only a path
|
|
95
|
+
* within the admin path on our own site counts; otherwise a link such as
|
|
96
|
+
* `?to=https://elsewhere` would turn our login screen into a relay.
|
|
97
|
+
*/
|
|
98
|
+
export function safeReturnPath(to, paths) {
|
|
99
|
+
if (!to || !to.startsWith('/') || to.startsWith('//') || to.includes('\\')) return paths.base;
|
|
100
|
+
const origin = 'https://admin.invalid';
|
|
101
|
+
let url;
|
|
102
|
+
try {
|
|
103
|
+
url = new URL(to, origin);
|
|
104
|
+
} catch {
|
|
105
|
+
return paths.base;
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
url.origin !== origin ||
|
|
109
|
+
!isAdminPath(url.pathname, paths) ||
|
|
110
|
+
isOpenPath(url.pathname, paths)
|
|
111
|
+
) {
|
|
112
|
+
return paths.base;
|
|
113
|
+
}
|
|
114
|
+
return url.pathname + url.search;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The login screen, with the path you came from. Read back with `safeReturnPath`. */
|
|
118
|
+
export function loginAddress(from, paths) {
|
|
119
|
+
const to = from.pathname + from.search;
|
|
120
|
+
return to === paths.base ? paths.login : `${paths.login}?to=${encodeURIComponent(to)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether a request comes from another site. Every method that can change
|
|
125
|
+
* something, a fetch with JSON too, must come from our own origin. A browser
|
|
126
|
+
* always sets an Origin on such a request; without one, it was not a form on
|
|
127
|
+
* our screen. This holds even when SvelteKit's own check is off for a webhook.
|
|
128
|
+
*/
|
|
129
|
+
export function fromOtherSite(method, origin, ownOrigin) {
|
|
130
|
+
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return false;
|
|
131
|
+
return origin !== ownOrigin;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const FORM = /^(multipart\/form-data|application\/x-www-form-urlencoded)/;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* What the hook does with a request under the admin path.
|
|
138
|
+
*
|
|
139
|
+
* - `pass`: let it through.
|
|
140
|
+
* - `redirect`: a page, a link from a mail or a form without a session goes to
|
|
141
|
+
* the login screen (`location`), and back afterwards.
|
|
142
|
+
* - `unauthorized`: a fetch to an endpoint without a session gets a 401; there
|
|
143
|
+
* is nowhere to send it.
|
|
144
|
+
* - `forbidden`: it can change something and does not come from our site.
|
|
145
|
+
*/
|
|
146
|
+
export function adminDecision({ url, method, contentType, origin, signedIn, paths }) {
|
|
147
|
+
if (signedIn || isOpenPath(url.pathname, paths)) {
|
|
148
|
+
return fromOtherSite(method, origin, url.origin) ? { kind: 'forbidden' } : { kind: 'pass' };
|
|
149
|
+
}
|
|
150
|
+
if (method === 'GET' || method === 'HEAD' || FORM.test(contentType ?? '')) {
|
|
151
|
+
return { kind: 'redirect', location: loginAddress(url, paths) };
|
|
152
|
+
}
|
|
153
|
+
return { kind: 'unauthorized' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The answer to an `unauthorized` (401) or `forbidden` (403) decision. The
|
|
158
|
+
* package chooses no words (F-0011): the body is the project's own text from
|
|
159
|
+
* `responses`, or empty when it passes none. `ADMIN_HEADERS` are on it too.
|
|
160
|
+
*/
|
|
161
|
+
export function refusal(kind, responses = {}) {
|
|
162
|
+
const status = kind === 'forbidden' ? 403 : 401;
|
|
163
|
+
const text = (kind === 'forbidden' ? responses.forbidden : responses.unauthorized) ?? '';
|
|
164
|
+
const headers = new Headers(ADMIN_HEADERS);
|
|
165
|
+
if (!text) return new Response(null, { status, headers });
|
|
166
|
+
headers.set('Content-Type', 'text/plain; charset=utf-8');
|
|
167
|
+
return new Response(text, { status, headers });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The settings logging in needs that are missing, by the names the project
|
|
172
|
+
* gave them. Empty when everything is there. Without them nobody gets in
|
|
173
|
+
* (F-0005). The service_role key is not among them: only a password link
|
|
174
|
+
* needs it, and the database has it anyway (F-0004).
|
|
175
|
+
*/
|
|
176
|
+
export function missingSettings(settings, names) {
|
|
177
|
+
const missing = [];
|
|
178
|
+
if (!settings.supabaseUrl) missing.push(names.supabaseUrl);
|
|
179
|
+
if (!settings.anonKey) missing.push(names.anonKey);
|
|
180
|
+
if (settings.allowList.size === 0) missing.push(names.allowList);
|
|
181
|
+
return missing;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The outcome of signing in, from Supabase's error. */
|
|
185
|
+
export function signInOutcome(error) {
|
|
186
|
+
if (!error) return 'ok';
|
|
187
|
+
return error.status === 429 ? 'rate_limited' : 'invalid_credentials';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The outcome of sending a password link, from Supabase's error. */
|
|
191
|
+
export function linkOutcome(error) {
|
|
192
|
+
if (!error) return 'ok';
|
|
193
|
+
return error.status === 429 ? 'rate_limited' : 'send_failed';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The outcome of saving a password, from Supabase's error. The same password as now is fine: you know it already. */
|
|
197
|
+
export function passwordOutcome(error) {
|
|
198
|
+
if (!error || error.code === 'same_password') return 'ok';
|
|
199
|
+
return error.code === 'weak_password' ? 'weak_password' : 'save_failed';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The link parameters on the password screen. They are used only on saving, never on opening. */
|
|
203
|
+
export function readPasswordLink(url) {
|
|
204
|
+
return {
|
|
205
|
+
tokenHash: url.searchParams.get('token_hash') ?? '',
|
|
206
|
+
code: url.searchParams.get('code') ?? '',
|
|
207
|
+
linkError: url.searchParams.has('error')
|
|
208
|
+
};
|
|
209
|
+
}
|
package/src/server.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Cookies } from '@sveltejs/kit';
|
|
2
|
+
import type { Handle } from '@sveltejs/kit/hooks';
|
|
3
|
+
import type { AdminPaths, AdminResponses, SettingNames } from './rules.js';
|
|
4
|
+
|
|
5
|
+
export interface AdminOptions {
|
|
6
|
+
/** Read at runtime in the project's src/env.ts (F-0005). An empty string means missing. */
|
|
7
|
+
supabaseUrl: string;
|
|
8
|
+
anonKey: string;
|
|
9
|
+
serviceRoleKey: string;
|
|
10
|
+
/** `ADMIN_EMAILS`: addresses separated by commas, semicolons, spaces or new lines. */
|
|
11
|
+
allowList: string;
|
|
12
|
+
/** Where the admin lives and its two open screens. */
|
|
13
|
+
paths?: Partial<AdminPaths>;
|
|
14
|
+
/** The names `missingSettings()` reports, if the project's variables are named differently. */
|
|
15
|
+
settingNames?: Partial<SettingNames>;
|
|
16
|
+
/** The body of the hook's 401 and 403, in the project's own words. Without it the body is empty. */
|
|
17
|
+
responses?: AdminResponses;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Admin {
|
|
21
|
+
email: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SignInOutcome = 'ok' | 'invalid_credentials' | 'rate_limited' | 'missing_setting';
|
|
25
|
+
export type LinkOutcome = 'ok' | 'invalid_email' | 'rate_limited' | 'send_failed';
|
|
26
|
+
export type PasswordOutcome =
|
|
27
|
+
| 'ok'
|
|
28
|
+
| 'password_too_short'
|
|
29
|
+
| 'passwords_differ'
|
|
30
|
+
| 'link_invalid'
|
|
31
|
+
| 'not_signed_in'
|
|
32
|
+
| 'weak_password'
|
|
33
|
+
| 'save_failed'
|
|
34
|
+
| 'missing_setting';
|
|
35
|
+
|
|
36
|
+
export interface AdminLogin {
|
|
37
|
+
paths: AdminPaths;
|
|
38
|
+
/** For hooks.server.ts. Sets `event.locals.admin` when someone is signed in. */
|
|
39
|
+
handle: Handle;
|
|
40
|
+
currentAdmin(cookies: Cookies): Promise<Admin | null>;
|
|
41
|
+
mayLogIn(email: string): boolean;
|
|
42
|
+
/** The names of the settings that are missing; empty when everything is there. */
|
|
43
|
+
missingSettings(): string[];
|
|
44
|
+
signIn(cookies: Cookies, email: string, password: string): Promise<SignInOutcome>;
|
|
45
|
+
/** `ok` for every valid address, on the list or not; only one on the list gets a mail. */
|
|
46
|
+
sendPasswordLink(cookies: Cookies, email: string, origin: string): Promise<LinkOutcome>;
|
|
47
|
+
setPassword(
|
|
48
|
+
cookies: Cookies,
|
|
49
|
+
input: { tokenHash?: string; code?: string; password: string; repeat: string }
|
|
50
|
+
): Promise<PasswordOutcome>;
|
|
51
|
+
signOut(cookies: Cookies): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createAdmin(options: AdminOptions): AdminLogin;
|
package/src/server.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The admin login with Supabase Auth (F-0011). Server-only: the keys and the
|
|
2
|
+
// cookie stay on the server, the browser never talks to Supabase itself.
|
|
3
|
+
//
|
|
4
|
+
// The client with the anon key is only for logging in and reads or writes no
|
|
5
|
+
// table. The database stays with the service_role key, as everywhere (F-0004);
|
|
6
|
+
// that key is used here only to create a user on their first password link.
|
|
7
|
+
//
|
|
8
|
+
// The package reads no environment variable. The project reads them in its
|
|
9
|
+
// src/env.ts at runtime (F-0005) and passes them in; a missing one is an empty
|
|
10
|
+
// string, and then nobody gets in.
|
|
11
|
+
|
|
12
|
+
import { redirect } from '@sveltejs/kit';
|
|
13
|
+
import { createServerClient } from '@supabase/ssr';
|
|
14
|
+
import { createClient } from '@supabase/supabase-js';
|
|
15
|
+
import {
|
|
16
|
+
ADMIN_HEADERS,
|
|
17
|
+
adminDecision,
|
|
18
|
+
adminPaths,
|
|
19
|
+
isAdminPath,
|
|
20
|
+
linkOutcome,
|
|
21
|
+
mayManage,
|
|
22
|
+
missingSettings,
|
|
23
|
+
normaliseEmail,
|
|
24
|
+
parseAllowList,
|
|
25
|
+
passwordOutcome,
|
|
26
|
+
passwordProblem,
|
|
27
|
+
refusal,
|
|
28
|
+
signInOutcome
|
|
29
|
+
} from './rules.js';
|
|
30
|
+
|
|
31
|
+
const DEFAULT_NAMES = {
|
|
32
|
+
supabaseUrl: 'SUPABASE_URL',
|
|
33
|
+
anonKey: 'SUPABASE_ANON_KEY',
|
|
34
|
+
serviceRoleKey: 'SUPABASE_SERVICE_ROLE_KEY',
|
|
35
|
+
allowList: 'ADMIN_EMAILS'
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function createAdmin(options) {
|
|
39
|
+
const paths = adminPaths(options.paths);
|
|
40
|
+
const settings = {
|
|
41
|
+
supabaseUrl: options.supabaseUrl ?? '',
|
|
42
|
+
anonKey: options.anonKey ?? '',
|
|
43
|
+
serviceRoleKey: options.serviceRoleKey ?? '',
|
|
44
|
+
allowList: parseAllowList(options.allowList)
|
|
45
|
+
};
|
|
46
|
+
const names = { ...DEFAULT_NAMES, ...options.settingNames };
|
|
47
|
+
const missing = missingSettings(settings, names);
|
|
48
|
+
const ready = missing.length === 0;
|
|
49
|
+
|
|
50
|
+
function mayLogIn(email) {
|
|
51
|
+
return mayManage(email, settings.allowList);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The login client for this request. The cookie belongs to the admin path only, and no script in the browser needs it. */
|
|
55
|
+
function authClient(cookies) {
|
|
56
|
+
return createServerClient(settings.supabaseUrl, settings.anonKey, {
|
|
57
|
+
cookies: {
|
|
58
|
+
getAll: () => cookies.getAll(),
|
|
59
|
+
setAll: (toSet) => {
|
|
60
|
+
for (const { name, value, options: cookieOptions } of toSet) {
|
|
61
|
+
cookies.set(name, value, {
|
|
62
|
+
...cookieOptions,
|
|
63
|
+
path: paths.base,
|
|
64
|
+
httpOnly: true,
|
|
65
|
+
sameSite: 'lax'
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Who is signed in with this cookie, or null. `getClaims` checks the
|
|
75
|
+
* token's signature and refreshes an expired one. An address no longer on
|
|
76
|
+
* the allow list does not count, even with a valid session: that is how
|
|
77
|
+
* someone's access is taken away.
|
|
78
|
+
*/
|
|
79
|
+
async function currentAdmin(cookies) {
|
|
80
|
+
if (!ready) return null;
|
|
81
|
+
const { data, error } = await authClient(cookies).auth.getClaims();
|
|
82
|
+
if (error || !data) return null;
|
|
83
|
+
const email = data.claims.email;
|
|
84
|
+
return typeof email === 'string' && mayLogIn(email) ? { email: normaliseEmail(email) } : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The hook for hooks.server.ts. Combine it with others through `sequence`. */
|
|
88
|
+
async function handle({ event, resolve }) {
|
|
89
|
+
if (!isAdminPath(event.url.pathname, paths)) return resolve(event);
|
|
90
|
+
|
|
91
|
+
const admin = await currentAdmin(event.cookies);
|
|
92
|
+
if (admin) event.locals.admin = admin;
|
|
93
|
+
|
|
94
|
+
const decision = adminDecision({
|
|
95
|
+
url: event.url,
|
|
96
|
+
method: event.request.method,
|
|
97
|
+
contentType: event.request.headers.get('content-type'),
|
|
98
|
+
origin: event.request.headers.get('origin'),
|
|
99
|
+
signedIn: !!admin,
|
|
100
|
+
paths
|
|
101
|
+
});
|
|
102
|
+
// SvelteKit turns this redirect into the right answer for a page load
|
|
103
|
+
// and for a form with use:enhance. It has no content that could end up
|
|
104
|
+
// in a frame, a search engine or a cache.
|
|
105
|
+
if (decision.kind === 'redirect') redirect(303, decision.location);
|
|
106
|
+
|
|
107
|
+
const response =
|
|
108
|
+
decision.kind === 'pass'
|
|
109
|
+
? await resolve(event)
|
|
110
|
+
: refusal(decision.kind, options.responses);
|
|
111
|
+
for (const [name, value] of Object.entries(ADMIN_HEADERS)) response.headers.set(name, value);
|
|
112
|
+
return response;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* An address not on the list gets the same outcome as a wrong password,
|
|
117
|
+
* and Supabase is not called. So the screen gives away nothing about who
|
|
118
|
+
* has access.
|
|
119
|
+
*/
|
|
120
|
+
async function signIn(cookies, email, password) {
|
|
121
|
+
if (!ready) return 'missing_setting';
|
|
122
|
+
if (!mayLogIn(email) || !password) return 'invalid_credentials';
|
|
123
|
+
const { error } = await authClient(cookies).auth.signInWithPassword({
|
|
124
|
+
email: normaliseEmail(email),
|
|
125
|
+
password
|
|
126
|
+
});
|
|
127
|
+
if (error && error.status !== 429 && error.code !== 'invalid_credentials') {
|
|
128
|
+
console.error('Admin sign in failed:', error.message);
|
|
129
|
+
}
|
|
130
|
+
return signInOutcome(error);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Sends a link to set a password, the first time or when it is forgotten.
|
|
135
|
+
* Supabase only mails a user that exists, so someone on the list who is no
|
|
136
|
+
* user yet is created first, without a password. The outcome is the same
|
|
137
|
+
* for every address, and only one on the list gets a mail.
|
|
138
|
+
*
|
|
139
|
+
* The link goes to the project's own password screen, which uses it only
|
|
140
|
+
* once the password is saved.
|
|
141
|
+
*/
|
|
142
|
+
async function sendPasswordLink(cookies, email, origin) {
|
|
143
|
+
if (!normaliseEmail(email).includes('@')) return 'invalid_email';
|
|
144
|
+
if (!ready || !mayLogIn(email)) return 'ok';
|
|
145
|
+
if (!settings.serviceRoleKey) {
|
|
146
|
+
console.error(`Sending password link failed: ${names.serviceRoleKey} is missing.`);
|
|
147
|
+
return 'send_failed';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const service = createClient(settings.supabaseUrl, settings.serviceRoleKey, {
|
|
151
|
+
auth: { autoRefreshToken: false, persistSession: false }
|
|
152
|
+
});
|
|
153
|
+
const { error: createError } = await service.auth.admin.createUser({
|
|
154
|
+
email: normaliseEmail(email),
|
|
155
|
+
email_confirm: true
|
|
156
|
+
});
|
|
157
|
+
// Existing already is exactly right.
|
|
158
|
+
if (createError && createError.code !== 'email_exists') {
|
|
159
|
+
console.error('Creating admin user failed:', createError.message);
|
|
160
|
+
return linkOutcome(createError);
|
|
161
|
+
}
|
|
162
|
+
const { error } = await authClient(cookies).auth.resetPasswordForEmail(normaliseEmail(email), {
|
|
163
|
+
redirectTo: origin + paths.password
|
|
164
|
+
});
|
|
165
|
+
if (error) console.error('Sending password link failed:', error.message);
|
|
166
|
+
return linkOutcome(error);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Saves a new password, with the link from the mail (`tokenHash` or `code`)
|
|
171
|
+
* or while signed in. The password is checked first: a link works only
|
|
172
|
+
* once, so it is spent only when the rest is right.
|
|
173
|
+
*/
|
|
174
|
+
async function setPassword(cookies, { tokenHash = '', code = '', password, repeat }) {
|
|
175
|
+
const problem = passwordProblem(password, repeat);
|
|
176
|
+
if (problem) return problem;
|
|
177
|
+
if (!ready) return 'missing_setting';
|
|
178
|
+
|
|
179
|
+
const supabase = authClient(cookies);
|
|
180
|
+
if (tokenHash || code) {
|
|
181
|
+
const { data, error } = tokenHash
|
|
182
|
+
? await supabase.auth.verifyOtp({ token_hash: tokenHash, type: 'recovery' })
|
|
183
|
+
: await supabase.auth.exchangeCodeForSession(code);
|
|
184
|
+
if (error || !mayLogIn(data.user?.email ?? '')) {
|
|
185
|
+
if (!error) await supabase.auth.signOut({ scope: 'local' });
|
|
186
|
+
return 'link_invalid';
|
|
187
|
+
}
|
|
188
|
+
} else if (!(await currentAdmin(cookies))) {
|
|
189
|
+
return 'not_signed_in';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { error } = await supabase.auth.updateUser({ password });
|
|
193
|
+
const outcome = passwordOutcome(error);
|
|
194
|
+
if (outcome === 'save_failed') console.error('Saving password failed:', error.message);
|
|
195
|
+
return outcome;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function signOut(cookies) {
|
|
199
|
+
if (ready) await authClient(cookies).auth.signOut({ scope: 'local' });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
paths,
|
|
204
|
+
handle,
|
|
205
|
+
currentAdmin,
|
|
206
|
+
mayLogIn,
|
|
207
|
+
missingSettings: () => [...missing],
|
|
208
|
+
signIn,
|
|
209
|
+
sendPasswordLink,
|
|
210
|
+
setPassword,
|
|
211
|
+
signOut
|
|
212
|
+
};
|
|
213
|
+
}
|