@axium/storage 0.1.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.md +157 -0
- package/README.md +17 -0
- package/dist/client.d.ts +15 -0
- package/dist/client.js +63 -0
- package/dist/common.d.ts +65 -0
- package/dist/common.js +13 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plugin.d.ts +57 -0
- package/dist/plugin.js +62 -0
- package/dist/polyfills.d.ts +62 -0
- package/dist/polyfills.js +24 -0
- package/dist/selection.d.ts +17 -0
- package/dist/selection.js +32 -0
- package/dist/server.d.ts +61 -0
- package/dist/server.js +311 -0
- package/lib/StorageItemList.svelte +121 -0
- package/lib/StorageSidebar.svelte +41 -0
- package/lib/StorageSidebarItem.svelte +170 -0
- package/lib/tsconfig.json +10 -0
- package/package.json +44 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { getSessionAndUser } from '@axium/server/auth';
|
|
2
|
+
import { addConfigDefaults, config } from '@axium/server/config';
|
|
3
|
+
import { connect, database } from '@axium/server/database';
|
|
4
|
+
import { dirs } from '@axium/server/io';
|
|
5
|
+
import { checkAuth, getToken, parseBody, withError } from '@axium/server/requests';
|
|
6
|
+
import { addRoute } from '@axium/server/routes';
|
|
7
|
+
import { error } from '@sveltejs/kit';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { writeFile } from 'node:fs/promises';
|
|
11
|
+
import { join } from 'node:path/posix';
|
|
12
|
+
import * as z from 'zod';
|
|
13
|
+
import { StorageItemUpdate } from './common.js';
|
|
14
|
+
import './polyfills.js';
|
|
15
|
+
const defaultCASMime = [/video\/.*/, /audio\/.*/];
|
|
16
|
+
addConfigDefaults({
|
|
17
|
+
storage: {
|
|
18
|
+
enabled: true,
|
|
19
|
+
app_enabled: true,
|
|
20
|
+
data: dirs.at(-1) + '/storage',
|
|
21
|
+
trash_duration: 30,
|
|
22
|
+
limits: {
|
|
23
|
+
user_size: 1000,
|
|
24
|
+
item_size: 100,
|
|
25
|
+
user_items: 10_000,
|
|
26
|
+
},
|
|
27
|
+
cas: {
|
|
28
|
+
enabled: true,
|
|
29
|
+
include: [],
|
|
30
|
+
exclude: [],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
function parseItem(item) {
|
|
35
|
+
return {
|
|
36
|
+
...item,
|
|
37
|
+
hash: item.hash.toHex(),
|
|
38
|
+
dataURL: `/raw/storage/${item.id}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Returns the current usage of the storage for a user in bytes.
|
|
43
|
+
*/
|
|
44
|
+
export async function currentUsage(userId) {
|
|
45
|
+
connect();
|
|
46
|
+
const result = await database
|
|
47
|
+
.selectFrom('storage')
|
|
48
|
+
.where('userId', '=', userId)
|
|
49
|
+
.select(database.fn.countAll().as('items'))
|
|
50
|
+
.select(eb => eb.fn.sum('size').as('bytes'))
|
|
51
|
+
.executeTakeFirstOrThrow();
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
export async function get(itemId) {
|
|
55
|
+
connect();
|
|
56
|
+
const result = await database.selectFrom('storage').where('id', '=', itemId).selectAll().executeTakeFirstOrThrow();
|
|
57
|
+
return parseItem(result);
|
|
58
|
+
}
|
|
59
|
+
let _getLimits = null;
|
|
60
|
+
/**
|
|
61
|
+
* Define the handler to get limits for a user externally.
|
|
62
|
+
*/
|
|
63
|
+
export function useLimits(handler) {
|
|
64
|
+
_getLimits = handler;
|
|
65
|
+
}
|
|
66
|
+
export async function getLimits(userId) {
|
|
67
|
+
try {
|
|
68
|
+
return await _getLimits(userId);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return config.storage.limits;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
addRoute({
|
|
75
|
+
path: '/api/storage/item/:id',
|
|
76
|
+
params: { id: z.uuid() },
|
|
77
|
+
async GET(event) {
|
|
78
|
+
if (!config.storage.enabled)
|
|
79
|
+
error(503, 'User storage is disabled');
|
|
80
|
+
const itemId = event.params.id;
|
|
81
|
+
const item = await get(itemId);
|
|
82
|
+
if (!item)
|
|
83
|
+
error(404, 'Item not found');
|
|
84
|
+
await checkAuth(event, item.userId);
|
|
85
|
+
return item;
|
|
86
|
+
},
|
|
87
|
+
async PATCH(event) {
|
|
88
|
+
if (!config.storage.enabled)
|
|
89
|
+
error(503, 'User storage is disabled');
|
|
90
|
+
const itemId = event.params.id;
|
|
91
|
+
const body = await parseBody(event, StorageItemUpdate);
|
|
92
|
+
const item = await get(itemId);
|
|
93
|
+
if (!item)
|
|
94
|
+
error(404, 'Item not found');
|
|
95
|
+
await checkAuth(event, item.userId);
|
|
96
|
+
const values = {};
|
|
97
|
+
if ('restrict' in body)
|
|
98
|
+
values.restricted = body.restrict;
|
|
99
|
+
if ('trash' in body)
|
|
100
|
+
values.trashedAt = body.trash ? new Date() : null;
|
|
101
|
+
if ('owner' in body)
|
|
102
|
+
values.userId = body.owner;
|
|
103
|
+
if ('name' in body)
|
|
104
|
+
values.name = body.name;
|
|
105
|
+
if (!Object.keys(values).length)
|
|
106
|
+
error(400, 'No valid fields to update');
|
|
107
|
+
return parseItem(await database
|
|
108
|
+
.updateTable('storage')
|
|
109
|
+
.where('id', '=', itemId)
|
|
110
|
+
.set(values)
|
|
111
|
+
.returningAll()
|
|
112
|
+
.executeTakeFirstOrThrow()
|
|
113
|
+
.catch(withError('Could not update item')));
|
|
114
|
+
},
|
|
115
|
+
async DELETE(event) {
|
|
116
|
+
if (!config.storage.enabled)
|
|
117
|
+
error(503, 'User storage is disabled');
|
|
118
|
+
const itemId = event.params.id;
|
|
119
|
+
const item = await get(itemId);
|
|
120
|
+
if (!item)
|
|
121
|
+
error(404, 'Item not found');
|
|
122
|
+
await checkAuth(event, item.userId);
|
|
123
|
+
await database
|
|
124
|
+
.deleteFrom('storage')
|
|
125
|
+
.where('id', '=', itemId)
|
|
126
|
+
.returningAll()
|
|
127
|
+
.executeTakeFirstOrThrow()
|
|
128
|
+
.catch(withError('Could not delete item'));
|
|
129
|
+
const { count } = await database
|
|
130
|
+
.selectFrom('storage')
|
|
131
|
+
.where('hash', '=', Uint8Array.fromHex(item.hash))
|
|
132
|
+
.select(eb => eb.fn.countAll().as('count'))
|
|
133
|
+
.executeTakeFirstOrThrow();
|
|
134
|
+
if (!Number(count))
|
|
135
|
+
unlinkSync(join(config.storage.data, item.hash));
|
|
136
|
+
return item;
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
addRoute({
|
|
140
|
+
path: '/api/storage/directory/:id',
|
|
141
|
+
params: { id: z.uuid() },
|
|
142
|
+
async GET(event) {
|
|
143
|
+
if (!config.storage.enabled)
|
|
144
|
+
error(503, 'User storage is disabled');
|
|
145
|
+
const itemId = event.params.id;
|
|
146
|
+
const item = await get(itemId);
|
|
147
|
+
if (!item)
|
|
148
|
+
error(404, 'Item not found');
|
|
149
|
+
await checkAuth(event, item.userId);
|
|
150
|
+
if (item.type != 'inode/directory')
|
|
151
|
+
error(409, 'Item is not a directory');
|
|
152
|
+
const items = await database
|
|
153
|
+
.selectFrom('storage')
|
|
154
|
+
.where('parentId', '=', itemId)
|
|
155
|
+
.where('trashedAt', '!=', null)
|
|
156
|
+
.selectAll()
|
|
157
|
+
.execute();
|
|
158
|
+
return items.map(parseItem);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
addRoute({
|
|
162
|
+
path: '/raw/storage',
|
|
163
|
+
async PUT(event) {
|
|
164
|
+
if (!config.storage.enabled)
|
|
165
|
+
error(503, 'User storage is disabled');
|
|
166
|
+
const token = getToken(event);
|
|
167
|
+
if (!token)
|
|
168
|
+
error(401, 'Missing session token');
|
|
169
|
+
const { userId } = await getSessionAndUser(token).catch(withError('Invalid session token', 401));
|
|
170
|
+
const [usage, limits] = await Promise.all([currentUsage(userId), getLimits(userId)]).catch(withError('Could not fetch usage and/or limits'));
|
|
171
|
+
const name = event.request.headers.get('x-name');
|
|
172
|
+
if ((name?.length || 0) > 255)
|
|
173
|
+
error(400, 'Name is too long');
|
|
174
|
+
const maybeParentId = event.request.headers.get('x-parent');
|
|
175
|
+
const parentId = maybeParentId
|
|
176
|
+
? await z
|
|
177
|
+
.uuid()
|
|
178
|
+
.parseAsync(maybeParentId)
|
|
179
|
+
.catch(() => error(400, 'Invalid parent ID'))
|
|
180
|
+
: null;
|
|
181
|
+
const size = Number(event.request.headers.get('content-length'));
|
|
182
|
+
if (Number.isNaN(size))
|
|
183
|
+
error(411, 'Missing or invalid content length header');
|
|
184
|
+
if (usage.items >= limits.user_items)
|
|
185
|
+
error(409, 'Too many items');
|
|
186
|
+
if ((usage.bytes + size) / 1_000_000 >= limits.user_size)
|
|
187
|
+
error(413, 'Not enough space');
|
|
188
|
+
if (size > limits.item_size * 1_000_000)
|
|
189
|
+
error(413, 'File size exceeds maximum size');
|
|
190
|
+
const content = await event.request.bytes();
|
|
191
|
+
// @todo: add this to the audit log
|
|
192
|
+
if (content.byteLength > size)
|
|
193
|
+
error(400, 'Content length does not match size header');
|
|
194
|
+
const type = event.request.headers.get('content-type') || 'application/octet-stream';
|
|
195
|
+
const useCAS = config.storage.cas.enabled &&
|
|
196
|
+
(defaultCASMime.some(pattern => pattern.test(type)) || config.storage.cas.include.some(mime => type.match(mime)));
|
|
197
|
+
const hash = createHash('BLAKE2b512').update(content).digest();
|
|
198
|
+
// @todo: make this atomic
|
|
199
|
+
const result = await database
|
|
200
|
+
.insertInto('storage')
|
|
201
|
+
.values({ userId: userId, hash, name, size, type, immutable: useCAS, parentId })
|
|
202
|
+
.returningAll()
|
|
203
|
+
.executeTakeFirstOrThrow()
|
|
204
|
+
.catch(withError('Could not create item'));
|
|
205
|
+
const path = join(config.storage.data, result.id);
|
|
206
|
+
const _noDupe = () => {
|
|
207
|
+
writeFileSync(path, content);
|
|
208
|
+
return parseItem(result);
|
|
209
|
+
};
|
|
210
|
+
if (!useCAS)
|
|
211
|
+
return _noDupe();
|
|
212
|
+
const existing = await database
|
|
213
|
+
.selectFrom('storage')
|
|
214
|
+
.where('hash', '=', hash)
|
|
215
|
+
.where('id', '!=', result.id)
|
|
216
|
+
.selectAll()
|
|
217
|
+
.executeTakeFirst();
|
|
218
|
+
if (!existing)
|
|
219
|
+
return _noDupe();
|
|
220
|
+
linkSync(join(config.storage.data, existing.id), path);
|
|
221
|
+
return parseItem(result);
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
addRoute({
|
|
225
|
+
path: '/raw/storage/:id',
|
|
226
|
+
params: { id: z.uuid() },
|
|
227
|
+
async GET(event) {
|
|
228
|
+
if (!config.storage.enabled)
|
|
229
|
+
error(503, 'User storage is disabled');
|
|
230
|
+
const itemId = event.params.id;
|
|
231
|
+
const item = await get(itemId);
|
|
232
|
+
if (!item)
|
|
233
|
+
error(404, 'Item not found');
|
|
234
|
+
await checkAuth(event, item.userId);
|
|
235
|
+
if (item.trashedAt)
|
|
236
|
+
error(410, 'Trashed items can not be downloaded');
|
|
237
|
+
const content = new Uint8Array(readFileSync(join(config.storage.data, item.id)));
|
|
238
|
+
return new Response(content, {
|
|
239
|
+
headers: {
|
|
240
|
+
'Content-Type': item.type,
|
|
241
|
+
'Content-Disposition': `attachment; filename="${item.name}"`,
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
async POST(event) {
|
|
246
|
+
if (!config.storage.enabled)
|
|
247
|
+
error(503, 'User storage is disabled');
|
|
248
|
+
const itemId = event.params.id;
|
|
249
|
+
const item = await get(itemId);
|
|
250
|
+
if (!item)
|
|
251
|
+
error(404, 'Item not found');
|
|
252
|
+
const { accessor } = await checkAuth(event, item.userId);
|
|
253
|
+
if (item.immutable)
|
|
254
|
+
error(403, 'Item is immutable');
|
|
255
|
+
if (item.trashedAt)
|
|
256
|
+
error(410, 'Trashed items can not be changed');
|
|
257
|
+
if (item.restricted && item.userId != accessor.id)
|
|
258
|
+
error(403, 'Item editing is restricted to the owner');
|
|
259
|
+
const type = event.request.headers.get('content-type') || 'application/octet-stream';
|
|
260
|
+
// @todo: add this to the audit log
|
|
261
|
+
if (type != item.type)
|
|
262
|
+
error(400, 'Content type does not match existing item type');
|
|
263
|
+
const size = Number(event.request.headers.get('content-length'));
|
|
264
|
+
if (Number.isNaN(size))
|
|
265
|
+
error(411, 'Missing or invalid content length header');
|
|
266
|
+
const [usage, limits] = await Promise.all([currentUsage(item.userId), getLimits(item.userId)]).catch(withError('Could not fetch usage and/or limits'));
|
|
267
|
+
if ((usage.bytes + size - item.size) / 1_000_000 >= limits.user_size)
|
|
268
|
+
error(413, 'Not enough space');
|
|
269
|
+
if (size > limits.item_size * 1_000_000)
|
|
270
|
+
error(413, 'File size exceeds maximum size');
|
|
271
|
+
const content = await event.request.bytes();
|
|
272
|
+
// @todo: add this to the audit log
|
|
273
|
+
if (content.byteLength > size)
|
|
274
|
+
error(400, 'Content length does not match size header');
|
|
275
|
+
const hash = createHash('BLAKE2b512').update(content).digest();
|
|
276
|
+
// @todo: make this atomic
|
|
277
|
+
const result = await database
|
|
278
|
+
.updateTable('storage')
|
|
279
|
+
.where('id', '=', itemId)
|
|
280
|
+
.set({ size, modifiedAt: new Date(), hash })
|
|
281
|
+
.returningAll()
|
|
282
|
+
.executeTakeFirstOrThrow()
|
|
283
|
+
.catch(withError('Could not update item'));
|
|
284
|
+
await writeFile(join(config.storage.data, result.id), content).catch(withError('Could not write'));
|
|
285
|
+
return parseItem(result);
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
addRoute({
|
|
289
|
+
path: '/api/users/:id/storage',
|
|
290
|
+
params: { id: z.uuid() },
|
|
291
|
+
async OPTIONS(event) {
|
|
292
|
+
if (!config.storage.enabled)
|
|
293
|
+
error(503, 'User storage is disabled');
|
|
294
|
+
const userId = event.params.id;
|
|
295
|
+
await checkAuth(event, userId);
|
|
296
|
+
const [usage, limits] = await Promise.all([currentUsage(userId), getLimits(userId)]).catch(withError('Could not fetch data'));
|
|
297
|
+
return { usage, limits };
|
|
298
|
+
},
|
|
299
|
+
async GET(event) {
|
|
300
|
+
if (!config.storage.enabled)
|
|
301
|
+
error(503, 'User storage is disabled');
|
|
302
|
+
const userId = event.params.id;
|
|
303
|
+
await checkAuth(event, userId);
|
|
304
|
+
const [items, usage, limits] = await Promise.all([
|
|
305
|
+
database.selectFrom('storage').where('userId', '=', userId).selectAll().execute(),
|
|
306
|
+
currentUsage(userId),
|
|
307
|
+
getLimits(userId),
|
|
308
|
+
]).catch(withError('Could not fetch data'));
|
|
309
|
+
return { usage, limits, items: items.map(parseItem) };
|
|
310
|
+
},
|
|
311
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { formatBytes } from '@axium/core/format';
|
|
3
|
+
import { forMime as iconForMime } from '@axium/core/icons';
|
|
4
|
+
import { FormDialog, Icon } from '@axium/server/lib';
|
|
5
|
+
import { deleteItem, getDirectoryMetadata, updateItem } from '@axium/storage/client';
|
|
6
|
+
import type { StorageItemMetadata } from '@axium/storage/common';
|
|
7
|
+
|
|
8
|
+
const { id }: { id: string } = $props();
|
|
9
|
+
|
|
10
|
+
let items = $state<StorageItemMetadata[]>([]);
|
|
11
|
+
let activeIndex = $state<number>(-1);
|
|
12
|
+
let activeItem = $derived(items[activeIndex]);
|
|
13
|
+
const dialogs = $state<Record<string, HTMLDialogElement>>({});
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
{#snippet action(name: string, icon: string, i: number)}
|
|
17
|
+
<Icon
|
|
18
|
+
i={icon}
|
|
19
|
+
--size="14px"
|
|
20
|
+
onclick={(e: Event) => {
|
|
21
|
+
e.stopPropagation();
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
activeIndex = i;
|
|
24
|
+
dialogs[name].showModal();
|
|
25
|
+
}}
|
|
26
|
+
class="action"
|
|
27
|
+
/>
|
|
28
|
+
{/snippet}
|
|
29
|
+
|
|
30
|
+
{#snippet _itemName()}
|
|
31
|
+
{#if activeItem.name}
|
|
32
|
+
<strong>{activeItem.name.length > 23 ? activeItem.name.slice(0, 20) + '...' : activeItem.name}</strong>
|
|
33
|
+
{:else}
|
|
34
|
+
this
|
|
35
|
+
{/if}
|
|
36
|
+
{/snippet}
|
|
37
|
+
|
|
38
|
+
<div class="FilesList">
|
|
39
|
+
{#await getDirectoryMetadata(id).then(data => (items = data)) then}
|
|
40
|
+
{#each items as item, i (item.id)}
|
|
41
|
+
<div class="FilesListItem">
|
|
42
|
+
<Icon i={iconForMime(item.type)} />
|
|
43
|
+
<span class="name">{item.name}</span>
|
|
44
|
+
<span>{item.modifiedAt.toLocaleString()}</span>
|
|
45
|
+
<span>{formatBytes(item.size)}</span>
|
|
46
|
+
{@render action('rename', 'edit', i)}
|
|
47
|
+
{@render action('download', 'download', i)}
|
|
48
|
+
{@render action('delete', 'delete', i)}
|
|
49
|
+
</div>
|
|
50
|
+
{:else}
|
|
51
|
+
<i>No items.</i>
|
|
52
|
+
{/each}
|
|
53
|
+
{:catch error}
|
|
54
|
+
<i style:color="#c44">{error.message}</i>
|
|
55
|
+
{/await}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<FormDialog
|
|
59
|
+
bind:dialog={dialogs.rename}
|
|
60
|
+
submitText="Rename"
|
|
61
|
+
submit={async (data: { name: string }) => {
|
|
62
|
+
await updateItem(activeItem.id, data);
|
|
63
|
+
activeItem.name = data.name;
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<div>
|
|
67
|
+
<label for="name">Name</label>
|
|
68
|
+
<input name="name" type="text" required value={activeItem.name} />
|
|
69
|
+
</div>
|
|
70
|
+
</FormDialog>
|
|
71
|
+
<FormDialog
|
|
72
|
+
bind:dialog={dialogs.delete}
|
|
73
|
+
submitText="Delete"
|
|
74
|
+
submitDanger
|
|
75
|
+
submit={async () => {
|
|
76
|
+
await deleteItem(activeItem.id);
|
|
77
|
+
if (activeIndex != -1) items.splice(activeIndex, 1);
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<p>Are you sure you want to delete {@render _itemName()}?</p>
|
|
81
|
+
</FormDialog>
|
|
82
|
+
<FormDialog
|
|
83
|
+
bind:dialog={dialogs.download}
|
|
84
|
+
submitText="Download"
|
|
85
|
+
submit={async () => {
|
|
86
|
+
open(activeItem.dataURL, '_blank');
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<p>
|
|
90
|
+
We are not responsible for the contents of this file. <br />
|
|
91
|
+
Are you sure you want to download {@render _itemName()}?
|
|
92
|
+
</p>
|
|
93
|
+
</FormDialog>
|
|
94
|
+
|
|
95
|
+
<style>
|
|
96
|
+
.FilesList {
|
|
97
|
+
display: flex;
|
|
98
|
+
flex-direction: column;
|
|
99
|
+
gap: 0.5em;
|
|
100
|
+
padding: 0.5em;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.FilesListItem {
|
|
104
|
+
display: grid;
|
|
105
|
+
grid-template-columns: 1em 4fr 15em 5em repeat(1em, 3);
|
|
106
|
+
align-items: center;
|
|
107
|
+
gap: 0.5em;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.action {
|
|
111
|
+
visibility: hidden;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.FilesListItem:hover .action {
|
|
115
|
+
visibility: visible;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.action:hover {
|
|
119
|
+
cursor: pointer;
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getDirectoryMetadata, type _Sidebar } from '@axium/storage/client';
|
|
3
|
+
import type { StorageItemMetadata } from '@axium/storage/common';
|
|
4
|
+
import { setContext } from 'svelte';
|
|
5
|
+
import { ItemSelection } from '../src/selection.js';
|
|
6
|
+
import StorageSidebarItem from './StorageSidebarItem.svelte';
|
|
7
|
+
|
|
8
|
+
const { root }: { root: string } = $props();
|
|
9
|
+
|
|
10
|
+
let items = $state<StorageItemMetadata[]>([]);
|
|
11
|
+
|
|
12
|
+
const allItems: StorageItemMetadata[] = [];
|
|
13
|
+
|
|
14
|
+
const sidebar = $state<_Sidebar>({
|
|
15
|
+
selection: new ItemSelection(allItems),
|
|
16
|
+
items: allItems,
|
|
17
|
+
async getDirectory(id: string, assignTo?: StorageItemMetadata[]) {
|
|
18
|
+
const data = await getDirectoryMetadata(id);
|
|
19
|
+
this.items.push(...data);
|
|
20
|
+
assignTo = data;
|
|
21
|
+
return data;
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
setContext('files:sidebar', () => sidebar);
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<div id="FilesSidebar">
|
|
29
|
+
{#await sidebar.getDirectory(root, items)}
|
|
30
|
+
<i>Loading...</i>
|
|
31
|
+
{:then}
|
|
32
|
+
{#each items as _, i (_.id)}
|
|
33
|
+
<StorageSidebarItem bind:item={items[i]} bind:items />
|
|
34
|
+
{/each}
|
|
35
|
+
{:catch error}
|
|
36
|
+
<i style:color="#c44">{error.message}</i>
|
|
37
|
+
{/await}
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
</style>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import * as icon from '@axium/core/icons';
|
|
3
|
+
import { ClipboardCopy, FormDialog, Icon } from '@axium/server/lib';
|
|
4
|
+
import { deleteItem, updateItem, type _Sidebar } from '@axium/storage/client';
|
|
5
|
+
import type { StorageItemMetadata } from '@axium/storage/common';
|
|
6
|
+
import { getContext } from 'svelte';
|
|
7
|
+
import StorageSidebarItem from './StorageSidebarItem.svelte';
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
item = $bindable(),
|
|
11
|
+
items = $bindable(),
|
|
12
|
+
debug = false,
|
|
13
|
+
}: {
|
|
14
|
+
item: StorageItemMetadata;
|
|
15
|
+
/** The items list for the parent directory */
|
|
16
|
+
items: StorageItemMetadata[];
|
|
17
|
+
debug?: boolean;
|
|
18
|
+
} = $props();
|
|
19
|
+
|
|
20
|
+
const sb = getContext<() => _Sidebar>('files:sidebar')();
|
|
21
|
+
|
|
22
|
+
const dialogs = $state<Record<string, HTMLDialogElement>>({});
|
|
23
|
+
let popover = $state<HTMLDivElement>();
|
|
24
|
+
|
|
25
|
+
function oncontextmenu(e: MouseEvent) {
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
e.stopPropagation();
|
|
28
|
+
popover?.togglePopover();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function onclick(e: MouseEvent) {
|
|
32
|
+
if (e.shiftKey) sb.selection.toggleRange(item.id);
|
|
33
|
+
else if (e.ctrlKey) sb.selection.toggle(item.id);
|
|
34
|
+
else {
|
|
35
|
+
sb.selection.clear();
|
|
36
|
+
sb.selection.add(item.id);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let children = $state<StorageItemMetadata[]>([]);
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
{#snippet action(name: string, i: string, text: string)}
|
|
44
|
+
<div
|
|
45
|
+
onclick={e => {
|
|
46
|
+
e.stopPropagation();
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
dialogs[name].showModal();
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<Icon {i} --size="14px" />
|
|
52
|
+
{text}
|
|
53
|
+
</div>
|
|
54
|
+
{/snippet}
|
|
55
|
+
|
|
56
|
+
{#snippet _itemName()}
|
|
57
|
+
{#if item.name}
|
|
58
|
+
<strong>{item.name.length > 23 ? item.name.slice(0, 20) + '...' : item.name}</strong>
|
|
59
|
+
{:else}
|
|
60
|
+
this
|
|
61
|
+
{/if}
|
|
62
|
+
{/snippet}
|
|
63
|
+
|
|
64
|
+
{#if item.type == 'inode/directory'}
|
|
65
|
+
<details>
|
|
66
|
+
<summary class={['StorageSidebarItem', sb.selection.has(item.id) && 'selected']} {onclick} {oncontextmenu}>
|
|
67
|
+
<Icon i={icon.forMime(item.type)} />
|
|
68
|
+
<span class="name">{item.name}</span>
|
|
69
|
+
</summary>
|
|
70
|
+
<div>
|
|
71
|
+
{#await sb.getDirectory(item.id, children)}
|
|
72
|
+
<i>Loading...</i>
|
|
73
|
+
{:then}
|
|
74
|
+
{#each children as _, i (_.id)}
|
|
75
|
+
<StorageSidebarItem bind:item={children[i]} bind:items={children} />
|
|
76
|
+
{/each}
|
|
77
|
+
{:catch error}
|
|
78
|
+
<i style:color="#c44">{error.message}</i>
|
|
79
|
+
{/await}
|
|
80
|
+
</div>
|
|
81
|
+
</details>
|
|
82
|
+
{:else}
|
|
83
|
+
<div class={['StorageSidebarItem', sb.selection.has(item.id) && 'selected']} {onclick} {oncontextmenu}>
|
|
84
|
+
<Icon i={icon.forMime(item.type)} />
|
|
85
|
+
<span class="name">{item.name}</span>
|
|
86
|
+
</div>
|
|
87
|
+
{/if}
|
|
88
|
+
|
|
89
|
+
<div popover bind:this={popover}>
|
|
90
|
+
{@render action('rename', 'pen', 'Rename')}
|
|
91
|
+
{@render action('delete', 'trash', 'Delete')}
|
|
92
|
+
{#if item.type == 'cas_item'}
|
|
93
|
+
{@render action('download', 'download', 'Download')}
|
|
94
|
+
{/if}
|
|
95
|
+
{#if debug}
|
|
96
|
+
<ClipboardCopy value={item.id} />
|
|
97
|
+
{/if}
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<FormDialog
|
|
101
|
+
bind:dialog={dialogs.rename}
|
|
102
|
+
submitText="Rename"
|
|
103
|
+
submit={async (data: { name: string }) => {
|
|
104
|
+
await updateItem(item.id, data);
|
|
105
|
+
item.name = data.name;
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<div>
|
|
109
|
+
<label for="name">Name</label>
|
|
110
|
+
<input name="name" type="text" required value={item.name} />
|
|
111
|
+
</div>
|
|
112
|
+
</FormDialog>
|
|
113
|
+
<FormDialog
|
|
114
|
+
bind:dialog={dialogs.delete}
|
|
115
|
+
submitText="Delete"
|
|
116
|
+
submitDanger
|
|
117
|
+
submit={async () => {
|
|
118
|
+
await deleteItem(item.id);
|
|
119
|
+
const index = items.findIndex(r => r.id === item.id);
|
|
120
|
+
if (index !== -1) items.splice(index, 1);
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<p>Are you sure you want to delete {@render _itemName()}?</p>
|
|
124
|
+
</FormDialog>
|
|
125
|
+
<FormDialog
|
|
126
|
+
bind:dialog={dialogs.download}
|
|
127
|
+
submitText="Download"
|
|
128
|
+
submit={async () => {
|
|
129
|
+
open(item.dataURL, '_blank');
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<p>
|
|
133
|
+
We are not responsible for the contents of this file. <br />
|
|
134
|
+
Are you sure you want to download {@render _itemName()}?
|
|
135
|
+
</p>
|
|
136
|
+
</FormDialog>
|
|
137
|
+
|
|
138
|
+
<style>
|
|
139
|
+
.StorageSidebarItem {
|
|
140
|
+
display: grid;
|
|
141
|
+
grid-template-columns: 1em 1fr;
|
|
142
|
+
align-items: center;
|
|
143
|
+
text-align: left;
|
|
144
|
+
gap: 0.5em;
|
|
145
|
+
border-radius: 0.5em;
|
|
146
|
+
border: 1px solid transparent;
|
|
147
|
+
padding: 0.25em 0.75em 0.25em 0.5em;
|
|
148
|
+
font-size: 14px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.name {
|
|
152
|
+
overflow: hidden;
|
|
153
|
+
text-overflow: ellipsis;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.StorageSidebarItem:hover {
|
|
157
|
+
background: #334;
|
|
158
|
+
cursor: pointer;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.selected {
|
|
162
|
+
border: 1px solid #555;
|
|
163
|
+
background: #334;
|
|
164
|
+
color: #fff;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
details > div {
|
|
168
|
+
padding-left: 0.5em;
|
|
169
|
+
}
|
|
170
|
+
</style>
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@axium/storage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"author": "James Prevett <axium@jamespre.dev> (https://jamespre.dev)",
|
|
5
|
+
"description": "User file storage for Axium",
|
|
6
|
+
"funding": {
|
|
7
|
+
"type": "individual",
|
|
8
|
+
"url": "https://github.com/sponsors/james-pre"
|
|
9
|
+
},
|
|
10
|
+
"license": "LGPL-3.0-or-later",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/james-pre/axium.git"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/james-pre/axium#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/james-pre/axium/issues"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "dist/index.js",
|
|
21
|
+
"types": "dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./dist/index.js",
|
|
24
|
+
"./*": "./dist/*.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"lib"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@axium/client": ">=0.1.0",
|
|
35
|
+
"@axium/core": ">=0.4.0",
|
|
36
|
+
"@axium/server": ">=0.16.0",
|
|
37
|
+
"@sveltejs/kit": "^2.23.0",
|
|
38
|
+
"utilium": "^2.3.8"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"blakejs": "^1.2.1",
|
|
42
|
+
"zod": "^4.0.5"
|
|
43
|
+
}
|
|
44
|
+
}
|