lfss 0.7.15__py3-none-any.whl → 0.8.1__py3-none-any.whl

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.
Readme.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Lightweight File Storage Service (LFSS)
2
2
  [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/)
3
3
 
4
- My experiment on a lightweight file/object storage service.
4
+ My experiment on a lightweight and high-performance file/object storage service.
5
5
  It stores small files and metadata in sqlite, large files in the filesystem.
6
6
  Tested on 2 million files, and it works fine...
7
7
 
@@ -23,7 +23,7 @@ Or, you can start a web server at `/frontend` and open `index.html` in your brow
23
23
 
24
24
  The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
25
25
  Authentication is done via `Authorization` header with the value `Bearer <token>`, or through the `token` query parameter.
26
- You can refer to `frontend` as an application example, and `frontend/api.js` or `lfss/client/api.py` for the API usage.
26
+ You can refer to `frontend` as an application example, and `frontend/api.js` or `lfss/api/connector.py` for the API usage.
27
27
 
28
28
  By default, the service exposes all files to the public for `GET` requests,
29
29
  but file-listing is restricted to the user's own files.
docs/Permission.md CHANGED
@@ -1,13 +1,15 @@
1
1
 
2
2
  # Permission System
3
- There are two roles in the system: Admin and User ('user' are like 'bucket' to some extent).
3
+ There are two user roles in the system: Admin and Normal User ("users" are like "buckets" to some extent).
4
+
5
+ ## Ownership
6
+ A file is owned by the user who created it, may not necessarily be the user under whose path the file is stored (admin can create files under any user's path).
4
7
 
5
8
  ## File access with `GET` permission
6
9
  The `GET` is used to access the file (if path is not ending with `/`), or to list the files under a path (if path is ending with `/`).
7
10
 
8
11
  ### File access
9
12
  For accessing file content, the user must have `GET` permission of the file, which is determined by the `permission` field of both the owner and the file.
10
- (Note: The owner of the file is the user who created the file, may not necessarily be the user under whose path the file is stored.)
11
13
 
12
14
  There are four types of permissions: `unset`, `public`, `protected`, `private`.
13
15
  Non-admin users can access files based on:
frontend/api.js CHANGED
@@ -34,6 +34,8 @@
34
34
  * @property {DirectoryRecord[]} dirs - the list of directories in the directory
35
35
  * @property {FileRecord[]} files - the list of files in the directory
36
36
  *
37
+ * @typedef {"" | "url" | "file_size" | "create_time" | "access_time" | "mime_type"} FileSortKey
38
+ * @typedef {"" | "dirname" } DirectorySortKey
37
39
  */
38
40
 
39
41
  export const permMap = {
@@ -71,7 +73,8 @@ export default class Connector {
71
73
  method: 'PUT',
72
74
  headers: {
73
75
  'Authorization': 'Bearer ' + this.config.token,
74
- 'Content-Type': 'application/octet-stream'
76
+ 'Content-Type': 'application/octet-stream',
77
+ 'Content-Length': fileBytes.byteLength
75
78
  },
76
79
  body: fileBytes
77
80
  });
@@ -81,6 +84,38 @@ export default class Connector {
81
84
  return (await res.json()).url;
82
85
  }
83
86
 
87
+ /**
88
+ * @param {string} path - the path to the file (url)
89
+ * @param {File} file - the file to upload
90
+ * @returns {Promise<string>} - the promise of the request, the url of the file
91
+ */
92
+ async post(path, file, {
93
+ conflict = 'abort',
94
+ permission = 0
95
+ } = {}){
96
+ if (path.startsWith('/')){ path = path.slice(1); }
97
+ const dst = new URL(this.config.endpoint + '/' + path);
98
+ dst.searchParams.append('conflict', conflict);
99
+ dst.searchParams.append('permission', permission);
100
+ // post as multipart form data
101
+ const formData = new FormData();
102
+ formData.append('file', file);
103
+ const res = await fetch(dst.toString(), {
104
+ method: 'POST',
105
+ // don't include the content type, let the browser handle it
106
+ // https://muffinman.io/blog/uploading-files-using-fetch-multipart-form-data/
107
+ headers: {
108
+ 'Authorization': 'Bearer ' + this.config.token,
109
+ },
110
+ body: formData
111
+ });
112
+
113
+ if (res.status != 200 && res.status != 201){
114
+ throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await res.json()}`);
115
+ }
116
+ return (await res.json()).url;
117
+ }
118
+
84
119
  /**
85
120
  * @param {string} path - the path to the file (url), should end with .json
86
121
  * @param {Objec} data - the data to upload
@@ -133,18 +168,18 @@ export default class Connector {
133
168
  return await res.json();
134
169
  }
135
170
 
171
+ _sanitizeDirPath(path){
172
+ if (path.startsWith('/')){ path = path.slice(1); }
173
+ if (!path.endsWith('/')){ path += '/'; }
174
+ return path;
175
+ }
136
176
  /**
137
177
  * @param {string} path - the path to the file directory, should ends with '/'
138
- * @param {Object} options - the options for the request
139
178
  * @returns {Promise<PathListResponse>} - the promise of the request
140
179
  */
141
- async listPath(path, {
142
- flat = false
143
- } = {}){
144
- if (path.startsWith('/')){ path = path.slice(1); }
145
- if (!path.endsWith('/')){ path += '/'; }
180
+ async listPath(path){
181
+ path = this._sanitizeDirPath(path);
146
182
  const dst = new URL(this.config.endpoint + '/' + path);
147
- dst.searchParams.append('flat', flat);
148
183
  const res = await fetch(dst.toString(), {
149
184
  method: 'GET',
150
185
  headers: {
@@ -157,6 +192,128 @@ export default class Connector {
157
192
  return await res.json();
158
193
  }
159
194
 
195
+ /**
196
+ * @param {string} path - the path to the file directory, should ends with '/'
197
+ * @param {boolean} flat - whether to list the files in subdirectories
198
+ * @returns {Promise<number>} - the promise of the request
199
+ * */
200
+ async countFiles(path, {
201
+ flat = false
202
+ } = {}){
203
+ path = this._sanitizeDirPath(path);
204
+ const dst = new URL(this.config.endpoint + '/_api/count-files');
205
+ dst.searchParams.append('path', path);
206
+ dst.searchParams.append('flat', flat);
207
+ const res = await fetch(dst.toString(), {
208
+ method: 'GET',
209
+ headers: {
210
+ 'Authorization': 'Bearer ' + this.config.token
211
+ },
212
+ });
213
+ if (res.status != 200){
214
+ throw new Error(`Failed to count files, status code: ${res.status}, message: ${await res.json()}`);
215
+ }
216
+ return (await res.json()).count;
217
+ }
218
+
219
+ /**
220
+ * @typedef {Object} ListFilesOptions
221
+ * @property {number} offset - the offset of the list
222
+ * @property {number} limit - the limit of the list
223
+ * @property {FileSortKey} orderBy - the key to order the files
224
+ * @property {boolean} orderDesc - whether to order the files in descending order
225
+ * @property {boolean} flat - whether to list the files in subdirectories
226
+ *
227
+ * @param {string} path - the path to the file directory, should ends with '/'
228
+ * @param {ListFilesOptions} options - the options for the request
229
+ * @returns {Promise<FileRecord[]>} - the promise of the request
230
+ */
231
+ async listFiles(path, {
232
+ offset = 0,
233
+ limit = 1000,
234
+ orderBy = 'create_time',
235
+ orderDesc = false,
236
+ flat = false
237
+ } = {}){
238
+ path = this._sanitizeDirPath(path);
239
+ const dst = new URL(this.config.endpoint + '/_api/list-files');
240
+ dst.searchParams.append('path', path);
241
+ dst.searchParams.append('offset', offset);
242
+ dst.searchParams.append('limit', limit);
243
+ dst.searchParams.append('order_by', orderBy);
244
+ dst.searchParams.append('order_desc', orderDesc);
245
+ dst.searchParams.append('flat', flat);
246
+ const res = await fetch(dst.toString(), {
247
+ method: 'GET',
248
+ headers: {
249
+ 'Authorization': 'Bearer ' + this.config.token
250
+ },
251
+ });
252
+ if (res.status != 200){
253
+ throw new Error(`Failed to list files, status code: ${res.status}, message: ${await res.json()}`);
254
+ }
255
+ return await res.json();
256
+ }
257
+
258
+ /**
259
+ * @param {string} path - the path to the file directory, should ends with '/'
260
+ * @returns {Promise<number>} - the promise of the request
261
+ **/
262
+ async countDirs(path){
263
+ path = this._sanitizeDirPath(path);
264
+ const dst = new URL(this.config.endpoint + '/_api/count-dirs');
265
+ dst.searchParams.append('path', path);
266
+ const res = await fetch(dst.toString(), {
267
+ method: 'GET',
268
+ headers: {
269
+ 'Authorization': 'Bearer ' + this.config.token
270
+ },
271
+ });
272
+ if (res.status != 200){
273
+ throw new Error(`Failed to count directories, status code: ${res.status}, message: ${await res.json()}`);
274
+ }
275
+ return (await res.json()).count;
276
+ }
277
+
278
+ /**
279
+ * @typedef {Object} ListDirsOptions
280
+ * @property {number} offset - the offset of the list
281
+ * @property {number} limit - the limit of the list
282
+ * @property {DirectorySortKey} orderBy - the key to order the directories
283
+ * @property {boolean} orderDesc - whether to order the directories in descending order
284
+ * @property {boolean} skim - whether to skim the directories
285
+ *
286
+ * @param {string} path - the path to the file directory, should ends with '/'
287
+ * @param {ListDirsOptions} options - the options for the request
288
+ * @returns {Promise<DirectoryRecord[]>} - the promise of the request
289
+ **/
290
+ async listDirs(path, {
291
+ offset = 0,
292
+ limit = 1000,
293
+ orderBy = 'dirname',
294
+ orderDesc = false,
295
+ skim = true
296
+ } = {}){
297
+ path = this._sanitizeDirPath(path);
298
+ const dst = new URL(this.config.endpoint + '/_api/list-dirs');
299
+ dst.searchParams.append('path', path);
300
+ dst.searchParams.append('offset', offset);
301
+ dst.searchParams.append('limit', limit);
302
+ dst.searchParams.append('order_by', orderBy);
303
+ dst.searchParams.append('order_desc', orderDesc);
304
+ dst.searchParams.append('skim', skim);
305
+ const res = await fetch(dst.toString(), {
306
+ method: 'GET',
307
+ headers: {
308
+ 'Authorization': 'Bearer ' + this.config.token
309
+ },
310
+ });
311
+ if (res.status != 200){
312
+ throw new Error(`Failed to list directories, status code: ${res.status}, message: ${await res.json()}`);
313
+ }
314
+ return await res.json();
315
+ }
316
+
160
317
  /**
161
318
  * Check the user information by the token
162
319
  * @returns {Promise<UserRecord>} - the promise of the request
@@ -216,4 +373,110 @@ export default class Connector {
216
373
  }
217
374
  }
218
375
 
376
+ }
377
+
378
+ /**
379
+ * a function to wrap the listDirs and listFiles function into one
380
+ * it will return the list of directories and files in the directory,
381
+ * making directories first (if the offset is less than the number of directories),
382
+ * and files after that.
383
+ *
384
+ *
385
+ * @typedef {Object} ListPathOptions
386
+ * @property {number} offset - the offset of the list
387
+ * @property {number} limit - the limit of the list
388
+ * @property {ListFilesOptions} orderBy - the key to order the files (if set to url, will list the directories using dirname)
389
+ * @property {boolean} orderDesc - whether to order the files in descending order
390
+ *
391
+ * @param {Connector} conn - the connector to the API
392
+ * @param {string} path - the path to the file directory
393
+ * @param {Object} options - the options for the request
394
+ * @returns {Promise<[PathListResponse, {dirs: number, files: number}]>} - the promise of the request
395
+ */
396
+ export async function listPath(conn, path, {
397
+ offset = 0,
398
+ limit = 1000,
399
+ orderBy = '',
400
+ orderDesc = false,
401
+ } = {}){
402
+
403
+ if (path === '/' || path === ''){
404
+ // this handles separate case for the root directory... please refer to the backend implementation
405
+ return [await conn.listPath(''), {dirs: 0, files: 0}];
406
+ }
407
+
408
+ orderBy = orderBy == 'none' ? '' : orderBy;
409
+ console.debug('listPath', path, offset, limit, orderBy, orderDesc);
410
+
411
+ const [dirCount, fileCount] = await Promise.all([
412
+ conn.countDirs(path),
413
+ conn.countFiles(path)
414
+ ]);
415
+
416
+ const dirOffset = offset;
417
+ const fileOffset = Math.max(offset - dirCount, 0);
418
+ const dirThispage = Math.max(Math.min(dirCount - dirOffset, limit), 0);
419
+ const fileLimit = limit - dirThispage;
420
+
421
+ console.debug('dirCount', dirCount, 'dirOffset', dirOffset, 'fileOffset', fileOffset, 'dirThispage', dirThispage, 'fileLimit', fileLimit);
422
+
423
+ const dirOrderBy = orderBy == 'url' ? 'dirname' : '';
424
+ const fileOrderBy = orderBy;
425
+
426
+ const [dirList, fileList] = await Promise.all([
427
+ (async () => {
428
+ if (offset < dirCount) {
429
+ return await conn.listDirs(path, {
430
+ offset: dirOffset,
431
+ limit: dirThispage,
432
+ orderBy: dirOrderBy,
433
+ orderDesc: orderDesc
434
+ });
435
+ }
436
+ return [];
437
+ })(),
438
+ (async () => {
439
+ if (fileLimit >= 0 && fileCount > fileOffset) {
440
+ return await conn.listFiles(path, {
441
+ offset: fileOffset,
442
+ limit: fileLimit,
443
+ orderBy: fileOrderBy,
444
+ orderDesc: orderDesc
445
+ });
446
+ }
447
+ return [];
448
+ })()
449
+ ]);
450
+
451
+ return [{
452
+ dirs: dirList,
453
+ files: fileList
454
+ }, {
455
+ dirs: dirCount,
456
+ files: fileCount
457
+ }];
458
+ };
459
+
460
+ /**
461
+ * a function to wrap the upload function into one
462
+ * it will return the url of the file
463
+ *
464
+ * @typedef {Object} UploadOptions
465
+ * @property {string} conflict - the conflict resolution strategy, can be 'abort', 'replace', 'rename'
466
+ * @property {number} permission - the permission of the file, can be 0, 1, 2, 3
467
+ *
468
+ * @param {Connector} conn - the connector to the API
469
+ * @param {string} path - the path to the file (url)
470
+ * @param {File} file - the file to upload
471
+ * @param {UploadOptions} options - the options for the request
472
+ * @returns {Promise<string>} - the promise of the request, the url of the file
473
+ */
474
+ export async function uploadFile(conn, path, file, {
475
+ conflict = 'abort',
476
+ permission = 0
477
+ } = {}){
478
+ if (file.size < 1024 * 1024 * 10){
479
+ return await conn.put(path, file, {conflict, permission});
480
+ }
481
+ return await conn.post(path, file, {conflict, permission});
219
482
  }
frontend/index.html CHANGED
@@ -8,44 +8,56 @@
8
8
  </head>
9
9
  <body>
10
10
 
11
- <div class="container header" id="settings">
12
- <div class="input-group" style="min-width: 300px;">
13
- <label for="endpoint">Endpoint</label>
14
- <input type="text" id="endpoint" placeholder="http://localhost:8000" autocomplete="off">
15
- </div>
16
- <div class="input-group" style="min-width: 300px;">
17
- <label for="token">Token</label>
18
- <input type="text" id="token" placeholder="" autocomplete="off">
19
- </div>
11
+ <div class="container header">
20
12
  <div class="input-group">
21
13
  <span id='back-btn'>⬅️</span>
22
14
  <input type="text" id="path" placeholder="path/to/files/" autocomplete="off">
23
15
  </div>
24
- </div>
25
-
26
- <div class="container content" id="view">
27
- <div id="top-bar">
16
+ <div id="settings-bar">
28
17
  <div id="position-hint" class='disconnected'>
29
18
  <span></span>
30
19
  <label></label>
31
20
  </div>
32
- <div>
33
- <span style="margin-right: 0.25rem; color: #999; font-size: small;">
34
- Sort by</span>
35
- <select id="sort-by-sel">
36
- <option value="none">None</option>
37
- <option value="name">Name</option>
38
- <option value="size">Size</option>
39
- <option value="access">Accessed</option>
40
- <option value="create">Created</option>
41
- <option value="mime">Type</option>
42
- </select>
43
- <select id="sort-order-sel">
44
- <option value="asc">Ascending</option>
45
- <option value="desc">Descending</option>
46
- </select>
21
+ <div style="color: #999; font-size: small; display: flex; align-items: center; gap: .75rem;">
22
+ <div class="page-config">
23
+ <span>Limit</span>
24
+ <select id="page-limit-sel">
25
+ <option value="10">10</option>
26
+ <option value="50">50</option>
27
+ <option value="100">100</option>
28
+ <option value="500">500</option>
29
+ <option value="1000">1000</option>
30
+ <option value="5000">5000</option>
31
+ </select>
32
+ </div>
33
+
34
+ <div class="page-config">
35
+ <span>Page</span>
36
+ <input type="number" id="page-num-input" value="1" min="1" style="width: 50px;">
37
+ /
38
+ <label id="page-count-lbl">1</label>
39
+ </div>
40
+
41
+ <div class="page-config">
42
+ <span>Sort by</span>
43
+ <select id="sort-by-sel">
44
+ <option value="none">None</option>
45
+ <option value="url">Name</option>
46
+ <option value="file_size">Size</option>
47
+ <option value="access_time">Accessed</option>
48
+ <option value="create_time">Created</option>
49
+ <option value="mime_type">Type</option>
50
+ </select>
51
+ <select id="sort-order-sel">
52
+ <option value="asc">Ascending</option>
53
+ <option value="desc">Descending</option>
54
+ </select>
55
+ </div>
47
56
  </div>
48
57
  </div>
58
+ </div>
59
+
60
+ <div class="container content" id="view">
49
61
  <table id="files">
50
62
  <thead>
51
63
  <tr>
frontend/login.css ADDED
@@ -0,0 +1,21 @@
1
+
2
+ div#login-container{
3
+ display: flex;
4
+ flex-direction: column;
5
+ justify-content: center;
6
+ align-items: center;
7
+ gap: 0.5rem;
8
+ width: calc(max(70vw, 420px));
9
+ min-width: 420px;
10
+ }
11
+
12
+ label.login-lbl{
13
+ text-align: left;
14
+ font-weight: bold;
15
+ }
16
+
17
+ button#login-btn{
18
+ padding: 0.8rem;
19
+ padding-inline: 3rem;
20
+ margin-top: 0.5rem;
21
+ }
frontend/login.js ADDED
@@ -0,0 +1,83 @@
1
+
2
+ import { createFloatingWindow, showPopup } from "./popup.js";
3
+
4
+ /**
5
+ * @import { store } from "./state.js";
6
+ * @import { UserRecord } from "./api.js";
7
+ *
8
+ * Shows the login panel if necessary.
9
+ * @param {store} store - The store object.
10
+ * @returns {Promise<UserRecord>} - The user record.
11
+ */
12
+ export async function maybeShowLoginPanel(
13
+ store,
14
+ forceShowup = false
15
+ ){
16
+ if (!forceShowup){
17
+ try {
18
+ const user = await store.conn.whoami();
19
+ if (user.id !== 0){
20
+ return user;
21
+ }
22
+ }
23
+ catch (e) {
24
+ console.error(e);
25
+ }
26
+ }
27
+
28
+ const innerHTML = `
29
+ <div id="login-container">
30
+ <div class="input-group" style="min-width: 300px;">
31
+ <label for="endpoint-input" class="login-lbl">Endpoint</label>
32
+ <input type="text" id="endpoint-input" placeholder="http://localhost:8000" autocomplete="off">
33
+ </div>
34
+ <div class="input-group" style="min-width: 300px;">
35
+ <label for="token-input" class="login-lbl">Token</label>
36
+ <input type="text" id="token-input" placeholder="" autocomplete="off">
37
+ </div>
38
+
39
+ <button id="login-btn">Login</button>
40
+ </div>
41
+ `
42
+
43
+ const [win, closeWin] = createFloatingWindow(innerHTML, {
44
+ padding: '2rem',
45
+ });
46
+
47
+ const endpointInput = document.getElementById("endpoint-input");
48
+ const tokenInput = document.getElementById("token-input");
49
+ const loginBtn = document.getElementById("login-btn");
50
+
51
+ endpointInput.value = store.endpoint;
52
+ tokenInput.value = store.token;
53
+
54
+ endpointInput.addEventListener('keydown', (e) => {
55
+ if (e.key === 'Enter'){ loginBtn.click(); }
56
+ });
57
+ tokenInput.addEventListener('keydown', (e) => {
58
+ if (e.key === 'Enter'){ loginBtn.click(); }
59
+ });
60
+ endpointInput.addEventListener('input', () => {
61
+ store.endpoint = endpointInput.value;
62
+ });
63
+ tokenInput.addEventListener('input', () => {
64
+ store.token = tokenInput.value;
65
+ });
66
+
67
+ loginBtn.focus();
68
+
69
+ return new Promise((resolve, reject) => {
70
+ loginBtn.addEventListener('click', async () => {
71
+ try {
72
+ const user = await store.conn.whoami();
73
+ closeWin();
74
+ resolve(user);
75
+ }
76
+ catch (e) {
77
+ showPopup('Login failed: ' + e.message, {
78
+ level: 'error',
79
+ });
80
+ }
81
+ });
82
+ });
83
+ }