lfss 0.9.4__py3-none-any.whl → 0.11.4__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 +4 -4
- docs/Enviroment_variables.md +4 -2
- docs/Permission.md +4 -4
- docs/changelog.md +58 -0
- frontend/api.js +66 -4
- frontend/login.js +0 -1
- frontend/popup.js +18 -3
- frontend/scripts.js +46 -39
- frontend/utils.js +98 -1
- lfss/api/__init__.py +7 -4
- lfss/api/connector.py +47 -11
- lfss/cli/cli.py +9 -9
- lfss/cli/log.py +77 -0
- lfss/cli/vacuum.py +69 -19
- lfss/eng/config.py +7 -5
- lfss/eng/connection_pool.py +11 -7
- lfss/eng/database.py +346 -133
- lfss/eng/error.py +2 -0
- lfss/eng/log.py +91 -21
- lfss/eng/thumb.py +16 -23
- lfss/eng/utils.py +4 -5
- lfss/sql/init.sql +9 -4
- lfss/svc/app.py +1 -1
- lfss/svc/app_base.py +6 -2
- lfss/svc/app_dav.py +7 -7
- lfss/svc/app_native.py +90 -52
- lfss/svc/common_impl.py +5 -8
- {lfss-0.9.4.dist-info → lfss-0.11.4.dist-info}/METADATA +10 -8
- lfss-0.11.4.dist-info/RECORD +52 -0
- {lfss-0.9.4.dist-info → lfss-0.11.4.dist-info}/entry_points.txt +1 -0
- docs/Changelog.md +0 -27
- lfss-0.9.4.dist-info/RECORD +0 -51
- {lfss-0.9.4.dist-info → lfss-0.11.4.dist-info}/WHEEL +0 -0
Readme.md
CHANGED
@@ -1,5 +1,5 @@
|
|
1
|
-
#
|
2
|
-
[](https://pypi.org/project/lfss/)
|
1
|
+
# Lite File Storage Service (LFSS)
|
2
|
+
[](https://pypi.org/project/lfss/) [](https://pypi.org/project/lfss/)
|
3
3
|
|
4
4
|
My experiment on a lightweight and high-performance file/object storage service...
|
5
5
|
|
@@ -32,8 +32,8 @@ Or, you can start a web server at `/frontend` and open `index.html` in your brow
|
|
32
32
|
|
33
33
|
The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
|
34
34
|
The authentication can be acheived through one of the following methods:
|
35
|
-
1. `Authorization` header with the value `Bearer sha256(<username
|
36
|
-
2. `token` query parameter with the value `sha256(<username
|
35
|
+
1. `Authorization` header with the value `Bearer sha256(<username>:<password>)`.
|
36
|
+
2. `token` query parameter with the value `sha256(<username>:<password>)`.
|
37
37
|
3. HTTP Basic Authentication with the username and password (If WebDAV is enabled).
|
38
38
|
|
39
39
|
You can refer to `frontend` as an application example, `lfss/api/connector.py` for more APIs.
|
docs/Enviroment_variables.md
CHANGED
@@ -4,9 +4,11 @@
|
|
4
4
|
**Server**
|
5
5
|
- `LFSS_DATA`: The directory to store the data. Default is `.storage_data`.
|
6
6
|
- `LFSS_WEBDAV`: Enable WebDAV support. Default is `0`, set to `1` to enable.
|
7
|
-
- `LFSS_LARGE_FILE`: The size limit of the file to store in the database. Default is `
|
7
|
+
- `LFSS_LARGE_FILE`: The size limit of the file to store in the database. Default is `1m`.
|
8
8
|
- `LFSS_DEBUG`: Enable debug mode for more verbose logging. Default is `0`, set to `1` to enable.
|
9
|
+
- `LFSS_DISABLE_LOGGING`: Disable all file logging. Default is 0; set to `1` to disable file logging.
|
10
|
+
- `LFSS_ORIGIN`: The `Origin` header to allow CORS requests. Use `,` to separate multiple origins. Default is `*`.
|
9
11
|
|
10
12
|
**Client**
|
11
13
|
- `LFSS_ENDPOINT`: The fallback server endpoint. Default is `http://localhost:8000`.
|
12
|
-
- `LFSS_TOKEN`: The fallback token to authenticate. Should be `sha256(<username
|
14
|
+
- `LFSS_TOKEN`: The fallback token to authenticate. Should be `sha256(<username>:<password>)`.
|
docs/Permission.md
CHANGED
@@ -22,16 +22,16 @@ A file is owned by the user who created it, may not necessarily be the user unde
|
|
22
22
|
## File access with `GET` permission
|
23
23
|
|
24
24
|
### File access
|
25
|
-
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.
|
25
|
+
For accessing file content, the user must have `GET` permission of the file, which is determined by the `permission` field of both the path-owner and the file.
|
26
26
|
|
27
27
|
There are four types of permissions: `unset`, `public`, `protected`, `private`.
|
28
28
|
Non-admin users can access files based on:
|
29
29
|
|
30
30
|
- If the file is `public`, then all users can access it.
|
31
31
|
- If the file is `protected`, then only the logged-in user can access it.
|
32
|
-
- If the file is `private`, then only the owner can access it.
|
33
|
-
- If the file is `unset`, then the file's permission is inherited from the owner's permission.
|
34
|
-
- If both the owner and the file have `unset` permission, then the file is `public`.
|
32
|
+
- If the file is `private`, then only the owner/path-owner can access it.
|
33
|
+
- If the file is `unset`, then the file's permission is inherited from the path-owner's permission.
|
34
|
+
- If both the path-owner and the file have `unset` permission, then the file is `public`.
|
35
35
|
|
36
36
|
## File creation with `PUT`/`POST` permission
|
37
37
|
`PUT`/`POST` permission is not allowed for non-peer users.
|
docs/changelog.md
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
## 0.11
|
2
|
+
|
3
|
+
### 0.11.2
|
4
|
+
- Improve frontend directory upload feedback.
|
5
|
+
- Set default large file threashold to 1M.
|
6
|
+
- Increase default concurrent threads.
|
7
|
+
- Use sqlite for logging.
|
8
|
+
- Add vacuum logs.
|
9
|
+
- Refactor: use dir for directory path.
|
10
|
+
|
11
|
+
### 0.11.1
|
12
|
+
- Rename api `get_meta` function.
|
13
|
+
- Frontend support upload directory.
|
14
|
+
- Fix admin put to non-exists user path.
|
15
|
+
|
16
|
+
### 0.11.0
|
17
|
+
- Copy file as hard link.
|
18
|
+
- Add vacuum thumb and all.
|
19
|
+
- Thumb database use file_id as index.
|
20
|
+
- improve username and url check with regular expression.
|
21
|
+
|
22
|
+
## 0.10
|
23
|
+
|
24
|
+
### 0.10.0
|
25
|
+
- Inherit permission from path owner for `unset` permission files.
|
26
|
+
- Add timeout and verify options for client api.
|
27
|
+
- Bundle small files in memory.
|
28
|
+
|
29
|
+
## 0.9
|
30
|
+
|
31
|
+
### 0.9.5
|
32
|
+
- Stream bundle path as zip file.
|
33
|
+
- Update authentication token hash format (need to reset password).
|
34
|
+
|
35
|
+
### 0.9.4
|
36
|
+
- Decode WebDAV file name.
|
37
|
+
- Allow root-listing for WebDAV.
|
38
|
+
- Always return 207 status code for propfind.
|
39
|
+
- Refactor debounce utility.
|
40
|
+
|
41
|
+
### 0.9.3
|
42
|
+
- Fix empty file getting.
|
43
|
+
- HTTP `PUT/POST` default to overwrite the file.
|
44
|
+
- Use shared implementations for `PUT`, `GET`, `DELETE` methods.
|
45
|
+
- Inherit permission on overwriting `unset` permission files.
|
46
|
+
|
47
|
+
### 0.9.2
|
48
|
+
- Native copy function.
|
49
|
+
- Only enable basic authentication if WebDAV is enabled.
|
50
|
+
- `WWW-Authenticate` header is now added to the response when authentication fails.
|
51
|
+
|
52
|
+
### 0.9.1
|
53
|
+
- Add WebDAV support.
|
54
|
+
- Code refactor, use `lfss.eng` and `lfss.svc`.
|
55
|
+
|
56
|
+
### 0.9.0
|
57
|
+
- User peer access control, now user can share their path with other users.
|
58
|
+
- Fix high concurrency database locking on file getting.
|
frontend/api.js
CHANGED
@@ -69,6 +69,10 @@ export default class Connector {
|
|
69
69
|
/**
|
70
70
|
* @param {string} path - the path to the file (url)
|
71
71
|
* @param {File} file - the file to upload
|
72
|
+
* @param {Object} [options] - Optional upload configuration.
|
73
|
+
* @param {'abort' | 'overwrite' | 'skip'} [options.conflict='abort'] - Conflict resolution strategy:
|
74
|
+
* `'abort'` to cancel and raise 409, `'overwrite'` to replace.
|
75
|
+
* @param {number} [options.permission=0] - Optional permission setting for the file (refer to backend impl).
|
72
76
|
* @returns {Promise<string>} - the promise of the request, the url of the file
|
73
77
|
*/
|
74
78
|
async put(path, file, {
|
@@ -96,8 +100,12 @@ export default class Connector {
|
|
96
100
|
}
|
97
101
|
|
98
102
|
/**
|
99
|
-
* @param {string} path - the path to the file (url)
|
103
|
+
* @param {string} path - the path to the file (url), should end with .json
|
100
104
|
* @param {File} file - the file to upload
|
105
|
+
* @param {Object} [options] - Optional upload configuration.
|
106
|
+
* @param {'abort' | 'overwrite' | 'skip'} [options.conflict='abort'] - Conflict resolution strategy:
|
107
|
+
* `'abort'` to cancel and raise 409, `'overwrite'` to replace, `'skip'` to ignore if already exists.
|
108
|
+
* @param {number} [options.permission=0] - Optional permission setting for the file (refer to backend impl).
|
101
109
|
* @returns {Promise<string>} - the promise of the request, the url of the file
|
102
110
|
*/
|
103
111
|
async post(path, file, {
|
@@ -129,13 +137,23 @@ export default class Connector {
|
|
129
137
|
|
130
138
|
/**
|
131
139
|
* @param {string} path - the path to the file (url), should end with .json
|
132
|
-
* @param {
|
140
|
+
* @param {Object} data - the data to upload
|
141
|
+
* @param {Object} [options] - Optional upload configuration.
|
142
|
+
* @param {'abort' | 'overwrite' | 'skip'} [options.conflict='abort'] - Conflict resolution strategy:
|
143
|
+
* `'abort'` to cancel and raise 409, `'overwrite'` to replace, `'skip'` to ignore if already exists.
|
144
|
+
* @param {number} [options.permission=0] - Optional permission setting for the file (refer to backend impl).
|
133
145
|
* @returns {Promise<string>} - the promise of the request, the url of the file
|
134
146
|
*/
|
135
|
-
async putJson(path, data
|
147
|
+
async putJson(path, data, {
|
148
|
+
conflict = "overwrite",
|
149
|
+
permission = 0
|
150
|
+
} = {}){
|
136
151
|
if (!path.endsWith('.json')){ throw new Error('Upload object must end with .json'); }
|
137
152
|
if (path.startsWith('/')){ path = path.slice(1); }
|
138
|
-
const
|
153
|
+
const dst = new URL(this.config.endpoint + '/' + path);
|
154
|
+
dst.searchParams.append('conflict', conflict);
|
155
|
+
dst.searchParams.append('permission', permission);
|
156
|
+
const res = await fetch(dst.toString(), {
|
139
157
|
method: 'PUT',
|
140
158
|
headers: {
|
141
159
|
'Authorization': 'Bearer ' + this.config.token,
|
@@ -149,6 +167,50 @@ export default class Connector {
|
|
149
167
|
return (await res.json()).url;
|
150
168
|
}
|
151
169
|
|
170
|
+
/**
|
171
|
+
* @param {string} path - the path to the file (url), should have content type application/json
|
172
|
+
* @returns {Promise<Object>} - return the json object
|
173
|
+
*/
|
174
|
+
async getJson(path){
|
175
|
+
if (path.startsWith('/')){ path = path.slice(1); }
|
176
|
+
const res = await fetch(this.config.endpoint + '/' + path, {
|
177
|
+
method: 'GET',
|
178
|
+
headers: {
|
179
|
+
"Authorization": 'Bearer ' + this.config.token
|
180
|
+
},
|
181
|
+
});
|
182
|
+
if (res.status != 200){
|
183
|
+
throw new Error(`Failed to get object, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
184
|
+
}
|
185
|
+
return await res.json();
|
186
|
+
}
|
187
|
+
|
188
|
+
/**
|
189
|
+
* @param {string[]} paths - the paths to the files (url), should have content type plain/text, application/json, etc.
|
190
|
+
* @param {Object} [options] - Optional configuration.
|
191
|
+
* @param {boolean} [options.skipContent=false] - If true, skips fetching content and returns a record of <path, ''>.
|
192
|
+
* @returns {Promise<Record<string, string | null>>} - return the mapping of path to text content, non-existing paths will be ignored
|
193
|
+
*/
|
194
|
+
async getMultipleText(paths, {
|
195
|
+
skipContent = false
|
196
|
+
} = {}){
|
197
|
+
const url = new URL(this.config.endpoint + '/_api/get-multiple');
|
198
|
+
url.searchParams.append('skip_content', skipContent);
|
199
|
+
for (const path of paths){
|
200
|
+
url.searchParams.append('path', path);
|
201
|
+
}
|
202
|
+
const res = await fetch(url.toString(), {
|
203
|
+
method: 'GET',
|
204
|
+
headers: {
|
205
|
+
"Authorization": 'Bearer ' + this.config.token,
|
206
|
+
}
|
207
|
+
});
|
208
|
+
if (res.status != 200 && res.status != 206){
|
209
|
+
throw new Error(`Failed to get multiple files, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
|
210
|
+
}
|
211
|
+
return await res.json();
|
212
|
+
}
|
213
|
+
|
152
214
|
async delete(path){
|
153
215
|
if (path.startsWith('/')){ path = path.slice(1); }
|
154
216
|
const res = await fetch(this.config.endpoint + '/' + path, {
|
frontend/login.js
CHANGED
frontend/popup.js
CHANGED
@@ -109,7 +109,14 @@ export function showPopup(content = '', {
|
|
109
109
|
} = {}){
|
110
110
|
const popup = document.createElement("div");
|
111
111
|
popup.classList.add("popup-window");
|
112
|
-
|
112
|
+
/**
|
113
|
+
* @param {string} c
|
114
|
+
* @returns {void}
|
115
|
+
*/
|
116
|
+
function setPopupContent(c){
|
117
|
+
popup.innerHTML = showTime? `<span>[${new Date().toLocaleTimeString()}]</span> ${c}` : c;
|
118
|
+
}
|
119
|
+
setPopupContent(content);
|
113
120
|
popup.style.width = width;
|
114
121
|
const popupHeight = '1rem';
|
115
122
|
popup.style.height = popupHeight;
|
@@ -132,11 +139,19 @@ export function showPopup(content = '', {
|
|
132
139
|
if (level === "success") popup.style.backgroundColor = "darkgreen";
|
133
140
|
document.body.appendChild(popup);
|
134
141
|
shownPopups.push(popup);
|
135
|
-
|
142
|
+
|
143
|
+
function closePopup(){
|
136
144
|
if (popup.parentNode) document.body.removeChild(popup);
|
137
145
|
shownPopups.splice(shownPopups.indexOf(popup), 1);
|
138
146
|
for (let i = 0; i < shownPopups.length; i++) {
|
139
147
|
shownPopups[i].style.top = `${i * (parseInt(popupHeight) + 2*parseInt(paddingHeight))*1.2 + 0.5}rem`;
|
140
148
|
}
|
141
|
-
}
|
149
|
+
}
|
150
|
+
|
151
|
+
window.setTimeout(closePopup, timeout);
|
152
|
+
return {
|
153
|
+
elem: popup,
|
154
|
+
setContent: setPopupContent,
|
155
|
+
close: closePopup
|
156
|
+
}
|
142
157
|
}
|
frontend/scripts.js
CHANGED
@@ -5,6 +5,7 @@ import { showInfoPanel, showDirInfoPanel } from './info.js';
|
|
5
5
|
import { makeThumbHtml } from './thumb.js';
|
6
6
|
import { store } from './state.js';
|
7
7
|
import { maybeShowLoginPanel } from './login.js';
|
8
|
+
import { forEachFile } from './utils.js';
|
8
9
|
|
9
10
|
/** @type {import('./api.js').UserRecord}*/
|
10
11
|
let userRecord = null;
|
@@ -158,55 +159,61 @@ uploadFileNameInput.addEventListener('input', debounce(onFileNameInpuChange, 500
|
|
158
159
|
e.preventDefault();
|
159
160
|
e.stopPropagation();
|
160
161
|
});
|
161
|
-
window.addEventListener('drop', (e) => {
|
162
|
+
window.addEventListener('drop', async (e) => {
|
162
163
|
e.preventDefault();
|
163
164
|
e.stopPropagation();
|
164
|
-
const
|
165
|
-
if (
|
166
|
-
uploadFileSelector.files = files;
|
167
|
-
uploadFileNameInput.value = files[0].name;
|
165
|
+
const items = e.dataTransfer.items;
|
166
|
+
if (items.length == 1 && items[0].kind === 'file' && items[0].webkitGetAsEntry().isFile){
|
167
|
+
uploadFileSelector.files = e.dataTransfer.files;
|
168
|
+
uploadFileNameInput.value = e.dataTransfer.files[0].name;
|
168
169
|
uploadFileNameInput.focus();
|
170
|
+
return;
|
169
171
|
}
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
172
|
+
|
173
|
+
/** @type {[string, File][]} */
|
174
|
+
const uploadInputVal = uploadFileNameInput.value? uploadFileNameInput.value : '';
|
175
|
+
let dstPath = store.dirpath + uploadInputVal;
|
176
|
+
if (!dstPath.endsWith('/')){ dstPath += '/'; }
|
177
|
+
|
178
|
+
if (!confirm(`\
|
174
179
|
You are trying to upload multiple files at once.
|
175
180
|
This will directly upload the files to the [${dstPath}] directory without renaming.
|
176
181
|
Note that same name files will be overwritten.
|
177
|
-
Are you sure you want to proceed
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
}
|
186
|
-
catch (err){
|
187
|
-
showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
|
188
|
-
}
|
189
|
-
counter += 1;
|
190
|
-
console.log("Uploading file: ", counter, "/", files.length);
|
182
|
+
Are you sure you want to proceed?\
|
183
|
+
`)){ return; }
|
184
|
+
|
185
|
+
let counter = 0;
|
186
|
+
let totalCount = 0;
|
187
|
+
const uploadPopup = showPopup('Uploading multiple files...', {level: 'info', timeout: 999999});
|
188
|
+
async function uploadFileFn(path, file){
|
189
|
+
try{
|
190
|
+
await uploadFile(conn, path, file, {conflict: 'overwrite'});
|
191
191
|
}
|
192
|
-
|
193
|
-
|
194
|
-
for (let i = 0; i < files.length; i++){
|
195
|
-
const file = files[i];
|
196
|
-
const path = dstPath + file.name;
|
197
|
-
promises.push(uploadFileFn(file, path));
|
192
|
+
catch (err){
|
193
|
+
showPopup('Failed to upload file [' + file.name + ']: ' + err, {level: 'error', timeout: 5000});
|
198
194
|
}
|
199
|
-
|
200
|
-
|
201
|
-
() => {
|
202
|
-
showPopup('Upload success.', {level: 'success', timeout: 3000});
|
203
|
-
refreshFileList();
|
204
|
-
},
|
205
|
-
(err) => {
|
206
|
-
showPopup('Failed to upload some files: ' + err, {level: 'error', timeout: 5000});
|
207
|
-
}
|
208
|
-
);
|
195
|
+
console.log(`[${counter}/${totalCount}] Uploaded file: ${path}`);
|
196
|
+
uploadPopup.setContent(`Uploading multiple files... [${counter}/${totalCount}]`);
|
209
197
|
}
|
198
|
+
|
199
|
+
const promises = await forEachFile(e, async (relPath, filePromiseFn) => {
|
200
|
+
counter += 1;
|
201
|
+
const file = await filePromiseFn();
|
202
|
+
await uploadFileFn(dstPath + relPath, file);
|
203
|
+
});
|
204
|
+
totalCount = promises.length;
|
205
|
+
|
206
|
+
Promise.all(promises).then(
|
207
|
+
() => {
|
208
|
+
window.setTimeout(uploadPopup.close, 3000);
|
209
|
+
showPopup('Upload success.', {level: 'success', timeout: 3000});
|
210
|
+
refreshFileList();
|
211
|
+
},
|
212
|
+
(err) => {
|
213
|
+
showPopup('Failed to upload some files: ' + err, {level: 'error', timeout: 5000});
|
214
|
+
}
|
215
|
+
);
|
216
|
+
|
210
217
|
});
|
211
218
|
}
|
212
219
|
|
frontend/utils.js
CHANGED
@@ -93,4 +93,101 @@ export function asHtmlText(text){
|
|
93
93
|
anonElem.textContent = text;
|
94
94
|
const htmlText = anonElem.innerHTML;
|
95
95
|
return htmlText;
|
96
|
-
}
|
96
|
+
}
|
97
|
+
|
98
|
+
/**
|
99
|
+
* Iterates over all files dropped in the event,
|
100
|
+
* including files inside directories, and processes them
|
101
|
+
* using the provided callback with a concurrency limit.
|
102
|
+
*
|
103
|
+
* @param {Event} e The drop event.
|
104
|
+
* @param {(relPath: string, file: () => Promise<File>) => Promise<void>} callback A function
|
105
|
+
* that receives the relative path and a promise for the File.
|
106
|
+
* @param {number} [maxConcurrent=5] Maximum number of concurrent callback executions.
|
107
|
+
* @returns {Promise<Promise<void>[]>} A promise resolving to an array of callback promises.
|
108
|
+
*/
|
109
|
+
export async function forEachFile(e, callback, maxConcurrent = 16) {
|
110
|
+
const results = []; // to collect callback promises
|
111
|
+
|
112
|
+
// Concurrency barrier variables.
|
113
|
+
let activeCount = 0;
|
114
|
+
const queue = [];
|
115
|
+
|
116
|
+
/**
|
117
|
+
* Runs the given async task when below the concurrency limit.
|
118
|
+
* If at limit, waits until a slot is free.
|
119
|
+
*
|
120
|
+
* @param {() => Promise<any>} task An async function returning a promise.
|
121
|
+
* @returns {Promise<any>}
|
122
|
+
*/
|
123
|
+
async function runWithLimit(task) {
|
124
|
+
// If we reached the concurrency limit, wait for a free slot.
|
125
|
+
if (activeCount >= maxConcurrent) {
|
126
|
+
await new Promise(resolve => queue.push(resolve));
|
127
|
+
}
|
128
|
+
activeCount++;
|
129
|
+
try {
|
130
|
+
return await task();
|
131
|
+
} finally {
|
132
|
+
activeCount--;
|
133
|
+
// If there are waiting tasks, allow the next one to run.
|
134
|
+
if (queue.length) {
|
135
|
+
queue.shift()();
|
136
|
+
}
|
137
|
+
}
|
138
|
+
}
|
139
|
+
|
140
|
+
/**
|
141
|
+
* Recursively traverses a file system entry.
|
142
|
+
*
|
143
|
+
* @param {FileSystemEntry} entry The entry (file or directory).
|
144
|
+
* @param {string} path The current relative path.
|
145
|
+
*/
|
146
|
+
async function traverse(entry, path) {
|
147
|
+
if (entry.isFile) {
|
148
|
+
// Wrap file retrieval in a promise.
|
149
|
+
const filePromiseFn = () =>
|
150
|
+
new Promise((resolve, reject) => entry.file(resolve, reject));
|
151
|
+
// Use the concurrency barrier for the callback invocation.
|
152
|
+
results.push(runWithLimit(() => callback(path + entry.name, filePromiseFn)));
|
153
|
+
} else if (entry.isDirectory) {
|
154
|
+
const reader = entry.createReader();
|
155
|
+
|
156
|
+
async function readAllEntries(reader) {
|
157
|
+
const entries = [];
|
158
|
+
while (true) {
|
159
|
+
const chunk = await new Promise((resolve, reject) => {
|
160
|
+
reader.readEntries(resolve, reject);
|
161
|
+
});
|
162
|
+
if (chunk.length === 0) break;
|
163
|
+
entries.push(...chunk);
|
164
|
+
}
|
165
|
+
return entries;
|
166
|
+
}
|
167
|
+
|
168
|
+
const entries = await readAllEntries(reader);
|
169
|
+
await Promise.all(
|
170
|
+
entries.map(ent => traverse(ent, path + entry.name + '/'))
|
171
|
+
);
|
172
|
+
}
|
173
|
+
}
|
174
|
+
|
175
|
+
// Process using DataTransfer items if available.
|
176
|
+
if (e.dataTransfer && e.dataTransfer.items) {
|
177
|
+
await Promise.all(
|
178
|
+
Array.from(e.dataTransfer.items).map(async item => {
|
179
|
+
const entry = item.webkitGetAsEntry && item.webkitGetAsEntry();
|
180
|
+
if (entry) {
|
181
|
+
await traverse(entry, '');
|
182
|
+
}
|
183
|
+
})
|
184
|
+
);
|
185
|
+
} else if (e.dataTransfer && e.dataTransfer.files) {
|
186
|
+
// Fallback for browsers that support only dataTransfer.files.
|
187
|
+
Array.from(e.dataTransfer.files).forEach(file => {
|
188
|
+
results.push(runWithLimit(() => callback(file.name, Promise.resolve(file))));
|
189
|
+
});
|
190
|
+
}
|
191
|
+
return results;
|
192
|
+
}
|
193
|
+
|
lfss/api/__init__.py
CHANGED
@@ -113,7 +113,7 @@ def download_file(
|
|
113
113
|
print(f"File {file_path} already exists, skipping download.")
|
114
114
|
return True, error_msg
|
115
115
|
try:
|
116
|
-
fmeta = connector.
|
116
|
+
fmeta = connector.get_meta(src_url)
|
117
117
|
if fmeta is None:
|
118
118
|
error_msg = "File not found."
|
119
119
|
return False, error_msg
|
@@ -170,14 +170,15 @@ def download_directory(
|
|
170
170
|
_counter = 0
|
171
171
|
_counter_lock = Lock()
|
172
172
|
failed_items: list[tuple[str, str]] = []
|
173
|
+
file_count = 0
|
173
174
|
def get_file(c, src_url):
|
174
|
-
nonlocal _counter, failed_items
|
175
|
+
nonlocal _counter, failed_items, file_count, verbose
|
175
176
|
with _counter_lock:
|
176
177
|
_counter += 1
|
177
178
|
this_count = _counter
|
178
179
|
dst_path = f"{directory}{os.path.relpath(decode_uri_compnents(src_url), decode_uri_compnents(src_path))}"
|
179
180
|
if verbose:
|
180
|
-
print(f"[{this_count}] Downloading {src_url} to {dst_path}")
|
181
|
+
print(f"[{this_count}/{file_count}] Downloading {src_url} to {dst_path}")
|
181
182
|
|
182
183
|
if not (res:=download_file(
|
183
184
|
c, src_url, dst_path,
|
@@ -185,11 +186,13 @@ def download_directory(
|
|
185
186
|
))[0]:
|
186
187
|
failed_items.append((src_url, res[1]))
|
187
188
|
|
188
|
-
batch_size =
|
189
|
+
batch_size = 10_000
|
189
190
|
file_list: list[FileRecord] = []
|
190
191
|
with connector.session(n_concurrent) as c:
|
191
192
|
file_count = c.count_files(src_path, flat=True)
|
192
193
|
for offset in range(0, file_count, batch_size):
|
194
|
+
if verbose:
|
195
|
+
print(f"Retrieving file list... ({offset}/{file_count})", end='\r')
|
193
196
|
file_list.extend(c.list_files(
|
194
197
|
src_path, offset=offset, limit=batch_size, flat=True
|
195
198
|
))
|
lfss/api/connector.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
-
from typing import Optional, Literal
|
3
|
-
import
|
2
|
+
from typing import Optional, Literal
|
3
|
+
from collections.abc import Iterator
|
4
|
+
import os, json
|
4
5
|
import requests
|
5
6
|
import requests.adapters
|
6
7
|
import urllib.parse
|
@@ -14,12 +15,13 @@ from lfss.eng.utils import ensure_uri_compnents
|
|
14
15
|
|
15
16
|
_default_endpoint = os.environ.get('LFSS_ENDPOINT', 'http://localhost:8000')
|
16
17
|
_default_token = os.environ.get('LFSS_TOKEN', '')
|
18
|
+
num_t = float | int
|
17
19
|
|
18
20
|
class Connector:
|
19
21
|
class Session:
|
20
22
|
def __init__(
|
21
23
|
self, connector: Connector, pool_size: int = 10,
|
22
|
-
retry: int = 1, backoff_factor:
|
24
|
+
retry: int = 1, backoff_factor: num_t = 0.5, status_forcelist: list[int] = [503]
|
23
25
|
):
|
24
26
|
self.connector = connector
|
25
27
|
self.pool_size = pool_size
|
@@ -46,13 +48,21 @@ class Connector:
|
|
46
48
|
def __exit__(self, exc_type, exc_value, traceback):
|
47
49
|
self.close()
|
48
50
|
|
49
|
-
def __init__(self, endpoint=_default_endpoint, token=_default_token):
|
51
|
+
def __init__(self, endpoint=_default_endpoint, token=_default_token, timeout: Optional[num_t | tuple[num_t, num_t]]=None, verify: Optional[bool | str] = None):
|
52
|
+
"""
|
53
|
+
- endpoint: the URL of the LFSS server. Default to $LFSS_ENDPOINT or http://localhost:8000.
|
54
|
+
- token: the access token. Default to $LFSS_TOKEN.
|
55
|
+
- timeout: the timeout for each request, can be either a single value or a tuple of two values (connect, read), refer to requests.Session.request.
|
56
|
+
- verify: either a boolean or a string, to control SSL verification. Default to True, refer to requests.Session.request.
|
57
|
+
"""
|
50
58
|
assert token, "No token provided. Please set LFSS_TOKEN environment variable."
|
51
59
|
self.config = {
|
52
60
|
"endpoint": endpoint,
|
53
61
|
"token": token
|
54
62
|
}
|
55
63
|
self._session: Optional[requests.Session] = None
|
64
|
+
self.timeout = timeout
|
65
|
+
self.verify = verify
|
56
66
|
|
57
67
|
def session( self, pool_size: int = 10, **kwargs):
|
58
68
|
""" avoid creating a new session for each request. """
|
@@ -66,18 +76,22 @@ class Connector:
|
|
66
76
|
path = path[1:]
|
67
77
|
path = ensure_uri_compnents(path)
|
68
78
|
def f(**kwargs):
|
69
|
-
|
79
|
+
search_params_t = [
|
80
|
+
(k, str(v).lower() if isinstance(v, bool) else v)
|
81
|
+
for k, v in search_params.items()
|
82
|
+
] # tuple form
|
83
|
+
url = f"{self.config['endpoint']}/{path}" + "?" + urllib.parse.urlencode(search_params_t, doseq=True)
|
70
84
|
headers: dict = kwargs.pop('headers', {})
|
71
85
|
headers.update({
|
72
86
|
'Authorization': f"Bearer {self.config['token']}",
|
73
87
|
})
|
74
88
|
headers.update(extra_headers)
|
75
89
|
if self._session is not None:
|
76
|
-
response = self._session.request(method, url, headers=headers, **kwargs)
|
90
|
+
response = self._session.request(method, url, headers=headers, timeout=self.timeout, verify=self.verify, **kwargs)
|
77
91
|
response.raise_for_status()
|
78
92
|
else:
|
79
93
|
with requests.Session() as s:
|
80
|
-
response = s.request(method, url, headers=headers, **kwargs)
|
94
|
+
response = s.request(method, url, headers=headers, timeout=self.timeout, verify=self.verify, **kwargs)
|
81
95
|
response.raise_for_status()
|
82
96
|
return response
|
83
97
|
return f
|
@@ -88,7 +102,7 @@ class Connector:
|
|
88
102
|
|
89
103
|
# Skip ahead by checking if the file already exists
|
90
104
|
if conflict == 'skip-ahead':
|
91
|
-
exists = self.
|
105
|
+
exists = self.get_meta(path)
|
92
106
|
if exists is None:
|
93
107
|
conflict = 'skip'
|
94
108
|
else:
|
@@ -112,7 +126,7 @@ class Connector:
|
|
112
126
|
|
113
127
|
# Skip ahead by checking if the file already exists
|
114
128
|
if conflict == 'skip-ahead':
|
115
|
-
exists = self.
|
129
|
+
exists = self.get_meta(path)
|
116
130
|
if exists is None:
|
117
131
|
conflict = 'skip'
|
118
132
|
else:
|
@@ -144,7 +158,7 @@ class Connector:
|
|
144
158
|
|
145
159
|
# Skip ahead by checking if the file already exists
|
146
160
|
if conflict == 'skip-ahead':
|
147
|
-
exists = self.
|
161
|
+
exists = self.get_meta(path)
|
148
162
|
if exists is None:
|
149
163
|
conflict = 'skip'
|
150
164
|
else:
|
@@ -197,11 +211,22 @@ class Connector:
|
|
197
211
|
assert response.headers['Content-Type'] == 'application/json'
|
198
212
|
return response.json()
|
199
213
|
|
214
|
+
def get_multiple_text(self, *paths: str, skip_content = False) -> dict[str, Optional[str]]:
|
215
|
+
"""
|
216
|
+
Gets text contents of multiple files at once. Non-existing files will return None.
|
217
|
+
- skip_content: if True, the file contents will not be fetched, always be empty string ''.
|
218
|
+
"""
|
219
|
+
response = self._fetch_factory(
|
220
|
+
'GET', '_api/get-multiple',
|
221
|
+
{'path': paths, "skip_content": skip_content}
|
222
|
+
)()
|
223
|
+
return response.json()
|
224
|
+
|
200
225
|
def delete(self, path: str):
|
201
226
|
"""Deletes the file at the specified path."""
|
202
227
|
self._fetch_factory('DELETE', path)()
|
203
228
|
|
204
|
-
def
|
229
|
+
def get_meta(self, path: str) -> Optional[FileRecord | DirectoryRecord]:
|
205
230
|
"""Gets the metadata for the file at the specified path."""
|
206
231
|
try:
|
207
232
|
response = self._fetch_factory('GET', '_api/meta', {'path': path})()
|
@@ -213,6 +238,9 @@ class Connector:
|
|
213
238
|
if e.response.status_code == 404:
|
214
239
|
return None
|
215
240
|
raise e
|
241
|
+
# shorthand methods for type constraints
|
242
|
+
def get_fmeta(self, path: str) -> Optional[FileRecord]: assert (f:=self.get_meta(path)) is None or isinstance(f, FileRecord); return f
|
243
|
+
def get_dmeta(self, path: str) -> Optional[DirectoryRecord]: assert (d:=self.get_meta(path)) is None or isinstance(d, DirectoryRecord); return d
|
216
244
|
|
217
245
|
def list_path(self, path: str) -> PathContents:
|
218
246
|
"""
|
@@ -276,6 +304,14 @@ class Connector:
|
|
276
304
|
self._fetch_factory('POST', '_api/copy', {'src': src, 'dst': dst})(
|
277
305
|
headers = {'Content-Type': 'application/www-form-urlencoded'}
|
278
306
|
)
|
307
|
+
|
308
|
+
def bundle(self, path: str) -> Iterator[bytes]:
|
309
|
+
"""Bundle a path into a zip file."""
|
310
|
+
response = self._fetch_factory('GET', '_api/bundle', {'path': path})(
|
311
|
+
headers = {'Content-Type': 'application/www-form-urlencoded'},
|
312
|
+
stream = True
|
313
|
+
)
|
314
|
+
return response.iter_content(chunk_size=1024)
|
279
315
|
|
280
316
|
def whoami(self) -> UserRecord:
|
281
317
|
"""Gets information about the current user."""
|