@axium/storage 0.10.0 → 0.11.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/db.json +68 -0
- package/dist/client/local.d.ts +0 -13
- package/dist/common.d.ts +0 -39
- package/dist/common.js +0 -3
- package/dist/node.js +1 -3
- package/dist/server/api.js +19 -15
- package/dist/server/batch.js +6 -23
- package/dist/server/cli.js +84 -2
- package/dist/server/db.d.ts +1 -5
- package/dist/server/db.js +1 -16
- package/dist/server/hooks.d.ts +1 -4
- package/dist/server/hooks.js +1 -38
- package/dist/server/raw.js +8 -14
- package/package.json +8 -6
- package/routes/files/usage/+page.svelte +2 -9
package/db.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../server/schemas/db.json",
|
|
3
|
+
"format": 0,
|
|
4
|
+
"versions": [
|
|
5
|
+
{
|
|
6
|
+
"delta": false,
|
|
7
|
+
"tables": {
|
|
8
|
+
"storage": {
|
|
9
|
+
"columns": {
|
|
10
|
+
"id": { "type": "uuid", "required": true, "primary": true, "default": "gen_random_uuid()" },
|
|
11
|
+
"createdAt": { "type": "timestamptz", "required": true, "default": "now()" },
|
|
12
|
+
"hash": { "type": "bytea" },
|
|
13
|
+
"immutable": { "type": "bool", "required": true },
|
|
14
|
+
"modifiedAt": { "type": "timestamptz", "required": true, "default": "now()" },
|
|
15
|
+
"name": { "type": "text" },
|
|
16
|
+
"parentId": { "type": "uuid", "references": "storage.id", "onDelete": "cascade" },
|
|
17
|
+
"size": { "type": "integer", "required": true },
|
|
18
|
+
"trashedAt": { "type": "timestamptz" },
|
|
19
|
+
"type": { "type": "text", "required": true },
|
|
20
|
+
"userId": { "type": "uuid", "required": true, "references": "users.id", "onDelete": "cascade" },
|
|
21
|
+
"publicPermission": { "type": "integer", "required": true, "default": 0 },
|
|
22
|
+
"metadata": { "type": "jsonb", "required": true, "default": "{}" }
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"acl.storage": {
|
|
26
|
+
"columns": {
|
|
27
|
+
"userId": { "type": "uuid", "required": true, "primary": true, "references": "users.id", "onDelete": "cascade" },
|
|
28
|
+
"itemId": { "type": "uuid", "required": true, "primary": true, "references": "storage.id", "onDelete": "cascade" },
|
|
29
|
+
"createdAt": { "type": "timestamptz", "required": true, "default": "now()" },
|
|
30
|
+
"permission": { "type": "integer", "required": true, "check": "permission >= 0 AND permission <= 5" }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"indexes": ["storage:userId", "storage:parentId", "acl.storage:userId", "acl.storage:itemId"]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"delta": true,
|
|
38
|
+
"alter_tables": {
|
|
39
|
+
"storage": {
|
|
40
|
+
"drop_columns": ["publicPermission"]
|
|
41
|
+
},
|
|
42
|
+
"acl.storage": {
|
|
43
|
+
"drop_constraints": ["PK_acl_storage"],
|
|
44
|
+
"drop_columns": ["permission"],
|
|
45
|
+
"add_columns": {
|
|
46
|
+
"role": { "type": "text" },
|
|
47
|
+
"tag": { "type": "text" },
|
|
48
|
+
"read": { "type": "boolean", "required": true, "default": false },
|
|
49
|
+
"download": { "type": "boolean", "required": true, "default": false },
|
|
50
|
+
"write": { "type": "boolean", "required": true, "default": false },
|
|
51
|
+
"comment": { "type": "boolean", "required": true, "default": false },
|
|
52
|
+
"manage": { "type": "boolean", "required": true, "default": false }
|
|
53
|
+
},
|
|
54
|
+
"alter_columns": {
|
|
55
|
+
"userId": { "ops": ["drop_required"] }
|
|
56
|
+
},
|
|
57
|
+
"add_constraints": {
|
|
58
|
+
"unique_storage": { "type": "unique", "on": ["itemId", "userId", "role", "tag"], "nulls_not_distinct": true }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
"wipe": ["storage", "acl.storage"],
|
|
65
|
+
"acl_tables": {
|
|
66
|
+
"storage": "acl.storage"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/dist/client/local.d.ts
CHANGED
|
@@ -14,19 +14,6 @@ declare const StorageCache: z.ZodObject<{
|
|
|
14
14
|
name: z.ZodString;
|
|
15
15
|
userId: z.ZodUUID;
|
|
16
16
|
parentId: z.ZodNullable<z.ZodUUID>;
|
|
17
|
-
publicPermission: z.ZodEnum<{
|
|
18
|
-
readonly None: 0;
|
|
19
|
-
readonly Read: 1;
|
|
20
|
-
readonly Comment: 2;
|
|
21
|
-
readonly Edit: 3;
|
|
22
|
-
readonly Manage: 5;
|
|
23
|
-
}> & {
|
|
24
|
-
readonly None: 0;
|
|
25
|
-
readonly Read: 1;
|
|
26
|
-
readonly Comment: 2;
|
|
27
|
-
readonly Edit: 3;
|
|
28
|
-
readonly Manage: 5;
|
|
29
|
-
};
|
|
30
17
|
size: z.ZodInt;
|
|
31
18
|
trashedAt: z.ZodNullable<z.ZodCoercedDate<unknown>>;
|
|
32
19
|
type: z.ZodString;
|
package/dist/common.d.ts
CHANGED
|
@@ -83,19 +83,6 @@ export declare const StorageItemUpdate: z.ZodObject<{
|
|
|
83
83
|
name: z.ZodOptional<z.ZodString>;
|
|
84
84
|
owner: z.ZodOptional<z.ZodUUID>;
|
|
85
85
|
trash: z.ZodOptional<z.ZodBoolean>;
|
|
86
|
-
publicPermission: z.ZodOptional<z.ZodEnum<{
|
|
87
|
-
readonly None: 0;
|
|
88
|
-
readonly Read: 1;
|
|
89
|
-
readonly Comment: 2;
|
|
90
|
-
readonly Edit: 3;
|
|
91
|
-
readonly Manage: 5;
|
|
92
|
-
}> & {
|
|
93
|
-
readonly None: 0;
|
|
94
|
-
readonly Read: 1;
|
|
95
|
-
readonly Comment: 2;
|
|
96
|
-
readonly Edit: 3;
|
|
97
|
-
readonly Manage: 5;
|
|
98
|
-
}>;
|
|
99
86
|
}, z.core.$strip>;
|
|
100
87
|
export type StorageItemUpdate = z.infer<typeof StorageItemUpdate>;
|
|
101
88
|
export declare const StorageItemMetadata: z.ZodObject<{
|
|
@@ -108,19 +95,6 @@ export declare const StorageItemMetadata: z.ZodObject<{
|
|
|
108
95
|
name: z.ZodString;
|
|
109
96
|
userId: z.ZodUUID;
|
|
110
97
|
parentId: z.ZodNullable<z.ZodUUID>;
|
|
111
|
-
publicPermission: z.ZodEnum<{
|
|
112
|
-
readonly None: 0;
|
|
113
|
-
readonly Read: 1;
|
|
114
|
-
readonly Comment: 2;
|
|
115
|
-
readonly Edit: 3;
|
|
116
|
-
readonly Manage: 5;
|
|
117
|
-
}> & {
|
|
118
|
-
readonly None: 0;
|
|
119
|
-
readonly Read: 1;
|
|
120
|
-
readonly Comment: 2;
|
|
121
|
-
readonly Edit: 3;
|
|
122
|
-
readonly Manage: 5;
|
|
123
|
-
};
|
|
124
98
|
size: z.ZodInt;
|
|
125
99
|
trashedAt: z.ZodNullable<z.ZodCoercedDate<unknown>>;
|
|
126
100
|
type: z.ZodString;
|
|
@@ -148,19 +122,6 @@ export declare const StorageBatchUpdate: z.ZodObject<{
|
|
|
148
122
|
name: z.ZodOptional<z.ZodString>;
|
|
149
123
|
owner: z.ZodOptional<z.ZodUUID>;
|
|
150
124
|
trash: z.ZodOptional<z.ZodBoolean>;
|
|
151
|
-
publicPermission: z.ZodOptional<z.ZodEnum<{
|
|
152
|
-
readonly None: 0;
|
|
153
|
-
readonly Read: 1;
|
|
154
|
-
readonly Comment: 2;
|
|
155
|
-
readonly Edit: 3;
|
|
156
|
-
readonly Manage: 5;
|
|
157
|
-
}> & {
|
|
158
|
-
readonly None: 0;
|
|
159
|
-
readonly Read: 1;
|
|
160
|
-
readonly Comment: 2;
|
|
161
|
-
readonly Edit: 3;
|
|
162
|
-
readonly Manage: 5;
|
|
163
|
-
}>;
|
|
164
125
|
}, z.core.$strip>>;
|
|
165
126
|
content: z.ZodRecord<z.ZodUUID, z.ZodObject<{
|
|
166
127
|
offset: z.ZodInt;
|
package/dist/common.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Permission } from '@axium/core/access';
|
|
2
1
|
import * as z from 'zod';
|
|
3
2
|
export const syncProtocolVersion = 0;
|
|
4
3
|
/**
|
|
@@ -9,7 +8,6 @@ export const StorageItemUpdate = z
|
|
|
9
8
|
name: z.string(),
|
|
10
9
|
owner: z.uuid(),
|
|
11
10
|
trash: z.boolean(),
|
|
12
|
-
publicPermission: Permission,
|
|
13
11
|
})
|
|
14
12
|
.partial();
|
|
15
13
|
export const StorageItemMetadata = z.object({
|
|
@@ -23,7 +21,6 @@ export const StorageItemMetadata = z.object({
|
|
|
23
21
|
name: z.string(),
|
|
24
22
|
userId: z.uuid(),
|
|
25
23
|
parentId: z.uuid().nullable(),
|
|
26
|
-
publicPermission: Permission,
|
|
27
24
|
size: z.int().nonnegative(),
|
|
28
25
|
trashedAt: z.coerce.date().nullable(),
|
|
29
26
|
type: z.string(),
|
package/dist/node.js
CHANGED
|
@@ -25,7 +25,6 @@ export function colorItem(item) {
|
|
|
25
25
|
return styleText('red', name);
|
|
26
26
|
return name;
|
|
27
27
|
}
|
|
28
|
-
const publicPermString = ['---', 'r--', 'r-x', 'rwx', 'rwx', 'rwx'];
|
|
29
28
|
const __formatter = new Intl.DateTimeFormat('en-US', {
|
|
30
29
|
year: 'numeric',
|
|
31
30
|
month: 'short',
|
|
@@ -46,7 +45,6 @@ export function* formatItems({ items, users, humanReadable }) {
|
|
|
46
45
|
const owner = users[item.userId].name;
|
|
47
46
|
const type = item.type == 'inode/directory' ? 'd' : '-';
|
|
48
47
|
const ownerPerm = `r${item.immutable ? '-' : 'w'}x`;
|
|
49
|
-
|
|
50
|
-
yield `${type}${ownerPerm}${ownerPerm}${publicPerm}. ${owner.padEnd(nameWidth)} ${item.__size.padStart(sizeWidth)} ${formatDate(item.modifiedAt)} ${colorItem(item)}`;
|
|
48
|
+
yield `${type}${ownerPerm}${ownerPerm}. ${owner.padEnd(nameWidth)} ${item.__size.padStart(sizeWidth)} ${formatDate(item.modifiedAt)} ${colorItem(item)}`;
|
|
51
49
|
}
|
|
52
50
|
}
|
package/dist/server/api.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Permission } from '@axium/core';
|
|
2
1
|
import { checkAuthForItem, checkAuthForUser } from '@axium/server/auth';
|
|
3
2
|
import { config } from '@axium/server/config';
|
|
4
3
|
import { database } from '@axium/server/database';
|
|
@@ -9,7 +8,7 @@ import * as z from 'zod';
|
|
|
9
8
|
import { batchFormatVersion, StorageItemUpdate, syncProtocolVersion } from '../common.js';
|
|
10
9
|
import '../polyfills.js';
|
|
11
10
|
import { getLimits } from './config.js';
|
|
12
|
-
import {
|
|
11
|
+
import { deleteRecursive, getRecursive, getUserStats, parseItem } from './db.js';
|
|
13
12
|
addRoute({
|
|
14
13
|
path: '/api/storage',
|
|
15
14
|
OPTIONS() {
|
|
@@ -26,17 +25,15 @@ addRoute({
|
|
|
26
25
|
async GET(request, { id: itemId }) {
|
|
27
26
|
if (!config.storage.enabled)
|
|
28
27
|
error(503, 'User storage is disabled');
|
|
29
|
-
const { item } = await checkAuthForItem(request, 'storage', itemId,
|
|
28
|
+
const { item } = await checkAuthForItem(request, 'storage', itemId, { read: true });
|
|
30
29
|
return parseItem(item);
|
|
31
30
|
},
|
|
32
31
|
async PATCH(request, { id: itemId }) {
|
|
33
32
|
if (!config.storage.enabled)
|
|
34
33
|
error(503, 'User storage is disabled');
|
|
35
34
|
const body = await parseBody(request, StorageItemUpdate);
|
|
36
|
-
await checkAuthForItem(request, 'storage', itemId,
|
|
35
|
+
await checkAuthForItem(request, 'storage', itemId, { manage: true });
|
|
37
36
|
const values = {};
|
|
38
|
-
if ('publicPermission' in body)
|
|
39
|
-
values.publicPermission = body.publicPermission;
|
|
40
37
|
if ('trash' in body)
|
|
41
38
|
values.trashedAt = body.trash ? new Date() : null;
|
|
42
39
|
if ('owner' in body)
|
|
@@ -56,7 +53,7 @@ addRoute({
|
|
|
56
53
|
async DELETE(request, { id: itemId }) {
|
|
57
54
|
if (!config.storage.enabled)
|
|
58
55
|
error(503, 'User storage is disabled');
|
|
59
|
-
const auth = await checkAuthForItem(request, 'storage', itemId,
|
|
56
|
+
const auth = await checkAuthForItem(request, 'storage', itemId, { manage: true });
|
|
60
57
|
const item = parseItem(auth.item);
|
|
61
58
|
await deleteRecursive(item.type != 'inode/directory', itemId);
|
|
62
59
|
return item;
|
|
@@ -68,7 +65,7 @@ addRoute({
|
|
|
68
65
|
async GET(request, { id: itemId }) {
|
|
69
66
|
if (!config.storage.enabled)
|
|
70
67
|
error(503, 'User storage is disabled');
|
|
71
|
-
const { item } = await checkAuthForItem(request, 'storage', itemId,
|
|
68
|
+
const { item } = await checkAuthForItem(request, 'storage', itemId, { read: true });
|
|
72
69
|
if (item.type != 'inode/directory')
|
|
73
70
|
error(409, 'Item is not a directory');
|
|
74
71
|
const items = await database
|
|
@@ -86,7 +83,7 @@ addRoute({
|
|
|
86
83
|
async GET(request, { id: itemId }) {
|
|
87
84
|
if (!config.storage.enabled)
|
|
88
85
|
error(503, 'User storage is disabled');
|
|
89
|
-
const { item } = await checkAuthForItem(request, 'storage', itemId,
|
|
86
|
+
const { item } = await checkAuthForItem(request, 'storage', itemId, { read: true });
|
|
90
87
|
if (item.type != 'inode/directory')
|
|
91
88
|
error(409, 'Item is not a directory');
|
|
92
89
|
const items = await Array.fromAsync(getRecursive(itemId)).catch(withError('Could not get some directory items'));
|
|
@@ -133,12 +130,19 @@ addRoute({
|
|
|
133
130
|
return items.map(parseItem);
|
|
134
131
|
},
|
|
135
132
|
});
|
|
136
|
-
function existsInACL(column,
|
|
133
|
+
function existsInACL(column, user) {
|
|
137
134
|
return (eb) => eb.exists(eb
|
|
138
135
|
.selectFrom('acl.storage')
|
|
139
136
|
.whereRef('itemId', '=', `item.${column}`)
|
|
140
|
-
.where('userId', '=',
|
|
141
|
-
.where(
|
|
137
|
+
.where('userId', '=', user.id)
|
|
138
|
+
.where(eb => {
|
|
139
|
+
const ors = [eb('userId', '=', user.id)];
|
|
140
|
+
if (user.roles.length)
|
|
141
|
+
ors.push(eb('role', 'in', user.roles));
|
|
142
|
+
if (user.tags.length)
|
|
143
|
+
ors.push(eb('tag', 'in', user.tags));
|
|
144
|
+
return eb.or(ors);
|
|
145
|
+
}));
|
|
142
146
|
}
|
|
143
147
|
addRoute({
|
|
144
148
|
path: '/api/users/:id/storage/shared',
|
|
@@ -146,13 +150,13 @@ addRoute({
|
|
|
146
150
|
async GET(request, { id: userId }) {
|
|
147
151
|
if (!config.storage.enabled)
|
|
148
152
|
error(503, 'User storage is disabled');
|
|
149
|
-
await checkAuthForUser(request, userId);
|
|
153
|
+
const { user } = await checkAuthForUser(request, userId);
|
|
150
154
|
const items = await database
|
|
151
155
|
.selectFrom('storage as item')
|
|
152
156
|
.selectAll('item')
|
|
153
157
|
.where('trashedAt', 'is', null)
|
|
154
|
-
.where(existsInACL('id',
|
|
155
|
-
.where(eb => eb.not(existsInACL('parentId',
|
|
158
|
+
.where(existsInACL('id', user))
|
|
159
|
+
.where(eb => eb.not(existsInACL('parentId', user)))
|
|
156
160
|
.execute()
|
|
157
161
|
.catch(withError('Could not get storage items'));
|
|
158
162
|
return items.map(parseItem);
|
package/dist/server/batch.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import { Permission } from '@axium/core';
|
|
2
1
|
import * as acl from '@axium/server/acl';
|
|
3
2
|
import { audit } from '@axium/server/audit';
|
|
4
|
-
import {
|
|
3
|
+
import { requireSession } from '@axium/server/auth';
|
|
5
4
|
import config from '@axium/server/config';
|
|
6
5
|
import { database } from '@axium/server/database';
|
|
7
|
-
import {
|
|
6
|
+
import { withError } from '@axium/server/requests';
|
|
8
7
|
import { addRoute } from '@axium/server/routes';
|
|
9
8
|
import { error } from '@sveltejs/kit';
|
|
10
9
|
import { createHash } from 'node:crypto';
|
|
@@ -13,7 +12,7 @@ import { join } from 'node:path/posix';
|
|
|
13
12
|
import * as z from 'zod';
|
|
14
13
|
import { StorageBatchUpdate } from '../common.js';
|
|
15
14
|
import { getLimits } from './config.js';
|
|
16
|
-
import {
|
|
15
|
+
import { getRecursiveIds, getUserStats, parseItem } from './db.js';
|
|
17
16
|
addRoute({
|
|
18
17
|
path: '/api/storage/batch',
|
|
19
18
|
async POST(req) {
|
|
@@ -21,12 +20,7 @@ addRoute({
|
|
|
21
20
|
error(503, 'User storage is disabled');
|
|
22
21
|
if (!config.storage.batch.enabled)
|
|
23
22
|
error(503, 'Batch updates are disabled');
|
|
24
|
-
const
|
|
25
|
-
if (!token)
|
|
26
|
-
error(401, 'Missing session token');
|
|
27
|
-
const { userId, user } = await getSessionAndUser(token).catch(withError('Invalid session token', 401));
|
|
28
|
-
if (user.isSuspended)
|
|
29
|
-
error(403, 'User is suspended');
|
|
23
|
+
const { userId, user } = await requireSession(req);
|
|
30
24
|
const [usage, limits] = await Promise.all([getUserStats(userId), getLimits(userId)]).catch(withError('Could not fetch usage and/or limits'));
|
|
31
25
|
const batchHeaderSize = Number(req.headers.get('x-batch-header-size'));
|
|
32
26
|
if (!Number.isSafeInteger(batchHeaderSize) || batchHeaderSize < 2)
|
|
@@ -60,12 +54,11 @@ addRoute({
|
|
|
60
54
|
.selectFrom('storage')
|
|
61
55
|
.selectAll()
|
|
62
56
|
.where('id', 'in', [...deletedIds, ...Object.keys(header.metadata), ...changedIds])
|
|
63
|
-
.select(acl.from('storage', {
|
|
57
|
+
.select(acl.from('storage', { user }))
|
|
64
58
|
.$castTo()
|
|
65
59
|
.execute()
|
|
66
60
|
.catch(withError('Item(s) not found', 404));
|
|
67
61
|
for (const item of items) {
|
|
68
|
-
const permission = !changedIds.has(item.id) ? Permission.Manage : Permission.Edit;
|
|
69
62
|
if (changedIds.has(item.id)) {
|
|
70
63
|
// Extra checks for content changes
|
|
71
64
|
if (item.immutable)
|
|
@@ -77,19 +70,11 @@ addRoute({
|
|
|
77
70
|
if (limits.item_size && size > limits.item_size * 1_000_000)
|
|
78
71
|
error(413, 'Item size exceeds maximum size: ' + item.id);
|
|
79
72
|
}
|
|
80
|
-
if (item.publicPermission >= permission)
|
|
81
|
-
continue;
|
|
82
73
|
if (userId == item.userId)
|
|
83
74
|
continue;
|
|
84
75
|
if (!item.acl || !item.acl.length)
|
|
85
76
|
error(403, 'Missing permission for item: ' + item.id);
|
|
86
|
-
|
|
87
|
-
if (control.userId !== userId) {
|
|
88
|
-
await audit('acl_id_mismatch', userId, { item: item.id });
|
|
89
|
-
error(500, 'Access control entry does not match expected user ID');
|
|
90
|
-
}
|
|
91
|
-
if (control.permission >= permission)
|
|
92
|
-
continue;
|
|
77
|
+
acl.check(item.acl, changedIds.has(item.id) ? { write: true } : { manage: true });
|
|
93
78
|
error(403, 'Missing permission for item: ' + item.id);
|
|
94
79
|
}
|
|
95
80
|
if (limits.user_size && (usage.usedBytes + size - items.reduce((sum, item) => sum + item.size, 0)) / 1_000_000 >= limits.user_size)
|
|
@@ -118,8 +103,6 @@ addRoute({
|
|
|
118
103
|
results.set(item.id, parseItem(item));
|
|
119
104
|
for (const [itemId, update] of Object.entries(header.metadata)) {
|
|
120
105
|
const values = {};
|
|
121
|
-
if ('publicPermission' in update)
|
|
122
|
-
values.publicPermission = update.publicPermission;
|
|
123
106
|
if ('trash' in update)
|
|
124
107
|
values.trashedAt = update.trash ? new Date() : null;
|
|
125
108
|
if ('owner' in update)
|
package/dist/server/cli.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import { formatBytes } from '@axium/core
|
|
1
|
+
import { formatBytes, parseByteSize } from '@axium/core';
|
|
2
|
+
import { io } from '@axium/core/node';
|
|
3
|
+
import { lookupUser } from '@axium/server/cli';
|
|
2
4
|
import { count, database } from '@axium/server/database';
|
|
3
|
-
import { program } from 'commander';
|
|
5
|
+
import { Option, program } from 'commander';
|
|
6
|
+
import { styleText } from 'node:util';
|
|
7
|
+
import * as z from 'zod';
|
|
8
|
+
import { parseItem } from './db.js';
|
|
4
9
|
const cli = program.command('files').helpGroup('Plugins:').description('CLI integration for @axium/storage');
|
|
5
10
|
cli.command('usage')
|
|
6
11
|
.description('Show storage usage information')
|
|
@@ -12,3 +17,80 @@ cli.command('usage')
|
|
|
12
17
|
.executeTakeFirstOrThrow();
|
|
13
18
|
console.log(`${items} items totaling ${formatBytes(Number(size))}`);
|
|
14
19
|
});
|
|
20
|
+
const _byteSize = (msg) => (v) => parseByteSize(v) ?? io.exit(msg);
|
|
21
|
+
cli.command('query')
|
|
22
|
+
.alias('q')
|
|
23
|
+
.alias('find')
|
|
24
|
+
.description('Find storage items')
|
|
25
|
+
.option('-n, --name <name>', 'Filter by name')
|
|
26
|
+
.option('-t, --type <type>', 'Filter by MIME type')
|
|
27
|
+
.addOption(new Option('-u, --user <user>', 'Filter by user UUID or email').argParser(lookupUser))
|
|
28
|
+
.option('-m, --min-size <size>', 'Filter by minimum size', _byteSize('Invalid minimum size.'))
|
|
29
|
+
.option('-M, --max-size <size>', 'Filter by maximum size', _byteSize('Invalid maximum size.'))
|
|
30
|
+
.addOption(new Option('--size', 'Filter by exact size').conflicts(['minSize', 'maxSize']).argParser(_byteSize('Invalid size.')))
|
|
31
|
+
.option('-l, --limit <n>', 'Limit the number of results', (v) => z.coerce.number().int().min(1).max(1000).parse(v), 100)
|
|
32
|
+
.option('-j, --json', 'Output results as JSON', false)
|
|
33
|
+
.addOption(new Option('-f, --format <format>', 'How to format output lines').conflicts('json').default('{id} {type} {size} {userId} {name}'))
|
|
34
|
+
.action(async (opt) => {
|
|
35
|
+
let query = database
|
|
36
|
+
.selectFrom('storage')
|
|
37
|
+
.selectAll()
|
|
38
|
+
.limit(opt.limit + 1);
|
|
39
|
+
if (opt.name)
|
|
40
|
+
query = query.where('name', 'like', `%${opt.name}%`);
|
|
41
|
+
if (opt.type) {
|
|
42
|
+
const hasWildcard = opt.type.endsWith('/*') || opt.type.startsWith('*/');
|
|
43
|
+
query = query.where('type', hasWildcard ? 'like' : '=', !hasWildcard ? opt.type : opt.type.replace('*', '%'));
|
|
44
|
+
}
|
|
45
|
+
if (opt.user) {
|
|
46
|
+
const user = await opt.user;
|
|
47
|
+
query = query.where('userId', '=', user.id);
|
|
48
|
+
}
|
|
49
|
+
let validMinSize = false;
|
|
50
|
+
if (opt.minSize !== undefined) {
|
|
51
|
+
if (opt.minSize == 0)
|
|
52
|
+
io.warn('Minimum size of 0 has no effect, ignoring.');
|
|
53
|
+
else {
|
|
54
|
+
query = query.where('size', '>=', opt.minSize);
|
|
55
|
+
validMinSize = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (opt.maxSize) {
|
|
59
|
+
if (validMinSize && opt.maxSize < opt.minSize)
|
|
60
|
+
io.exit('Maximum size cannot be smaller than minimum size.');
|
|
61
|
+
query = query.where('size', '<=', opt.maxSize);
|
|
62
|
+
}
|
|
63
|
+
if (opt.size !== undefined) {
|
|
64
|
+
query = query.where('size', '=', opt.size);
|
|
65
|
+
}
|
|
66
|
+
const rawItems = await query.execute().catch(io.handleError);
|
|
67
|
+
const items = rawItems.map(parseItem);
|
|
68
|
+
if (!items.length) {
|
|
69
|
+
console.log(styleText(['italic', 'dim'], 'No storage items match the provided filters.'));
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
if (items.length > opt.limit) {
|
|
73
|
+
items.pop();
|
|
74
|
+
io.warn('Showing first', opt.limit, 'results, others have been omitted.');
|
|
75
|
+
}
|
|
76
|
+
if (opt.json) {
|
|
77
|
+
console.log(JSON.stringify(items, null, 4));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
let maxTypeLength = 0;
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
maxTypeLength = Math.max(maxTypeLength, item.type.length);
|
|
83
|
+
}
|
|
84
|
+
for (const item of items) {
|
|
85
|
+
const replacements = Object.assign(Object.create(null), {
|
|
86
|
+
id: item.id,
|
|
87
|
+
type: item.type.padStart(maxTypeLength),
|
|
88
|
+
userId: item.userId,
|
|
89
|
+
size: styleText('blueBright', item.type == 'inode/directory' ? ' - ' : formatBytes(item.size).padStart(9)),
|
|
90
|
+
name: styleText('yellow', JSON.stringify(item.name)),
|
|
91
|
+
modified: new Date(item.modifiedAt).toISOString(),
|
|
92
|
+
});
|
|
93
|
+
const text = opt.format.replaceAll(/(?<!\\)\{(\w+)\}/g, (_, key) => (key in replacements ? replacements[key] : `{${key}}`));
|
|
94
|
+
console.log(text);
|
|
95
|
+
}
|
|
96
|
+
});
|
package/dist/server/db.d.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { type Schema } from '@axium/server/database';
|
|
|
2
2
|
import type { Generated, Selectable } from 'kysely';
|
|
3
3
|
import type { StorageItemMetadata, StorageStats } from '../common.js';
|
|
4
4
|
import '../polyfills.js';
|
|
5
|
-
import type { Permission } from '@axium/core';
|
|
6
5
|
declare module '@axium/server/database' {
|
|
7
6
|
interface Schema {
|
|
8
7
|
storage: {
|
|
@@ -17,12 +16,9 @@ declare module '@axium/server/database' {
|
|
|
17
16
|
trashedAt: Date | null;
|
|
18
17
|
type: string;
|
|
19
18
|
userId: string;
|
|
20
|
-
publicPermission: Generated<Permission>;
|
|
21
19
|
metadata: Generated<Record<string, unknown>>;
|
|
22
20
|
};
|
|
23
|
-
|
|
24
|
-
interface ExpectedSchema {
|
|
25
|
-
storage: ColumnTypes<Schema['storage']>;
|
|
21
|
+
'acl.storage': DBAccessControl & DBBool<'read' | 'write' | 'manage' | 'download' | 'comment'>;
|
|
26
22
|
}
|
|
27
23
|
}
|
|
28
24
|
/**
|
package/dist/server/db.js
CHANGED
|
@@ -1,24 +1,9 @@
|
|
|
1
1
|
import { config } from '@axium/server/config';
|
|
2
|
-
import { database
|
|
2
|
+
import { database } from '@axium/server/database';
|
|
3
3
|
import { withError } from '@axium/server/requests';
|
|
4
4
|
import { unlinkSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path/posix';
|
|
6
6
|
import '../polyfills.js';
|
|
7
|
-
expectedTypes.storage = {
|
|
8
|
-
createdAt: { type: 'timestamptz', required: true, hasDefault: true },
|
|
9
|
-
hash: { type: 'bytea' },
|
|
10
|
-
id: { type: 'uuid', required: true, hasDefault: true },
|
|
11
|
-
immutable: { type: 'bool', required: true },
|
|
12
|
-
modifiedAt: { type: 'timestamptz', required: true, hasDefault: true },
|
|
13
|
-
name: { type: 'text' },
|
|
14
|
-
parentId: { type: 'uuid' },
|
|
15
|
-
size: { type: 'int4', required: true },
|
|
16
|
-
trashedAt: { type: 'timestamptz' },
|
|
17
|
-
type: { type: 'text', required: true },
|
|
18
|
-
userId: { type: 'uuid', required: true },
|
|
19
|
-
publicPermission: { type: 'int4', required: true, hasDefault: true },
|
|
20
|
-
metadata: { type: 'jsonb', required: true, hasDefault: true },
|
|
21
|
-
};
|
|
22
7
|
export function parseItem(item) {
|
|
23
8
|
return {
|
|
24
9
|
...item,
|
package/dist/server/hooks.d.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { OpOptions } from '@axium/server/database';
|
|
2
2
|
import '../common.js';
|
|
3
3
|
import './index.js';
|
|
4
4
|
export declare function statusText(): Promise<string>;
|
|
5
|
-
export declare function db_init(opt: InitOptions): Promise<void>;
|
|
6
|
-
export declare function db_wipe(opt: OpOptions): Promise<void>;
|
|
7
|
-
export declare function remove(opt: OpOptions): Promise<void>;
|
|
8
5
|
export declare function clean(opt: OpOptions): Promise<void>;
|
package/dist/server/hooks.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { formatBytes } from '@axium/core/format';
|
|
2
2
|
import { done, start } from '@axium/core/node/io';
|
|
3
|
-
import * as acl from '@axium/server/acl';
|
|
4
3
|
import config from '@axium/server/config';
|
|
5
|
-
import { count,
|
|
6
|
-
import { sql } from 'kysely';
|
|
4
|
+
import { count, database } from '@axium/server/database';
|
|
7
5
|
import { mkdirSync } from 'node:fs';
|
|
8
6
|
import '../common.js';
|
|
9
7
|
import './index.js';
|
|
@@ -16,41 +14,6 @@ export async function statusText() {
|
|
|
16
14
|
.executeTakeFirstOrThrow();
|
|
17
15
|
return `${items} items totaling ${formatBytes(Number(size))}`;
|
|
18
16
|
}
|
|
19
|
-
export async function db_init(opt) {
|
|
20
|
-
start('Creating table storage');
|
|
21
|
-
await database.schema
|
|
22
|
-
.createTable('storage')
|
|
23
|
-
.addColumn('id', 'uuid', col => col.primaryKey().defaultTo(sql `gen_random_uuid()`))
|
|
24
|
-
.addColumn('userId', 'uuid', col => col.notNull().references('users.id').onDelete('cascade'))
|
|
25
|
-
.addColumn('parentId', 'uuid', col => col.references('storage.id').onDelete('cascade').defaultTo(null))
|
|
26
|
-
.addColumn('createdAt', 'timestamptz', col => col.notNull().defaultTo(sql `now()`))
|
|
27
|
-
.addColumn('modifiedAt', 'timestamptz', col => col.notNull().defaultTo(sql `now()`))
|
|
28
|
-
.addColumn('size', 'integer', col => col.notNull())
|
|
29
|
-
.addColumn('trashedAt', 'timestamptz', col => col.defaultTo(null))
|
|
30
|
-
.addColumn('hash', 'bytea', col => col)
|
|
31
|
-
.addColumn('name', 'text', col => col.defaultTo(null))
|
|
32
|
-
.addColumn('type', 'text', col => col.notNull())
|
|
33
|
-
.addColumn('immutable', 'boolean', col => col.notNull())
|
|
34
|
-
.addColumn('publicPermission', 'integer', col => col.notNull().defaultTo(0))
|
|
35
|
-
.addColumn('metadata', 'jsonb', col => col.notNull().defaultTo('{}'))
|
|
36
|
-
.execute()
|
|
37
|
-
.then(done)
|
|
38
|
-
.catch(warnExists);
|
|
39
|
-
await createIndex('storage', 'userId');
|
|
40
|
-
await createIndex('storage', 'parentId');
|
|
41
|
-
await acl.createTable('storage');
|
|
42
|
-
}
|
|
43
|
-
export async function db_wipe(opt) {
|
|
44
|
-
start('Removing data from user storage');
|
|
45
|
-
await database.deleteFrom('storage').execute().then(done);
|
|
46
|
-
await acl.wipeTable('storage');
|
|
47
|
-
}
|
|
48
|
-
export async function remove(opt) {
|
|
49
|
-
start('Dropping table storage');
|
|
50
|
-
await database.schema.dropTable('storage').execute();
|
|
51
|
-
await acl.dropTable('storage');
|
|
52
|
-
done();
|
|
53
|
-
}
|
|
54
17
|
export async function clean(opt) {
|
|
55
18
|
start('Removing expired trash items');
|
|
56
19
|
const nDaysAgo = new Date(Date.now() - 86400000 * config.storage.trash_duration);
|
package/dist/server/raw.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { Permission } from '@axium/core';
|
|
2
1
|
import { audit } from '@axium/server/audit';
|
|
3
|
-
import { checkAuthForItem,
|
|
2
|
+
import { checkAuthForItem, requireSession } from '@axium/server/auth';
|
|
4
3
|
import { config } from '@axium/server/config';
|
|
5
4
|
import { database } from '@axium/server/database';
|
|
6
|
-
import { error,
|
|
5
|
+
import { error, withError } from '@axium/server/requests';
|
|
7
6
|
import { addRoute } from '@axium/server/routes';
|
|
8
7
|
import { createHash } from 'node:crypto';
|
|
9
8
|
import { linkSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -17,10 +16,7 @@ addRoute({
|
|
|
17
16
|
async PUT(request) {
|
|
18
17
|
if (!config.storage.enabled)
|
|
19
18
|
error(503, 'User storage is disabled');
|
|
20
|
-
const
|
|
21
|
-
if (!token)
|
|
22
|
-
error(401, 'Missing session token');
|
|
23
|
-
const { userId } = await getSessionAndUser(token).catch(withError('Invalid session token', 401));
|
|
19
|
+
const { userId } = await requireSession(request);
|
|
24
20
|
const [usage, limits] = await Promise.all([getUserStats(userId), getLimits(userId)]).catch(withError('Could not fetch usage and/or limits'));
|
|
25
21
|
const name = request.headers.get('x-name');
|
|
26
22
|
if (!name)
|
|
@@ -35,7 +31,7 @@ addRoute({
|
|
|
35
31
|
.catch(() => error(400, 'Invalid parent ID'))
|
|
36
32
|
: null;
|
|
37
33
|
if (parentId)
|
|
38
|
-
await checkAuthForItem(request, 'storage', parentId,
|
|
34
|
+
await checkAuthForItem(request, 'storage', parentId, { write: true });
|
|
39
35
|
const size = Number(request.headers.get('content-length'));
|
|
40
36
|
if (Number.isNaN(size))
|
|
41
37
|
error(411, 'Missing or invalid content length header');
|
|
@@ -98,11 +94,10 @@ addRoute({
|
|
|
98
94
|
addRoute({
|
|
99
95
|
path: '/raw/storage/:id',
|
|
100
96
|
params: { id: z.uuid() },
|
|
101
|
-
async GET(request,
|
|
97
|
+
async GET(request, { id: itemId }) {
|
|
102
98
|
if (!config.storage.enabled)
|
|
103
99
|
error(503, 'User storage is disabled');
|
|
104
|
-
const
|
|
105
|
-
const { item } = await checkAuthForItem(request, 'storage', itemId, Permission.Read);
|
|
100
|
+
const { item } = await checkAuthForItem(request, 'storage', itemId, { read: true });
|
|
106
101
|
if (item.trashedAt)
|
|
107
102
|
error(410, 'Trashed items can not be downloaded');
|
|
108
103
|
const content = new Uint8Array(readFileSync(join(config.storage.data, item.id)));
|
|
@@ -113,11 +108,10 @@ addRoute({
|
|
|
113
108
|
},
|
|
114
109
|
});
|
|
115
110
|
},
|
|
116
|
-
async POST(request,
|
|
111
|
+
async POST(request, { id: itemId }) {
|
|
117
112
|
if (!config.storage.enabled)
|
|
118
113
|
error(503, 'User storage is disabled');
|
|
119
|
-
const
|
|
120
|
-
const { item, session } = await checkAuthForItem(request, 'storage', itemId, Permission.Edit);
|
|
114
|
+
const { item, session } = await checkAuthForItem(request, 'storage', itemId, { write: true });
|
|
121
115
|
if (item.immutable)
|
|
122
116
|
error(405, 'Item is immutable');
|
|
123
117
|
if (item.type == 'inode/directory')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"author": "James Prevett <axium@jamespre.dev>",
|
|
5
5
|
"description": "User file storage for Axium",
|
|
6
6
|
"funding": {
|
|
@@ -32,15 +32,16 @@
|
|
|
32
32
|
"dist",
|
|
33
33
|
"lib",
|
|
34
34
|
"build",
|
|
35
|
-
"routes"
|
|
35
|
+
"routes",
|
|
36
|
+
"db.json"
|
|
36
37
|
],
|
|
37
38
|
"scripts": {
|
|
38
39
|
"build": "tsc"
|
|
39
40
|
},
|
|
40
41
|
"peerDependencies": {
|
|
41
|
-
"@axium/client": ">=0.
|
|
42
|
-
"@axium/core": ">=0.
|
|
43
|
-
"@axium/server": ">=0.
|
|
42
|
+
"@axium/client": ">=0.9.0",
|
|
43
|
+
"@axium/core": ">=0.12.0",
|
|
44
|
+
"@axium/server": ">=0.28.0",
|
|
44
45
|
"@sveltejs/kit": "^2.27.3",
|
|
45
46
|
"utilium": "^2.3.8"
|
|
46
47
|
},
|
|
@@ -52,7 +53,8 @@
|
|
|
52
53
|
"http_handler": "./build/handler.js",
|
|
53
54
|
"hooks": "./dist/server/hooks.js",
|
|
54
55
|
"routes": "./routes",
|
|
55
|
-
"cli": "./dist/server/cli.js"
|
|
56
|
+
"cli": "./dist/server/cli.js",
|
|
57
|
+
"db": "./db.json"
|
|
56
58
|
},
|
|
57
59
|
"client": {
|
|
58
60
|
"cli": "./dist/client/cli.js",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { NumberBar } from '@axium/client/components';
|
|
3
|
+
import '@axium/client/styles/list';
|
|
3
4
|
import { formatBytes } from '@axium/core/format';
|
|
4
5
|
import { StorageList } from '@axium/storage/components';
|
|
5
|
-
import '@axium/client/styles/list';
|
|
6
6
|
|
|
7
7
|
const { data } = $props();
|
|
8
8
|
const { limits } = data.info;
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
let items = $state(data.info.items.filter(i => i.type != 'inode/directory').sort((a, b) => Math.sign(b.size - a.size)));
|
|
11
11
|
const usedBytes = $state(data.info.usedBytes);
|
|
12
12
|
|
|
13
|
-
let dialogs = $state<Record<string, HTMLDialogElement>>({});
|
|
14
13
|
let barText = $derived(`Using ${formatBytes(usedBytes)} ${limits.user_size ? 'of ' + formatBytes(limits.user_size * 1_000_000) : ''}`);
|
|
15
14
|
</script>
|
|
16
15
|
|
|
@@ -18,12 +17,6 @@
|
|
|
18
17
|
<title>Your Storage Usage</title>
|
|
19
18
|
</svelte:head>
|
|
20
19
|
|
|
21
|
-
{#snippet action(name: string, i: string = 'pen')}
|
|
22
|
-
<span class="action" onclick={() => dialogs[name].showModal()}>
|
|
23
|
-
<Icon {i} --size="16px" />
|
|
24
|
-
</span>
|
|
25
|
-
{/snippet}
|
|
26
|
-
|
|
27
20
|
<h2>Storage Usage</h2>
|
|
28
21
|
|
|
29
22
|
<p><NumberBar max={limits.user_size * 1_000_000} value={usedBytes} text={barText} /></p>
|