@axium/storage 0.7.10 → 0.8.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/dist/{client.d.ts → client/api.d.ts} +1 -1
- package/dist/{client.js → client/api.js} +12 -4
- package/dist/client/cli.d.ts +1 -0
- package/dist/client/cli.js +124 -0
- package/dist/client/config.d.ts +15 -0
- package/dist/client/config.js +21 -0
- package/dist/client/hooks.d.ts +1 -0
- package/dist/client/hooks.js +6 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/paths.d.ts +2 -0
- package/dist/client/paths.js +16 -0
- package/dist/client/sync.d.ts +55 -0
- package/dist/client/sync.js +205 -0
- package/dist/common.d.ts +60 -0
- package/dist/common.js +20 -0
- package/dist/polyfills.d.ts +1 -1
- package/dist/polyfills.js +3 -3
- package/dist/server/api.d.ts +1 -0
- package/dist/server/api.js +187 -0
- package/dist/server/batch.d.ts +1 -0
- package/dist/server/batch.js +147 -0
- package/dist/server/cli.d.ts +1 -0
- package/dist/server/cli.js +14 -0
- package/dist/{server.d.ts → server/config.d.ts} +12 -52
- package/dist/server/config.js +48 -0
- package/dist/server/db.d.ts +47 -0
- package/dist/server/db.js +77 -0
- package/dist/{hooks.d.ts → server/hooks.d.ts} +3 -2
- package/dist/{hooks.js → server/hooks.js} +7 -3
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.js +5 -0
- package/dist/server/raw.d.ts +1 -0
- package/dist/server/raw.js +163 -0
- package/lib/List.svelte +1 -1
- package/package.json +15 -8
- package/routes/files/[id]/+page.svelte +9 -5
- package/dist/server.js +0 -412
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Permission } from '@axium/core';
|
|
2
|
+
import { audit } from '@axium/server/audit';
|
|
3
|
+
import { checkAuthForItem, getSessionAndUser } from '@axium/server/auth';
|
|
4
|
+
import { config } from '@axium/server/config';
|
|
5
|
+
import { database } from '@axium/server/database';
|
|
6
|
+
import { error, getToken, withError } from '@axium/server/requests';
|
|
7
|
+
import { addRoute } from '@axium/server/routes';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { linkSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path/posix';
|
|
11
|
+
import * as z from 'zod';
|
|
12
|
+
import '../polyfills.js';
|
|
13
|
+
import { defaultCASMime, getLimits } from './config.js';
|
|
14
|
+
import { currentUsage, parseItem } from './db.js';
|
|
15
|
+
addRoute({
|
|
16
|
+
path: '/raw/storage',
|
|
17
|
+
async PUT(request) {
|
|
18
|
+
if (!config.storage.enabled)
|
|
19
|
+
error(503, 'User storage is disabled');
|
|
20
|
+
const token = getToken(request);
|
|
21
|
+
if (!token)
|
|
22
|
+
error(401, 'Missing session token');
|
|
23
|
+
const { userId } = await getSessionAndUser(token).catch(withError('Invalid session token', 401));
|
|
24
|
+
const [usage, limits] = await Promise.all([currentUsage(userId), getLimits(userId)]).catch(withError('Could not fetch usage and/or limits'));
|
|
25
|
+
const name = request.headers.get('x-name');
|
|
26
|
+
if (!name)
|
|
27
|
+
error(400, 'Missing name header');
|
|
28
|
+
if (name.length > 255)
|
|
29
|
+
error(400, 'Name is too long');
|
|
30
|
+
const maybeParentId = request.headers.get('x-parent');
|
|
31
|
+
const parentId = maybeParentId
|
|
32
|
+
? await z
|
|
33
|
+
.uuid()
|
|
34
|
+
.parseAsync(maybeParentId)
|
|
35
|
+
.catch(() => error(400, 'Invalid parent ID'))
|
|
36
|
+
: null;
|
|
37
|
+
if (parentId)
|
|
38
|
+
await checkAuthForItem(request, 'storage', parentId, Permission.Edit);
|
|
39
|
+
const size = Number(request.headers.get('content-length'));
|
|
40
|
+
if (Number.isNaN(size))
|
|
41
|
+
error(411, 'Missing or invalid content length header');
|
|
42
|
+
if (limits.user_items && usage.items >= limits.user_items)
|
|
43
|
+
error(409, 'Too many items');
|
|
44
|
+
if (limits.user_size && (usage.bytes + size) / 1_000_000 >= limits.user_size)
|
|
45
|
+
error(413, 'Not enough space');
|
|
46
|
+
if (limits.item_size && size > limits.item_size * 1_000_000)
|
|
47
|
+
error(413, 'File size exceeds maximum size');
|
|
48
|
+
const content = await request.bytes();
|
|
49
|
+
if (content.byteLength > size) {
|
|
50
|
+
await audit('storage_size_mismatch', userId, { item: null });
|
|
51
|
+
error(400, 'Content length does not match size header');
|
|
52
|
+
}
|
|
53
|
+
const type = request.headers.get('content-type') || 'application/octet-stream';
|
|
54
|
+
const isDirectory = type == 'inode/directory';
|
|
55
|
+
if (isDirectory && size > 0)
|
|
56
|
+
error(400, 'Directories can not have content');
|
|
57
|
+
const useCAS = config.storage.cas.enabled &&
|
|
58
|
+
!isDirectory &&
|
|
59
|
+
(defaultCASMime.some(pattern => pattern.test(type)) || config.storage.cas.include.some(mime => type.match(mime)));
|
|
60
|
+
const hash = isDirectory ? null : createHash('BLAKE2b512').update(content).digest();
|
|
61
|
+
const tx = await database.startTransaction().execute();
|
|
62
|
+
try {
|
|
63
|
+
const item = parseItem(await tx
|
|
64
|
+
.insertInto('storage')
|
|
65
|
+
.values({ userId, hash, name, size, type, immutable: useCAS, parentId })
|
|
66
|
+
.returningAll()
|
|
67
|
+
.executeTakeFirstOrThrow());
|
|
68
|
+
const path = join(config.storage.data, item.id);
|
|
69
|
+
if (!useCAS) {
|
|
70
|
+
if (!isDirectory)
|
|
71
|
+
writeFileSync(path, content);
|
|
72
|
+
await tx.commit().execute();
|
|
73
|
+
return item;
|
|
74
|
+
}
|
|
75
|
+
const existing = await tx
|
|
76
|
+
.selectFrom('storage')
|
|
77
|
+
.select('id')
|
|
78
|
+
.where('hash', '=', hash)
|
|
79
|
+
.where('id', '!=', item.id)
|
|
80
|
+
.limit(1)
|
|
81
|
+
.executeTakeFirst();
|
|
82
|
+
if (!existing) {
|
|
83
|
+
if (!isDirectory)
|
|
84
|
+
writeFileSync(path, content);
|
|
85
|
+
await tx.commit().execute();
|
|
86
|
+
return item;
|
|
87
|
+
}
|
|
88
|
+
linkSync(join(config.storage.data, existing.id), path);
|
|
89
|
+
await tx.commit().execute();
|
|
90
|
+
return item;
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
await tx.rollback().execute();
|
|
94
|
+
throw withError('Could not create item', 500)(error);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
addRoute({
|
|
99
|
+
path: '/raw/storage/:id',
|
|
100
|
+
params: { id: z.uuid() },
|
|
101
|
+
async GET(request, params) {
|
|
102
|
+
if (!config.storage.enabled)
|
|
103
|
+
error(503, 'User storage is disabled');
|
|
104
|
+
const itemId = params.id;
|
|
105
|
+
const { item } = await checkAuthForItem(request, 'storage', itemId, Permission.Read);
|
|
106
|
+
if (item.trashedAt)
|
|
107
|
+
error(410, 'Trashed items can not be downloaded');
|
|
108
|
+
const content = new Uint8Array(readFileSync(join(config.storage.data, item.id)));
|
|
109
|
+
return new Response(content, {
|
|
110
|
+
headers: {
|
|
111
|
+
'Content-Type': item.type,
|
|
112
|
+
'Content-Disposition': `attachment; filename="${item.name}"`,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
async POST(request, params) {
|
|
117
|
+
if (!config.storage.enabled)
|
|
118
|
+
error(503, 'User storage is disabled');
|
|
119
|
+
const itemId = params.id;
|
|
120
|
+
const { item, session } = await checkAuthForItem(request, 'storage', itemId, Permission.Edit);
|
|
121
|
+
if (item.immutable)
|
|
122
|
+
error(405, 'Item is immutable');
|
|
123
|
+
if (item.type == 'inode/directory')
|
|
124
|
+
error(409, 'Directories do not have content');
|
|
125
|
+
if (item.trashedAt)
|
|
126
|
+
error(410, 'Trashed items can not be changed');
|
|
127
|
+
const type = request.headers.get('content-type') || 'application/octet-stream';
|
|
128
|
+
if (type != item.type) {
|
|
129
|
+
await audit('storage_type_mismatch', session?.userId, { item: item.id });
|
|
130
|
+
error(400, 'Content type does not match existing item type');
|
|
131
|
+
}
|
|
132
|
+
const size = Number(request.headers.get('content-length'));
|
|
133
|
+
if (Number.isNaN(size))
|
|
134
|
+
error(411, 'Missing or invalid content length header');
|
|
135
|
+
const [usage, limits] = await Promise.all([currentUsage(item.userId), getLimits(item.userId)]).catch(withError('Could not fetch usage and/or limits'));
|
|
136
|
+
if (limits.user_size && (usage.bytes + size - item.size) / 1_000_000 >= limits.user_size)
|
|
137
|
+
error(413, 'Not enough space');
|
|
138
|
+
if (limits.item_size && size > limits.item_size * 1_000_000)
|
|
139
|
+
error(413, 'File size exceeds maximum size');
|
|
140
|
+
const content = await request.bytes();
|
|
141
|
+
if (content.byteLength > size) {
|
|
142
|
+
await audit('storage_size_mismatch', session?.userId, { item: item.id });
|
|
143
|
+
error(400, 'Actual content length does not match header');
|
|
144
|
+
}
|
|
145
|
+
const hash = createHash('BLAKE2b512').update(content).digest();
|
|
146
|
+
const tx = await database.startTransaction().execute();
|
|
147
|
+
try {
|
|
148
|
+
const result = await tx
|
|
149
|
+
.updateTable('storage')
|
|
150
|
+
.where('id', '=', itemId)
|
|
151
|
+
.set({ size, modifiedAt: new Date(), hash })
|
|
152
|
+
.returningAll()
|
|
153
|
+
.executeTakeFirstOrThrow();
|
|
154
|
+
writeFileSync(join(config.storage.data, result.id), content);
|
|
155
|
+
await tx.commit().execute();
|
|
156
|
+
return parseItem(result);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
await tx.rollback().execute();
|
|
160
|
+
throw withError('Could not update item', 500)(error);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
});
|
package/lib/List.svelte
CHANGED
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
onclick={async () => {
|
|
54
54
|
if (item.type != 'inode/directory') {
|
|
55
55
|
// @todo get preview
|
|
56
|
-
} else if (appMode)
|
|
56
|
+
} else if (appMode) location.href = '/files/' + item.id;
|
|
57
57
|
else items = await getDirectoryMetadata(item.id);
|
|
58
58
|
}}
|
|
59
59
|
>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"author": "James Prevett <axium@jamespre.dev>",
|
|
5
5
|
"description": "User file storage for Axium",
|
|
6
6
|
"funding": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"types": "dist/index.d.ts",
|
|
22
22
|
"exports": {
|
|
23
23
|
".": "./dist/index.js",
|
|
24
|
+
"./client": "./dist/client/index.js",
|
|
24
25
|
"./*": "./dist/*.js",
|
|
25
26
|
"./sidebar": "./lib/sidebar.svelte.js",
|
|
26
27
|
"./components": "./lib/index.js",
|
|
@@ -37,20 +38,26 @@
|
|
|
37
38
|
"build": "tsc"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
|
-
"@axium/client": ">=0.
|
|
41
|
-
"@axium/core": ">=0.
|
|
42
|
-
"@axium/server": ">=0.
|
|
41
|
+
"@axium/client": ">=0.6.0",
|
|
42
|
+
"@axium/core": ">=0.9.0",
|
|
43
|
+
"@axium/server": ">=0.26.0",
|
|
43
44
|
"@sveltejs/kit": "^2.27.3",
|
|
44
45
|
"utilium": "^2.3.8"
|
|
45
46
|
},
|
|
46
47
|
"dependencies": {
|
|
47
|
-
"blakejs": "^1.2.1",
|
|
48
48
|
"zod": "^4.0.5"
|
|
49
49
|
},
|
|
50
50
|
"axium": {
|
|
51
|
-
"
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
"server": {
|
|
52
|
+
"http_handler": "./build/handler.js",
|
|
53
|
+
"hooks": "./dist/server/hooks.js",
|
|
54
|
+
"routes": "./routes",
|
|
55
|
+
"cli": "./dist/server/cli.js"
|
|
56
|
+
},
|
|
57
|
+
"client": {
|
|
58
|
+
"cli": "./dist/client/cli.js",
|
|
59
|
+
"hooks": "./dist/client/hooks.js"
|
|
60
|
+
},
|
|
54
61
|
"apps": [
|
|
55
62
|
{
|
|
56
63
|
"id": "files",
|
|
@@ -25,11 +25,15 @@
|
|
|
25
25
|
<Icon i="trash-can-undo" /> Restore
|
|
26
26
|
</button>
|
|
27
27
|
{:else if item.type == 'inode/directory'}
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
<button
|
|
29
|
+
class="icon-text"
|
|
30
|
+
onclick={e => {
|
|
31
|
+
e.preventDefault();
|
|
32
|
+
location.href = '/files' + (item.parentId ? '/' + item.parentId : '');
|
|
33
|
+
}}
|
|
34
|
+
>
|
|
35
|
+
<Icon i="folder-arrow-up" /> Back
|
|
36
|
+
</button>
|
|
33
37
|
<StorageList appMode bind:items />
|
|
34
38
|
<StorageAdd parentId={item.id} onadd={item => items.push(item)} />
|
|
35
39
|
{:else}
|
package/dist/server.js
DELETED
|
@@ -1,412 +0,0 @@
|
|
|
1
|
-
import { Permission, Severity } from '@axium/core';
|
|
2
|
-
import { addEvent, audit } from '@axium/server/audit';
|
|
3
|
-
import { checkAuthForItem, checkAuthForUser, getSessionAndUser } from '@axium/server/auth';
|
|
4
|
-
import { addConfigDefaults, config } from '@axium/server/config';
|
|
5
|
-
import { database, expectedTypes } from '@axium/server/database';
|
|
6
|
-
import { dirs } from '@axium/server/io';
|
|
7
|
-
import { error, getToken, parseBody, withError } from '@axium/server/requests';
|
|
8
|
-
import { addRoute } from '@axium/server/routes';
|
|
9
|
-
import { createHash } from 'node:crypto';
|
|
10
|
-
import { linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
-
import { join } from 'node:path/posix';
|
|
12
|
-
import * as z from 'zod';
|
|
13
|
-
import { StorageItemUpdate } from './common.js';
|
|
14
|
-
import './polyfills.js';
|
|
15
|
-
expectedTypes.storage = {
|
|
16
|
-
createdAt: { type: 'timestamptz', required: true, hasDefault: true },
|
|
17
|
-
hash: { type: 'bytea' },
|
|
18
|
-
id: { type: 'uuid', required: true, hasDefault: true },
|
|
19
|
-
immutable: { type: 'bool', required: true },
|
|
20
|
-
modifiedAt: { type: 'timestamptz', required: true, hasDefault: true },
|
|
21
|
-
name: { type: 'text' },
|
|
22
|
-
parentId: { type: 'uuid' },
|
|
23
|
-
size: { type: 'int4', required: true },
|
|
24
|
-
trashedAt: { type: 'timestamptz' },
|
|
25
|
-
type: { type: 'text', required: true },
|
|
26
|
-
userId: { type: 'uuid', required: true },
|
|
27
|
-
publicPermission: { type: 'int4', required: true, hasDefault: true },
|
|
28
|
-
metadata: { type: 'jsonb', required: true, hasDefault: true },
|
|
29
|
-
};
|
|
30
|
-
const defaultCASMime = [/video\/.*/, /audio\/.*/];
|
|
31
|
-
addConfigDefaults({
|
|
32
|
-
storage: {
|
|
33
|
-
enabled: true,
|
|
34
|
-
app_enabled: true,
|
|
35
|
-
data: dirs.at(-1) + '/storage',
|
|
36
|
-
trash_duration: 30,
|
|
37
|
-
limits: {
|
|
38
|
-
user_size: 1000,
|
|
39
|
-
item_size: 100,
|
|
40
|
-
user_items: 10_000,
|
|
41
|
-
},
|
|
42
|
-
cas: {
|
|
43
|
-
enabled: true,
|
|
44
|
-
include: [],
|
|
45
|
-
exclude: [],
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
addEvent({ source: '@axium/storage', name: 'storage_type_mismatch', severity: Severity.Warning, tags: ['mimetype'] });
|
|
50
|
-
addEvent({ source: '@axium/storage', name: 'storage_size_mismatch', severity: Severity.Warning, tags: [] });
|
|
51
|
-
export function parseItem(item) {
|
|
52
|
-
return {
|
|
53
|
-
...item,
|
|
54
|
-
dataURL: `/raw/storage/${item.id}`,
|
|
55
|
-
hash: item.hash?.toHex?.(),
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Returns the current usage of the storage for a user in bytes.
|
|
60
|
-
*/
|
|
61
|
-
export async function currentUsage(userId) {
|
|
62
|
-
const result = await database
|
|
63
|
-
.selectFrom('storage')
|
|
64
|
-
.where('userId', '=', userId)
|
|
65
|
-
.select(eb => eb.fn.countAll().as('items'))
|
|
66
|
-
.select(eb => eb.fn.sum('size').as('bytes'))
|
|
67
|
-
.executeTakeFirstOrThrow();
|
|
68
|
-
result.bytes = Number(result.bytes || 0);
|
|
69
|
-
result.items = Number(result.items);
|
|
70
|
-
return result;
|
|
71
|
-
}
|
|
72
|
-
export async function get(itemId) {
|
|
73
|
-
const result = await database
|
|
74
|
-
.selectFrom('storage')
|
|
75
|
-
.where('id', '=', itemId)
|
|
76
|
-
.selectAll()
|
|
77
|
-
.$narrowType()
|
|
78
|
-
.executeTakeFirstOrThrow();
|
|
79
|
-
return parseItem(result);
|
|
80
|
-
}
|
|
81
|
-
let _getLimits = null;
|
|
82
|
-
/**
|
|
83
|
-
* Define the handler to get limits for a user externally.
|
|
84
|
-
*/
|
|
85
|
-
export function useLimits(handler) {
|
|
86
|
-
_getLimits = handler;
|
|
87
|
-
}
|
|
88
|
-
export async function getLimits(userId) {
|
|
89
|
-
try {
|
|
90
|
-
return await _getLimits(userId);
|
|
91
|
-
}
|
|
92
|
-
catch {
|
|
93
|
-
return config.storage.limits;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
export async function* getRecursive(id) {
|
|
97
|
-
const items = await database.selectFrom('storage').where('parentId', '=', id).selectAll().execute();
|
|
98
|
-
for (const item of items) {
|
|
99
|
-
if (item.type != 'inode/directory')
|
|
100
|
-
yield item.id;
|
|
101
|
-
else
|
|
102
|
-
yield* getRecursive(item.id);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
export async function deleteRecursive(itemId, deleteSelf) {
|
|
106
|
-
const toDelete = await Array.fromAsync(getRecursive(itemId)).catch(withError('Could not get items to delete'));
|
|
107
|
-
if (deleteSelf)
|
|
108
|
-
toDelete.push(itemId);
|
|
109
|
-
await database.deleteFrom('storage').where('id', '=', itemId).returningAll().execute().catch(withError('Could not delete item'));
|
|
110
|
-
for (const id of toDelete)
|
|
111
|
-
unlinkSync(join(config.storage.data, id));
|
|
112
|
-
}
|
|
113
|
-
addRoute({
|
|
114
|
-
path: '/api/storage/item/:id',
|
|
115
|
-
params: { id: z.uuid() },
|
|
116
|
-
async GET(request, params) {
|
|
117
|
-
if (!config.storage.enabled)
|
|
118
|
-
error(503, 'User storage is disabled');
|
|
119
|
-
const itemId = params.id;
|
|
120
|
-
const { item } = await checkAuthForItem(request, 'storage', itemId, Permission.Read);
|
|
121
|
-
return parseItem(item);
|
|
122
|
-
},
|
|
123
|
-
async PATCH(request, params) {
|
|
124
|
-
if (!config.storage.enabled)
|
|
125
|
-
error(503, 'User storage is disabled');
|
|
126
|
-
const itemId = params.id;
|
|
127
|
-
const body = await parseBody(request, StorageItemUpdate);
|
|
128
|
-
await checkAuthForItem(request, 'storage', itemId, Permission.Manage);
|
|
129
|
-
const values = {};
|
|
130
|
-
if ('publicPermission' in body)
|
|
131
|
-
values.publicPermission = body.publicPermission;
|
|
132
|
-
if ('trash' in body)
|
|
133
|
-
values.trashedAt = body.trash ? new Date() : null;
|
|
134
|
-
if ('owner' in body)
|
|
135
|
-
values.userId = body.owner;
|
|
136
|
-
if ('name' in body)
|
|
137
|
-
values.name = body.name;
|
|
138
|
-
if (!Object.keys(values).length)
|
|
139
|
-
error(400, 'No valid fields to update');
|
|
140
|
-
return parseItem(await database
|
|
141
|
-
.updateTable('storage')
|
|
142
|
-
.where('id', '=', itemId)
|
|
143
|
-
.set(values)
|
|
144
|
-
.returningAll()
|
|
145
|
-
.executeTakeFirstOrThrow()
|
|
146
|
-
.catch(withError('Could not update item')));
|
|
147
|
-
},
|
|
148
|
-
async DELETE(request, params) {
|
|
149
|
-
if (!config.storage.enabled)
|
|
150
|
-
error(503, 'User storage is disabled');
|
|
151
|
-
const itemId = params.id;
|
|
152
|
-
const auth = await checkAuthForItem(request, 'storage', itemId, Permission.Manage);
|
|
153
|
-
const item = parseItem(auth.item);
|
|
154
|
-
await deleteRecursive(itemId, item.type != 'inode/directory');
|
|
155
|
-
return item;
|
|
156
|
-
},
|
|
157
|
-
});
|
|
158
|
-
addRoute({
|
|
159
|
-
path: '/api/storage/directory/:id',
|
|
160
|
-
params: { id: z.uuid() },
|
|
161
|
-
async GET(request, params) {
|
|
162
|
-
if (!config.storage.enabled)
|
|
163
|
-
error(503, 'User storage is disabled');
|
|
164
|
-
const itemId = params.id;
|
|
165
|
-
const { item } = await checkAuthForItem(request, 'storage', itemId, Permission.Read);
|
|
166
|
-
if (item.type != 'inode/directory')
|
|
167
|
-
error(409, 'Item is not a directory');
|
|
168
|
-
const items = await database
|
|
169
|
-
.selectFrom('storage')
|
|
170
|
-
.where('parentId', '=', itemId)
|
|
171
|
-
.where('trashedAt', 'is', null)
|
|
172
|
-
.selectAll()
|
|
173
|
-
.execute();
|
|
174
|
-
return items.map(parseItem);
|
|
175
|
-
},
|
|
176
|
-
});
|
|
177
|
-
addRoute({
|
|
178
|
-
path: '/raw/storage',
|
|
179
|
-
async PUT(request) {
|
|
180
|
-
if (!config.storage.enabled)
|
|
181
|
-
error(503, 'User storage is disabled');
|
|
182
|
-
const token = getToken(request);
|
|
183
|
-
if (!token)
|
|
184
|
-
error(401, 'Missing session token');
|
|
185
|
-
const { userId } = await getSessionAndUser(token).catch(withError('Invalid session token', 401));
|
|
186
|
-
const [usage, limits] = await Promise.all([currentUsage(userId), getLimits(userId)]).catch(withError('Could not fetch usage and/or limits'));
|
|
187
|
-
const name = request.headers.get('x-name');
|
|
188
|
-
if (!name)
|
|
189
|
-
error(400, 'Missing name header');
|
|
190
|
-
if (name.length > 255)
|
|
191
|
-
error(400, 'Name is too long');
|
|
192
|
-
const maybeParentId = request.headers.get('x-parent');
|
|
193
|
-
const parentId = maybeParentId
|
|
194
|
-
? await z
|
|
195
|
-
.uuid()
|
|
196
|
-
.parseAsync(maybeParentId)
|
|
197
|
-
.catch(() => error(400, 'Invalid parent ID'))
|
|
198
|
-
: null;
|
|
199
|
-
if (parentId)
|
|
200
|
-
await checkAuthForItem(request, 'storage', parentId, Permission.Edit);
|
|
201
|
-
const size = Number(request.headers.get('content-length'));
|
|
202
|
-
if (Number.isNaN(size))
|
|
203
|
-
error(411, 'Missing or invalid content length header');
|
|
204
|
-
if (limits.user_items && usage.items >= limits.user_items)
|
|
205
|
-
error(409, 'Too many items');
|
|
206
|
-
if (limits.user_size && (usage.bytes + size) / 1_000_000 >= limits.user_size)
|
|
207
|
-
error(413, 'Not enough space');
|
|
208
|
-
if (limits.item_size && size > limits.item_size * 1_000_000)
|
|
209
|
-
error(413, 'File size exceeds maximum size');
|
|
210
|
-
const content = await request.bytes();
|
|
211
|
-
if (content.byteLength > size) {
|
|
212
|
-
await audit('storage_size_mismatch', userId, { item: null });
|
|
213
|
-
error(400, 'Content length does not match size header');
|
|
214
|
-
}
|
|
215
|
-
const type = request.headers.get('content-type') || 'application/octet-stream';
|
|
216
|
-
const isDirectory = type == 'inode/directory';
|
|
217
|
-
if (isDirectory && size > 0)
|
|
218
|
-
error(400, 'Directories can not have content');
|
|
219
|
-
const useCAS = config.storage.cas.enabled &&
|
|
220
|
-
!isDirectory &&
|
|
221
|
-
(defaultCASMime.some(pattern => pattern.test(type)) || config.storage.cas.include.some(mime => type.match(mime)));
|
|
222
|
-
const hash = isDirectory ? null : createHash('BLAKE2b512').update(content).digest();
|
|
223
|
-
const tx = await database.startTransaction().execute();
|
|
224
|
-
try {
|
|
225
|
-
const item = parseItem(await tx
|
|
226
|
-
.insertInto('storage')
|
|
227
|
-
.values({ userId, hash, name, size, type, immutable: useCAS, parentId })
|
|
228
|
-
.returningAll()
|
|
229
|
-
.executeTakeFirstOrThrow());
|
|
230
|
-
const path = join(config.storage.data, item.id);
|
|
231
|
-
if (!useCAS) {
|
|
232
|
-
if (!isDirectory)
|
|
233
|
-
writeFileSync(path, content);
|
|
234
|
-
await tx.commit().execute();
|
|
235
|
-
return item;
|
|
236
|
-
}
|
|
237
|
-
const existing = await tx
|
|
238
|
-
.selectFrom('storage')
|
|
239
|
-
.select('id')
|
|
240
|
-
.where('hash', '=', hash)
|
|
241
|
-
.where('id', '!=', item.id)
|
|
242
|
-
.limit(1)
|
|
243
|
-
.executeTakeFirst();
|
|
244
|
-
if (!existing) {
|
|
245
|
-
if (!isDirectory)
|
|
246
|
-
writeFileSync(path, content);
|
|
247
|
-
await tx.commit().execute();
|
|
248
|
-
return item;
|
|
249
|
-
}
|
|
250
|
-
linkSync(join(config.storage.data, existing.id), path);
|
|
251
|
-
await tx.commit().execute();
|
|
252
|
-
return item;
|
|
253
|
-
}
|
|
254
|
-
catch (error) {
|
|
255
|
-
await tx.rollback().execute();
|
|
256
|
-
throw withError('Could not create item', 500)(error);
|
|
257
|
-
}
|
|
258
|
-
},
|
|
259
|
-
});
|
|
260
|
-
addRoute({
|
|
261
|
-
path: '/raw/storage/:id',
|
|
262
|
-
params: { id: z.uuid() },
|
|
263
|
-
async GET(request, params) {
|
|
264
|
-
if (!config.storage.enabled)
|
|
265
|
-
error(503, 'User storage is disabled');
|
|
266
|
-
const itemId = params.id;
|
|
267
|
-
const { item } = await checkAuthForItem(request, 'storage', itemId, Permission.Read);
|
|
268
|
-
if (item.trashedAt)
|
|
269
|
-
error(410, 'Trashed items can not be downloaded');
|
|
270
|
-
const content = new Uint8Array(readFileSync(join(config.storage.data, item.id)));
|
|
271
|
-
return new Response(content, {
|
|
272
|
-
headers: {
|
|
273
|
-
'Content-Type': item.type,
|
|
274
|
-
'Content-Disposition': `attachment; filename="${item.name}"`,
|
|
275
|
-
},
|
|
276
|
-
});
|
|
277
|
-
},
|
|
278
|
-
async POST(request, params) {
|
|
279
|
-
if (!config.storage.enabled)
|
|
280
|
-
error(503, 'User storage is disabled');
|
|
281
|
-
const itemId = params.id;
|
|
282
|
-
const { item, session } = await checkAuthForItem(request, 'storage', itemId, Permission.Edit);
|
|
283
|
-
if (item.immutable)
|
|
284
|
-
error(405, 'Item is immutable');
|
|
285
|
-
if (item.type == 'inode/directory')
|
|
286
|
-
error(409, 'Directories do not have content');
|
|
287
|
-
if (item.trashedAt)
|
|
288
|
-
error(410, 'Trashed items can not be changed');
|
|
289
|
-
const type = request.headers.get('content-type') || 'application/octet-stream';
|
|
290
|
-
if (type != item.type) {
|
|
291
|
-
await audit('storage_type_mismatch', session?.userId, { item: item.id });
|
|
292
|
-
error(400, 'Content type does not match existing item type');
|
|
293
|
-
}
|
|
294
|
-
const size = Number(request.headers.get('content-length'));
|
|
295
|
-
if (Number.isNaN(size))
|
|
296
|
-
error(411, 'Missing or invalid content length header');
|
|
297
|
-
const [usage, limits] = await Promise.all([currentUsage(item.userId), getLimits(item.userId)]).catch(withError('Could not fetch usage and/or limits'));
|
|
298
|
-
if (limits.user_size && (usage.bytes + size - item.size) / 1_000_000 >= limits.user_size)
|
|
299
|
-
error(413, 'Not enough space');
|
|
300
|
-
if (limits.item_size && size > limits.item_size * 1_000_000)
|
|
301
|
-
error(413, 'File size exceeds maximum size');
|
|
302
|
-
const content = await request.bytes();
|
|
303
|
-
if (content.byteLength > size) {
|
|
304
|
-
await audit('storage_size_mismatch', session?.userId, { item: item.id });
|
|
305
|
-
error(400, 'Actual content length does not match header');
|
|
306
|
-
}
|
|
307
|
-
const hash = createHash('BLAKE2b512').update(content).digest();
|
|
308
|
-
const tx = await database.startTransaction().execute();
|
|
309
|
-
try {
|
|
310
|
-
const result = await tx
|
|
311
|
-
.updateTable('storage')
|
|
312
|
-
.where('id', '=', itemId)
|
|
313
|
-
.set({ size, modifiedAt: new Date(), hash })
|
|
314
|
-
.returningAll()
|
|
315
|
-
.executeTakeFirstOrThrow();
|
|
316
|
-
writeFileSync(join(config.storage.data, result.id), content);
|
|
317
|
-
await tx.commit().execute();
|
|
318
|
-
return parseItem(result);
|
|
319
|
-
}
|
|
320
|
-
catch (error) {
|
|
321
|
-
await tx.rollback().execute();
|
|
322
|
-
throw withError('Could not update item', 500)(error);
|
|
323
|
-
}
|
|
324
|
-
},
|
|
325
|
-
});
|
|
326
|
-
addRoute({
|
|
327
|
-
path: '/api/users/:id/storage',
|
|
328
|
-
params: { id: z.uuid() },
|
|
329
|
-
async OPTIONS(request, params) {
|
|
330
|
-
if (!config.storage.enabled)
|
|
331
|
-
error(503, 'User storage is disabled');
|
|
332
|
-
const userId = params.id;
|
|
333
|
-
await checkAuthForUser(request, userId);
|
|
334
|
-
const [usage, limits] = await Promise.all([currentUsage(userId), getLimits(userId)]).catch(withError('Could not fetch data'));
|
|
335
|
-
return { usage, limits };
|
|
336
|
-
},
|
|
337
|
-
async GET(request, params) {
|
|
338
|
-
if (!config.storage.enabled)
|
|
339
|
-
error(503, 'User storage is disabled');
|
|
340
|
-
const userId = params.id;
|
|
341
|
-
await checkAuthForUser(request, userId);
|
|
342
|
-
const [items, usage, limits] = await Promise.all([
|
|
343
|
-
database.selectFrom('storage').where('userId', '=', userId).where('trashedAt', 'is', null).selectAll().execute(),
|
|
344
|
-
currentUsage(userId),
|
|
345
|
-
getLimits(userId),
|
|
346
|
-
]).catch(withError('Could not fetch data'));
|
|
347
|
-
return { usage, limits, items: items.map(parseItem) };
|
|
348
|
-
},
|
|
349
|
-
});
|
|
350
|
-
addRoute({
|
|
351
|
-
path: '/api/users/:id/storage/root',
|
|
352
|
-
params: { id: z.uuid() },
|
|
353
|
-
async GET(request, params) {
|
|
354
|
-
if (!config.storage.enabled)
|
|
355
|
-
error(503, 'User storage is disabled');
|
|
356
|
-
const userId = params.id;
|
|
357
|
-
await checkAuthForUser(request, userId);
|
|
358
|
-
const items = await database
|
|
359
|
-
.selectFrom('storage')
|
|
360
|
-
.where('userId', '=', userId)
|
|
361
|
-
.where('trashedAt', 'is', null)
|
|
362
|
-
.where('parentId', 'is', null)
|
|
363
|
-
.selectAll()
|
|
364
|
-
.execute()
|
|
365
|
-
.catch(withError('Could not get storage items'));
|
|
366
|
-
return items.map(parseItem);
|
|
367
|
-
},
|
|
368
|
-
});
|
|
369
|
-
function existsInACL(column, userId) {
|
|
370
|
-
return (eb) => eb.exists(eb
|
|
371
|
-
.selectFrom('acl.storage')
|
|
372
|
-
.whereRef('itemId', '=', `item.${column}`)
|
|
373
|
-
.where('userId', '=', userId)
|
|
374
|
-
.where('permission', '!=', Permission.None));
|
|
375
|
-
}
|
|
376
|
-
addRoute({
|
|
377
|
-
path: '/api/users/:id/storage/shared',
|
|
378
|
-
params: { id: z.uuid() },
|
|
379
|
-
async GET(request, params) {
|
|
380
|
-
if (!config.storage.enabled)
|
|
381
|
-
error(503, 'User storage is disabled');
|
|
382
|
-
const userId = params.id;
|
|
383
|
-
await checkAuthForUser(request, userId);
|
|
384
|
-
const items = await database
|
|
385
|
-
.selectFrom('storage as item')
|
|
386
|
-
.selectAll('item')
|
|
387
|
-
.where('trashedAt', 'is', null)
|
|
388
|
-
.where(existsInACL('id', userId))
|
|
389
|
-
.where(eb => eb.not(existsInACL('parentId', userId)))
|
|
390
|
-
.execute()
|
|
391
|
-
.catch(withError('Could not get storage items'));
|
|
392
|
-
return items.map(parseItem);
|
|
393
|
-
},
|
|
394
|
-
});
|
|
395
|
-
addRoute({
|
|
396
|
-
path: '/api/users/:id/storage/trash',
|
|
397
|
-
params: { id: z.uuid() },
|
|
398
|
-
async GET(request, params) {
|
|
399
|
-
if (!config.storage.enabled)
|
|
400
|
-
error(503, 'User storage is disabled');
|
|
401
|
-
const userId = params.id;
|
|
402
|
-
await checkAuthForUser(request, userId);
|
|
403
|
-
const items = await database
|
|
404
|
-
.selectFrom('storage')
|
|
405
|
-
.where('userId', '=', userId)
|
|
406
|
-
.where('trashedAt', 'is not', null)
|
|
407
|
-
.selectAll()
|
|
408
|
-
.execute()
|
|
409
|
-
.catch(withError('Could not get trash'));
|
|
410
|
-
return items.map(parseItem);
|
|
411
|
-
},
|
|
412
|
-
});
|