@axium/storage 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/common.d.ts CHANGED
@@ -48,7 +48,8 @@ export type StorageItemUpdate = z.infer<typeof StorageItemUpdate>;
48
48
  export interface StorageItemMetadata<T extends Record<string, unknown> = Record<string, unknown>> {
49
49
  createdAt: Date;
50
50
  dataURL: string;
51
- hash: string;
51
+ /** The hash of the file, or null if it is a directory */
52
+ hash: string | null;
52
53
  id: string;
53
54
  immutable: boolean;
54
55
  modifiedAt: Date;
package/dist/server.d.ts CHANGED
@@ -6,7 +6,7 @@ declare module '@axium/server/database' {
6
6
  interface Schema {
7
7
  storage: {
8
8
  createdAt: Generated<Date>;
9
- hash: Uint8Array;
9
+ hash: Uint8Array | null;
10
10
  id: Generated<string>;
11
11
  immutable: Generated<boolean>;
12
12
  modifiedAt: Generated<Date>;
@@ -24,6 +24,11 @@ declare module '@axium/server/database' {
24
24
  storage: ColumnTypes<Schema['storage']>;
25
25
  }
26
26
  }
27
+ /**
28
+ * @internal A storage item selected from the database.
29
+ */
30
+ interface SelectedItem extends Selectable<Schema['storage']> {
31
+ }
27
32
  declare module '@axium/server/config' {
28
33
  interface Config {
29
34
  storage: {
@@ -52,15 +57,16 @@ declare module '@axium/server/config' {
52
57
  export interface StorageItem extends StorageItemMetadata {
53
58
  data: Uint8Array<ArrayBufferLike>;
54
59
  }
55
- export declare function parseItem<T extends Record<string, unknown> = Record<string, unknown>>(item: Selectable<Schema['storage']>): StorageItemMetadata<T>;
60
+ export declare function parseItem<T extends SelectedItem>(item: T): Omit<T, keyof Schema['storage']> & StorageItemMetadata;
56
61
  /**
57
62
  * Returns the current usage of the storage for a user in bytes.
58
63
  */
59
64
  export declare function currentUsage(userId: string): Promise<StorageUsage>;
60
- export declare function get<T extends Record<string, unknown> = Record<string, unknown>>(itemId: string): Promise<StorageItemMetadata<T>>;
65
+ export declare function get(itemId: string): Promise<StorageItemMetadata>;
61
66
  export type ExternalLimitHandler = (userId?: string) => StorageLimits | Promise<StorageLimits>;
62
67
  /**
63
68
  * Define the handler to get limits for a user externally.
64
69
  */
65
70
  export declare function useLimits(handler: ExternalLimitHandler): void;
66
71
  export declare function getLimits(userId?: string): Promise<StorageLimits>;
72
+ export {};
package/dist/server.js CHANGED
@@ -8,7 +8,6 @@ import { addRoute } from '@axium/server/routes';
8
8
  import { error } from '@sveltejs/kit';
9
9
  import { createHash } from 'node:crypto';
10
10
  import { linkSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
11
- import { writeFile } from 'node:fs/promises';
12
11
  import { join } from 'node:path/posix';
13
12
  import * as z from 'zod';
14
13
  import { StorageItemUpdate } from './common.js';
@@ -50,9 +49,8 @@ addConfigDefaults({
50
49
  export function parseItem(item) {
51
50
  return {
52
51
  ...item,
53
- metadata: item.metadata,
54
52
  dataURL: `/raw/storage/${item.id}`,
55
- hash: item.hash.toHex(),
53
+ hash: item.hash?.toHex(),
56
54
  };
57
55
  }
58
56
  /**
@@ -140,6 +138,8 @@ addRoute({
140
138
  .returningAll()
141
139
  .executeTakeFirstOrThrow()
142
140
  .catch(withError('Could not delete item'));
141
+ if (item.type == 'inode/directory')
142
+ return item;
143
143
  const { count } = await database
144
144
  .selectFrom('storage')
145
145
  .where('hash', '=', Uint8Array.fromHex(item.hash))
@@ -191,6 +191,8 @@ addRoute({
191
191
  .parseAsync(maybeParentId)
192
192
  .catch(() => error(400, 'Invalid parent ID'))
193
193
  : null;
194
+ if (parentId)
195
+ await checkAuthForItem(event, 'storage', parentId, Permission.Edit);
194
196
  const size = Number(event.request.headers.get('content-length'));
195
197
  if (Number.isNaN(size))
196
198
  error(411, 'Missing or invalid content length header');
@@ -205,33 +207,48 @@ addRoute({
205
207
  if (content.byteLength > size)
206
208
  error(400, 'Content length does not match size header');
207
209
  const type = event.request.headers.get('content-type') || 'application/octet-stream';
210
+ const isDirectory = type == 'inode/directory';
211
+ if (isDirectory && size > 0)
212
+ error(400, 'Directories can not have content');
208
213
  const useCAS = config.storage.cas.enabled &&
214
+ !isDirectory &&
209
215
  (defaultCASMime.some(pattern => pattern.test(type)) || config.storage.cas.include.some(mime => type.match(mime)));
210
- const hash = createHash('BLAKE2b512').update(content).digest();
211
- // @todo: make this atomic
212
- const result = await database
213
- .insertInto('storage')
214
- .values({ userId: userId, hash, name, size, type, immutable: useCAS, parentId })
215
- .returningAll()
216
- .executeTakeFirstOrThrow()
217
- .catch(withError('Could not create item'));
218
- const path = join(config.storage.data, result.id);
219
- const _noDupe = () => {
220
- writeFileSync(path, content);
221
- return parseItem(result);
222
- };
223
- if (!useCAS)
224
- return _noDupe();
225
- const existing = await database
226
- .selectFrom('storage')
227
- .where('hash', '=', hash)
228
- .where('id', '!=', result.id)
229
- .selectAll()
230
- .executeTakeFirst();
231
- if (!existing)
232
- return _noDupe();
233
- linkSync(join(config.storage.data, existing.id), path);
234
- return parseItem(result);
216
+ const hash = isDirectory ? null : createHash('BLAKE2b512').update(content).digest();
217
+ const tx = await database.startTransaction().execute();
218
+ try {
219
+ const item = parseItem(await tx
220
+ .insertInto('storage')
221
+ .values({ userId, hash, name, size, type, immutable: useCAS, parentId })
222
+ .returningAll()
223
+ .executeTakeFirstOrThrow());
224
+ const path = join(config.storage.data, item.id);
225
+ if (!useCAS) {
226
+ if (!isDirectory)
227
+ writeFileSync(path, content);
228
+ return item;
229
+ }
230
+ const existing = await tx
231
+ .selectFrom('storage')
232
+ .select('id')
233
+ .where('hash', '=', hash)
234
+ .where('id', '!=', item.id)
235
+ .limit(1)
236
+ .executeTakeFirst();
237
+ if (!existing) {
238
+ if (!isDirectory)
239
+ writeFileSync(path, content);
240
+ return item;
241
+ }
242
+ linkSync(join(config.storage.data, existing.id), path);
243
+ return item;
244
+ }
245
+ catch (error) {
246
+ await tx.rollback().execute();
247
+ throw withError('Could not create item', 500)(error);
248
+ }
249
+ finally {
250
+ await tx.commit().execute();
251
+ }
235
252
  },
236
253
  });
237
254
  addRoute({
@@ -258,7 +275,9 @@ addRoute({
258
275
  const itemId = event.params.id;
259
276
  const { item } = await checkAuthForItem(event, 'storage', itemId, Permission.Edit);
260
277
  if (item.immutable)
261
- error(403, 'Item is immutable');
278
+ error(405, 'Item is immutable');
279
+ if (item.type == 'inode/directory')
280
+ error(409, 'Directories do not have content');
262
281
  if (item.trashedAt)
263
282
  error(410, 'Trashed items can not be changed');
264
283
  const type = event.request.headers.get('content-type') || 'application/octet-stream';
@@ -278,16 +297,22 @@ addRoute({
278
297
  if (content.byteLength > size)
279
298
  error(400, 'Content length does not match size header');
280
299
  const hash = createHash('BLAKE2b512').update(content).digest();
281
- // @todo: make this atomic
282
- const result = await database
283
- .updateTable('storage')
284
- .where('id', '=', itemId)
285
- .set({ size, modifiedAt: new Date(), hash })
286
- .returningAll()
287
- .executeTakeFirstOrThrow()
288
- .catch(withError('Could not update item'));
289
- await writeFile(join(config.storage.data, result.id), content).catch(withError('Could not write'));
290
- return parseItem(result);
300
+ const tx = await database.startTransaction().execute();
301
+ try {
302
+ const result = await tx
303
+ .updateTable('storage')
304
+ .where('id', '=', itemId)
305
+ .set({ size, modifiedAt: new Date(), hash })
306
+ .returningAll()
307
+ .executeTakeFirstOrThrow();
308
+ writeFileSync(join(config.storage.data, result.id), content);
309
+ await tx.commit().execute();
310
+ return parseItem(result);
311
+ }
312
+ catch (error) {
313
+ await tx.rollback().execute();
314
+ throw withError('Could not update item', 500)(error);
315
+ }
291
316
  },
292
317
  });
293
318
  addRoute({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/storage",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "author": "James Prevett <axium@jamespre.dev> (https://jamespre.dev)",
5
5
  "description": "User file storage for Axium",
6
6
  "funding": {