@axium/storage 0.15.1 → 0.16.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.
@@ -1,7 +1,23 @@
1
- import { StorageItemMetadata, type StorageItemUpdate, type UserStorage, type UserStorageInfo } from '../common.js';
1
+ import type { StorageItemUpdate, UserStorage, UserStorageInfo } from '../common.js';
2
+ import { StorageItemMetadata } from '../common.js';
3
+ import '../polyfills.js';
2
4
  export interface UploadOptions {
3
5
  parentId?: string;
4
6
  name?: string;
7
+ onProgress?(this: void, uploaded: number, total: number): void;
8
+ }
9
+ declare global {
10
+ interface NetworkInformation {
11
+ readonly downlink: number;
12
+ readonly downlinkMax?: number;
13
+ readonly effectiveType: 'slow-2g' | '2g' | '3g' | '4g';
14
+ readonly rtt: number;
15
+ readonly saveData: boolean;
16
+ readonly type: 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'wifi' | 'wimax' | 'other' | 'unknown';
17
+ }
18
+ interface Navigator {
19
+ connection?: NetworkInformation;
20
+ }
5
21
  }
6
22
  export declare function uploadItem(file: Blob | File, opt?: UploadOptions): Promise<StorageItemMetadata>;
7
23
  export declare function updateItem(fileId: string, data: Blob): Promise<StorageItemMetadata>;
@@ -1,13 +1,91 @@
1
1
  import { fetchAPI, prefix, token } from '@axium/client/requests';
2
- import { StorageItemMetadata } from '../common.js';
2
+ import { blake2b } from 'blakejs';
3
3
  import { prettifyError } from 'zod';
