@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.
@@ -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
- declare global {
5
- interface NetworkInformation {
6
- readonly downlink: number;
7
- readonly downlinkMax?: number;
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 uploadItemStream(stream: ReadableStream<Uint8Array<ArrayBuffer>>, opt: UploadStreamOptions): Promise<StorageItemMetadata>;
29
- export declare function updateItem(fileId: string, data: Blob): Promise<StorageItemMetadata>;
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 {};
@@ -1,22 +1,9 @@
1
1
  import { fetchAPI, prefix, token } from '@axium/client/requests';
2
- import { blake2b } from 'blakejs';
2
+ import { pick } from 'utilium';
3
3
  import { prettifyError } from 'zod';
4
4
  import { StorageItemMetadata } from '../common.js';
5
5
  import '../polyfills.js';
6
- const uploadConfig = {
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
- export async function uploadItem(file, opt = {}) {
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 chunkSize = upload.max_transfer_size * 1_000_000;
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 < opt.size; offset += chunkSize) {
114
- const size = Math.min(chunkSize, opt.size - offset);
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
- 'content-length': size.toString(),
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
- response = await fetch(rawStorage('chunk'), {
125
- method: 'POST',
126
- headers,
127
- body: new ReadableStream({
128
- type: 'bytes',
129
- async pull(controller) {
130
- if (bytesReadForChunk >= size) {
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
- if (!buffer.length) {
135
- const { done, value } = await reader.read();
136
- if (done) {
137
- controller.close();
138
- return;
139
- }
140
- buffer = value;
141
- }
142
- const take = Math.min(buffer.length, size - bytesReadForChunk);
143
- const chunk = buffer.subarray(0, take);
144
- buffer = buffer.subarray(take);
145
- bytesReadForChunk += take;
146
- controller.enqueue(chunk);
147
- opt.onProgress?.(offset + bytesReadForChunk, opt.size);
148
- },
149
- }),
150
- // @ts-expect-error 2769
151
- duplex: 'half',
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 + size != opt.size && response.status != 204)
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 updateItem(fileId, data) {
161
- const init = {
162
- method: 'POST',
163
- headers: {
164
- 'Content-Type': data.type,
165
- 'Content-Length': data.size.toString(),
166
- },
167
- body: data,
168
- };
169
- if (data instanceof File)
170
- init.headers['X-Name'] = data.name;
171
- if (token)
172
- init.headers.Authorization = 'Bearer ' + token;
173
- const response = await fetch(rawStorage(fileId), init).catch(handleFetchFailed);
174
- return await handleResponse(response);
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.uploadItem(new Blob([], { type: 'inode/directory' }), { parentId: parent?.id, name });
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.uploadItemStream(stream, {
138
+ const item = await api.createItem(stream, {
139
139
  parentId: parent?.id,
140
140
  name,
141
141
  size: stats.size,
@@ -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, downloadItem, updateItem, uploadItem } from './api.js';
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 uploadItem(new Blob([], { type: 'inode/directory' }), uploadOpts);
138
- _items.set(dirent._path, Object.assign(pick(dir, 'id', 'modifiedAt'), { path: dirent._path, hash: null }));
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 content = fs.readFileSync(join(sync.local_path, dirent._path));
143
- const file = await uploadItem(new Blob([content], { type }), uploadOpts);
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 blob = await downloadItem(item.id);
169
- const content = await blob.bytes();
170
- fs.writeFileSync(fullPath, content);
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 blob = await downloadItem(item.id);
178
- const content = await blob.bytes();
179
- fs.writeFileSync(join(sync.local_path, item.path), content);
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 type = mime.getType(item.path) || 'application/octet-stream';
184
- const content = fs.readFileSync(join(sync.local_path, item.path));
185
- const updated = await updateItem(item.id, new Blob([content], { type }));
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" | "createdAt" | "modifiedAt" | "size">;
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" | "createdAt" | "modifiedAt" | "size">;
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" | "createdAt" | "modifiedAt" | "size">;
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: z.string(),
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: z.string(),
28
+ name: StorageItemName,
27
29
  userId: z.uuid(),
28
30
  parentId: z.uuid().nullable(),
29
- size: z.coerce.bigint().nonnegative(),
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: z.string(),
129
- size: z.coerce.bigint().nonnegative(),
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(),
@@ -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');
@@ -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
- fd: number;
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>;
@@ -1,10 +1,12 @@
1
1
  import { getConfig } from '@axium/core';
2
- import { authSessionForItem, requireSession } from '@axium/server/auth';
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 { closeSync, linkSync, mkdirSync, openSync } from 'node:fs';
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 { 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');
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
- closeSync(fd);
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: createHash('BLAKE2b512'),
155
+ hash,
156
+ hashStream: Writable.toWeb(hash),
121
157
  file,
122
- fd,
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(() => {
@@ -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, writeSync } from 'node:fs';
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, parseItem } from './db.js';
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('content-length') || -1);
52
+ const size = BigInt(request.headers.get('x-chunk-size') || -1);
54
53
  if (size < 0n)
55
- error(411, 'Missing or invalid content length');
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
- writeSync(upload.fd, content); // opened with 'a', this appends
67
- upload.hash.update(content);
68
- upload.uploadedBytes += BigInt(size);
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
- const item = await createNewItem(upload.init, upload.userId, path => {
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 range = request.headers.get('range');
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
- if (!getConfig('@axium/storage').enabled)
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
- const tx = await database.startTransaction().execute();
165
- try {
166
- const result = await tx
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 { uploadItem } from '@axium/storage/client';
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 uploadItem(file, {
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 uploadItem(file, { parentId });
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 activeIndex = $state<number>(0);
25
- const activeItem = $derived(items[activeIndex]);
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
- .map((item, i) => [item, i] as const)
43
- .toSorted(
44
- sort
45
- ? ([_a], [_b]) => {
46
- const [a, b] = sort?.descending ? [_b, _a] : [_a, _b];
47
- // @ts-expect-error 2362 — `Date`s have a `valueOf` and can be treated like numbers
48
- return sort.by == 'name' ? a.name.localeCompare(b.name) : a[sort.by] - b[sort.by];
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, i: number)}
61
+ {#snippet action(name: string, icon: string, id: string)}
56
62
  <span
57
63
  class="icon-text action"
58
64
  onclick={() => {
59
- activeIndex = i;
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 [item, i] (item.id)}
88
+ {#each sortedItems as item (item.id)}
83
89
  {@const trash = () => {
84
- activeIndex = i;
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
- activeIndex = i;
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: () => ((activeIndex = i), dialogs.rename.showModal()) },
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: () => ((activeIndex = i), dialogs['share:' + item.id].showModal()),
107
+ action: () => ((activeId = item.id), dialogs['share:' + item.id].showModal()),
105
108
  },
106
- { i: 'download', text: text('storage.generic.download'), action: () => ((activeIndex = i), dialogs.download.showModal()) },
107
- { i: 'link-horizontal', text: text('storage.List.copy_link'), action: () => ((activeIndex = i), copyShortURL(item)) },
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', i)}
130
- {@render action('share:' + item.id, 'user-group', i)}
131
- {@render action('download', 'download', i)}
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(activeItem.id, data);
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
- open(activeItem.type != 'inode/directory' ? activeItem.dataURL : '/raw/storage/directory-zip/' + activeItem.id, '_blank')}
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.22.3",
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.2.0",
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 activeIndex = $state<number>(-1);
17
- const activeItem = $derived(activeIndex == -1 ? null : items[activeIndex]);
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(index: number, dialog: () => HTMLDialogElement) {
20
+ function action(id: string, dialog: () => HTMLDialogElement) {
21
21
  return (e: Event) => {
22
22
  e.stopPropagation();
23
23
  e.preventDefault();
24
- activeIndex = index;
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, i (item.id)}
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(i, () => restoreDialog)}>
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(i, () => deleteDialog)}>
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={async () => {
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={async () => {
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>