@axium/storage 0.24.4 → 0.25.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.
@@ -9,10 +9,11 @@ export interface CreateItemInit {
9
9
  name: string;
10
10
  size: number;
11
11
  type: string;
12
+ signal?: AbortSignal;
12
13
  }
13
14
  export declare function createItem(stream: ReadableStream<Uint8Array<ArrayBuffer>>, init: CreateItemInit): Promise<StorageItemMetadata>;
14
15
  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>;
16
+ export declare function updateItem(fileId: string, newSize: number | bigint, stream: ReadableStream<Uint8Array<ArrayBuffer>>, onProgress?: ProgressHandler, signal?: AbortSignal): Promise<StorageItemMetadata>;
16
17
  export declare function getItemMetadata(fileId: string, options?: GetItemOptions): Promise<StorageItemMetadata>;
17
18
  /** Gets the metadata for all items in a directory. */
18
19
  export declare function getDirectoryMetadata(parentId: string): Promise<StorageItemMetadata[]>;
@@ -39,14 +39,21 @@ async function handleResponse(response) {
39
39
  throw prettifyError(e);
40
40
  }
41
41
  }
42
- async function _upload(upload, stream, itemSize, onProgress) {
42
+ async function _upload(upload, stream, itemSize, onProgress, signal) {
43
43
  if (upload.status == 'created')
44
44
  return upload.item;
45
+ signal?.addEventListener('abort', () => {
46
+ void fetch(rawStorage('upload'), {
47
+ method: 'DELETE',
48
+ headers: { 'x-upload': upload.token },
49
+ });
50
+ });
45
51
  const targetChunkSize = upload.max_transfer_size * 1_000_000;
46
52
  let response;
47
53
  const reader = stream.getReader();
48
54
  let buffer = new Uint8Array(0);
49
55
  for (let offset = 0; offset < itemSize; offset += targetChunkSize) {
56
+ signal?.throwIfAborted();
50
57
  const chunkSize = Math.min(targetChunkSize, itemSize - offset);
51
58
  let bytesReadForChunk = 0;
52
59
  const headers = {
@@ -108,10 +115,11 @@ async function _upload(upload, stream, itemSize, onProgress) {
108
115
  body = chunkData.subarray(0, bytesReadForChunk);
109
116
  onProgress?.(offset + bytesReadForChunk, itemSize);
110
117
  }
111
- response = await fetch(rawStorage('chunk'), {
118
+ response = await fetch(rawStorage('upload'), {
112
119
  method: 'POST',
113
120
  headers,
114
121
  body,
122
+ signal,
115
123
  ...init,
116
124
  }).catch(handleFetchFailed);
117
125
  if (!response.ok)
@@ -132,14 +140,14 @@ export async function createItem(stream, init) {
132
140
  if (!init.name)
133
141
  throw 'item name is required';
134
142
  const upload = await fetchAPI('PUT', 'storage', { ...init, hash: null });
135
- return await _upload(upload, stream, init.size, init.onProgress);
143
+ return await _upload(upload, stream, init.size, init.onProgress, init.signal);
136
144
  }
137
145
  export async function createItemFromFile(file, init) {
138
146
  return await createItem(file.stream(), { ...pick(file, 'name', 'size', 'type'), ...init });
139
147
  }
140
- export async function updateItem(fileId, newSize, stream, onProgress) {
148
+ export async function updateItem(fileId, newSize, stream, onProgress, signal) {
141
149
  const upload = await fetchAPI('POST', 'storage/item/:id', newSize, fileId);
142
- return await _upload(upload, stream, Number(newSize), onProgress);
150
+ return await _upload(upload, stream, Number(newSize), onProgress, signal);
143
151
  }
144
152
  export async function getItemMetadata(fileId, options = {}) {
145
153
  return await fetchAPI('GET', 'storage/item/:id', options, fileId);
@@ -76,7 +76,7 @@ export function computeDelta(sync) {
76
76
  }));
77
77
  const delta = {
78
78
  _items: items,
79
- items: Array.from(items.values()),
79
+ items: items.values().toArray(),
80
80
  synced: Array.from(synced.difference(modified)).map(p => items.get(p)),
81
81
  modified: Array.from(modified).map(p => items.get(p)),
82
82
  local_only: Array.from(new Set(files.keys()).difference(items)).map(p => files.get(p)),
package/dist/common.d.ts CHANGED
@@ -244,7 +244,7 @@ export declare const UploadInitResult: z.ZodDiscriminatedUnion<[z.ZodObject<{
244
244
  }, z.core.$strip>;
245
245
  max_transfer_size: z.ZodInt;
246
246
  status: z.ZodLiteral<"accepted">;
247
- token: z.ZodBase64;
247
+ token: z.ZodBase64URL;
248
248
  }, z.core.$strip>, z.ZodObject<{
249
249
  status: z.ZodLiteral<"created">;
250
250
  item: z.ZodObject<{
@@ -510,7 +510,7 @@ declare const StorageAPI: {
510
510
  }, z.core.$strip>;
511
511
  max_transfer_size: z.ZodInt;
512
512
  status: z.ZodLiteral<"accepted">;
513
- token: z.ZodBase64;
513
+ token: z.ZodBase64URL;
514
514
  }, z.core.$strip>, z.ZodObject<{
515
515
  status: z.ZodLiteral<"created">;
516
516
  item: z.ZodObject<{
@@ -743,7 +743,7 @@ declare const StorageAPI: {
743
743
  }, z.core.$strip>;
744
744
  max_transfer_size: z.ZodInt;
745
745
  status: z.ZodLiteral<"accepted">;
746
- token: z.ZodBase64;
746
+ token: z.ZodBase64URL;
747
747
  }, z.core.$strip>, z.ZodObject<{
748
748
  status: z.ZodLiteral<"created">;
749
749
  item: z.ZodObject<{
package/dist/common.js CHANGED
@@ -144,7 +144,7 @@ export const UploadInitResult = z.discriminatedUnion('status', [
144
144
  StoragePublicConfig.safeExtend({
145
145
  status: z.literal('accepted'),
146
146
  /** Used for chunked uploads */
147
- token: z.base64(),
147
+ token: z.base64url(),
148
148
  }),
149
149
  z.object({
150
150
  status: z.literal('created'),
@@ -126,6 +126,6 @@ addRoute({
126
126
  await tx.rollback().execute();
127
127
  throw withError('Could not update item', 500)(error);
128
128
  }
129
- return Array.from(results.values());
129
+ return results.values().toArray();
130
130
  },
131
131
  });
@@ -28,8 +28,11 @@ export interface UploadInfo {
28
28
  init: StorageItemInit;
29
29
  /** If set we are updating an existing item. Explicit null used to avoid bugs */
30
30
  itemId: string | null;
31
- /** Remove the upload from pending and clean up resources */
32
- remove(): void;
31
+ /**
32
+ * Remove the upload from pending and clean up resources
33
+ * @param isSuccess whether the upload was successful. If not, abort-style behavior will be used instead.
34
+ */
35
+ remove(isSuccess?: boolean): void;
33
36
  }
34
37
  export declare function startUpload(init: StorageItemInit, session: Session, itemId: string | null): string;
35
38
  export declare function requireUpload(request: Request): Promise<UploadInfo>;
@@ -4,7 +4,7 @@ import { authRequestForItem, authSessionForItem, requireSession } from '@axium/s
4
4
  import { database } from '@axium/server/database';
5
5
  import { error, withError } from '@axium/server/requests';
6
6
  import { createHash, randomBytes } from 'node:crypto';
7
- import { createWriteStream, linkSync, mkdirSync } from 'node:fs';
7
+ import { createWriteStream, linkSync, mkdirSync, unlinkSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { Writable } from 'node:stream';
10
10
  import * as z from 'zod';
@@ -138,20 +138,29 @@ export async function finishItemUpdate(itemId, size, hash, writeContent) {
138
138
  const inProgress = new Map();
139
139
  export function startUpload(init, session, itemId) {
140
140
  const { temp_dir, upload_timeout } = getConfig('@axium/storage');
141
- const token = randomBytes(32);
141
+ const token = randomBytes(32), tokenB64 = token.toBase64({ alphabet: 'base64url', omitPadding: true });
142
142
  mkdirSync(temp_dir, { recursive: true });
143
143
  const file = join(temp_dir, token.toHex());
144
144
  let removed = false;
145
- function remove() {
145
+ function remove(isSuccess = false) {
146
146
  if (removed)
147
147
  return;
148
148
  removed = true;
149
- inProgress.delete(token.toBase64());
150
- void stream.close();
149
+ inProgress.delete(tokenB64);
150
+ if (isSuccess)
151
+ void stream.close();
152
+ else
153
+ void stream.abort();
151
154
  hash.end();
155
+ try {
156
+ unlinkSync(file);
157
+ }
158
+ catch {
159
+ // probably renamed
160
+ }
152
161
  }
153
162
  const hash = createHash('BLAKE2b512'), stream = Writable.toWeb(createWriteStream(file));
154
- inProgress.set(token.toBase64(), {
163
+ inProgress.set(tokenB64, {
155
164
  hash,
156
165
  file,
157
166
  stream,
@@ -165,7 +174,7 @@ export function startUpload(init, session, itemId) {
165
174
  setTimeout(() => {
166
175
  remove();
167
176
  }, upload_timeout * 60_000);
168
- return token.toBase64();
177
+ return tokenB64;
169
178
  }
170
179
  export async function requireUpload(request) {
171
180
  const token = request.headers.get('x-upload');
@@ -4,7 +4,7 @@ import { authRequestForItem, requireSession } from '@axium/server/auth';
4
4
  import { error, withError } from '@axium/server/requests';
5
5
  import { addRoute } from '@axium/server/routes';
6
6
  import { createHash } from 'node:crypto';
7
- import { copyFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
7
+ import { copyFileSync, renameSync, writeFileSync } from 'node:fs';
8
8
  import { join } from 'node:path/posix';
9
9
  import * as z from 'zod';
10
10
  import { streamRead } from '../node.js';
@@ -44,7 +44,7 @@ addRoute({
44
44
  },
45
45
  });
46
46
  addRoute({
47
- path: '/raw/storage/chunk',
47
+ path: '/raw/storage/upload',
48
48
  async POST(request) {
49
49
  if (!getConfig('@axium/storage').enabled)
50
50
  error(503, 'User storage is disabled');
@@ -70,6 +70,9 @@ addRoute({
70
70
  /* @todo Figure out if we need to handle stream cancellation differently.
71
71
  Right now an error with this chunk cancels the streams but may not cleanly fail the upload */
72
72
  await request.body.pipeThrough(counter).pipeTo(upload.stream, { preventClose: true });
73
+ if (request.signal.aborted) {
74
+ return;
75
+ }
73
76
  if (actualSize != size) {
74
77
  upload.remove();
75
78
  await audit('storage_size_mismatch', upload.userId, { item: null });
@@ -82,7 +85,6 @@ addRoute({
82
85
  upload.init.hash ??= hash.toHex();
83
86
  if (hash.toHex() != upload.init.hash)
84
87
  error(409, 'Hash mismatch');
85
- upload.remove();
86
88
  function writeContent(path) {
87
89
  try {
88
90
  renameSync(upload.file, path);
@@ -93,16 +95,22 @@ addRoute({
93
95
  copyFileSync(upload.file, path);
94
96
  }
95
97
  }
96
- const item = upload.itemId
97
- ? await finishItemUpdate(upload.itemId, upload.init.size, hash, writeContent)
98
- : await createNewItem(upload.init, upload.userId, writeContent);
99
98
  try {
100
- unlinkSync(upload.file);
99
+ const item = upload.itemId
100
+ ? await finishItemUpdate(upload.itemId, upload.init.size, hash, writeContent)
101
+ : await createNewItem(upload.init, upload.userId, writeContent);
102
+ return item;
101
103
  }
102
- catch {
103
- // probably renamed
104
+ finally {
105
+ upload.remove(true);
104
106
  }
105
- return item;
107
+ },
108
+ async DELETE(request) {
109
+ if (!getConfig('@axium/storage').enabled)
110
+ error(503, 'User storage is disabled');
111
+ const upload = await requireUpload(request);
112
+ upload.remove();
113
+ return new Response(null, { status: 204 });
106
114
  },
107
115
  });
108
116
  function parseRange(itemSize, range) {
package/lib/Add.svelte CHANGED
@@ -4,16 +4,19 @@
4
4
  import { createItemFromFile } from '@axium/storage/client';
5
5
  import type { StorageItemMetadata } from '@axium/storage/common';
6
6
  import { text } from '@axium/client';
7
+ import { toast } from '@axium/client/toast';
7
8
 
8
9
  const { parentId, onAdd }: { parentId?: string; onAdd?(item: StorageItemMetadata): void } = $props();
9
10
 
10
- let uploadDialog = $state<HTMLDialogElement>()!;
11
- let uploadProgress = $state<[number, number][]>([]);
12
- let files = $state<FileList>()!;
11
+ let uploadDialog = $state<HTMLDialogElement>()!,
12
+ uploadProgress = $state<[number, number][]>([]),
13
+ uploading = $state(false),
14
+ controller = $state(new AbortController()),
15
+ files = $state<FileList>()!;
13
16
 
14
- let createDialog = $state<HTMLDialogElement>()!;
15
- let createType = $state<string>();
16
- let createIncludesContent = $state(false);
17
+ let createDialog = $state<HTMLDialogElement>()!,
18
+ createType = $state<string>(),
19
+ createIncludesContent = $state(false);
17
20
  </script>
18
21
 
19
22
  {#snippet _item(type: string, text: string, includeContent: boolean = false)}
@@ -37,23 +40,41 @@
37
40
 
38
41
  <span class="menu-item" onclick={() => uploadDialog.showModal()}><Icon i="upload" />{text('storage.Add.upload')}</span>
39
42
  {@render _item('inode/directory', text('storage.Add.new_folder'))}
40
- {@render _item('text/plain', text('storage.Add.plain_text'))}
43
+ {@render _item('text/plain', text('storage.Add.plain_text'), true)}
41
44
  </Popover>
42
45
 
43
46
  <FormDialog
44
47
  bind:dialog={uploadDialog}
45
48
  submitText={text('storage.Add.upload')}
46
- cancel={() => (files = new DataTransfer().files)}
49
+ cancel={() => {
50
+ files = new DataTransfer().files;
51
+ if (!uploading) return;
52
+ uploading = false;
53
+ uploadProgress = [];
54
+ controller.abort();
55
+ toast('info', text('storage.Add.cancelled'));
56
+ controller = new AbortController();
57
+ }}
47
58
  submit={async () => {
59
+ uploading = true;
48
60
  for (const [i, file] of Array.from(files!).entries()) {
49
- const item = await createItemFromFile(file, {
50
- parentId,
51
- onProgress(uploaded, total) {
52
- uploadProgress[i] = [uploaded, total];
53
- },
54
- });
55
- onAdd?.(item);
61
+ try {
62
+ const item = await createItemFromFile(file, {
63
+ parentId,
64
+ onProgress(uploaded, total) {
65
+ uploadProgress[i] = [uploaded, total];
66
+ },
67
+ signal: controller.signal,
68
+ });
69
+ onAdd?.(item);
70
+ } catch (e) {
71
+ if (e && e instanceof DOMException && e.name == 'AbortError') return;
72
+ throw e;
73
+ }
56
74
  }
75
+ uploading = false;
76
+ uploadProgress = [];
77
+ files = new DataTransfer().files;
57
78
  }}
58
79
  >
59
80
  <Upload bind:files bind:progress={uploadProgress} multiple />
package/locales/en.json CHANGED
@@ -78,7 +78,8 @@
78
78
  "new_folder": "New Folder",
79
79
  "plain_text": "Plain Text",
80
80
  "text": "Add",
81
- "upload": "Upload"
81
+ "upload": "Upload",
82
+ "cancelled": "Upload cancelled"
82
83
  },
83
84
  "List": {
84
85
  "copy_link": "Copy Link",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/storage",
3
- "version": "0.24.4",
3
+ "version": "0.25.0",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "User file storage for Axium",
6
6
  "funding": {