4
- async function _upload(method, url, data, extraHeaders = {}) {
4
+ import { StorageItemMetadata } from '../common.js';
5
+ import '../polyfills.js';
6
+ function rawStorage(suffix) {
7
+ const raw = '/raw/storage' + (suffix ? '/' + suffix : '');
8
+ if (prefix[0] == '/')
9
+ return raw;
10
+ const url = new URL(prefix);
11
+ url.pathname = raw;
12
+ return url;
13
+ }
14
+ const conTypeToSpeed = {
15
+ 'slow-2g': 1,
16
+ '2g': 4,
17
+ '3g': 16,
18
+ '4g': 64,
19
+ };
20
+ async function handleError(response) {
21
+ if (response.headers.get('Content-Type')?.trim() != 'application/json')
22
+ throw await response.text();
23
+ const json = await response.json();
24
+ throw json.message;
25
+ }
26
+ export async function uploadItem(file, opt = {}) {
27
+ if (file instanceof File)
28
+ opt.name ||= file.name;
29
+ if (!opt.name)
30
+ throw 'item name is required';
31
+ const content = await file.bytes();
32
+ /**
33
+ * 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.
34
+ */
35
+ const hash = content.length < 10_000_000 ? blake2b(content).toHex() : null;
36
+ const upload = await fetchAPI('PUT', 'storage', {
37
+ parentId: opt.parentId,
38
+ name: opt.name,
39
+ type: file.type,
40
+ size: file.size,
41
+ hash,
42
+ });
43
+ if (upload.status == 'created')
44
+ return upload.item;
45
+ let chunkSize = upload.max_transfer_size * 1_000_000;
46
+ if (globalThis.navigator?.connection) {
47
+ chunkSize = Math.min(upload.max_transfer_size, conTypeToSpeed[globalThis.navigator.connection.effectiveType]) * 1_000_000;
48
+ }
49
+ let response;
50
+ for (let offset = 0; offset < content.length; offset += chunkSize) {
51
+ const size = Math.min(chunkSize, content.length - offset);
52
+ response = await fetch(rawStorage('chunk'), {
53
+ method: 'POST',
54
+ headers: {
55
+ 'x-upload': upload.token,
56
+ 'x-offset': offset.toString(),
57
+ 'content-length': size.toString(),
58
+ 'content-type': 'application/octet-stream',
59
+ },
60
+ body: content.slice(offset, offset + size),
61
+ });
62
+ if (!response.ok)
63
+ await handleError(response);
64
+ opt.onProgress?.(offset + size, content.length);
65
+ if (offset + size != content.length && response.status != 204)
66
+ console.warn('Unexpected end of upload before last chunk');
67
+ }
68
+ if (!response)
69
+ throw new Error('BUG: No response');
70
+ if (!response.headers.get('Content-Type')?.includes('application/json')) {
71
+ throw new Error(`Unexpected response type: ${response.headers.get('Content-Type')}`);
72
+ }
73
+ const json = await response.json().catch(() => ({ message: 'Unknown server error (invalid JSON response)' }));
74
+ if (!response.ok)
75
+ await handleError(response);
76
+ try {
77
+ return StorageItemMetadata.parse(json);
78
+ }
79
+ catch (e) {
80
+ throw prettifyError(e);
81
+ }
82
+ }
83
+ export async function updateItem(fileId, data) {
5
84
  const init = {
6
- method,
85
+ method: 'POST',
7
86
  headers: {
8
87
  'Content-Type': data.type,
9
88
  'Content-Length': data.size.toString(),
10
- ...extraHeaders,
11
89
  },
12
90
  body: data,
13
91
  };
@@ -15,7 +93,7 @@ async function _upload(method, url, data, extraHeaders = {}) {
15
93
  init.headers['X-Name'] = data.name;
16
94
  if (token)
17
95
  init.headers.Authorization = 'Bearer ' + token;
18
- const response = await fetch(url, init);
96
+ const response = await fetch(rawStorage(fileId), init);
19
97
  if (!response.headers.get('Content-Type')?.includes('application/json')) {
20
98
  throw new Error(`Unexpected response type: ${response.headers.get('Content-Type')}`);
21
99
  }
@@ -29,25 +107,6 @@ async function _upload(method, url, data, extraHeaders = {}) {
29
107
  throw prettifyError(e);
30
108
  }
31
109
  }
32
- function rawStorage(fileId) {
33
- const raw = '/raw/storage' + (fileId ? '/' + fileId : '');
34
- if (prefix[0] == '/')
35
- return raw;
36
- const url = new URL(prefix);
37
- url.pathname = raw;
38
- return url;
39
- }
40
- export async function uploadItem(file, opt = {}) {
41
- const headers = {};
42
- if (opt.parentId)
43
- headers['x-parent'] = opt.parentId;
44
- if (opt.name)
45
- headers['x-name'] = opt.name;
46
- return await _upload('PUT', rawStorage(), file, headers);
47
- }
48
- export async function updateItem(fileId, data) {
49
- return await _upload('POST', rawStorage(fileId), data);
50
- }
51
110
  export async function getItemMetadata(fileId) {
52
111
  return await fetchAPI('GET', 'storage/item/:id', undefined, fileId);
53
112
  }
package/dist/common.d.ts CHANGED
@@ -160,9 +160,7 @@ export declare const StoragePublicConfig: z.ZodObject<{
160
160
  max_items: z.ZodInt;
161
161
  max_item_size: z.ZodInt;
162
162
  }, z.core.$strip>;
163
- chunk: z.ZodBoolean;
164
163
  max_transfer_size: z.ZodInt;
165
- max_chunks: z.ZodInt;
166
164
  }, z.core.$strip>;
167
165
  export interface StoragePublicConfig extends z.infer<typeof StoragePublicConfig> {
168
166
  }
@@ -172,9 +170,7 @@ export declare const StorageConfig: z.ZodObject<{
172
170
  max_items: z.ZodInt;
173
171
  max_item_size: z.ZodInt;
174
172
  }, z.core.$strip>;
175
- chunk: z.ZodBoolean;
176
173
  max_transfer_size: z.ZodInt;
177
- max_chunks: z.ZodInt;
178
174
  cas: z.ZodObject<{
179
175
  enabled: z.ZodOptional<z.ZodBoolean>;
180
176
  include: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -188,12 +184,73 @@ export declare const StorageConfig: z.ZodObject<{
188
184
  user_size: z.ZodInt;
189
185
  }, z.core.$strip>;
190
186
  trash_duration: z.ZodNumber;
187
+ temp_dir: z.ZodString;
188
+ upload_timeout: z.ZodNumber;
191
189
  }, z.core.$strip>;
192
190
  declare module '@axium/core/plugins' {
193
191
  interface $PluginConfigs {
194
192
  '@axium/storage': z.infer<typeof StorageConfig>;
195
193
  }
196
194
  }
195
+ export declare const StorageItemInit: z.ZodObject<{
196
+ name: z.ZodString;
197
+ size: z.ZodInt;
198
+ type: z.ZodString;
199
+ parentId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
200
+ hash: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
201
+ }, z.core.$strip>;
202
+ export interface StorageItemInit extends z.infer<typeof StorageItemInit> {
203
+ }
204
+ export declare const UploadInitResult: z.ZodDiscriminatedUnion<[z.ZodObject<{
205
+ batch: z.ZodObject<{
206
+ enabled: z.ZodBoolean;
207
+ max_items: z.ZodInt;
208
+ max_item_size: z.ZodInt;
209
+ }, z.core.$strip>;
210
+ max_transfer_size: z.ZodInt;
211
+ status: z.ZodLiteral<"accepted">;
212
+ token: z.ZodBase64;
213
+ }, z.core.$strip>, z.ZodObject<{
214
+ status: z.ZodLiteral<"created">;
215
+ item: z.ZodObject<{
216
+ createdAt: z.ZodCoercedDate<unknown>;
217
+ dataURL: z.ZodString;
218
+ hash: z.ZodOptional<z.ZodNullable<z.ZodString>>;
219
+ id: z.ZodUUID;
220
+ immutable: z.ZodBoolean;
221
+ modifiedAt: z.ZodCoercedDate<unknown>;
222
+ name: z.ZodString;
223
+ userId: z.ZodUUID;
224
+ parentId: z.ZodNullable<z.ZodUUID>;
225
+ size: z.ZodInt;
226
+ trashedAt: z.ZodNullable<z.ZodCoercedDate<unknown>>;
227
+ type: z.ZodString;
228
+ metadata: z.ZodRecord<z.ZodString, z.ZodUnknown>;
229
+ acl: z.ZodOptional<z.ZodArray<z.ZodObject<{
230
+ itemId: z.ZodUUID;
231
+ userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
232
+ role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
233
+ tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
234
+ user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
235
+ id: z.ZodUUID;
236
+ name: z.ZodString;
237
+ email: z.ZodOptional<z.ZodEmail>;
238
+ emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
239
+ image: z.ZodOptional<z.ZodNullable<z.ZodURL>>;
240
+ preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
241
+ debug: z.ZodDefault<z.ZodBoolean>;
242
+ }, z.core.$strip>>>;
243
+ roles: z.ZodArray<z.ZodString>;
244
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
245
+ registeredAt: z.ZodCoercedDate<unknown>;
246
+ isAdmin: z.ZodOptional<z.ZodBoolean>;
247
+ isSuspended: z.ZodOptional<z.ZodBoolean>;
248
+ }, z.core.$strip>>>;
249
+ createdAt: z.ZodCoercedDate<unknown>;
250
+ }, z.core.$loose>>>;
251
+ }, z.core.$strip>;
252
+ }, z.core.$strip>], "status">;
253
+ export type UploadInitResult = z.infer<typeof UploadInitResult>;
197
254
  declare const StorageAPI: {
198
255
  readonly 'users/:id/storage': {
199
256
  readonly OPTIONS: z.ZodObject<{
@@ -380,12 +437,65 @@ declare const StorageAPI: {
380
437
  max_items: z.ZodInt;
381
438
  max_item_size: z.ZodInt;
382
439
  }, z.core.$strip>;
383
- chunk: z.ZodBoolean;
384
440
  max_transfer_size: z.ZodInt;
385
- max_chunks: z.ZodInt;
386
441
  syncProtocolVersion: z.ZodInt32;
387
442
  batchFormatVersion: z.ZodInt32;
388
443
  }, z.core.$strip>;
444
+ readonly PUT: readonly [z.ZodObject<{
445
+ name: z.ZodString;
446
+ size: z.ZodInt;
447
+ type: z.ZodString;
448
+ parentId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
449
+ hash: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
450
+ }, z.core.$strip>, z.ZodDiscriminatedUnion<[z.ZodObject<{
451
+ batch: z.ZodObject<{
452
+ enabled: z.ZodBoolean;
453
+ max_items: z.ZodInt;
454
+ max_item_size: z.ZodInt;
455
+ }, z.core.$strip>;
456
+ max_transfer_size: z.ZodInt;
457
+ status: z.ZodLiteral<"accepted">;
458
+ token: z.ZodBase64;
459
+ }, z.core.$strip>, z.ZodObject<{
460
+ status: z.ZodLiteral<"created">;
461
+ item: z.ZodObject<{
462
+ createdAt: z.ZodCoercedDate<unknown>;
463
+ dataURL: z.ZodString;
464
+ hash: z.ZodOptional<z.ZodNullable<z.ZodString>>;
465
+ id: z.ZodUUID;
466
+ immutable: z.ZodBoolean;
467
+ modifiedAt: z.ZodCoercedDate<unknown>;
468
+ name: z.ZodString;
469
+ userId: z.ZodUUID;
470
+ parentId: z.ZodNullable<z.ZodUUID>;
471
+ size: z.ZodInt;
472
+ trashedAt: z.ZodNullable<z.ZodCoercedDate<unknown>>;
473
+ type: z.ZodString;
474
+ metadata: z.ZodRecord<z.ZodString, z.ZodUnknown>;
475
+ acl: z.ZodOptional<z.ZodArray<z.ZodObject<{
476
+ itemId: z.ZodUUID;
477
+ userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
478
+ role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
479
+ tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
480
+ user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
481
+ id: z.ZodUUID;
482
+ name: z.ZodString;
483
+ email: z.ZodOptional<z.ZodEmail>;
484
+ emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
485
+ image: z.ZodOptional<z.ZodNullable<z.ZodURL>>;
486
+ preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
487
+ debug: z.ZodDefault<z.ZodBoolean>;
488
+ }, z.core.$strip>>>;
489
+ roles: z.ZodArray<z.ZodString>;
490
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
491
+ registeredAt: z.ZodCoercedDate<unknown>;
492
+ isAdmin: z.ZodOptional<z.ZodBoolean>;
493
+ isSuspended: z.ZodOptional<z.ZodBoolean>;
494
+ }, z.core.$strip>>>;
495
+ createdAt: z.ZodCoercedDate<unknown>;
496
+ }, z.core.$loose>>>;
497
+ }, z.core.$strip>;
498
+ }, z.core.$strip>], "status">];
389
499
  };
