@axium/storage 0.22.3 → 0.23.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/client/api.d.ts +8 -21
- package/dist/client/api.js +80 -112
- package/dist/client/cli/commands.js +2 -2
- package/dist/client/sync.js +17 -14
- package/dist/common.d.ts +57 -3
- package/dist/common.js +8 -5
- package/dist/server/api.js +17 -3
- package/dist/server/item.d.ts +13 -3
- package/dist/server/item.js +53 -16
- package/dist/server/raw.js +50 -57
- package/lib/Add.svelte +3 -3
- package/lib/List.svelte +43 -39
- package/package.json +2 -2
- package/routes/files/trash/+page.svelte +18 -20
package/dist/client/api.d.ts
CHANGED
|
@@ -1,32 +1,18 @@
|
|
|
1
1
|
import type { GetItemOptions, StorageItemUpdate, UserStorage, UserStorageInfo, UserStorageOptions } from '../common.js';
|
|
2
2
|
import { StorageItemMetadata } from '../common.js';
|
|
3
3
|
import '../polyfills.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
readonly effectiveType: 'slow-2g' | '2g' | '3g' | '4g';
|
|
9
|
-
readonly rtt: number;
|
|
10
|
-
readonly saveData: boolean;
|
|
11
|
-
readonly type: 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'wifi' | 'wimax' | 'other' | 'unknown';
|
|
12
|
-
}
|
|
13
|
-
interface Navigator {
|
|
14
|
-
connection?: NetworkInformation;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
export interface UploadOptions {
|
|
4
|
+
type ProgressHandler = (this: void, uploaded: number, total: number) => void;
|
|
5
|
+
export declare function createDirectory(name: string, parentId?: string): Promise<StorageItemMetadata>;
|
|
6
|
+
export interface CreateItemInit {
|
|
7
|
+
onProgress?: ProgressHandler;
|
|
18
8
|
parentId?: string;
|
|
19
|
-
name?: string;
|
|
20
|
-
onProgress?(this: void, uploaded: number, total: number): void;
|
|
21
|
-
}
|
|
22
|
-
export declare function uploadItem(file: Blob | File, opt?: UploadOptions): Promise<StorageItemMetadata>;
|
|
23
|
-
export interface UploadStreamOptions extends UploadOptions {
|
|
24
9
|
name: string;
|
|
25
10
|
size: number;
|
|
26
11
|
type: string;
|
|
27
12
|
}
|
|
28
|
-
export declare function
|
|
29
|
-
export declare function
|
|
13
|
+
export declare function createItem(stream: ReadableStream<Uint8Array<ArrayBuffer>>, init: CreateItemInit): Promise<StorageItemMetadata>;
|
|
14
|
+
export declare function createItemFromFile(file: File, init: Partial<CreateItemInit>): Promise<StorageItemMetadata>;
|
|
15
|
+
export declare function updateItem(fileId: string, newSize: number | bigint, stream: ReadableStream<Uint8Array<ArrayBuffer>>, onProgress?: ProgressHandler): Promise<StorageItemMetadata>;
|
|
30
16
|
export declare function getItemMetadata(fileId: string, options?: GetItemOptions): Promise<StorageItemMetadata>;
|
|
31
17
|
/** Gets the metadata for all items in a directory. */
|
|
32
18
|
export declare function getDirectoryMetadata(parentId: string): Promise<StorageItemMetadata[]>;
|
|
@@ -39,3 +25,4 @@ export declare function getUserStats(userId: string): Promise<UserStorageInfo>;
|
|
|
39
25
|
export declare function getUserTrash(userId: string): Promise<StorageItemMetadata[]>;
|
|
40
26
|
export declare function itemsSharedWith(userId: string): Promise<StorageItemMetadata[]>;
|
|
41
27
|
export declare function getUserStorageRoot(userId: string): Promise<StorageItemMetadata[]>;
|
|
28
|
+
export {};
|
package/dist/client/api.js
CHANGED
|
@@ -1,22 +1,9 @@
|
|
|
1
1
|
import { fetchAPI, prefix, token } from '@axium/client/requests';
|
|
2
|
-
import {
|
|
2
|
+
import { pick } from 'utilium';
|
|
3
3
|
import { prettifyError } from 'zod';
|
|
4
4
|
import { StorageItemMetadata } from '../common.js';
|
|
5
5
|
import '../polyfills.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Requests below this amount in MB will be hashed client-side to avoid bandwidth usage.
|
|
9
|
-
* This is most useful when the client has plenty of compute but a poor network connection.
|
|
10
|
-
* For good connections, this isn't really useful since it takes longer than just uploading.
|
|
11
|
-
* Note that hashing takes a really long time client-side though.
|
|
12
|
-
*/
|
|
13
|
-
hashThreshold: 10,
|
|
14
|
-
/**
|
|
15
|
-
* Set an upper limit for chunk size in MB, independent of `max_transfer_size`.
|
|
16
|
-
* Smaller chunks means better UX but more latency from RTT and more requests.
|
|
17
|
-
*/
|
|
18
|
-
uxChunkSize: 10,
|
|
19
|
-
};
|
|
6
|
+
import { warnOnce } from 'ioium';
|
|
20
7
|
function rawStorage(suffix) {
|
|
21
8
|
const raw = '/raw/storage' + (suffix ? '/' + suffix : '');
|
|
22
9
|
if (prefix[0] == '/')
|
|
@@ -25,12 +12,6 @@ function rawStorage(suffix) {
|
|
|
25
12
|
url.pathname = raw;
|
|
26
13
|
return url;
|
|
27
14
|
}
|
|
28
|
-
const conTypeToSpeed = {
|
|
29
|
-
'slow-2g': 1,
|
|
30
|
-
'2g': 4,
|
|
31
|
-
'3g': 16,
|
|
32
|
-
'4g': 64,
|
|
33
|
-
};
|
|
34
15
|
function handleFetchFailed(e) {
|
|
35
16
|
if (!(e instanceof Error) || e.message != 'fetch failed')
|
|
36
17
|
throw e;
|
|
@@ -58,120 +39,107 @@ async function handleResponse(response) {
|
|
|
58
39
|
throw prettifyError(e);
|
|
59
40
|
}
|
|
60
41
|
}
|
|
61
|
-
|
|
62
|
-
if (file instanceof File)
|
|
63
|
-
opt.name ||= file.name;
|
|
64
|
-
if (!opt.name)
|
|
65
|
-
throw 'item name is required';
|
|
66
|
-
const content = await file.bytes();
|
|
67
|
-
/** For big files, it takes a *really* long time to compute the hash, so we just don't do it ahead of time and leave it up to the server. */
|
|
68
|
-
const hash = content.length < uploadConfig.hashThreshold * 1_000_000 ? blake2b(content).toHex() : null;
|
|
69
|
-
const upload = await fetchAPI('PUT', 'storage', {
|
|
70
|
-
parentId: opt.parentId,
|
|
71
|
-
name: opt.name,
|
|
72
|
-
type: file.type,
|
|
73
|
-
size: file.size,
|
|
74
|
-
hash,
|
|
75
|
-
});
|
|
76
|
-
if (upload.status == 'created')
|
|
77
|
-
return upload.item;
|
|
78
|
-
const chunkSize = Math.min(upload.max_transfer_size, globalThis.navigator?.connection ? conTypeToSpeed[globalThis.navigator.connection.effectiveType] : uploadConfig.uxChunkSize) * 1_000_000;
|
|
79
|
-
opt.onProgress?.(0, content.length);
|
|
80
|
-
let response;
|
|
81
|
-
for (let offset = 0; offset < content.length; offset += chunkSize) {
|
|
82
|
-
const size = Math.min(chunkSize, content.length - offset);
|
|
83
|
-
const headers = {
|
|
84
|
-
'x-upload': upload.token,
|
|
85
|
-
'x-offset': offset.toString(),
|
|
86
|
-
'content-length': size.toString(),
|
|
87
|
-
'content-type': 'application/octet-stream',
|
|
88
|
-
};
|
|
89
|
-
if (token)
|
|
90
|
-
headers.authorization = 'Bearer ' + token;
|
|
91
|
-
response = await fetch(rawStorage('chunk'), {
|
|
92
|
-
method: 'POST',
|
|
93
|
-
headers,
|
|
94
|
-
body: content.slice(offset, offset + size),
|
|
95
|
-
}).catch(handleFetchFailed);
|
|
96
|
-
if (!response.ok)
|
|
97
|
-
await handleError(response);
|
|
98
|
-
opt.onProgress?.(offset + size, content.length);
|
|
99
|
-
if (offset + size != content.length && response.status != 204)
|
|
100
|
-
console.warn('Unexpected end of upload before last chunk');
|
|
101
|
-
}
|
|
102
|
-
return await handleResponse(response);
|
|
103
|
-
}
|
|
104
|
-
export async function uploadItemStream(stream, opt) {
|
|
105
|
-
opt.onProgress?.(0, opt.size);
|
|
106
|
-
const upload = await fetchAPI('PUT', 'storage', { ...opt, hash: null });
|
|
42
|
+
async function _upload(upload, stream, itemSize, onProgress) {
|
|
107
43
|
if (upload.status == 'created')
|
|
108
44
|
return upload.item;
|
|
109
|
-
const
|
|
45
|
+
const targetChunkSize = upload.max_transfer_size * 1_000_000;
|
|
110
46
|
let response;
|
|
111
47
|
const reader = stream.getReader();
|
|
112
48
|
let buffer = new Uint8Array(0);
|
|
113
|
-
for (let offset = 0; offset <
|
|
114
|
-
const
|
|
49
|
+
for (let offset = 0; offset < itemSize; offset += targetChunkSize) {
|
|
50
|
+
const chunkSize = Math.min(targetChunkSize, itemSize - offset);
|
|
115
51
|
let bytesReadForChunk = 0;
|
|
116
52
|
const headers = {
|
|
117
53
|
'x-upload': upload.token,
|
|
118
54
|
'x-offset': offset.toString(),
|
|
119
|
-
'
|
|
55
|
+
'x-chunk-size': chunkSize.toString(),
|
|
56
|
+
'content-length': chunkSize.toString(),
|
|
120
57
|
'content-type': 'application/octet-stream',
|
|
121
58
|
};
|
|
122
59
|
if (token)
|
|
123
60
|
headers.authorization = 'Bearer ' + token;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
61
|
+
let body = new ReadableStream({
|
|
62
|
+
async pull(controller) {
|
|
63
|
+
if (bytesReadForChunk >= chunkSize) {
|
|
64
|
+
controller.close();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!buffer.length) {
|
|
68
|
+
const { done, value } = await reader.read();
|
|
69
|
+
if (done) {
|
|
131
70
|
controller.close();
|
|
132
71
|
return;
|
|
133
72
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
73
|
+
buffer = value;
|
|
74
|
+
}
|
|
75
|
+
const take = Math.min(buffer.length, chunkSize - bytesReadForChunk);
|
|
76
|
+
const chunk = buffer.subarray(0, take);
|
|
77
|
+
buffer = buffer.subarray(take);
|
|
78
|
+
bytesReadForChunk += take;
|
|
79
|
+
controller.enqueue(chunk);
|
|
80
|
+
onProgress?.(offset + bytesReadForChunk, itemSize);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
let init = { duplex: 'half' };
|
|
84
|
+
/**
|
|
85
|
+
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=1387483
|
|
86
|
+
*/
|
|
87
|
+
if (globalThis.navigator?.userAgent?.toLowerCase().includes('firefox')) {
|
|
88
|
+
await body.cancel();
|
|
89
|
+
init = {};
|
|
90
|
+
warnOnce('Using a workaround for uploading on Firefox [https://bugzilla.mozilla.org/show_bug.cgi?id=1387483]');
|
|
91
|
+
const chunkData = new Uint8Array(chunkSize);
|
|
92
|
+
let bytesReadForChunk = 0;
|
|
93
|
+
if (buffer.length > 0) {
|
|
94
|
+
const take = Math.min(buffer.length, chunkSize);
|
|
95
|
+
chunkData.set(buffer.subarray(0, take), 0);
|
|
96
|
+
buffer = buffer.subarray(take);
|
|
97
|
+
bytesReadForChunk += take;
|
|
98
|
+
}
|
|
99
|
+
while (bytesReadForChunk < chunkSize) {
|
|
100
|
+
const { done, value } = await reader.read();
|
|
101
|
+
if (done)
|
|
102
|
+
break;
|
|
103
|
+
const take = Math.min(value.length, chunkSize - bytesReadForChunk);
|
|
104
|
+
chunkData.set(value.subarray(0, take), bytesReadForChunk);
|
|
105
|
+
buffer = value.subarray(take);
|
|
106
|
+
bytesReadForChunk += take;
|
|
107
|
+
}
|
|
108
|
+
body = chunkData.subarray(0, bytesReadForChunk);
|
|
109
|
+
onProgress?.(offset + bytesReadForChunk, itemSize);
|
|
110
|
+
}
|
|
111
|
+
response = await fetch(rawStorage('chunk'), {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers,
|
|
114
|
+
body,
|
|
115
|
+
...init,
|
|
152
116
|
}).catch(handleFetchFailed);
|
|
153
117
|
if (!response.ok)
|
|
154
118
|
await handleError(response);
|
|
155
|
-
if (offset +
|
|
119
|
+
if (offset + chunkSize != itemSize && response.status != 204)
|
|
156
120
|
console.warn('Unexpected end of upload before last chunk');
|
|
157
121
|
}
|
|
158
122
|
return await handleResponse(response);
|
|
159
123
|
}
|
|
160
|
-
export async function
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return await
|
|
124
|
+
export async function createDirectory(name, parentId) {
|
|
125
|
+
const upload = await fetchAPI('PUT', 'storage', { name, parentId, type: 'inode/directory', size: 0, hash: null });
|
|
126
|
+
if (upload.status != 'created')
|
|
127
|
+
throw new Error('Bug! Creating a directory resulted in an `accepted` status');
|
|
128
|
+
return upload.item;
|
|
129
|
+
}
|
|
130
|
+
export async function createItem(stream, init) {
|
|
131
|
+
init.onProgress?.(0, init.size);
|
|
132
|
+
if (!init.name)
|
|
133
|
+
throw 'item name is required';
|
|
134
|
+
const upload = await fetchAPI('PUT', 'storage', { ...init, hash: null });
|
|
135
|
+
return await _upload(upload, stream, init.size, init.onProgress);
|
|
136
|
+
}
|
|
137
|
+
export async function createItemFromFile(file, init) {
|
|
138
|
+
return await createItem(file.stream(), { ...pick(file, 'name', 'size', 'type'), ...init });
|
|
139
|
+
}
|
|
140
|
+
export async function updateItem(fileId, newSize, stream, onProgress) {
|
|
141
|
+
const upload = await fetchAPI('POST', 'storage/item/:id', newSize, fileId);
|
|
142
|
+
return await _upload(upload, stream, Number(newSize), onProgress);
|
|
175
143
|
}
|
|
176
144
|
export async function getItemMetadata(fileId, options = {}) {
|
|
177
145
|
return await fetchAPI('GET', 'storage/item/:id', options, fileId);
|
|
@@ -82,7 +82,7 @@ export const mkdir = new Command('mkdir')
|
|
|
82
82
|
.argument('<path>', 'remote folder path to create')
|
|
83
83
|
.action(async (path) => {
|
|
84
84
|
const { parent, name } = await resolvePathWithParent(path);
|
|
85
|
-
const item = await api.
|
|
85
|
+
const item = await api.createDirectory(name, parent?.id);
|
|
86
86
|
const { items } = await syncCache();
|
|
87
87
|
items.push(item);
|
|
88
88
|
writeCache();
|
|
@@ -135,7 +135,7 @@ export const upload = new Command('upload')
|
|
|
135
135
|
const stream = Readable.toWeb(fs.createReadStream(local));
|
|
136
136
|
const type = mime.getType(local) || 'application/octet-stream';
|
|
137
137
|
const _ = __addDisposableResource(env_1, io.start('Uploading ' + name), false);
|
|
138
|
-
const item = await api.
|
|
138
|
+
const item = await api.createItem(stream, {
|
|
139
139
|
parentId: parent?.id,
|
|
140
140
|
name,
|
|
141
141
|
size: stats.size,
|
package/dist/client/sync.js
CHANGED
|
@@ -5,10 +5,11 @@ import mime from 'mime';
|
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import * as fs from 'node:fs';
|
|
7
7
|
import { basename, dirname, join, matchesGlob, relative } from 'node:path';
|
|
8
|
+
import { Readable, Writable } from 'node:stream';
|
|
8
9
|
import { pick } from 'utilium';
|
|
9
10
|
import * as z from 'zod';
|
|
10
11
|
import '../polyfills.js';
|
|
11
|
-
import { deleteItem,
|
|
12
|
+
import { deleteItem, downloadItemStream, updateItem, createItem, createDirectory } from './api.js';
|
|
12
13
|
/**
|
|
13
14
|
* A Sync is a storage item that has been selected for synchronization by the user.
|
|
14
15
|
* Importantly, it is *only* the "top-level" item.
|
|
@@ -134,13 +135,15 @@ export async function doSync(sync, opt) {
|
|
|
134
135
|
name: dirent.name,
|
|
135
136
|
};
|
|
136
137
|
if (dirent.isDirectory()) {
|
|
137
|
-
const dir = await
|
|
138
|
-
_items.set(dirent._path, Object.assign(pick(dir, 'id', 'modifiedAt'), { path: dirent._path
|
|
138
|
+
const dir = await createDirectory(dirent.name, uploadOpts.parentId);
|
|
139
|
+
_items.set(dirent._path, Object.assign(pick(dir, 'id', 'modifiedAt'), { path: dirent._path }));
|
|
139
140
|
}
|
|
140
141
|
else {
|
|
141
142
|
const type = mime.getType(dirent._path) || 'application/octet-stream';
|
|
142
|
-
const
|
|
143
|
-
|
|
143
|
+
const { size } = fs.statSync(join(sync.local_path, dirent._path));
|
|
144
|
+
// cast because Node.js' internals use different types from lib.dom.d.ts
|
|
145
|
+
const stream = Readable.toWeb(fs.createReadStream(join(sync.local_path, dirent._path)));
|
|
146
|
+
const file = await createItem(stream, { ...uploadOpts, type, size });
|
|
144
147
|
_items.set(dirent._path, Object.assign(pick(file, 'id', 'modifiedAt', 'hash'), { path: dirent._path }));
|
|
145
148
|
}
|
|
146
149
|
});
|
|
@@ -165,24 +168,24 @@ export async function doSync(sync, opt) {
|
|
|
165
168
|
fs.mkdirSync(fullPath, { recursive: true });
|
|
166
169
|
}
|
|
167
170
|
else {
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
+
const writeStream = fs.createWriteStream(fullPath);
|
|
172
|
+
const stream = await downloadItemStream(item.id);
|
|
173
|
+
await stream.pipeTo(Writable.toWeb(writeStream));
|
|
171
174
|
}
|
|
172
175
|
});
|
|
173
176
|
if (!opt.verbose && delta.modified.length)
|
|
174
177
|
io.start('Syncing modified items');
|
|
175
178
|
await applyAction(opt, delta.modified, item => (opt.verbose ? 'Updating ' : '') + item.path, async (item) => {
|
|
176
179
|
if (item.modifiedAt.getTime() > fs.statSync(join(sync.local_path, item.path)).mtime.getTime()) {
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
+
const writeStream = fs.createWriteStream(join(sync.local_path, item.path));
|
|
181
|
+
const stream = await downloadItemStream(item.id);
|
|
182
|
+
await stream.pipeTo(Writable.toWeb(writeStream));
|
|
180
183
|
return 'server.';
|
|
181
184
|
}
|
|
182
185
|
else {
|
|
183
|
-
const
|
|
184
|
-
const
|
|
185
|
-
const updated = await updateItem(item.id,
|
|
186
|
+
const { size } = fs.statSync(join(sync.local_path, item.path));
|
|
187
|
+
const stream = Readable.toWeb(fs.createReadStream(join(sync.local_path, item.path)));
|
|
188
|
+
const updated = await updateItem(item.id, size, stream);
|
|
186
189
|
_items.set(item.path, Object.assign(pick(updated, 'id', 'modifiedAt', 'hash'), { path: item.path }));
|
|
187
190
|
return 'local.';
|
|
188
191
|
}
|
package/dist/common.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as z from 'zod';
|
|
2
|
+
export declare const StorageItemSize: z.ZodCoercedBigInt<unknown>;
|
|
3
|
+
export declare const StorageItemName: z.ZodString;
|
|
2
4
|
/**
|
|
3
5
|
* An update to file metadata.
|
|
4
6
|
*/
|
|
@@ -58,7 +60,7 @@ export interface StorageItemMetadata<T extends Record<string, unknown> = Record<
|
|
|
58
60
|
}
|
|
59
61
|
export declare const StorageItemSorting: z.ZodObject<{
|
|
60
62
|
descending: z.ZodOptional<z.ZodBoolean>;
|
|
61
|
-
by: z.ZodLiteral<"name" | "
|
|
63
|
+
by: z.ZodLiteral<"name" | "size" | "createdAt" | "modifiedAt">;
|
|
62
64
|
}, z.core.$strip>;
|
|
63
65
|
export interface StorageItemSorting extends z.infer<typeof StorageItemSorting> {
|
|
64
66
|
}
|
|
@@ -147,7 +149,7 @@ export interface UserStorage extends z.infer<typeof UserStorage> {
|
|
|
147
149
|
export declare const UserStorageOptions: z.ZodDefault<z.ZodObject<{
|
|
148
150
|
sort: z.ZodOptional<z.ZodObject<{
|
|
149
151
|
descending: z.ZodOptional<z.ZodBoolean>;
|
|
150
|
-
by: z.ZodLiteral<"name" | "
|
|
152
|
+
by: z.ZodLiteral<"name" | "size" | "createdAt" | "modifiedAt">;
|
|
151
153
|
}, z.core.$strip>>;
|
|
152
154
|
}, z.core.$strip>>;
|
|
153
155
|
export interface UserStorageOptions extends z.infer<typeof UserStorageOptions> {
|
|
@@ -295,7 +297,7 @@ declare const StorageAPI: {
|
|
|
295
297
|
readonly GET: readonly [z.ZodDefault<z.ZodObject<{
|
|
296
298
|
sort: z.ZodOptional<z.ZodObject<{
|
|
297
299
|
descending: z.ZodOptional<z.ZodBoolean>;
|
|
298
|
-
by: z.ZodLiteral<"name" | "
|
|
300
|
+
by: z.ZodLiteral<"name" | "size" | "createdAt" | "modifiedAt">;
|
|
299
301
|
}, z.core.$strip>>;
|
|
300
302
|
}, z.core.$strip>>, z.ZodObject<{
|
|
301
303
|
items: z.ZodArray<z.ZodObject<{
|
|
@@ -725,6 +727,58 @@ declare const StorageAPI: {
|
|
|
725
727
|
name: z.ZodString;
|
|
726
728
|
}, z.core.$strip>>>;
|
|
727
729
|
}, z.core.$strip>];
|
|
730
|
+
readonly POST: readonly [z.ZodCoercedBigInt<unknown>, z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
731
|
+
batch: z.ZodObject<{
|
|
732
|
+
enabled: z.ZodBoolean;
|
|
733
|
+
max_items: z.ZodInt;
|
|
734
|
+
max_item_size: z.ZodInt;
|
|
735
|
+
}, z.core.$strip>;
|
|
736
|
+
max_transfer_size: z.ZodInt;
|
|
737
|
+
status: z.ZodLiteral<"accepted">;
|
|
738
|
+
token: z.ZodBase64;
|
|
739
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
740
|
+
status: z.ZodLiteral<"created">;
|
|
741
|
+
item: z.ZodObject<{
|
|
742
|
+
createdAt: z.ZodCoercedDate<unknown>;
|
|
743
|
+
dataURL: z.ZodString;
|
|
744
|
+
hash: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
745
|
+
id: z.ZodUUID;
|
|
746
|
+
immutable: z.ZodBoolean;
|
|
747
|
+
modifiedAt: z.ZodCoercedDate<unknown>;
|
|
748
|
+
name: z.ZodString;
|
|
749
|
+
userId: z.ZodUUID;
|
|
750
|
+
parentId: z.ZodNullable<z.ZodUUID>;
|
|
751
|
+
size: z.ZodCoercedBigInt<unknown>;
|
|
752
|
+
trashedAt: z.ZodNullable<z.ZodCoercedDate<unknown>>;
|
|
753
|
+
type: z.ZodString;
|
|
754
|
+
metadata: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
755
|
+
acl: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
756
|
+
itemId: z.ZodUUID;
|
|
757
|
+
userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
|
|
758
|
+
role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
759
|
+
tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
760
|
+
user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
761
|
+
id: z.ZodUUID;
|
|
762
|
+
name: z.ZodString;
|
|
763
|
+
email: z.ZodOptional<z.ZodEmail>;
|
|
764
|
+
emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
|
|
765
|
+
preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
|
|
766
|
+
debug: z.ZodDefault<z.ZodBoolean>;
|
|
767
|
+
}, z.core.$strip>>>;
|
|
768
|
+
roles: z.ZodArray<z.ZodString>;
|
|
769
|
+
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
770
|
+
registeredAt: z.ZodCoercedDate<unknown>;
|
|
771
|
+
isAdmin: z.ZodOptional<z.ZodBoolean>;
|
|
772
|
+
isSuspended: z.ZodOptional<z.ZodBoolean>;
|
|
773
|
+
}, z.core.$strip>>>;
|
|
774
|
+
createdAt: z.ZodCoercedDate<unknown>;
|
|
775
|
+
}, z.core.$catchall<z.ZodBoolean>>>>;
|
|
776
|
+
parents: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
777
|
+
id: z.ZodUUID;
|
|
778
|
+
name: z.ZodString;
|
|
779
|
+
}, z.core.$strip>>>;
|
|
780
|
+
}, z.core.$strip>;
|
|
781
|
+
}, z.core.$strip>], "status">];
|
|
728
782
|
};
|
|
729
783
|
readonly 'storage/directory/:id': {
|
|
730
784
|
readonly GET: z.ZodArray<z.ZodObject<{
|
package/dist/common.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { $API, AccessControl, serverConfigs } from '@axium/core';
|
|
2
2
|
import * as z from 'zod';
|
|
3
|
+
export const StorageItemSize = z.coerce.bigint().nonnegative();
|
|
4
|
+
export const StorageItemName = z.string().nonempty().max(255);
|
|
3
5
|
/**
|
|
4
6
|
* An update to file metadata.
|
|
5
7
|
*/
|
|
6
8
|
export const StorageItemUpdate = z
|
|
7
9
|
.object({
|
|
8
|
-
name:
|
|
10
|
+
name: StorageItemName,
|
|
9
11
|
owner: z.uuid(),
|
|
10
12
|
trash: z.boolean(),
|
|
11
13
|
})
|
|
@@ -23,10 +25,10 @@ export const StorageItemMetadata = z.object({
|
|
|
23
25
|
id: z.uuid(),
|
|
24
26
|
immutable: z.boolean(),
|
|
25
27
|
modifiedAt: z.coerce.date(),
|
|
26
|
-
name:
|
|
28
|
+
name: StorageItemName,
|
|
27
29
|
userId: z.uuid(),
|
|
28
30
|
parentId: z.uuid().nullable(),
|
|
29
|
-
size:
|
|
31
|
+
size: StorageItemSize,
|
|
30
32
|
trashedAt: z.coerce.date().nullable(),
|
|
31
33
|
type: z.string(),
|
|
32
34
|
metadata: z.record(z.string(), z.unknown()),
|
|
@@ -125,8 +127,8 @@ export const StorageConfig = StoragePublicConfig.safeExtend({
|
|
|
125
127
|
});
|
|
126
128
|
serverConfigs.set('@axium/storage', StorageConfig);
|
|
127
129
|
export const StorageItemInit = z.object({
|
|
128
|
-
name:
|
|
129
|
-
size:
|
|
130
|
+
name: StorageItemName,
|
|
131
|
+
size: StorageItemSize,
|
|
130
132
|
type: z.string(),
|
|
131
133
|
parentId: z.uuid().nullish(),
|
|
132
134
|
hash: z.hex().nullish(),
|
|
@@ -170,6 +172,7 @@ const StorageAPI = {
|
|
|
170
172
|
GET: [GetItemOptions, StorageItemMetadata],
|
|
171
173
|
DELETE: StorageItemMetadata,
|
|
172
174
|
PATCH: [StorageItemUpdate, StorageItemMetadata],
|
|
175
|
+
POST: [StorageItemSize, UploadInitResult],
|
|
173
176
|
},
|
|
174
177
|
'storage/directory/:id': {
|
|
175
178
|
GET: StorageItemMetadata.array(),
|
package/dist/server/api.js
CHANGED
|
@@ -6,11 +6,11 @@ import { error, json, parseBody, parseSearch, withError } from '@axium/server/re
|
|
|
6
6
|
import { addRoute } from '@axium/server/routes';
|
|
7
7
|
import { pick } from 'utilium';
|
|
8
8
|
import * as z from 'zod';
|
|
9
|
-
import { batchFormatVersion, GetItemOptions, StorageItemInit, StorageItemUpdate, syncProtocolVersion, UserStorageOptions, } from '../common.js';
|
|
9
|
+
import { batchFormatVersion, GetItemOptions, StorageItemInit, StorageItemSize, StorageItemUpdate, syncProtocolVersion, UserStorageOptions, } from '../common.js';
|
|
10
10
|
import '../polyfills.js';
|
|
11
11
|
import { getLimits } from './config.js';
|
|
12
12
|
import { deleteRecursive, getParents, getRecursive, getUserStats, parseItem } from './db.js';
|
|
13
|
-
import { checkNewItem, createNewItem, startUpload } from './item.js';
|
|
13
|
+
import { checkItemUpdate, checkNewItem, createNewItem, startUpload } from './item.js';
|
|
14
14
|
addRoute({
|
|
15
15
|
path: '/api/storage',
|
|
16
16
|
OPTIONS() {
|
|
@@ -34,7 +34,7 @@ addRoute({
|
|
|
34
34
|
return json({
|
|
35
35
|
...pick(rest, 'batch', 'max_transfer_size'),
|
|
36
36
|
status: 'accepted',
|
|
37
|
-
token: startUpload(init, session),
|
|
37
|
+
token: startUpload(init, session, null),
|
|
38
38
|
}, { status: 202 });
|
|
39
39
|
},
|
|
40
40
|
});
|
|
@@ -73,6 +73,20 @@ addRoute({
|
|
|
73
73
|
.executeTakeFirstOrThrow()
|
|
74
74
|
.catch(withError('Could not update item')));
|
|
75
75
|
},
|
|
76
|
+
async POST(request, { id: itemId }) {
|
|
77
|
+
const { enabled, ...rest } = getConfig('@axium/storage');
|
|
78
|
+
if (!enabled)
|
|
79
|
+
error(503, 'User storage is disabled');
|
|
80
|
+
const size = await parseBody(request, StorageItemSize);
|
|
81
|
+
const { item, session } = await checkItemUpdate(request, itemId);
|
|
82
|
+
if (!session)
|
|
83
|
+
error(401, 'You must be logged in to change file contents');
|
|
84
|
+
return json({
|
|
85
|
+
...pick(rest, 'batch', 'max_transfer_size'),
|
|
86
|
+
status: 'accepted',
|
|
87
|
+
token: startUpload({ ...item, size }, session, itemId),
|
|
88
|
+
}, { status: 202 });
|
|
89
|
+
},
|
|
76
90
|
async DELETE(request, { id: itemId }) {
|
|
77
91
|
if (!getConfig('@axium/storage').enabled)
|
|
78
92
|
error(503, 'User storage is disabled');
|
package/dist/server/item.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Session } from '@axium/core';
|
|
2
|
-
import { type SessionAndUser } from '@axium/server/auth';
|
|
2
|
+
import { type SessionAndUser, type SessionInternal } from '@axium/server/auth';
|
|
3
3
|
import { type Hash } from 'node:crypto';
|
|
4
4
|
import type { StorageItemInit, StorageItemMetadata } from '../common.js';
|
|
5
5
|
import '../polyfills.js';
|
|
@@ -12,15 +12,25 @@ export interface NewItemResult {
|
|
|
12
12
|
export declare function useCAS(type: string): boolean;
|
|
13
13
|
export declare function checkNewItem(init: StorageItemInit, session: SessionAndUser): Promise<NewItemResult>;
|
|
14
14
|
export declare function createNewItem(init: StorageItemInit, userId: string, writeContent?: (path: string) => void): Promise<StorageItemMetadata>;
|
|
15
|
+
export interface ItemUpdateCheckResult {
|
|
16
|
+
item: StorageItemMetadata;
|
|
17
|
+
session?: SessionInternal;
|
|
18
|
+
}
|
|
19
|
+
export declare function checkItemUpdate(request: Request, itemId: string): Promise<ItemUpdateCheckResult>;
|
|
20
|
+
export declare function finishItemUpdate(itemId: string, size: bigint, hash: Uint8Array<ArrayBuffer>, writeContent?: (path: string) => void): Promise<StorageItemMetadata>;
|
|
15
21
|
export interface UploadInfo {
|
|
16
22
|
file: string;
|
|
17
|
-
|
|
23
|
+
stream: WritableStream;
|
|
18
24
|
hash: Hash;
|
|
25
|
+
hashStream: WritableStream;
|
|
19
26
|
uploadedBytes: bigint;
|
|
20
27
|
sessionId: string;
|
|
21
28
|
userId: string;
|
|
22
29
|
init: StorageItemInit;
|
|
30
|
+
/** If set we are updating an existing item. Explicit null used to avoid bugs */
|
|
31
|
+
itemId: string | null;
|
|
32
|
+
/** Remove the upload from pending and clean up resources */
|
|
23
33
|
remove(): void;
|
|
24
34
|
}
|
|
25
|
-
export declare function startUpload(init: StorageItemInit, session: Session): string;
|
|
35
|
+
export declare function startUpload(init: StorageItemInit, session: Session, itemId: string | null): string;
|
|
26
36
|
export declare function requireUpload(request: Request): Promise<UploadInfo>;
|
package/dist/server/item.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { getConfig } from '@axium/core';
|
|
2
|
-
import {
|
|
2
|
+
import { audit } from '@axium/server/audit';
|
|
3
|
+
import { authRequestForItem, authSessionForItem, requireSession } from '@axium/server/auth';
|
|
3
4
|
import { database } from '@axium/server/database';
|
|
4
5
|
import { error, withError } from '@axium/server/requests';
|
|
5
6
|
import { createHash, randomBytes } from 'node:crypto';
|
|
6
|
-
import {
|
|
7
|
+
import { createWriteStream, linkSync, mkdirSync } from 'node:fs';
|
|
7
8
|
import { join } from 'node:path';
|
|
9
|
+
import { Writable } from 'node:stream';
|
|
8
10
|
import * as z from 'zod';
|
|
9
11
|
import '../polyfills.js';
|
|
10
12
|
import { defaultCASMime, getLimits } from './config.js';
|
|
@@ -16,13 +18,8 @@ export function useCAS(type) {
|
|
|
16
18
|
(defaultCASMime.some(pattern => pattern.test(type)) || cas.include?.some(mime => type.match(mime))));
|
|
17
19
|
}
|
|
18
20
|
export async function checkNewItem(init, session) {
|
|
19
|
-
const {
|
|
20
|
-
const
|
|
21
|
-
const [usage, limits] = await Promise.all([getUserStats(userId), getLimits(userId)]).catch(withError('Could not fetch usage and/or limits'));
|
|
22
|
-
if (!name)
|
|
23
|
-
error(400, 'Missing name');
|
|
24
|
-
if (name.length > 255)
|
|
25
|
-
error(400, 'Name is too long');
|
|
21
|
+
const { size, type, hash } = init;
|
|
22
|
+
const [usage, limits] = await Promise.all([getUserStats(session.userId), getLimits(session.userId)]).catch(withError('Could not fetch usage and/or limits'));
|
|
26
23
|
const parentId = init.parentId
|
|
27
24
|
? await z
|
|
28
25
|
.uuid()
|
|
@@ -31,8 +28,6 @@ export async function checkNewItem(init, session) {
|
|
|
31
28
|
: null;
|
|
32
29
|
if (parentId)
|
|
33
30
|
await authSessionForItem('storage', parentId, { write: true }, session);
|
|
34
|
-
if (BigInt(size) < 0n)
|
|
35
|
-
error(411, 'Missing or invalid content length');
|
|
36
31
|
if (limits.user_items && usage.itemCount >= limits.user_items)
|
|
37
32
|
error(409, 'Too many items');
|
|
38
33
|
if (limits.user_size && (usage.usedBytes + size) / 1000000n >= limits.user_size)
|
|
@@ -101,29 +96,71 @@ export async function createNewItem(init, userId, writeContent) {
|
|
|
101
96
|
throw withError('Could not create item', 500)(error);
|
|
102
97
|
}
|
|
103
98
|
}
|
|
99
|
+
export async function checkItemUpdate(request, itemId) {
|
|
100
|
+
if (!getConfig('@axium/storage').enabled)
|
|
101
|
+
error(503, 'User storage is disabled');
|
|
102
|
+
const { item, session } = await authRequestForItem(request, 'storage', itemId, { write: true }, true);
|
|
103
|
+
if (item.immutable)
|
|
104
|
+
error(405, 'Item is immutable');
|
|
105
|
+
if (item.type == 'inode/directory')
|
|
106
|
+
error(409, 'Directories do not have content');
|
|
107
|
+
if (item.trashedAt)
|
|
108
|
+
error(410, 'Trashed items can not be changed');
|
|
109
|
+
const type = request.headers.get('content-type') || 'application/octet-stream';
|
|
110
|
+
if (type != item.type) {
|
|
111
|
+
await audit('storage_type_mismatch', session?.userId, { item: item.id });
|
|
112
|
+
error(400, 'Content type does not match existing item type');
|
|
113
|
+
}
|
|
114
|
+
return { item: parseItem(item), session };
|
|
115
|
+
}
|
|
116
|
+
export async function finishItemUpdate(itemId, size, hash, writeContent) {
|
|
117
|
+
const tx = await database.startTransaction().execute();
|
|
118
|
+
const { data: dataDir } = getConfig('@axium/storage');
|
|
119
|
+
const path = join(dataDir, itemId);
|
|
120
|
+
try {
|
|
121
|
+
const result = await tx
|
|
122
|
+
.updateTable('storage')
|
|
123
|
+
.where('id', '=', itemId)
|
|
124
|
+
.set({ size, modifiedAt: new Date(), hash })
|
|
125
|
+
.returningAll()
|
|
126
|
+
.executeTakeFirstOrThrow();
|
|
127
|
+
if (!writeContent)
|
|
128
|
+
error(501, 'Missing writeContent (this is a bug!)');
|
|
129
|
+
writeContent(path);
|
|
130
|
+
await tx.commit().execute();
|
|
131
|
+
return parseItem(result);
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
await tx.rollback().execute();
|
|
135
|
+
throw withError('Could not update item', 500)(error);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
104
138
|
const inProgress = new Map();
|
|
105
|
-
export function startUpload(init, session) {
|
|
139
|
+
export function startUpload(init, session, itemId) {
|
|
106
140
|
const { temp_dir, upload_timeout } = getConfig('@axium/storage');
|
|
107
141
|
const token = randomBytes(32);
|
|
108
142
|
mkdirSync(temp_dir, { recursive: true });
|
|
109
143
|
const file = join(temp_dir, token.toHex());
|
|
110
|
-
const fd = openSync(file, 'a');
|
|
111
144
|
let removed = false;
|
|
112
145
|
function remove() {
|
|
113
146
|
if (removed)
|
|
114
147
|
return;
|
|
115
148
|
removed = true;
|
|
116
149
|
inProgress.delete(token.toBase64());
|
|
117
|
-
|
|
150
|
+
void stream.close();
|
|
151
|
+
hash.end();
|
|
118
152
|
}
|
|
153
|
+
const hash = createHash('BLAKE2b512'), stream = Writable.toWeb(createWriteStream(file));
|
|
119
154
|
inProgress.set(token.toBase64(), {
|
|
120
|
-
hash
|
|
155
|
+
hash,
|
|
156
|
+
hashStream: Writable.toWeb(hash),
|
|
121
157
|
file,
|
|
122
|
-
|
|
158
|
+
stream,
|
|
123
159
|
uploadedBytes: 0n,
|
|
124
160
|
sessionId: session.id,
|
|
125
161
|
userId: session.userId,
|
|
126
162
|
init,
|
|
163
|
+
itemId,
|
|
127
164
|
remove,
|
|
128
165
|
});
|
|
129
166
|
setTimeout(() => {
|
package/dist/server/raw.js
CHANGED
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import { getConfig } from '@axium/core';
|
|
2
2
|
import { audit } from '@axium/server/audit';
|
|
3
3
|
import { authRequestForItem, requireSession } from '@axium/server/auth';
|
|
4
|
-
import { database } from '@axium/server/database';
|
|
5
4
|
import { error, withError } from '@axium/server/requests';
|
|
6
5
|
import { addRoute } from '@axium/server/routes';
|
|
7
6
|
import { createHash } from 'node:crypto';
|
|
8
|
-
import { copyFileSync, createReadStream, renameSync, unlinkSync, writeFileSync
|
|
7
|
+
import { copyFileSync, createReadStream, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
8
|
import { join } from 'node:path/posix';
|
|
10
9
|
import { Readable } from 'node:stream';
|
|
11
10
|
import * as z from 'zod';
|
|
12
11
|
import '../polyfills.js';
|
|
13
12
|
import { getLimits } from './config.js';
|
|
14
|
-
import { getUserStats
|
|
15
|
-
import { checkNewItem, createNewItem, requireUpload } from './item.js';
|
|
13
|
+
import { getUserStats } from './db.js';
|
|
14
|
+
import { checkItemUpdate, checkNewItem, createNewItem, finishItemUpdate, requireUpload } from './item.js';
|
|
16
15
|
export function _contentDispositionFor(name, suffix = '') {
|
|
17
16
|
const fallback = name
|
|
18
17
|
.replace(/[\r\n]/g, '')
|
|
@@ -50,22 +49,36 @@ addRoute({
|
|
|
50
49
|
if (!getConfig('@axium/storage').enabled)
|
|
51
50
|
error(503, 'User storage is disabled');
|
|
52
51
|
const upload = await requireUpload(request);
|
|
53
|
-
const size = BigInt(request.headers.get('
|
|
52
|
+
const size = BigInt(request.headers.get('x-chunk-size') || -1);
|
|
54
53
|
if (size < 0n)
|
|
55
|
-
error(411, 'Missing or invalid
|
|
54
|
+
error(411, 'Missing or invalid chunk size');
|
|
56
55
|
if (upload.uploadedBytes + size > upload.init.size)
|
|
57
56
|
error(413, 'Upload exceeds allowed size');
|
|
58
|
-
const content = await request.bytes();
|
|
59
|
-
if (content.byteLength != Number(size)) {
|
|
60
|
-
await audit('storage_size_mismatch', upload.userId, { item: null });
|
|
61
|
-
error(400, `Content length mismatch: expected ${size}, got ${content.byteLength}`);
|
|
62
|
-
}
|
|
63
57
|
const offset = BigInt(request.headers.get('x-offset') || -1);
|
|
64
58
|
if (offset != upload.uploadedBytes)
|
|
65
59
|
error(400, `Expected offset ${upload.uploadedBytes} but got ${offset}`);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
if (!request.body)
|
|
61
|
+
error(400, 'Missing request body');
|
|
62
|
+
let actualSize = 0n;
|
|
63
|
+
const counter = new TransformStream({
|
|
64
|
+
transform(chunk, controller) {
|
|
65
|
+
actualSize += BigInt(chunk.length);
|
|
66
|
+
controller.enqueue(chunk);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const [forFile, forHash] = request.body.pipeThrough(counter).tee();
|
|
70
|
+
/* @todo Figure out if we need to handle stream cancellation differently.
|
|
71
|
+
Right now an error with this chunk cancels the streams but may not cleanly fail the upload */
|
|
72
|
+
await Promise.all([
|
|
73
|
+
forFile.pipeTo(upload.stream, { preventClose: true }),
|
|
74
|
+
forHash.pipeTo(upload.hashStream, { preventClose: true }),
|
|
75
|
+
]);
|
|
76
|
+
if (actualSize != size) {
|
|
77
|
+
upload.remove();
|
|
78
|
+
await audit('storage_size_mismatch', upload.userId, { item: null });
|
|
79
|
+
error(400, `Content length mismatch: expected ${size}, got ${actualSize}`);
|
|
80
|
+
}
|
|
81
|
+
upload.uploadedBytes += actualSize;
|
|
69
82
|
if (upload.uploadedBytes != upload.init.size)
|
|
70
83
|
return new Response(null, { status: 204 });
|
|
71
84
|
const hash = upload.hash.digest();
|
|
@@ -73,7 +86,7 @@ addRoute({
|
|
|
73
86
|
if (hash.toHex() != upload.init.hash)
|
|
74
87
|
error(409, 'Hash mismatch');
|
|
75
88
|
upload.remove();
|
|
76
|
-
|
|
89
|
+
function writeContent(path) {
|
|
77
90
|
try {
|
|
78
91
|
renameSync(upload.file, path);
|
|
79
92
|
}
|
|
@@ -82,7 +95,10 @@ addRoute({
|
|
|
82
95
|
throw e;
|
|
83
96
|
copyFileSync(upload.file, path);
|
|
84
97
|
}
|
|
85
|
-
}
|
|
98
|
+
}
|
|
99
|
+
const item = upload.itemId
|
|
100
|
+
? await finishItemUpdate(upload.itemId, upload.init.size, hash, writeContent)
|
|
101
|
+
: await createNewItem(upload.init, upload.userId, writeContent);
|
|
86
102
|
try {
|
|
87
103
|
unlinkSync(upload.file);
|
|
88
104
|
}
|
|
@@ -92,6 +108,19 @@ addRoute({
|
|
|
92
108
|
return item;
|
|
93
109
|
},
|
|
94
110
|
});
|
|
111
|
+
function parseRange(itemSize, range) {
|
|
112
|
+
let start = 0, end = Number(itemSize - 1n), length = Number(itemSize);
|
|
113
|
+
if (range) {
|
|
114
|
+
const [_start, _end = end] = range
|
|
115
|
+
.replace(/bytes=/, '')
|
|
116
|
+
.split('-')
|
|
117
|
+
.map(val => (val && Number.isSafeInteger(parseInt(val)) ? parseInt(val) : undefined));
|
|
118
|
+
start = typeof _start == 'number' ? _start : Number(itemSize) - _end;
|
|
119
|
+
end = typeof _start == 'number' ? _end : end;
|
|
120
|
+
length = end - start + 1;
|
|
121
|
+
}
|
|
122
|
+
return { start, end, length };
|
|
123
|
+
}
|
|
95
124
|
addRoute({
|
|
96
125
|
path: '/raw/storage/:id',
|
|
97
126
|
params: { id: z.uuid() },
|
|
@@ -103,17 +132,7 @@ addRoute({
|
|
|
103
132
|
if (item.trashedAt)
|
|
104
133
|
error(410, 'Trashed items can not be downloaded');
|
|
105
134
|
const path = join(config.data, item.id);
|
|
106
|
-
const
|
|
107
|
-
let start = 0, end = Number(item.size - 1n), length = Number(item.size);
|
|
108
|
-
if (range) {
|
|
109
|
-
const [_start, _end = end] = range
|
|
110
|
-
.replace(/bytes=/, '')
|
|
111
|
-
.split('-')
|
|
112
|
-
.map(val => (val && Number.isSafeInteger(parseInt(val)) ? parseInt(val) : undefined));
|
|
113
|
-
start = typeof _start == 'number' ? _start : Number(item.size) - _end;
|
|
114
|
-
end = typeof _start == 'number' ? _end : end;
|
|
115
|
-
length = end - start + 1;
|
|
116
|
-
}
|
|
135
|
+
const { start, end, length } = parseRange(item.size, request.headers.get('range'));
|
|
117
136
|
if (start >= item.size || end >= item.size || start > end || start < 0) {
|
|
118
137
|
return new Response(null, {
|
|
119
138
|
status: 416,
|
|
@@ -133,20 +152,7 @@ addRoute({
|
|
|
133
152
|
});
|
|
134
153
|
},
|
|
135
154
|
async POST(request, { id: itemId }) {
|
|
136
|
-
|
|
137
|
-
error(503, 'User storage is disabled');
|
|
138
|
-
const { item, session } = await authRequestForItem(request, 'storage', itemId, { write: true }, true);
|
|
139
|
-
if (item.immutable)
|
|
140
|
-
error(405, 'Item is immutable');
|
|
141
|
-
if (item.type == 'inode/directory')
|
|
142
|
-
error(409, 'Directories do not have content');
|
|
143
|
-
if (item.trashedAt)
|
|
144
|
-
error(410, 'Trashed items can not be changed');
|
|
145
|
-
const type = request.headers.get('content-type') || 'application/octet-stream';
|
|
146
|
-
if (type != item.type) {
|
|
147
|
-
await audit('storage_type_mismatch', session?.userId, { item: item.id });
|
|
148
|
-
error(400, 'Content type does not match existing item type');
|
|
149
|
-
}
|
|
155
|
+
const { item, session } = await checkItemUpdate(request, itemId);
|
|
150
156
|
const size = Number(request.headers.get('content-length'));
|
|
151
157
|
if (Number.isNaN(size))
|
|
152
158
|
error(411, 'Missing or invalid content length header');
|
|
@@ -161,21 +167,8 @@ addRoute({
|
|
|
161
167
|
error(400, 'Actual content length does not match header');
|
|
162
168
|
}
|
|
163
169
|
const hash = createHash('BLAKE2b512').update(content).digest();
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
.updateTable('storage')
|
|
168
|
-
.where('id', '=', itemId)
|
|
169
|
-
.set({ size: BigInt(size), modifiedAt: new Date(), hash })
|
|
170
|
-
.returningAll()
|
|
171
|
-
.executeTakeFirstOrThrow();
|
|
172
|
-
writeFileSync(join(getConfig('@axium/storage').data, result.id), content);
|
|
173
|
-
await tx.commit().execute();
|
|
174
|
-
return parseItem(result);
|
|
175
|
-
}
|
|
176
|
-
catch (error) {
|
|
177
|
-
await tx.rollback().execute();
|
|
178
|
-
throw withError('Could not update item', 500)(error);
|
|
179
|
-
}
|
|
170
|
+
return await finishItemUpdate(itemId, BigInt(size), hash, path => {
|
|
171
|
+
writeFileSync(path, content);
|
|
172
|
+
});
|
|
180
173
|
},
|
|
181
174
|
});
|
package/lib/Add.svelte
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { forMime } from '@axium/core/icons';
|
|
3
3
|
import { FormDialog, Icon, Popover, Upload } from '@axium/client/components';
|
|
4
|
-
import {
|
|
4
|
+
import { createItemFromFile } from '@axium/storage/client';
|
|
5
5
|
import type { StorageItemMetadata } from '@axium/storage/common';
|
|
6
6
|
import { text } from '@axium/client';
|
|
7
7
|
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
cancel={() => (files = new DataTransfer().files)}
|
|
47
47
|
submit={async () => {
|
|
48
48
|
for (const [i, file] of Array.from(files!).entries()) {
|
|
49
|
-
const item = await
|
|
49
|
+
const item = await createItemFromFile(file, {
|
|
50
50
|
parentId,
|
|
51
51
|
onProgress(uploaded, total) {
|
|
52
52
|
uploadProgress[i] = [uploaded, total];
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
submitText={text('storage.Add.create')}
|
|
65
65
|
submit={async (data: { name: string; content?: string }) => {
|
|
66
66
|
const file = new File(createIncludesContent ? [data.content!] : [], data.name, { type: createType });
|
|
67
|
-
const item = await
|
|
67
|
+
const item = await createItemFromFile(file, { parentId });
|
|
68
68
|
onAdd?.(item);
|
|
69
69
|
}}
|
|
70
70
|
>
|
package/lib/List.svelte
CHANGED
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
user,
|
|
22
22
|
}: { appMode?: boolean; items: (StorageItemMetadata & AccessControllable)[]; emptyText?: string; user?: UserPublic } = $props();
|
|
23
23
|
|
|
24
|
-
let
|
|
25
|
-
const activeItem = $derived(items
|
|
24
|
+
let activeId = $state<string>();
|
|
25
|
+
const activeItem = $derived(items.find(item => item.id === activeId));
|
|
26
26
|
const activeItemName = $derived(formatItemName(activeItem));
|
|
27
27
|
const dialogs = $state<Record<string, HTMLDialogElement>>({});
|
|
28
28
|
|
|
@@ -38,25 +38,31 @@
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const sortedItems = $derived(
|
|
41
|
-
items
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
: undefined
|
|
51
|
-
)
|
|
41
|
+
items.toSorted(
|
|
42
|
+
sort
|
|
43
|
+
? (_a, _b) => {
|
|
44
|
+
const [a, b] = sort?.descending ? [_b, _a] : [_a, _b];
|
|
45
|
+
// @ts-expect-error 2362 — `Date`s have a `valueOf` and can be treated like numbers
|
|
46
|
+
return sort.by == 'name' ? a.name.localeCompare(b.name) : a[sort.by] - b[sort.by];
|
|
47
|
+
}
|
|
48
|
+
: undefined
|
|
49
|
+
)
|
|
52
50
|
);
|
|
51
|
+
|
|
52
|
+
function removeActiveItem() {
|
|
53
|
+
const index = items.findIndex(item => item.id === activeId);
|
|
54
|
+
if (index == -1) return console.warn('Can not remove active item because it does not exist');
|
|
55
|
+
items.splice(index, 1);
|
|
56
|
+
activeId = undefined;
|
|
57
|
+
dialogs.preview.close();
|
|
58
|
+
}
|
|
53
59
|
</script>
|
|
54
60
|
|
|
55
|
-
{#snippet action(name: string, icon: string,
|
|
61
|
+
{#snippet action(name: string, icon: string, id: string)}
|
|
56
62
|
<span
|
|
57
63
|
class="icon-text action"
|
|
58
64
|
onclick={() => {
|
|
59
|
-
|
|
65
|
+
activeId = id;
|
|
60
66
|
dialogs[name].showModal();
|
|
61
67
|
}}
|
|
62
68
|
>
|
|
@@ -79,32 +85,33 @@
|
|
|
79
85
|
</span>
|
|
80
86
|
{/each}
|
|
81
87
|
</div>
|
|
82
|
-
{#each sortedItems as
|
|
88
|
+
{#each sortedItems as item (item.id)}
|
|
83
89
|
{@const trash = () => {
|
|
84
|
-
|
|
85
|
-
toastStatus(
|
|
86
|
-
updateItemMetadata(activeItem.id, { trash: true }).then(() => items.splice(activeIndex, 1)),
|
|
87
|
-
text('storage.generic.trash_success')
|
|
88
|
-
);
|
|
90
|
+
activeId = item.id;
|
|
91
|
+
toastStatus(updateItemMetadata(activeId, { trash: true }).then(removeActiveItem), text('storage.generic.trash_success'));
|
|
89
92
|
}}
|
|
90
93
|
<div
|
|
91
94
|
class="list-item"
|
|
92
95
|
onclick={async () => {
|
|
93
96
|
if (item.type != 'inode/directory') {
|
|
94
|
-
|
|
97
|
+
activeId = item.id;
|
|
95
98
|
dialogs.preview.showModal();
|
|
96
99
|
} else if (appMode) location.href = '/files/' + item.id;
|
|
97
100
|
else items = await getDirectoryMetadata(item.id);
|
|
98
101
|
}}
|
|
99
102
|
{@attach contextMenu(
|
|
100
|
-
{ i: 'pencil', text: text('storage.generic.rename'), action: () => ((
|
|
103
|
+
{ i: 'pencil', text: text('storage.generic.rename'), action: () => ((activeId = item.id), dialogs.rename.showModal()) },
|
|
101
104
|
{
|
|
102
105
|
i: 'user-group',
|
|
103
106
|
text: text('storage.List.share'),
|
|
104
|
-
action: () => ((
|
|
107
|
+
action: () => ((activeId = item.id), dialogs['share:' + item.id].showModal()),
|
|
105
108
|
},
|
|
106
|
-
{
|
|
107
|
-
|
|
109
|
+
{
|
|
110
|
+
i: 'download',
|
|
111
|
+
text: text('storage.generic.download'),
|
|
112
|
+
action: () => ((activeId = item.id), dialogs.download.showModal()),
|
|
113
|
+
},
|
|
114
|
+
{ i: 'link-horizontal', text: text('storage.List.copy_link'), action: () => ((activeId = item.id), copyShortURL(item)) },
|
|
108
115
|
{ i: 'trash', text: text('storage.generic.trash'), action: trash },
|
|
109
116
|
user?.preferences?.debug && {
|
|
110
117
|
i: 'hashtag',
|
|
@@ -126,9 +133,9 @@
|
|
|
126
133
|
e.stopImmediatePropagation();
|
|
127
134
|
}}
|
|
128
135
|
>
|
|
129
|
-
{@render action('rename', 'pencil',
|
|
130
|
-
{@render action('share:' + item.id, 'user-group',
|
|
131
|
-
{@render action('download', 'download',
|
|
136
|
+
{@render action('rename', 'pencil', item.id)}
|
|
137
|
+
{@render action('share:' + item.id, 'user-group', item.id)}
|
|
138
|
+
{@render action('download', 'download', item.id)}
|
|
132
139
|
<span class="icon-text action" onclick={trash}>
|
|
133
140
|
<Icon i="trash" --size="14px" />
|
|
134
141
|
</span>
|
|
@@ -142,12 +149,7 @@
|
|
|
142
149
|
|
|
143
150
|
<dialog bind:this={dialogs.preview} class="preview" onclick={e => e.stopPropagation()} {@attach closeOnBackGesture}>
|
|
144
151
|
{#if activeItem}
|
|
145
|
-
<Preview
|
|
146
|
-
item={activeItem}
|
|
147
|
-
previewDialog={dialogs.preview}
|
|
148
|
-
shareDialog={dialogs['share:' + activeItem.id]}
|
|
149
|
-
onDelete={() => items.splice(activeIndex, 1)}
|
|
150
|
-
/>
|
|
152
|
+
<Preview item={activeItem} previewDialog={dialogs.preview} shareDialog={dialogs['share:' + activeId]} onDelete={removeActiveItem} />
|
|
151
153
|
{/if}
|
|
152
154
|
</dialog>
|
|
153
155
|
|
|
@@ -155,8 +157,8 @@
|
|
|
155
157
|
bind:dialog={dialogs.rename}
|
|
156
158
|
submitText={text('storage.generic.rename')}
|
|
157
159
|
submit={async (data: { name: string }) => {
|
|
158
|
-
if (!activeItem) throw text('storage.generic.no_item');
|
|
159
|
-
await updateItemMetadata(
|
|
160
|
+
if (!activeId || !activeItem) throw text('storage.generic.no_item');
|
|
161
|
+
await updateItemMetadata(activeId, data);
|
|
160
162
|
activeItem.name = data.name;
|
|
161
163
|
}}
|
|
162
164
|
>
|
|
@@ -168,8 +170,10 @@
|
|
|
168
170
|
<FormDialog
|
|
169
171
|
bind:dialog={dialogs.download}
|
|
170
172
|
submitText={text('storage.generic.download')}
|
|
171
|
-
submit={async () =>
|
|
172
|
-
|
|
173
|
+
submit={async () => {
|
|
174
|
+
if (!activeId || !activeItem) throw text('storage.generic.no_item');
|
|
175
|
+
open(activeItem.type != 'inode/directory' ? activeItem.dataURL : '/raw/storage/directory-zip/' + activeId, '_blank');
|
|
176
|
+
}}
|
|
173
177
|
>
|
|
174
178
|
<p>{text('storage.generic.download_confirm_named', { name: activeItemName })}</p>
|
|
175
179
|
</FormDialog>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"author": "James Prevett <axium@jamespre.dev>",
|
|
5
5
|
"description": "User file storage for Axium",
|
|
6
6
|
"funding": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@axium/server": ">=0.39.0",
|
|
46
46
|
"@sveltejs/kit": "^2.27.3",
|
|
47
47
|
"commander": "^14.0.0",
|
|
48
|
-
"ioium": "^1.
|
|
48
|
+
"ioium": "^1.3.0",
|
|
49
49
|
"kysely": "^0.28.15",
|
|
50
50
|
"mime": "^4.1.0",
|
|
51
51
|
"utilium": "^3.1.0"
|
|
@@ -13,18 +13,26 @@
|
|
|
13
13
|
let restoreDialog = $state<HTMLDialogElement>()!;
|
|
14
14
|
let deleteDialog = $state<HTMLDialogElement>()!;
|
|
15
15
|
|
|
16
|
-
let
|
|
17
|
-
const activeItem = $derived(
|
|
16
|
+
let activeId = $state<string>();
|
|
17
|
+
const activeItem = $derived(items.find(item => item.id === activeId));
|
|
18
18
|
const activeItemName = $derived(formatItemName(activeItem));
|
|
19
19
|
|
|
20
|
-
function action(
|
|
20
|
+
function action(id: string, dialog: () => HTMLDialogElement) {
|
|
21
21
|
return (e: Event) => {
|
|
22
22
|
e.stopPropagation();
|
|
23
23
|
e.preventDefault();
|
|
24
|
-
|
|
24
|
+
activeId = id;
|
|
25
25
|
dialog().showModal();
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
function useAndClearActive(thunk: () => any) {
|
|
30
|
+
if (!activeItem) throw text('storage.generic.no_item');
|
|
31
|
+
const result = thunk();
|
|
32
|
+
const index = items.findIndex(item => item.id === activeId);
|
|
33
|
+
if (index !== -1) items.splice(index, 1);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
28
36
|
</script>
|
|
29
37
|
|
|
30
38
|
<svelte:head>
|
|
@@ -38,16 +46,16 @@
|
|
|
38
46
|
<span>{text('page.files.trash_page.last_modified')}</span>
|
|
39
47
|
<span>{text('storage.List.size')}</span>
|
|
40
48
|
</div>
|
|
41
|
-
{#each items as item
|
|
49
|
+
{#each items as item (item.id)}
|
|
42
50
|
<div class="list-item">
|
|
43
51
|
<dfn title={item.type}><Icon i={iconForMime(item.type)} /></dfn>
|
|
44
52
|
<span class="name">{item.name}</span>
|
|
45
53
|
<span>{item.modifiedAt.toLocaleString()}</span>
|
|
46
54
|
<span>{formatBytes(item.size)}</span>
|
|
47
|
-
<span class="action" onclick={action(
|
|
55
|
+
<span class="action" onclick={action(item.id, () => restoreDialog)}>
|
|
48
56
|
<Icon i="rotate-left" --size="14px" />
|
|
49
57
|
</span>
|
|
50
|
-
<span class="action" onclick={action(
|
|
58
|
+
<span class="action" onclick={action(item.id, () => deleteDialog)}>
|
|
51
59
|
<Icon i="trash-can-xmark" --size="14px" --fill="#c44" />
|
|
52
60
|
</span>
|
|
53
61
|
</div>
|
|
@@ -59,11 +67,7 @@
|
|
|
59
67
|
<FormDialog
|
|
60
68
|
bind:dialog={restoreDialog}
|
|
61
69
|
submitText={text('page.files.trash_page.restore')}
|
|
62
|
-
submit={
|
|
63
|
-
if (!activeItem) throw text('storage.generic.no_item');
|
|
64
|
-
await updateItemMetadata(activeItem.id, { trash: false });
|
|
65
|
-
items.splice(activeIndex, 1);
|
|
66
|
-
}}
|
|
70
|
+
submit={useAndClearActive(() => updateItemMetadata(activeId!, { trash: false }))}
|
|
67
71
|
>
|
|
68
72
|
<p>{text('page.files.trash_page.restore_confirm', { name: activeItemName })}</p>
|
|
69
73
|
</FormDialog>
|
|
@@ -71,15 +75,9 @@
|
|
|
71
75
|
bind:dialog={deleteDialog}
|
|
72
76
|
submitText={text('page.files.trash_page.delete')}
|
|
73
77
|
submitDanger
|
|
74
|
-
submit={
|
|
75
|
-
if (!activeItem) throw text('storage.generic.no_item');
|
|
76
|
-
await deleteItem(activeItem.id);
|
|
77
|
-
items.splice(activeIndex, 1);
|
|
78
|
-
}}
|
|
78
|
+
submit={useAndClearActive(() => deleteItem(activeId!))}
|
|
79
79
|
>
|
|
80
|
-
<p>
|
|
81
|
-
{text('page.files.trash_page.delete_confirm', { name: activeItemName })}
|
|
82
|
-
</p>
|
|
80
|
+
<p>{text('page.files.trash_page.delete_confirm', { name: activeItemName })}</p>
|
|
83
81
|
</FormDialog>
|
|
84
82
|
|
|
85
83
|
<style>
|