lfss 0.7.15__py3-none-any.whl → 0.8.0__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 +2 -2
- docs/Permission.md +4 -2
- frontend/api.js +214 -8
- frontend/index.html +40 -28
- frontend/login.css +21 -0
- frontend/login.js +83 -0
- frontend/scripts.js +73 -84
- frontend/state.js +19 -4
- frontend/styles.css +26 -8
- frontend/thumb.css +6 -0
- frontend/thumb.js +6 -2
- lfss/{client → api}/__init__.py +52 -35
- lfss/{client/api.py → api/connector.py} +89 -8
- lfss/cli/cli.py +1 -1
- lfss/cli/user.py +1 -1
- lfss/src/connection_pool.py +3 -2
- lfss/src/database.py +158 -72
- lfss/src/datatype.py +8 -3
- lfss/src/error.py +3 -1
- lfss/src/server.py +67 -9
- lfss/src/stat.py +1 -1
- lfss/src/utils.py +47 -13
- {lfss-0.7.15.dist-info → lfss-0.8.0.dist-info}/METADATA +4 -3
- lfss-0.8.0.dist-info/RECORD +43 -0
- lfss-0.7.15.dist-info/RECORD +0 -41
- {lfss-0.7.15.dist-info → lfss-0.8.0.dist-info}/WHEEL +0 -0
- {lfss-0.7.15.dist-info → lfss-0.8.0.dist-info}/entry_points.txt +0 -0
Readme.md
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
# Lightweight File Storage Service (LFSS)
|
2
2
|
[](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/
|
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 (
|
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 = {
|
@@ -133,18 +135,18 @@ export default class Connector {
|
|
133
135
|
return await res.json();
|
134
136
|
}
|
135
137
|
|
138
|
+
_sanitizeDirPath(path){
|
139
|
+
if (path.startsWith('/')){ path = path.slice(1); }
|
140
|
+
if (!path.endsWith('/')){ path += '/'; }
|
141
|
+
return path;
|
142
|
+
}
|
136
143
|
/**
|
137
144
|
* @param {string} path - the path to the file directory, should ends with '/'
|
138
|
-
* @param {Object} options - the options for the request
|
139
145
|
* @returns {Promise<PathListResponse>} - the promise of the request
|
140
146
|
*/
|
141
|
-
async listPath(path
|
142
|
-
|
143
|
-
} = {}){
|
144
|
-
if (path.startsWith('/')){ path = path.slice(1); }
|
145
|
-
if (!path.endsWith('/')){ path += '/'; }
|
147
|
+
async listPath(path){
|
148
|
+
path = this._sanitizeDirPath(path);
|
146
149
|
const dst = new URL(this.config.endpoint + '/' + path);
|
147
|
-
dst.searchParams.append('flat', flat);
|
148
150
|
const res = await fetch(dst.toString(), {
|
149
151
|
method: 'GET',
|
150
152
|
headers: {
|
@@ -157,6 +159,128 @@ export default class Connector {
|
|
157
159
|
return await res.json();
|
158
160
|
}
|
159
161
|
|
162
|
+
/**
|
163
|
+
* @param {string} path - the path to the file directory, should ends with '/'
|
164
|
+
* @param {boolean} flat - whether to list the files in subdirectories
|
165
|
+
* @returns {Promise<number>} - the promise of the request
|
166
|
+
* */
|
167
|
+
async countFiles(path, {
|
168
|
+
flat = false
|
169
|
+
} = {}){
|
170
|
+
path = this._sanitizeDirPath(path);
|
171
|
+
const dst = new URL(this.config.endpoint + '/_api/count-files');
|
172
|
+
dst.searchParams.append('path', path);
|
173
|
+
dst.searchParams.append('flat', flat);
|
174
|
+
const res = await fetch(dst.toString(), {
|
175
|
+
method: 'GET',
|
176
|
+
headers: {
|
177
|
+
'Authorization': 'Bearer ' + this.config.token
|
178
|
+
},
|
179
|
+
});
|
180
|
+
if (res.status != 200){
|
181
|
+
throw new Error(`Failed to count files, status code: ${res.status}, message: ${await res.json()}`);
|
182
|
+
}
|
183
|
+
return (await res.json()).count;
|
184
|
+
}
|
185
|
+
|
186
|
+
/**
|
187
|
+
* @typedef {Object} ListFilesOptions
|
188
|
+
* @property {number} offset - the offset of the list
|
189
|
+
* @property {number} limit - the limit of the list
|
190
|
+
* @property {FileSortKey} orderBy - the key to order the files
|
191
|
+
* @property {boolean} orderDesc - whether to order the files in descending order
|
192
|
+
* @property {boolean} flat - whether to list the files in subdirectories
|
193
|
+
*
|
194
|
+
* @param {string} path - the path to the file directory, should ends with '/'
|
195
|
+
* @param {ListFilesOptions} options - the options for the request
|
196
|
+
* @returns {Promise<FileRecord[]>} - the promise of the request
|
197
|
+
*/
|
198
|
+
async listFiles(path, {
|
199
|
+
offset = 0,
|
200
|
+
limit = 1000,
|
201
|
+
orderBy = 'create_time',
|
202
|
+
orderDesc = false,
|
203
|
+
flat = false
|
204
|
+
} = {}){
|
205
|
+
path = this._sanitizeDirPath(path);
|
206
|
+
const dst = new URL(this.config.endpoint + '/_api/list-files');
|
207
|
+
dst.searchParams.append('path', path);
|
208
|
+
dst.searchParams.append('offset', offset);
|
209
|
+
dst.searchParams.append('limit', limit);
|
210
|
+
dst.searchParams.append('order_by', orderBy);
|
211
|
+
dst.searchParams.append('order_desc', orderDesc);
|
212
|
+
dst.searchParams.append('flat', flat);
|
213
|
+
const res = await fetch(dst.toString(), {
|
214
|
+
method: 'GET',
|
215
|
+
headers: {
|
216
|
+
'Authorization': 'Bearer ' + this.config.token
|
217
|
+
},
|
218
|
+
});
|
219
|
+
if (res.status != 200){
|
220
|
+
throw new Error(`Failed to list files, status code: ${res.status}, message: ${await res.json()}`);
|
221
|
+
}
|
222
|
+
return await res.json();
|
223
|
+
}
|
224
|
+
|
225
|
+
/**
|
226
|
+
* @param {string} path - the path to the file directory, should ends with '/'
|
227
|
+
* @returns {Promise<number>} - the promise of the request
|
228
|
+
**/
|
229
|
+
async countDirs(path){
|
230
|
+
path = this._sanitizeDirPath(path);
|
231
|
+
const dst = new URL(this.config.endpoint + '/_api/count-dirs');
|
232
|
+
dst.searchParams.append('path', path);
|
233
|
+
const res = await fetch(dst.toString(), {
|
234
|
+
method: 'GET',
|
235
|
+
headers: {
|
236
|
+
'Authorization': 'Bearer ' + this.config.token
|
237
|
+
},
|
238
|
+
});
|
239
|
+
if (res.status != 200){
|
240
|
+
throw new Error(`Failed to count directories, status code: ${res.status}, message: ${await res.json()}`);
|
241
|
+
}
|
242
|
+
return (await res.json()).count;
|
243
|
+
}
|
244
|
+
|
245
|
+
/**
|
246
|
+
* @typedef {Object} ListDirsOptions
|
247
|
+
* @property {number} offset - the offset of the list
|
248
|
+
* @property {number} limit - the limit of the list
|
249
|
+
* @property {DirectorySortKey} orderBy - the key to order the directories
|
250
|
+
* @property {boolean} orderDesc - whether to order the directories in descending order
|
251
|
+
* @property {boolean} skim - whether to skim the directories
|
252
|
+
*
|
253
|
+
* @param {string} path - the path to the file directory, should ends with '/'
|
254
|
+
* @param {ListDirsOptions} options - the options for the request
|
255
|
+
* @returns {Promise<DirectoryRecord[]>} - the promise of the request
|
256
|
+
**/
|
257
|
+
async listDirs(path, {
|
258
|
+
offset = 0,
|
259
|
+
limit = 1000,
|
260
|
+
orderBy = 'dirname',
|
261
|
+
orderDesc = false,
|
262
|
+
skim = true
|
263
|
+
} = {}){
|
264
|
+
path = this._sanitizeDirPath(path);
|
265
|
+
const dst = new URL(this.config.endpoint + '/_api/list-dirs');
|
266
|
+
dst.searchParams.append('path', path);
|
267
|
+
dst.searchParams.append('offset', offset);
|
268
|
+
dst.searchParams.append('limit', limit);
|
269
|
+
dst.searchParams.append('order_by', orderBy);
|
270
|
+
dst.searchParams.append('order_desc', orderDesc);
|
271
|
+
dst.searchParams.append('skim', skim);
|
272
|
+
const res = await fetch(dst.toString(), {
|
273
|
+
method: 'GET',
|
274
|
+
headers: {
|
275
|
+
'Authorization': 'Bearer ' + this.config.token
|
276
|
+
},
|
277
|
+
});
|
278
|
+
if (res.status != 200){
|
279
|
+
throw new Error(`Failed to list directories, status code: ${res.status}, message: ${await res.json()}`);
|
280
|
+
}
|
281
|
+
return await res.json();
|
282
|
+
}
|
283
|
+
|
160
284
|
/**
|
161
285
|
* Check the user information by the token
|
162
286
|
* @returns {Promise<UserRecord>} - the promise of the request
|
@@ -216,4 +340,86 @@ export default class Connector {
|
|
216
340
|
}
|
217
341
|
}
|
218
342
|
|
219
|
-
}
|
343
|
+
}
|
344
|
+
|
345
|
+
/**
|
346
|
+
* a function to wrap the listDirs and listFiles function into one
|
347
|
+
* it will return the list of directories and files in the directory,
|
348
|
+
* making directories first (if the offset is less than the number of directories),
|
349
|
+
* and files after that.
|
350
|
+
*
|
351
|
+
*
|
352
|
+
* @typedef {Object} ListPathOptions
|
353
|
+
* @property {number} offset - the offset of the list
|
354
|
+
* @property {number} limit - the limit of the list
|
355
|
+
* @property {ListFilesOptions} orderBy - the key to order the files (if set to url, will list the directories using dirname)
|
356
|
+
* @property {boolean} orderDesc - whether to order the files in descending order
|
357
|
+
*
|
358
|
+
* @param {Connector} conn - the connector to the API
|
359
|
+
* @param {string} path - the path to the file directory
|
360
|
+
* @param {Object} options - the options for the request
|
361
|
+
* @returns {Promise<[PathListResponse, {dirs: number, files: number}]>} - the promise of the request
|
362
|
+
*/
|
363
|
+
export async function listPath(conn, path, {
|
364
|
+
offset = 0,
|
365
|
+
limit = 1000,
|
366
|
+
orderBy = '',
|
367
|
+
orderDesc = false,
|
368
|
+
} = {}){
|
369
|
+
|
370
|
+
if (path === '/' || path === ''){
|
371
|
+
// this handles separate case for the root directory... please refer to the backend implementation
|
372
|
+
return [await conn.listPath(''), {dirs: 0, files: 0}];
|
373
|
+
}
|
374
|
+
|
375
|
+
orderBy = orderBy == 'none' ? '' : orderBy;
|
376
|
+
console.debug('listPath', path, offset, limit, orderBy, orderDesc);
|
377
|
+
|
378
|
+
const [dirCount, fileCount] = await Promise.all([
|
379
|
+
conn.countDirs(path),
|
380
|
+
conn.countFiles(path)
|
381
|
+
]);
|
382
|
+
|
383
|
+
const dirOffset = offset;
|
384
|
+
const fileOffset = Math.max(offset - dirCount, 0);
|
385
|
+
const dirThispage = Math.max(Math.min(dirCount - dirOffset, limit), 0);
|
386
|
+
const fileLimit = limit - dirThispage;
|
387
|
+
|
388
|
+
console.debug('dirCount', dirCount, 'dirOffset', dirOffset, 'fileOffset', fileOffset, 'dirThispage', dirThispage, 'fileLimit', fileLimit);
|
389
|
+
|
390
|
+
const dirOrderBy = orderBy == 'url' ? 'dirname' : '';
|
391
|
+
const fileOrderBy = orderBy;
|
392
|
+
|
393
|
+
const [dirList, fileList] = await Promise.all([
|
394
|
+
(async () => {
|
395
|
+
if (offset < dirCount) {
|
396
|
+
return await conn.listDirs(path, {
|
397
|
+
offset: dirOffset,
|
398
|
+
limit: dirThispage,
|
399
|
+
orderBy: dirOrderBy,
|
400
|
+
orderDesc: orderDesc
|
401
|
+
});
|
402
|
+
}
|
403
|
+
return [];
|
404
|
+
})(),
|
405
|
+
(async () => {
|
406
|
+
if (fileLimit >= 0 && fileCount > fileOffset) {
|
407
|
+
return await conn.listFiles(path, {
|
408
|
+
offset: fileOffset,
|
409
|
+
limit: fileLimit,
|
410
|
+
orderBy: fileOrderBy,
|
411
|
+
orderDesc: orderDesc
|
412
|
+
});
|
413
|
+
}
|
414
|
+
return [];
|
415
|
+
})()
|
416
|
+
]);
|
417
|
+
|
418
|
+
return [{
|
419
|
+
dirs: dirList,
|
420
|
+
files: fileList
|
421
|
+
}, {
|
422
|
+
dirs: dirCount,
|
423
|
+
files: fileCount
|
424
|
+
}];
|
425
|
+
};
|
frontend/index.html
CHANGED
@@ -8,44 +8,56 @@
|
|
8
8
|
</head>
|
9
9
|
<body>
|
10
10
|
|
11
|
-
<div class="container header"
|
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
|
-
|
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
|
-
<
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
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
|
+
}
|