390
500
  readonly 'storage/batch': {
391
501
  readonly POST: readonly [z.ZodArray<z.ZodObject<{
package/dist/common.js CHANGED
@@ -79,12 +79,8 @@ export const StoragePublicConfig = z.object({
79
79
  /** Maximum size in KiB per item */
80
80
  max_item_size: z.int().positive(),
81
81
  }),
82
- /** Whether to split files larger than `max_transfer_size` into multiple chunks */
83
- chunk: z.boolean(),
84
82
  /** Maximum size in MiB per transfer/request */
85
83
  max_transfer_size: z.int().positive(),
86
- /** Maximum number of chunks */
87
- max_chunks: z.int().positive(),
88
84
  });
89
85
  export const StorageConfig = StoragePublicConfig.safeExtend({
90
86
  /** Content Addressable Storage (CAS) configuration */
@@ -106,8 +102,30 @@ export const StorageConfig = StoragePublicConfig.safeExtend({
106
102
  limits: StorageLimits,
107
103
  /** How many days files are kept in the trash */
108
104
  trash_duration: z.number(),
105
+ /** Where to put in-progress chunked uploads */
106
+ temp_dir: z.string(),
107
+ /** How many minutes before an in-progress upload times out */
108
+ upload_timeout: z.number(),
109
109
  });
110
110
  setServerConfig('@axium/storage', StorageConfig);
111
+ export const StorageItemInit = z.object({
112
+ name: z.string(),
113
+ size: z.int().nonnegative(),
114
+ type: z.string(),
115
+ parentId: z.uuid().nullish(),
116
+ hash: z.hex().nullish(),
117
+ });
118
+ export const UploadInitResult = z.discriminatedUnion('status', [
119
+ StoragePublicConfig.safeExtend({
120
+ status: z.literal('accepted'),
121
+ /** Used for chunked uploads */
122
+ token: z.base64(),
123
+ }),
124
+ z.object({
125
+ status: z.literal('created'),
126
+ item: StorageItemMetadata,
127
+ }),
128
+ ]);
111
129
  const StorageAPI = {
112
130
  'users/:id/storage': {
113
131
  OPTIONS: UserStorageInfo,
@@ -127,6 +145,7 @@ const StorageAPI = {
127
145
  syncProtocolVersion: z.int32().nonnegative(),
128
146
  batchFormatVersion: z.int32().nonnegative(),
129
147
  }),
148
+ PUT: [StorageItemInit, UploadInitResult],
130
149
  },
131
150
  'storage/batch': {
132
151
  POST: [StorageBatchUpdate.array(), StorageItemMetadata.array()],
package/dist/polyfills.js CHANGED
@@ -7,12 +7,17 @@ https://github.com/microsoft/TypeScript/issues/61695
7
7
 
8
8
  @todo Remove when TypeScript 5.9 is released
9
9
  */
10
- import { debug } from '@axium/core/node/io';
10
+ import { debug } from '@axium/core/io';
11
11
  Uint8Array.prototype.toHex ??=
12
12
  (debug('Using a polyfill of Uint8Array.prototype.toHex'),
13
13
  function toHex() {
14
14
  return [...this].map(b => b.toString(16).padStart(2, '0')).join('');
15
15
  });
16
+ Uint8Array.prototype.toBase64 ??=
17
+ (debug('Using a polyfill of Uint8Array.prototype.toBase64'),
18
+ function toBase64() {
19
+ return btoa(String.fromCharCode(...this));
20
+ });
16
21
  Uint8Array.fromHex ??=
17
22
  (debug('Using a polyfill of Uint8Array.fromHex'),
18
23
  function fromHex(hex) {
@@ -1,24 +1,42 @@
1
1
  import { getConfig } from '@axium/core';
2
- import { checkAuthForItem, checkAuthForUser } from '@axium/server/auth';
2
+ import { authRequestForItem, checkAuthForUser, requireSession } from '@axium/server/auth';
3
3
  import { database } from '@axium/server/database';
4
- import { error, parseBody, withError } from '@axium/server/requests';
4
+ import { error, json, parseBody, withError } from '@axium/server/requests';
5
5
  import { addRoute } from '@axium/server/routes';
6
6
  import { pick } from 'utilium';
7
7
  import * as z from 'zod';
8
- import { batchFormatVersion, StorageItemUpdate, syncProtocolVersion } from '../common.js';
8
+ import { batchFormatVersion, StorageItemInit, StorageItemUpdate, syncProtocolVersion } from '../common.js';
9
9
  import '../polyfills.js';
10
10
  import { getLimits } from './config.js';
11
11
  import { deleteRecursive, getRecursive, getUserStats, parseItem } from './db.js';
12
12
  import { from as aclFrom } from '@axium/server/acl';
13
+ import { checkNewItem, createNewItem, startUpload } from './item.js';
13
14
  addRoute({
14
15
  path: '/api/storage',
15
16
  OPTIONS() {
16
17
  return {
17
- ...pick(getConfig('@axium/storage'), 'batch', 'chunk', 'max_chunks', 'max_transfer_size'),
18
+ ...pick(getConfig('@axium/storage'), 'batch', 'max_transfer_size'),
18
19
  syncProtocolVersion,
19
20
  batchFormatVersion,
20
21
  };
21
22
  },
23
+ async PUT(request) {
24
+ const { enabled, ...rest } = getConfig('@axium/storage');
25
+ if (!enabled)
26
+ error(503, 'User storage is disabled');
27
+ const session = await requireSession(request);
28
+ const init = await parseBody(request, StorageItemInit);
29
+ const { existing } = await checkNewItem(init, session);
30
+ if (existing || init.type == 'inode/directory') {
31
+ const item = await createNewItem(init, session.userId);
32
+ return json({ status: 'created', item }, { status: 201 });
33
+ }
34
+ return json({
35
+ ...pick(rest, 'batch', 'max_transfer_size'),
36
+ status: 'accepted',
37
+ token: startUpload(init, session),
38
+ }, { status: 202 });
39
+ },
22
40
  });
23
41
  addRoute({
24
42
  path: '/api/storage/item/:id',
@@ -26,14 +44,14 @@ addRoute({
26
44
  async GET(request, { id: itemId }) {
27
45
  if (!getConfig('@axium/storage').enabled)
28
46
  error(503, 'User storage is disabled');
29
- const { item } = await checkAuthForItem(request, 'storage', itemId, { read: true });
47
+ const { item } = await authRequestForItem(request, 'storage', itemId, { read: true });
30
48
  return parseItem(item);
31
49
  },
32
50
  async PATCH(request, { id: itemId }) {
33
51
  if (!getConfig('@axium/storage').enabled)
34
52
  error(503, 'User storage is disabled');
35
53
  const body = await parseBody(request, StorageItemUpdate);
36
- await checkAuthForItem(request, 'storage', itemId, { manage: true });
54
+ await authRequestForItem(request, 'storage', itemId, { manage: true });
37
55
  const values = {};
38
56
  if ('trash' in body)
39
57
  values.trashedAt = body.trash ? new Date() : null;
@@ -54,7 +72,7 @@ addRoute({
54
72
  async DELETE(request, { id: itemId }) {
55
73
  if (!getConfig('@axium/storage').enabled)
56
74
  error(503, 'User storage is disabled');
57
- const auth = await checkAuthForItem(request, 'storage', itemId, { manage: true });
75
+ const auth = await authRequestForItem(request, 'storage', itemId, { manage: true });
58
76
  const item = parseItem(auth.item);
59
77
  await deleteRecursive(item.type != 'inode/directory', itemId);
60
78
  return item;
@@ -66,7 +84,7 @@ addRoute({
66
84
  async GET(request, { id: itemId }) {
67
85
  if (!getConfig('@axium/storage').enabled)
68
86
  error(503, 'User storage is disabled');
69
- const { item } = await checkAuthForItem(request, 'storage', itemId, { read: true });
87
+ const { item } = await authRequestForItem(request, 'storage', itemId, { read: true });
70
88
  if (item.type != 'inode/directory')
71
89
  error(409, 'Item is not a directory');
72
90
  const items = await database
@@ -85,7 +103,7 @@ addRoute({
85
103
  async GET(request, { id: itemId }) {
86
104
  if (!getConfig('@axium/storage').enabled)
87
105
  error(503, 'User storage is disabled');
88
- const { item } = await checkAuthForItem(request, 'storage', itemId, { read: true });
106
+ const { item } = await authRequestForItem(request, 'storage', itemId, { read: true });
89
107
  if (item.type != 'inode/directory')
90
108
  error(409, 'Item is not a directory');
91
109
  const items = await Array.fromAsync(getRecursive(itemId)).catch(withError('Could not get some directory items'));
@@ -0,0 +1,26 @@
1
+ import { type Session } from '@axium/core';
2
+ import { type SessionAndUser } from '@axium/server/auth';
3
+ import { type Hash } from 'node:crypto';
4
+ import type { StorageItemInit, StorageItemMetadata } from '../common.js';
5
+ import '../polyfills.js';
6
+ export interface NewItemResult {
7
+ existing?: {
8
+ id: string;
9
+ };
10
+ needsHashing?: boolean;
11
+ }
12
+ export declare function useCAS(type: string): boolean;
13
+ export declare function checkNewItem(init: StorageItemInit, session: SessionAndUser): Promise<NewItemResult>;
14
+ export declare function createNewItem(init: StorageItemInit, userId: string, writeContent?: (path: string) => void): Promise<StorageItemMetadata>;
15
+ export interface UploadInfo {
16
+ file: string;
17
+ fd: number;
18
+ hash: Hash;
19
+ uploadedBytes: number;
20
+ sessionId: string;
21
+ userId: string;
22
+ init: StorageItemInit;
23
+ remove(): void;
24
+ }
25
+ export declare function startUpload(init: StorageItemInit, session: Session): string;
26
+ export declare function requireUpload(request: Request): Promise<UploadInfo>;
@@ -0,0 +1,136 @@
1
+ import { getConfig } from '@axium/core';
2
+ import { authSessionForItem, requireSession } from '@axium/server/auth';
3
+ import { database } from '@axium/server/database';
4
+ import { error, withError } from '@axium/server/requests';
5
+ import { createHash, randomBytes } from 'node:crypto';
6
+ import { closeSync, linkSync, mkdirSync, openSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import * as z from 'zod';
9
+ import '../polyfills.js';
10
+ import { defaultCASMime, getLimits } from './config.js';
11
+ import { getUserStats, parseItem } from './db.js';
12
+ export function useCAS(type) {
13
+ const { cas } = getConfig('@axium/storage');
14
+ return !!(cas.enabled &&
15
+ type != 'inode/directory' &&
16
+ (defaultCASMime.some(pattern => pattern.test(type)) || cas.include?.some(mime => type.match(mime))));
17
+ }
18
+ export async function checkNewItem(init, session) {
19
+ const { userId } = session;
20
+ const { size, name, type, hash } = init;
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');
26
+ const parentId = init.parentId
27
+ ? await z
28
+ .uuid()
29
+ .parseAsync(init.parentId)
30
+ .catch(() => error(400, 'Invalid parent ID'))
31
+ : null;
32
+ if (parentId)
33
+ await authSessionForItem('storage', parentId, { write: true }, session);
34
+ if (Number.isNaN(size))
35
+ error(411, 'Missing or invalid content length');
36
+ if (limits.user_items && usage.itemCount >= limits.user_items)
37
+ error(409, 'Too many items');
38
+ if (limits.user_size && (usage.usedBytes + size) / 1_000_000 >= limits.user_size)
39
+ error(413, 'Not enough space');
40
+ if (limits.item_size && size > limits.item_size * 1_000_000)
41
+ error(413, 'File size exceeds maximum size');
42
+ const isDirectory = type == 'inode/directory';
43
+ if (isDirectory && size > 0)
44
+ error(400, 'Directories can not have content');
45
+ if (!useCAS(type))
46
+ return {};
47
+ if (!hash)
48
+ return { needsHashing: true };
49
+ const existing = await database
50
+ .selectFrom('storage')
51
+ .select('id')
52
+ .where(eb => eb.and({ hash: Uint8Array.fromHex(hash), immutable: true }))
53
+ .limit(1)
54
+ .executeTakeFirst();
55
+ return { existing };
56
+ }
57
+ export async function createNewItem(init, userId, writeContent) {
58
+ const tx = await database.startTransaction().execute();
59
+ const { data: dataDir } = getConfig('@axium/storage');
60
+ const immutable = useCAS(init.type);
61
+ try {
62
+ const hash = typeof init.hash == 'string' ? Uint8Array.fromHex(init.hash) : null;
63
+ const existing = immutable
64
+ ? await database
65
+ .selectFrom('storage')
66
+ .select('id')
67
+ .where(eb => eb.and({ hash, immutable: true }))
68
+ .limit(1)
69
+ .executeTakeFirst()
70
+ : null;
71
+ const item = parseItem(await tx
72
+ .insertInto('storage')
73
+ .values({
74
+ ...init,
75
+ userId,
76
+ immutable,
77
+ hash,
78
+ })
79
+ .returningAll()
80
+ .executeTakeFirstOrThrow());
81
+ const path = join(dataDir, item.id);
82
+ if (existing)
83
+ linkSync(join(dataDir, existing.id), path);
84
+ else if (init.type != 'inode/directory') {
85
+ if (!writeContent)
86
+ error(501, 'Missing writeContent (this is a bug!)');
87
+ writeContent(path);
88
+ }
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
+ const inProgress = new Map();
98
+ export function startUpload(init, session) {
99
+ const { temp_dir, upload_timeout } = getConfig('@axium/storage');
100
+ const token = randomBytes(32);
101
+ mkdirSync(temp_dir, { recursive: true });
102
+ const file = join(temp_dir, token.toHex());
103
+ const fd = openSync(file, 'a');
104
+ function remove() {
105
+ inProgress.delete(token.toBase64());
106
+ closeSync(fd);
107
+ }
108
+ inProgress.set(token.toBase64(), {
109
+ hash: createHash('BLAKE2b512'),
110
+ file,
111
+ fd,
112
+ uploadedBytes: 0,
113
+ sessionId: session.id,
114
+ userId: session.userId,
115
+ init,
116
+ remove,
117
+ });
118
+ setTimeout(() => {
119
+ remove();
120
+ }, upload_timeout * 60_000);
121
+ return token.toBase64();
122
+ }
123
+ export async function requireUpload(request) {
124
+ const token = request.headers.get('x-upload');
125
+ if (!token)
126
+ error(401, 'Missing upload token');
127
+ const upload = inProgress.get(token);
128
+ if (!upload)
129
+ error(400, 'Invalid upload token');
130
+ const session = await requireSession(request);
131
+ if (session.id != upload.sessionId)
132
+ error(403, 'Upload does not belong to the current session');
133
+ if (session.userId != upload.userId)
134
+ error(403, 'Upload does not belong to the current user');
135
+ return upload;
136
+ }
@@ -52,95 +52,80 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
52
52
  });
53
53
  import { getConfig } from '@axium/core';
54
54
  import { audit } from '@axium/server/audit';
55
- import { checkAuthForItem, requireSession } from '@axium/server/auth';
55
+ import { authRequestForItem, requireSession } from '@axium/server/auth';
56
56
  import { database } from '@axium/server/database';
57
57
  import { error, withError } from '@axium/server/requests';
58
58
  import { addRoute } from '@axium/server/routes';
59
59
  import { createHash } from 'node:crypto';
60
- import { closeSync, linkSync, openSync, readSync, writeFileSync } from 'node:fs';
60
+ import { closeSync, openSync, readFileSync, readSync, renameSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
61
61
  import { join } from 'node:path/posix';
62
62
  import * as z from 'zod';
63
63
  import '../polyfills.js';
64
- import { defaultCASMime, getLimits } from './config.js';
64
+ import { getLimits } from './config.js';
65
65
  import { getUserStats, parseItem } from './db.js';
66
+ import { checkNewItem, createNewItem, requireUpload } from './item.js';
66
67
  addRoute({
67
68
  path: '/raw/storage',
68
69
  async PUT(request) {
69
70
  if (!getConfig('@axium/storage').enabled)
70
71
  error(503, 'User storage is disabled');
71
- const { userId } = await requireSession(request);
72
- const [usage, limits] = await Promise.all([getUserStats(userId), getLimits(userId)]).catch(withError('Could not fetch usage and/or limits'));
73
- const name = request.headers.get('x-name');
74
- if (!name)
75
- error(400, 'Missing name header');
76
- if (name.length > 255)
77
- error(400, 'Name is too long');
78
- const maybeParentId = request.headers.get('x-parent');
79
- const parentId = maybeParentId
80
- ? await z
81
- .uuid()
82
- .parseAsync(maybeParentId)
83
- .catch(() => error(400, 'Invalid parent ID'))
84
- : null;
85
- if (parentId)
86
- await checkAuthForItem(request, 'storage', parentId, { write: true });
87
- const size = Number(request.headers.get('content-length'));
88
- if (Number.isNaN(size))
89
- error(411, 'Missing or invalid content length header');
90
- if (limits.user_items && usage.itemCount >= limits.user_items)
91
- error(409, 'Too many items');
92
- if (limits.user_size && (usage.usedBytes + size) / 1_000_000 >= limits.user_size)
93
- error(413, 'Not enough space');
94
- if (limits.item_size && size > limits.item_size * 1_000_000)
95
- error(413, 'File size exceeds maximum size');
72
+ const session = await requireSession(request);
73
+ const { userId } = session;
74
+ const name = request.headers.get('x-name'); // checked in `checkNewItem`
75
+ const parentId = request.headers.get('x-parent');
76
+ const size = Number(request.headers.get('x-size'));
77
+ const type = request.headers.get('content-type') || 'application/octet-stream';
96
78
  const content = await request.bytes();
97
79
  if (content.byteLength > size) {
98
80
  await audit('storage_size_mismatch', userId, { item: null });
99
81
  error(400, 'Content length does not match size header');
100
82
  }
101
- const type = request.headers.get('content-type') || 'application/octet-stream';
102
- const isDirectory = type == 'inode/directory';
103
- if (isDirectory && size > 0)
104
- error(400, 'Directories can not have content');
105
- const useCAS = getConfig('@axium/storage').cas.enabled &&
106
- !isDirectory &&
107
- (defaultCASMime.some(pattern => pattern.test(type)) || getConfig('@axium/storage').cas.include?.some(mime => type.match(mime)));
108
- const hash = isDirectory ? null : createHash('BLAKE2b512').update(content).digest();
109
- const tx = await database.startTransaction().execute();
110
- try {
111
- const item = parseItem(await tx
112
- .insertInto('storage')
113
- .values({ userId, hash, name, size, type, immutable: useCAS, parentId })
114
- .returningAll()
115
- .executeTakeFirstOrThrow());
116
- const path = join(getConfig('@axium/storage').data, item.id);
117
- if (!useCAS) {
118
- if (!isDirectory)
119
- writeFileSync(path, content);
120
- await tx.commit().execute();
121
- return item;
83
+ const hash = type == 'inode/directory' ? null : createHash('BLAKE2b512').update(content).digest();
84
+ const init = { name, size, type, parentId, hash: hash?.toHex() };
85
+ await checkNewItem(init, session);
86
+ return await createNewItem(init, userId, path => writeFileSync(path, content));
87
+ },
88
+ });
89
+ addRoute({
90
+ path: '/raw/storage/chunk',
91
+ async POST(request) {
92
+ if (!getConfig('@axium/storage').enabled)
93
+ error(503, 'User storage is disabled');
94
+ const upload = await requireUpload(request);
95
+ const size = Number(request.headers.get('content-length'));
96
+ if (Number.isNaN(size))
97
+ error(411, 'Missing or invalid content length');
98
+ if (upload.uploadedBytes + size > upload.init.size)
99
+ error(413, 'Upload exceeds allowed size');
100
+ const content = await request.bytes();
101
+ if (content.byteLength != size) {
102
+ await audit('storage_size_mismatch', upload.userId, { item: null });
103
+ error(400, `Content length mismatch: expected ${size}, got ${content.byteLength}`);
104
+ }
105
+ const offset = Number(request.headers.get('x-offset'));
106
+ if (offset != upload.uploadedBytes)
107
+ error(400, `Expected offset ${upload.uploadedBytes} but got ${offset}`);
108
+ writeSync(upload.fd, content); // opened with 'a', this appends
109
+ upload.hash.update(content);
110
+ upload.uploadedBytes += size;
111
+ if (upload.uploadedBytes != upload.init.size)
112
+ return new Response(null, { status: 204 });
113
+ const hash = upload.hash.digest();
114
+ upload.init.hash ??= hash.toHex();
115
+ if (hash.toHex() != upload.init.hash)
116
+ error(409, 'Hash mismatch');
117
+ upload.remove();
118
+ return await createNewItem(upload.init, upload.userId, path => {
119
+ try {
120
+ renameSync(upload.file, path);
122
121
  }
123
- const existing = await tx
124
- .selectFrom('storage')
125
- .select('id')
126
- .where('hash', '=', hash)
127
- .where('id', '!=', item.id)
128
- .limit(1)
129
- .executeTakeFirst();
130
- if (!existing) {
131
- if (!isDirectory)
132
- writeFileSync(path, content);
133
- await tx.commit().execute();
134
- return item;
122
+ catch (e) {
123
+ if (e.code != 'EXDEV')
124
+ throw e;
125
+ writeFileSync(path, readFileSync(upload.file));
126
+ unlinkSync(upload.file);
135
127
  }
136
- linkSync(join(getConfig('@axium/storage').data, existing.id), path);
137
- await tx.commit().execute();
138
- return item;
139
- }
140
- catch (error) {
141
- await tx.rollback().execute();
142
- throw withError('Could not create item', 500)(error);
143
- }
128
+ });
144
129
  },
145
130
  });
146
131
  addRoute({
@@ -151,7 +136,7 @@ addRoute({
151
136
  try {
152
137
  if (!getConfig('@axium/storage').enabled)
153
138
  error(503, 'User storage is disabled');
154
- const { item } = await checkAuthForItem(request, 'storage', itemId, { read: true });
139
+ const { item } = await authRequestForItem(request, 'storage', itemId, { read: true });
155
140
  if (item.trashedAt)
156
141
  error(410, 'Trashed items can not be downloaded');
157
142
  const path = join(getConfig('@axium/storage').data, item.id);
@@ -198,7 +183,7 @@ addRoute({
198
183
  async POST(request, { id: itemId }) {
199
184
  if (!getConfig('@axium/storage').enabled)
200
185
  error(503, 'User storage is disabled');
201
- const { item, session } = await checkAuthForItem(request, 'storage', itemId, { write: true });
186
+ const { item, session } = await authRequestForItem(request, 'storage', itemId, { write: true });
202
187
  if (item.immutable)
203
188
  error(405, 'Item is immutable');
204
189
  if (item.type == 'inode/directory')
package/lib/Add.svelte CHANGED
@@ -7,7 +7,8 @@
7
7
  const { parentId, onAdd }: { parentId?: string; onAdd?(item: StorageItemMetadata): void } = $props();
8
8
 
9
9
  let uploadDialog = $state<HTMLDialogElement>()!;
10
- let input = $state<HTMLInputElement>();
10
+ let uploadProgress = $state<[number, number][]>([]);
11
+ let input = $state<HTMLInputElement>()!;
11
12
 
12
13
  let createDialog = $state<HTMLDialogElement>()!;
13
14
  let createType = $state<string>();
@@ -43,13 +44,18 @@
43
44
  bind:dialog={uploadDialog}
44
45
  submitText="Upload"
45
46
  submit={async () => {
46
- for (const file of input?.files!) {
47
- const item = await uploadItem(file, { parentId });
47
+ for (const [i, file] of Array.from(input.files!).entries()) {
48
+ const item = await uploadItem(file, {
49
+ parentId,
50
+ onProgress(uploaded, total) {
51
+ uploadProgress[i] = [uploaded, total];
52
+ },
53
+ });
48
54
  onAdd?.(item);
49
55
  }
50
56
  }}
51
57
  >
52
- <Upload bind:input multiple />
58
+ <Upload bind:input bind:progress={uploadProgress} multiple />
53
59
  </FormDialog>
54
60
 
55
61
  <FormDialog
package/lib/List.svelte CHANGED
@@ -70,7 +70,7 @@
70
70
  }}
71
71
  >
72
72
  {@render action('rename', 'pencil', i)}
73
- {@render action('share' + item.id, 'user-group', i)}
73
+ {@render action('share:' + item.id, 'user-group', i)}
74
74
  <AccessControlDialog
75
75
  bind:dialog={dialogs['share:' + item.id]}
76
76
  {item}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/storage",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "User file storage for Axium",
6
6
  "funding": {
@@ -41,11 +41,12 @@
41
41
  "peerDependencies": {
42
42
  "@axium/client": ">=0.13.0",
43
43
  "@axium/core": ">=0.19.0",
44
- "@axium/server": ">=0.34.0",
44
+ "@axium/server": ">=0.35.0",
45
45
  "@sveltejs/kit": "^2.27.3",
46
46
  "utilium": "^2.3.8"
47
47
  },
48
48
  "dependencies": {
49
+ "blakejs": "^1.2.1",
49
50
  "zod": "^4.0.5"
50
51
  },
51
52
  "axium": {
@@ -80,7 +81,6 @@
80
81
  "include": [],
81
82
  "exclude": []
82
83
  },
83
- "chunk": false,
84
84
  "data": "/srv/axium/storage",
85
85
  "enabled": true,
86
86
  "limits": {
@@ -88,9 +88,10 @@
88
88
  "item_size": 100,
89
89
  "user_items": 10000
90
90
  },
91
- "max_chunks": 10,
92
91
  "max_transfer_size": 100,
93
- "trash_duration": 30
92
+ "trash_duration": 30,
93
+ "temp_dir": "/tmp/axium",
94
+ "upload_timeout": 15
94
95
  }
95
96
  }
96
97
  }