@axium/server 0.40.2 → 0.41.1
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/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/pfp.d.ts +1 -0
- package/dist/api/pfp.js +121 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +15 -0
- package/dist/db/schema.json +18 -0
- package/package.json +4 -2
- package/routes/account/+page.svelte +64 -20
- package/routes/admin/users/[id]/+page.svelte +6 -8
- package/schemas/config.json +17 -0
package/dist/api/index.d.ts
CHANGED
package/dist/api/index.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/api/pfp.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import { warn } from 'ioium/node';
|
|
3
|
+
import { execSync } from 'node:child_process';
|
|
4
|
+
import * as z from 'zod';
|
|
5
|
+
import { checkAuthForUser } from '../auth.js';
|
|
6
|
+
import { config } from '../config.js';
|
|
7
|
+
import { database as db } from '../database.js';
|
|
8
|
+
import { error, withError } from '../requests.js';
|
|
9
|
+
import { addRoute } from '../routes.js';
|
|
10
|
+
let imageSize;
|
|
11
|
+
try {
|
|
12
|
+
const mod = await import('image-size');
|
|
13
|
+
imageSize = mod.imageSize;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
try {
|
|
17
|
+
// Fall back to `identify` from ImageMagick
|
|
18
|
+
execSync('command -v identify');
|
|
19
|
+
imageSize = function identifyImageSize(input) {
|
|
20
|
+
const stdout = execSync('identify -ping -format "%w %h" -', { input, timeout: 1000 });
|
|
21
|
+
const [width, height] = stdout.toString().trim().split(' ').map(Number);
|
|
22
|
+
return { width, height };
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
warn('Can not determine profile picture dimensions because neither image-size or ImageMagick is available');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
addRoute({
|
|
30
|
+
path: '/raw/pfp/:id',
|
|
31
|
+
params: { id: z.uuid() },
|
|
32
|
+
async HEAD(request, { id: userId }) {
|
|
33
|
+
const pfp = await db.selectFrom('profile_pictures').selectAll().where('userId', '=', userId).executeTakeFirst();
|
|
34
|
+
if (!pfp)
|
|
35
|
+
error(404, 'Profile picture not found');
|
|
36
|
+
if (!pfp.isPublic) {
|
|
37
|
+
try {
|
|
38
|
+
await checkAuthForUser(request, userId);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
if (!(e instanceof Error))
|
|
42
|
+
throw e;
|
|
43
|
+
if (e.message == 'User ID mismatch')
|
|
44
|
+
error(403, "User's profile picture is not public");
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return new Response(null, {
|
|
49
|
+
headers: {
|
|
50
|
+
'content-type': pfp.type,
|
|
51
|
+
'content-length': pfp.data.length.toString(),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
async GET(request, { id: userId }) {
|
|
56
|
+
const pfp = await db.selectFrom('profile_pictures').selectAll().where('userId', '=', userId).executeTakeFirst();
|
|
57
|
+
if (!pfp)
|
|
58
|
+
error(404, 'Profile picture not found');
|
|
59
|
+
if (!pfp.isPublic) {
|
|
60
|
+
try {
|
|
61
|
+
await checkAuthForUser(request, userId);
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
if (!(e instanceof Error))
|
|
65
|
+
throw e;
|
|
66
|
+
if (e.message == 'User ID mismatch')
|
|
67
|
+
error(403, "User's profile picture is not public");
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return new Response(pfp.data, {
|
|
72
|
+
headers: {
|
|
73
|
+
'content-type': pfp.type,
|
|
74
|
+
'content-length': pfp.data.length.toString(),
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
async POST(request, { id: userId }) {
|
|
79
|
+
const { enabled, max_size, max_length } = config.user_pfp;
|
|
80
|
+
if (!enabled)
|
|
81
|
+
error(503, 'Custom profile pictures are disabled');
|
|
82
|
+
await checkAuthForUser(request, userId);
|
|
83
|
+
const type = request.headers.get('content-type');
|
|
84
|
+
if (!type)
|
|
85
|
+
error(400, 'Missing Content-Type header');
|
|
86
|
+
if (!type.startsWith('image/'))
|
|
87
|
+
error(415, 'Only image files are allowed');
|
|
88
|
+
const size = Number(request.headers.get('content-length'));
|
|
89
|
+
if (!Number.isSafeInteger(size))
|
|
90
|
+
error(400, 'Invalid Content-Length header');
|
|
91
|
+
if (max_size && size / 1000 > max_size)
|
|
92
|
+
error(413, `Profile picture must be smaller than ${max_size} KB`);
|
|
93
|
+
const data = await request.bytes();
|
|
94
|
+
if (data.byteLength != size)
|
|
95
|
+
error(400, 'Content-Length does not match actual data size');
|
|
96
|
+
const { width, height } = imageSize?.(data) || { width: 0, height: 0 };
|
|
97
|
+
if (imageSize && (!width || !height))
|
|
98
|
+
error(400, 'Invalid image dimensions');
|
|
99
|
+
if (max_length && (width > max_length || height > max_length))
|
|
100
|
+
error(413, `Profile picture must be smaller than ${max_length}x${max_length} pixels`);
|
|
101
|
+
const { isInsert } = await db
|
|
102
|
+
.insertInto('profile_pictures')
|
|
103
|
+
.values({ userId, data, type })
|
|
104
|
+
.onConflict(oc => oc.column('userId').doUpdateSet({ data, type }))
|
|
105
|
+
.returning(sql `xmax = 0`.as('isInsert'))
|
|
106
|
+
.executeTakeFirstOrThrow()
|
|
107
|
+
.catch(withError('Failed to upload profile picture', 500));
|
|
108
|
+
return new Response(null, { status: isInsert ? 201 : 200 });
|
|
109
|
+
},
|
|
110
|
+
async DELETE(request, { id: userId }) {
|
|
111
|
+
await checkAuthForUser(request, userId);
|
|
112
|
+
const result = await db
|
|
113
|
+
.deleteFrom('profile_pictures')
|
|
114
|
+
.where('userId', '=', userId)
|
|
115
|
+
.executeTakeFirst()
|
|
116
|
+
.catch(withError('Failed to delete profile picture', 500));
|
|
117
|
+
if (!result?.numDeletedRows)
|
|
118
|
+
error(404, 'Profile picture not found');
|
|
119
|
+
return new Response(null, { status: 204 });
|
|
120
|
+
},
|
|
121
|
+
});
|
package/dist/config.d.ts
CHANGED
|
@@ -42,6 +42,11 @@ export declare const Config: z.ZodObject<{
|
|
|
42
42
|
request_size_limit: z.ZodOptional<z.ZodOptional<z.ZodNumber>>;
|
|
43
43
|
show_duplicate_state: z.ZodOptional<z.ZodBoolean>;
|
|
44
44
|
user_discovery: z.ZodOptional<z.ZodLiteral<"disabled" | "user" | "admin" | "public">>;
|
|
45
|
+
user_pfp: z.ZodOptional<z.ZodObject<{
|
|
46
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
47
|
+
max_size: z.ZodOptional<z.ZodNumber>;
|
|
48
|
+
max_length: z.ZodOptional<z.ZodNumber>;
|
|
49
|
+
}, z.core.$loose>>;
|
|
45
50
|
verifications: z.ZodOptional<z.ZodObject<{
|
|
46
51
|
timeout: z.ZodOptional<z.ZodNumber>;
|
|
47
52
|
email: z.ZodOptional<z.ZodBoolean>;
|
|
@@ -119,6 +124,11 @@ export declare const ConfigFile: z.ZodObject<{
|
|
|
119
124
|
request_size_limit: z.ZodOptional<z.ZodOptional<z.ZodOptional<z.ZodNumber>>>;
|
|
120
125
|
show_duplicate_state: z.ZodOptional<z.ZodOptional<z.ZodBoolean>>;
|
|
121
126
|
user_discovery: z.ZodOptional<z.ZodOptional<z.ZodLiteral<"disabled" | "user" | "admin" | "public">>>;
|
|
127
|
+
user_pfp: z.ZodOptional<z.ZodOptional<z.ZodObject<{
|
|
128
|
+
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
129
|
+
max_size: z.ZodOptional<z.ZodNumber>;
|
|
130
|
+
max_length: z.ZodOptional<z.ZodNumber>;
|
|
131
|
+
}, z.core.$loose>>>;
|
|
122
132
|
verifications: z.ZodOptional<z.ZodOptional<z.ZodObject<{
|
|
123
133
|
timeout: z.ZodOptional<z.ZodNumber>;
|
|
124
134
|
email: z.ZodOptional<z.ZodBoolean>;
|
package/dist/config.js
CHANGED
|
@@ -63,6 +63,16 @@ export const Config = z
|
|
|
63
63
|
show_duplicate_state: z.boolean(),
|
|
64
64
|
/** Who can use the user discovery API. For example, setting to `admin` means regular users need to type a full email in the ACL dialog and won't be shown results */
|
|
65
65
|
user_discovery: z.literal(['disabled', 'admin', 'user', 'public']),
|
|
66
|
+
user_pfp: z
|
|
67
|
+
.looseObject({
|
|
68
|
+
/** Whether user's can upload custom profile pictures */
|
|
69
|
+
enabled: z.boolean(),
|
|
70
|
+
/** Max PFP size in KB. Set to zero for no limit */
|
|
71
|
+
max_size: z.number().min(0),
|
|
72
|
+
/** Max dimensions on a side. Set to zero for no limit */
|
|
73
|
+
max_length: z.number().min(0),
|
|
74
|
+
})
|
|
75
|
+
.partial(),
|
|
66
76
|
verifications: z
|
|
67
77
|
.looseObject({
|
|
68
78
|
/** In minutes */
|
|
@@ -130,6 +140,11 @@ export const defaultConfig = {
|
|
|
130
140
|
show_duplicate_state: false,
|
|
131
141
|
request_size_limit: 0,
|
|
132
142
|
user_discovery: 'user',
|
|
143
|
+
user_pfp: {
|
|
144
|
+
enabled: true,
|
|
145
|
+
max_size: 500,
|
|
146
|
+
max_length: 2000,
|
|
147
|
+
},
|
|
133
148
|
verifications: {
|
|
134
149
|
timeout: 60,
|
|
135
150
|
email: false,
|
package/dist/db/schema.json
CHANGED
|
@@ -87,6 +87,24 @@
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"delta": true,
|
|
93
|
+
"alter_tables": {
|
|
94
|
+
"users": {
|
|
95
|
+
"drop_columns": ["image"]
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"add_tables": {
|
|
99
|
+
"profile_pictures": {
|
|
100
|
+
"columns": {
|
|
101
|
+
"userId": { "type": "uuid", "required": true, "primary": true, "references": "users.id", "onDelete": "cascade" },
|
|
102
|
+
"type": { "type": "text", "required": true },
|
|
103
|
+
"data": { "type": "bytea", "required": true },
|
|
104
|
+
"isPublic": { "type": "boolean", "required": true, "default": false }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
90
108
|
}
|
|
91
109
|
],
|
|
92
110
|
"wipe": ["users", "verifications", "passkeys", "sessions", "audit_log"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.1",
|
|
4
4
|
"author": "James Prevett <axium@jamespre.dev>",
|
|
5
5
|
"funding": {
|
|
6
6
|
"type": "individual",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"clean": "rm -rf build .svelte-kit node_modules/{.vite,.vite-temp}"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@axium/client": ">=0.
|
|
50
|
+
"@axium/client": ">=0.21.0",
|
|
51
51
|
"@axium/core": ">=0.24.0",
|
|
52
52
|
"kysely": "^0.28.0",
|
|
53
53
|
"utilium": "^2.6.0",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"@types/pg": "^8.11.11",
|
|
60
60
|
"commander": "^14.0.0",
|
|
61
61
|
"cookie_v1": "npm:cookie@^1.0.2",
|
|
62
|
+
"ioium": "^0.1.0",
|
|
62
63
|
"logzen": "^0.7.0",
|
|
63
64
|
"pg": "^8.14.1",
|
|
64
65
|
"svelte": "^5.36.0"
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
"vite-plugin-mkcert": "^1.17.8"
|
|
69
70
|
},
|
|
70
71
|
"optionalDependencies": {
|
|
72
|
+
"image-size": "^2.0.2",
|
|
71
73
|
"ua-parser-js": "^2.0.9"
|
|
72
74
|
}
|
|
73
75
|
}
|
|
@@ -1,25 +1,50 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { fetchAPI, text } from '@axium/client';
|
|
3
|
-
import { ClipboardCopy, FormDialog, Icon, Logout, SessionList, ZodForm } from '@axium/client/components';
|
|
3
|
+
import { ClipboardCopy, FormDialog, Icon, Logout, Popover, SessionList, UserPFP, ZodForm } from '@axium/client/components';
|
|
4
4
|
import '@axium/client/styles/account';
|
|
5
5
|
import { createPasskey, deletePasskey, deleteUser, sendVerificationEmail, updatePasskey, updateUser } from '@axium/client/user';
|
|
6
6
|
import { preferenceLabels, Preferences } from '@axium/core/preferences';
|
|
7
|
-
import { getUserImage } from '@axium/core/user';
|
|
8
7
|
import type { PageProps } from './$types';
|
|
8
|
+
import { toast, toastStatus } from '@axium/client/toast';
|
|
9
|
+
import { contextMenu } from '@axium/client/attachments';
|
|
10
|
+
import { upload } from 'utilium/dom.js';
|
|
9
11
|
|
|
10
12
|
const { data }: PageProps = $props();
|
|
11
13
|
const { canVerify } = data;
|
|
12
14
|
|
|
13
|
-
let verificationSent = $state(false)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
let verificationSent = $state(false),
|
|
16
|
+
currentSession = $state(data.currentSession),
|
|
17
|
+
user = $state(data.user),
|
|
18
|
+
passkeys = $state(data.passkeys),
|
|
19
|
+
sessions = $state(data.sessions),
|
|
20
|
+
hasDefaultPFP = $state(false);
|
|
18
21
|
|
|
19
22
|
async function _editUser(data: Record<string, FormDataEntryValue>) {
|
|
20
23
|
const result = await updateUser(user.id, data);
|
|
21
24
|
user = result;
|
|
22
25
|
}
|
|
26
|
+
|
|
27
|
+
async function updatePFP() {
|
|
28
|
+
try {
|
|
29
|
+
const file = await upload('image/*');
|
|
30
|
+
const response = await fetch('/raw/pfp/' + user.id, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { 'content-type': file.type, 'content-length': file.size.toString() },
|
|
33
|
+
body: file,
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
if (response.headers.get('content-type')?.endsWith('/json')) {
|
|
37
|
+
const error = await response.json();
|
|
38
|
+
throw error.message;
|
|
39
|
+
} else {
|
|
40
|
+
throw new Error('Failed to update profile picture');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
await toast('success', text('page.account.pfp.toast_updated'));
|
|
44
|
+
} catch (e) {
|
|
45
|
+
await toast('error', e);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
23
48
|
</script>
|
|
24
49
|
|
|
25
50
|
<svelte:head>
|
|
@@ -33,8 +58,30 @@
|
|
|
33
58
|
{/snippet}
|
|
34
59
|
|
|
35
60
|
<div class="Account flex-content">
|
|
36
|
-
<div id="pfp-container">
|
|
37
|
-
<
|
|
61
|
+
<div id="pfp-container" {@attach contextMenu({ i: 'upload', text: text('page.account.pfp.update'), action: updatePFP })}>
|
|
62
|
+
<UserPFP {user} --size="100px" bind:isDefault={hasDefaultPFP} />
|
|
63
|
+
{#if hasDefaultPFP}
|
|
64
|
+
<Icon i="regular/camera" id="pfp-menu-toggle" onclick={updatePFP} />
|
|
65
|
+
{:else}
|
|
66
|
+
<Popover>
|
|
67
|
+
{#snippet toggle()}
|
|
68
|
+
<Icon i="regular/camera" id="pfp-menu-toggle" />
|
|
69
|
+
{/snippet}
|
|
70
|
+
|
|
71
|
+
<div class="menu-item" onclick={updatePFP}>
|
|
72
|
+
<Icon i="upload" />
|
|
73
|
+
<span>{text('page.account.pfp.update')}</span>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div
|
|
77
|
+
class="menu-item"
|
|
78
|
+
onclick={() => toastStatus(fetch('/raw/pfp/' + user.id, { method: 'DELETE' }), text('page.account.pfp.toast_removed'))}
|
|
79
|
+
>
|
|
80
|
+
<Icon i="trash" />
|
|
81
|
+
<span>{text('page.account.pfp.remove')}</span>
|
|
82
|
+
</div>
|
|
83
|
+
</Popover>
|
|
84
|
+
{/if}
|
|
38
85
|
</div>
|
|
39
86
|
<p class="greeting">{text('page.account.greeting', { name: user.name })}</p>
|
|
40
87
|
|
|
@@ -185,19 +232,16 @@
|
|
|
185
232
|
width: 100px;
|
|
186
233
|
height: 100px;
|
|
187
234
|
margin-top: 3em;
|
|
188
|
-
|
|
189
|
-
:global(.MenuToggle) {
|
|
190
|
-
float: right;
|
|
191
|
-
position: relative;
|
|
192
|
-
top: -24px;
|
|
193
|
-
}
|
|
194
235
|
}
|
|
195
236
|
|
|
196
|
-
#pfp {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
237
|
+
:global(#pfp-menu-toggle) {
|
|
238
|
+
float: right;
|
|
239
|
+
position: relative;
|
|
240
|
+
top: -24px;
|
|
241
|
+
|
|
242
|
+
&:hover {
|
|
243
|
+
cursor: pointer;
|
|
244
|
+
}
|
|
201
245
|
}
|
|
202
246
|
|
|
203
247
|
.greeting {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { deleteUser, fetchAPI, text } from '@axium/client';
|
|
3
|
-
import { ClipboardCopy, FormDialog, Icon, SessionList, ZodForm, ZodInput } from '@axium/client/components';
|
|
3
|
+
import { ClipboardCopy, FormDialog, Icon, SessionList, UserPFP, ZodForm, ZodInput } from '@axium/client/components';
|
|
4
4
|
import { toast } from '@axium/client/toast';
|
|
5
5
|
import '@axium/client/styles/account';
|
|
6
6
|
import { preferenceLabels, Preferences, User } from '@axium/core';
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
let user = $state(data.user);
|
|
11
11
|
const { session } = data;
|
|
12
12
|
|
|
13
|
-
let sessions = $state(user.sessions)
|
|
13
|
+
let sessions = $state(user.sessions),
|
|
14
|
+
hasDefaultPFP = $state(false);
|
|
14
15
|
|
|
15
16
|
async function updateValue(val: User) {
|
|
16
17
|
try {
|
|
@@ -94,12 +95,9 @@
|
|
|
94
95
|
</div>
|
|
95
96
|
<div class="item info">
|
|
96
97
|
<p>{text('page.admin.users.profile_image')}</p>
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
<
|
|
100
|
-
{:else}
|
|
101
|
-
<i>{text('page.admin.users.default_image')}</i>
|
|
102
|
-
<p></p>
|
|
98
|
+
<UserPFP {user} bind:isDefault={hasDefaultPFP} />
|
|
99
|
+
{#if hasDefaultPFP}
|
|
100
|
+
<p>{text('page.admin.users.default_image')}</p>
|
|
103
101
|
{/if}
|
|
104
102
|
</div>
|
|
105
103
|
<div class="item info">
|
package/schemas/config.json
CHANGED
|
@@ -147,6 +147,23 @@
|
|
|
147
147
|
"public"
|
|
148
148
|
]
|
|
149
149
|
},
|
|
150
|
+
"user_pfp": {
|
|
151
|
+
"type": "object",
|
|
152
|
+
"properties": {
|
|
153
|
+
"enabled": {
|
|
154
|
+
"type": "boolean"
|
|
155
|
+
},
|
|
156
|
+
"max_size": {
|
|
157
|
+
"type": "number",
|
|
158
|
+
"minimum": 0
|
|
159
|
+
},
|
|
160
|
+
"max_length": {
|
|
161
|
+
"type": "number",
|
|
162
|
+
"minimum": 0
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
"additionalProperties": {}
|
|
166
|
+
},
|
|
150
167
|
"verifications": {
|
|
151
168
|
"type": "object",
|
|
152
169
|
"properties": {
|