@axium/storage 0.14.2 → 0.15.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.
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.
@@ -0,0 +1,11 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { StorageItemMetadata } from '../common.js';
3
+ export declare const previews: Map<string, Snippet<[item: StorageItemMetadata<Record<string, unknown>>]>>;
4
+ export interface Opener {
5
+ /** Mime types supported by this opener */
6
+ types: string[];
7
+ name: string;
8
+ /** Get a URL to open the item with another plugin */
9
+ openURL(item: StorageItemMetadata): string;
10
+ }
11
+ export declare const openers: Opener[];
@@ -0,0 +1,2 @@
1
+ export const previews = new Map();
2
+ export const openers = [];
@@ -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 : '');
@@ -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
@@ -1,10 +1,11 @@
1
1
  <script lang="ts">
2
- import { AccessControlDialog, FormDialog, Icon } from '@axium/client/components';
2
+ import { AccessControlDialog, FormDialog, Icon, Popover } from '@axium/client/components';
3
3
  import '@axium/client/styles/list';
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 { openers, previews } from '@axium/storage/client/3rd-party';
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={['icon-text', !preview ? 'action' : '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
 
@@ -47,11 +48,12 @@
47
48
  <span>Size</span>
48
49
  </div>
49
50
  {#each items as item, i (item.id)}
51
+ {@const itemOpeners = openers.filter(opener => opener.types.includes(item.type))}
50
52
  <div
51
53
  class="list-item"
52
54
  onclick={async () => {
53
55
  if (item.type != 'inode/directory') {
54
- // @todo get preview
56
+ dialogs['preview:' + item.id].showModal();
55
57
  } else if (appMode) location.href = '/files/' + item.id;
56
58
  else items = await getDirectoryMetadata(item.id);
57
59
  }}
@@ -70,7 +72,7 @@
70
72
  {@render action('rename', 'pencil', i)}
71
73
  {@render action('share' + item.id, 'user-group', i)}
72
74
  <AccessControlDialog
73
- bind:dialog={dialogs['share' + item.id]}
75
+ bind:dialog={dialogs['share:' + item.id]}
74
76
  {item}
75
77
  itemType="storage"
76
78
  editable={(item.acl?.find(
@@ -83,6 +85,62 @@
83
85
  />
84
86
  {@render action('download', 'download', i)}
85
87
  {@render action('trash', 'trash', i)}
88
+ <dialog bind:this={dialogs['preview:' + item.id]} class="preview">
89
+ <div class="preview-top-bar">
90
+ <div class="title">{item.name}</div>
91
+ {#if itemOpeners.length}
92
+ {@const [first, ...others] = itemOpeners}
93
+ <div class="openers">
94
+ <span>Open with <a href={first.openURL(item)} target="_blank">{first.name}</a></span>
95
+ {#if others.length}
96
+ <Popover>
97
+ {#snippet toggle()}
98
+ <span class="popover-toggle"><Icon i="caret-down" /></span>
99
+ {/snippet}
100
+ {#each others as opener}
101
+ <a href={opener.openURL(item)} target="_blank">{opener.name}</a>
102
+ {/each}
103
+ </Popover>
104
+ {/if}
105
+ </div>
106
+ {/if}
107
+ <div class="actions">
108
+ {@render action('rename', 'pencil', i, true)}
109
+ {@render action('share:' + item.id, 'user-group', i, true)}
110
+ {@render action('download', 'download', i, true)}
111
+ {@render action('trash', 'trash', i, true)}
112
+ <span class="mobile-hide" onclick={() => dialogs['preview:' + item.id].close()}>
113
+ <Icon i="xmark" --size="20px" />
114
+ </span>
115
+ </div>
116
+ </div>
117
+ <div class="content">
118
+ {#if item.type.startsWith('image/')}
119
+ <img src={item.dataURL} alt={item.name} width="100%" />
120
+ {:else if item.type.startsWith('audio/')}
121
+ <audio src={item.dataURL} controls></audio>
122
+ {:else if item.type.startsWith('video/')}
123
+ <video src={item.dataURL} controls width="100%">
124
+ <track kind="captions" />
125
+ </video>
126
+ {:else if item.type == 'application/pdf'}
127
+ <object data={item.dataURL} type="application/pdf" width="100%" height="100%">
128
+ <embed src={item.dataURL} type="application/pdf" width="100%" height="100%" />
129
+ <p>PDF not displayed? <a href={item.dataURL} download={item.name}>Download</a></p>
130
+ </object>
131
+ {:else if item.type.startsWith('text/')}
132
+ <pre
133
+ class="full-fill preview-text">{#await downloadItem(item.id).then( b => b.text() ) then content}{content}{/await}</pre>
134
+ {:else if previews.has(item.type)}
135
+ {@render previews.get(item.type)!(item)}
136
+ {:else}
137
+ <div class="full-fill no-preview">
138
+ <Icon i="eye-slash" --size="50px" />
139
+ <span>Preview not available</span>
140
+ </div>
141
+ {/if}
142
+ </div>
143
+ </dialog>
86
144
  </div>
87
145
  </div>
88
146
  {:else}
@@ -121,19 +179,120 @@
121
179
  submitText="Download"
122
180
  submit={async () => {
123
181
  if (activeItem!.type == 'inode/directory') {
182
+ /** @todo ZIP support */
124
183
  const children = await getDirectoryMetadata(activeItem!.id);
125
184
  for (const child of children) open(child.dataURL, '_blank');
126
185
  } else open(activeItem!.dataURL, '_blank');
127
186
  }}
128
187
  >
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>
188
+ <p>Are you sure you want to download {@render _itemName()}?</p>
133
189
  </FormDialog>
134
190
 
135
191
  <style>
136
192
  .list-item {
137
193
  grid-template-columns: 1em 4fr 15em 5em repeat(4, 1em);
138
194
  }
195
+
196
+ dialog.preview {
197
+ inset: 0;
198
+ width: 100%;
199
+ height: 100%;
200
+ background-color: #000a;
201
+ border: none;
202
+ padding: 1em;
203
+ word-wrap: normal;
204
+ anchor-scope: --preview-openers;
205
+
206
+ .preview-action:hover {
207
+ cursor: pointer;
208
+ }
209
+
210
+ .preview-top-bar {
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 1em;
214
+ justify-content: space-between;
215
+ padding: 0;
216
+ position: absolute;
217
+ inset: 0.5em 1em 0;
218
+ height: fit-content;
219
+
220
+ > div {
221
+ display: flex;
222
+ gap: 1em;
223
+ align-items: center;
224
+ }
225
+ }
226
+
227
+ .openers {
228
+ padding: 1em;
229
+ border: 1px solid var(--border-accent);
230
+ border-radius: 1em;
231
+ height: 2em;
232
+ anchor-name: --preview-openers;
233
+ }
234
+
235
+ .openers :global([popover]) {
236
+ inset: anchor(bottom) anchor(right) auto anchor(left);
237
+ position-anchor: --preview-openers;
238
+ width: anchor-size(width);
239
+ }
240
+
241
+ .actions {
242
+ right: 0;
243
+ }
244
+
245
+ .content {
246
+ position: absolute;
247
+ inset: 3em 10em 0;
248
+
249
+ .full-fill {
250
+ position: absolute;
251
+ inset: 0;
252
+ width: 100%;
253
+ height: 100%;
254
+ }
255
+
256
+ .preview-text {
257
+ white-space: pre-wrap;
258
+ overflow-y: scroll;
259
+ line-height: 1.6;
260
+ background-color: var(--bg-menu);
261
+ font-family: monospace;
262
+ }
263
+ }
264
+
265
+ .no-preview {
266
+ display: flex;
267
+ flex-direction: column;
268
+ gap: 1em;
269
+ align-items: center;
270
+ justify-content: center;
271
+ }
272
+
273
+ @media (width < 700px) {
274
+ .preview-top-bar {
275
+ flex-direction: column;
276
+
277
+ .actions {
278
+ justify-content: space-around;
279
+ width: 100%;
280
+
281
+ .preview-action {
282
+ padding: 1em;
283
+ flex: 1 1 0;
284
+ border-radius: 1em;
285
+ border: 1px solid var(--border-accent);
286
+ padding: 1em;
287
+ justify-content: center;
288
+ display: flex;
289
+ }
290
+ }
291
+ }
292
+
293
+ .content {
294
+ inset: 10em 1em 0;
295
+ }
296
+ }
297
+ }
139
298
  </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.2",
3
+ "version": "0.15.1",
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}