@axium/storage 0.14.1 → 0.15.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/README.md CHANGED
@@ -1,17 +1,4 @@
1
1
  # Axium Storage
2
2
 
3
- This is a plugin for allowing users to store data on an Axium server.
4
-
5
- ## Usage
6
-
7
- Update the configuration to include the files data directory:
8
-
9
- ```json
10
- {
11
- "storage": {
12
- "data": "/path/to/storage/data"
13
- }
14
- }
15
- ```
16
-
17
- Also, make sure to run `axium plugin init @axium/storage` to add the necessary database tables for the plugin.
3
+ File storage and management for Axium.
4
+ This plugin provides a competitor to big tech's cloud storage.
@@ -1,4 +1,4 @@
1
- import type { StorageItemMetadata, StorageItemUpdate, UserStorage, UserStorageInfo } from '../common.js';
1
+ import { StorageItemMetadata, type StorageItemUpdate, type UserStorage, type UserStorageInfo } from '../common.js';
2
2
  export interface UploadOptions {
3
3
  parentId?: string;
4
4
  name?: string;
@@ -1,4 +1,6 @@
1
1
  import { fetchAPI, prefix, token } from '@axium/client/requests';
2
+ import { StorageItemMetadata } from '../common.js';
3
+ import { prettifyError } from 'zod';
2
4
  async function _upload(method, url, data, extraHeaders = {}) {
3
5
  const init = {
4
6
  method,
@@ -20,7 +22,12 @@ async function _upload(method, url, data, extraHeaders = {}) {
20
22
  const json = await response.json().catch(() => ({ message: 'Unknown server error (invalid JSON response)' }));
21
23
  if (!response.ok)
22
24
  throw new Error(json.message);
23
- return json;
25
+ try {
26
+ return StorageItemMetadata.parse(json);
27
+ }
28
+ catch (e) {
29
+ throw prettifyError(e);
30
+ }
24
31
  }
25
32
  function rawStorage(fileId) {
26
33
  const raw = '/raw/storage' + (fileId ? '/' + fileId : '');
@@ -0,0 +1,4 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { StorageItemMetadata } from '../common.js';
3
+ declare const _default: Map<string, Snippet<[item: StorageItemMetadata<Record<string, unknown>>]>>;
4
+ export default _default;
@@ -0,0 +1 @@
1
+ export default new Map();
@@ -48,7 +48,7 @@ export async function getLimits(userId) {
48
48
  limits = await _getLimits(userId);
49
49
  }
50
50
  catch {
51
- limits = getConfig('@axium/storage').limits;
51
+ limits = structuredClone(getConfig('@axium/storage').limits);
52
52
  }
53
53
  limits.user_size ||= Number(await _unlimitedLimit());
54
54
  return limits;
@@ -1,3 +1,55 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
1
53
  import { getConfig } from '@axium/core';
2
54
  import { audit } from '@axium/server/audit';
3
55
  import { checkAuthForItem, requireSession } from '@axium/server/auth';
@@ -5,7 +57,7 @@ import { database } from '@axium/server/database';
5
57
  import { error, withError } from '@axium/server/requests';
6
58
  import { addRoute } from '@axium/server/routes';
7
59
  import { createHash } from 'node:crypto';
8
- import { linkSync, readFileSync, writeFileSync } from 'node:fs';
60
+ import { closeSync, linkSync, openSync, readSync, writeFileSync } from 'node:fs';
9
61
  import { join } from 'node:path/posix';
10
62
  import * as z from 'zod';
11
63
  import '../polyfills.js';
@@ -95,18 +147,53 @@ addRoute({
95
147
  path: '/raw/storage/:id',
96
148
  params: { id: z.uuid() },
97
149
  async GET(request, { id: itemId }) {
98
- if (!getConfig('@axium/storage').enabled)
99
- error(503, 'User storage is disabled');
100
- const { item } = await checkAuthForItem(request, 'storage', itemId, { read: true });
101
- if (item.trashedAt)
102
- error(410, 'Trashed items can not be downloaded');
103
- const content = new Uint8Array(readFileSync(join(getConfig('@axium/storage').data, item.id)));
104
- return new Response(content, {
105
- headers: {
106
- 'Content-Type': item.type,
107
- 'Content-Disposition': `attachment; filename="${item.name}"`,
108
- },
109
- });
150
+ const env_1 = { stack: [], error: void 0, hasError: false };
151
+ try {
152
+ if (!getConfig('@axium/storage').enabled)
153
+ error(503, 'User storage is disabled');
154
+ const { item } = await checkAuthForItem(request, 'storage', itemId, { read: true });
155
+ if (item.trashedAt)
156
+ error(410, 'Trashed items can not be downloaded');
157
+ const path = join(getConfig('@axium/storage').data, item.id);
158
+ const range = request.headers.get('range');
159
+ const fd = openSync(path, 'r');
160
+ const _ = __addDisposableResource(env_1, { [Symbol.dispose]: () => closeSync(fd) }, false);
161
+ let start = 0, end = item.size - 1, length = item.size;
162
+ if (range) {
163
+ const [_start, _end = item.size - 1] = range
164
+ .replace(/bytes=/, '')
165
+ .split('-')
166
+ .map(val => (val && Number.isSafeInteger(parseInt(val)) ? parseInt(val) : undefined));
167
+ start = typeof _start == 'number' ? _start : item.size - _end;
168
+ end = typeof _start == 'number' ? _end : item.size - 1;
169
+ length = end - start + 1;
170
+ }
171
+ if (start >= item.size || end >= item.size || start > end || start < 0) {
172
+ return new Response(null, {
173
+ status: 416,
174
+ headers: { 'Content-Range': `bytes */${item.size}` },
175
+ });
176
+ }
177
+ const content = new Uint8Array(length);
178
+ readSync(fd, content, 0, length, start);
179
+ return new Response(content, {
180
+ status: length == item.size ? 200 : 206,
181
+ headers: {
182
+ 'Content-Range': `bytes ${start}-${end}/${item.size}`,
183
+ 'Accept-Ranges': 'bytes',
184
+ 'Content-Length': String(length),
185
+ 'Content-Type': item.type,
186
+ 'Content-Disposition': `attachment; filename="${item.name}"`,
187
+ },
188
+ });
189
+ }
190
+ catch (e_1) {
191
+ env_1.error = e_1;
192
+ env_1.hasError = true;
193
+ }
194
+ finally {
195
+ __disposeResources(env_1);
196
+ }
110
197
  },
111
198
  async POST(request, { id: itemId }) {
112
199
  if (!getConfig('@axium/storage').enabled)
package/lib/Add.svelte CHANGED
@@ -4,7 +4,7 @@
4
4
  import { uploadItem } from '@axium/storage/client';
5
5
  import type { StorageItemMetadata } from '@axium/storage/common';
6
6
 
7
- const { parentId, onadd }: { parentId?: string; onadd?(item: StorageItemMetadata): void } = $props();
7
+ const { parentId, onAdd }: { parentId?: string; onAdd?(item: StorageItemMetadata): void } = $props();
8
8
 
9
9
  let uploadDialog = $state<HTMLDialogElement>()!;
10
10
  let input = $state<HTMLInputElement>();
@@ -45,7 +45,7 @@
45
45
  submit={async () => {
46
46
  for (const file of input?.files!) {
47
47
  const item = await uploadItem(file, { parentId });
48
- onadd?.(item);
48
+ onAdd?.(item);
49
49
  }
50
50
  }}
51
51
  >
@@ -58,7 +58,7 @@
58
58
  submit={async (data: { name: string; content?: string }) => {
59
59
  const file = new File(createIncludesContent ? [data.content!] : [], data.name, { type: createType });
60
60
  const item = await uploadItem(file, { parentId });
61
- onadd?.(item);
61
+ onAdd?.(item);
62
62
  }}
63
63
  >
64
64
  <div>
package/lib/List.svelte CHANGED
@@ -4,7 +4,8 @@
4
4
  import type { AccessControllable, UserPublic } from '@axium/core';
5
5
  import { formatBytes } from '@axium/core/format';
6
6
  import { forMime as iconForMime } from '@axium/core/icons';
7
- import { getDirectoryMetadata, updateItemMetadata } from '@axium/storage/client';
7
+ import { downloadItem, getDirectoryMetadata, updateItemMetadata } from '@axium/storage/client';
8
+ import previews from '@axium/storage/client/previews';
8
9
  import type { StorageItemMetadata } from '@axium/storage/common';
9
10
 
10
11
  let {
@@ -19,15 +20,15 @@
19
20
  const dialogs = $state<Record<string, HTMLDialogElement>>({});
20
21
  </script>
21
22
 
22
- {#snippet action(name: string, icon: string, i: number)}
23
+ {#snippet action(name: string, icon: string, i: number, preview: boolean = false)}
23
24
  <span
24
- class="action"
25
+ class={[!preview && 'action']}
25
26
  onclick={() => {
26
27
  activeIndex = i;
27
28
  dialogs[name].showModal();
28
29
  }}
29
30
  >
30
- <Icon i={icon} --size="14px" />
31
+ <Icon i={icon} --size={preview ? '18px' : '14px'} />
31
32
  </span>
32
33
  {/snippet}
33
34
 
@@ -51,7 +52,7 @@
51
52
  class="list-item"
52
53
  onclick={async () => {
53
54
  if (item.type != 'inode/directory') {
54
- // @todo get preview
55
+ dialogs['preview:' + item.id].showModal();
55
56
  } else if (appMode) location.href = '/files/' + item.id;
56
57
  else items = await getDirectoryMetadata(item.id);
57
58
  }}
@@ -83,6 +84,41 @@
83
84
  />
84
85
  {@render action('download', 'download', i)}
85
86
  {@render action('trash', 'trash', i)}
87
+ <dialog bind:this={dialogs['preview:' + item.id]} class="preview">
88
+ <div class="title">{item.name}</div>
89
+ <div class="actions">
90
+ {@render action('rename', 'pencil', i, true)}
91
+ {@render action('share' + item.id, 'user-group', i, true)}
92
+ {@render action('download', 'download', i, true)}
93
+ {@render action('trash', 'trash', i, true)}
94
+ <span onclick={() => dialogs['preview:' + item.id].close()}><Icon i="xmark" --size="20px" /></span>
95
+ </div>
96
+ <div class="content">
97
+ {#if item.type.startsWith('image/')}
98
+ <img src={item.dataURL} alt={item.name} width="100%" />
99
+ {:else if item.type.startsWith('audio/')}
100
+ <audio src={item.dataURL} controls></audio>
101
+ {:else if item.type.startsWith('video/')}
102
+ <video src={item.dataURL} controls width="100%">
103
+ <track kind="captions" />
104
+ </video>
105
+ {:else if item.type == 'application/pdf'}
106
+ <object data={item.dataURL} type="application/pdf" width="100%" height="100%">
107
+ <embed src={item.dataURL} type="application/pdf" width="100%" height="100%" />
108
+ <p>PDF not displayed? <a href={item.dataURL} download={item.name}>Download</a></p>
109
+ </object>
110
+ {:else if item.type.startsWith('text/')}
111
+ <pre class="preview-text">{#await downloadItem(item.id).then(b => b.text()) then content}{content}{/await}</pre>
112
+ {:else if previews.has(item.type)}
113
+ {@render previews.get(item.type)!(item)}
114
+ {:else}
115
+ <div class="no-preview">
116
+ <Icon i="eye-slash" />
117
+ <span>Preview not available</span>
118
+ </div>
119
+ {/if}
120
+ </div>
121
+ </dialog>
86
122
  </div>
87
123
  </div>
88
124
  {:else}
@@ -121,19 +157,86 @@
121
157
  submitText="Download"
122
158
  submit={async () => {
123
159
  if (activeItem!.type == 'inode/directory') {
160
+ /** @todo ZIP support */
124
161
  const children = await getDirectoryMetadata(activeItem!.id);
125
162
  for (const child of children) open(child.dataURL, '_blank');
126
163
  } else open(activeItem!.dataURL, '_blank');
127
164
  }}
128
165
  >
129
- <p>
130
- We are not responsible for the contents of this {activeItem?.type == 'inode/directory' ? 'folder' : 'file'}. <br />
131
- Are you sure you want to download {@render _itemName()}?
132
- </p>
166
+ <p>Are you sure you want to download {@render _itemName()}?</p>
133
167
  </FormDialog>
134
168
 
135
169
  <style>
136
170
  .list-item {
137
171
  grid-template-columns: 1em 4fr 15em 5em repeat(4, 1em);
138
172
  }
173
+
174
+ dialog.preview {
175
+ inset: 0;
176
+ width: 100%;
177
+ height: 100%;
178
+ background-color: #0008;
179
+ border: none;
180
+ padding: 1em;
181
+ word-wrap: normal;
182
+
183
+ .title,
184
+ .actions {
185
+ align-items: center;
186
+ display: flex;
187
+ align-items: center;
188
+ gap: 1em;
189
+ position: absolute;
190
+ top: 1em;
191
+ padding: 1em;
192
+ height: 1em;
193
+ }
194
+
195
+ .title {
196
+ left: 1em;
197
+ overflow: hidden;
198
+ white-space: nowrap;
199
+ text-overflow: ellipsis;
200
+ }
201
+
202
+ .actions {
203
+ right: 0;
204
+ }
205
+
206
+ .content {
207
+ position: absolute;
208
+ inset: 3em 10em 0;
209
+
210
+ .preview-text {
211
+ position: absolute;
212
+ inset: 0;
213
+ width: 100%;
214
+ height: 100%;
215
+ white-space: pre-wrap;
216
+ overflow-y: scroll;
217
+ line-height: 1.6;
218
+ background-color: var(--bg-menu);
219
+ font-family: monospace;
220
+ }
221
+ }
222
+
223
+ .no-preview {
224
+ display: flex;
225
+ flex-direction: column;
226
+ gap: 1em;
227
+ align-items: center;
228
+ justify-content: center;
229
+ }
230
+
231
+ @media (width < 700px) {
232
+ .actions {
233
+ top: 3em;
234
+ left: 1em;
235
+ }
236
+
237
+ .content {
238
+ inset: 5em 1em 0;
239
+ }
240
+ }
241
+ }
139
242
  </style>
package/lib/Usage.svelte CHANGED
@@ -16,9 +16,9 @@
16
16
  <NumberBar
17
17
  max={info.limits.user_size && info.limits.user_size * 1_000_000}
18
18
  value={info.usedBytes}
19
- text="Using {formatBytes(info.usedBytes)} {!info.limits.user_size
19
+ text="{formatBytes(info.usedBytes)} {!info.limits.user_size
20
20
  ? ''
21
- : 'of ' + formatBytes(info.limits.user_size * 1_000_000)}"
21
+ : '/ ' + formatBytes(info.limits.user_size * 1_000_000)}"
22
22
  />
23
23
  </a>
24
24
  </p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/storage",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "User file storage for Axium",
6
6
  "funding": {
@@ -10,4 +10,4 @@
10
10
  </svelte:head>
11
11
 
12
12
  <StorageList appMode bind:items user={data.session?.user} />
13
- <StorageAdd onadd={item => items.push(item)} />
13
+ <StorageAdd onAdd={item => items.push(item)} />
@@ -35,7 +35,7 @@
35
35
  <Icon i="folder-arrow-up" /> Back
36
36
  </button>
37
37
  <StorageList appMode bind:items user={data.session?.user} />
38
- <StorageAdd parentId={item.id} onadd={item => items.push(item)} />
38
+ <StorageAdd parentId={item.id} onAdd={item => items.push(item)} />
39
39
  {:else}
40
40
  <p>No preview available.</p>
41
41
  {/if}