lfss 0.1.0__py3-none-any.whl → 0.2.3__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 ADDED
@@ -0,0 +1,28 @@
1
+ # Lightweight File Storage Service (LFSS)
2
+ [![PyPI](https://img.shields.io/pypi/v/lfss)](https://pypi.org/project/lfss/)
3
+
4
+ A lightweight file/object storage service!
5
+
6
+ Usage:
7
+ ```sh
8
+ pip install .
9
+ lfss-user add <username> <password>
10
+ lfss-serve
11
+ ```
12
+
13
+ By default, the data will be stored in `.storage_data`.
14
+ You can change storage directory using the `LFSS_DATA` environment variable.
15
+
16
+ I provide a simple client to interact with the service.
17
+ Just start a web server at `/frontend` and open `index.html` in your browser, or use:
18
+ ```sh
19
+ lfss-panel
20
+ ```
21
+
22
+ The API usage is simple, just `GET`, `PUT`, `DELETE` to the `/<username>/file/url` path.
23
+ Authentication is done via `Authorization` header, with the value `Bearer <token>`.
24
+ You can refer to `frontend` as an application example, and `frontend/api.js` for the API usage.
25
+
26
+ By default, the service exposes all files to the public for `GET` requests,
27
+ but file-listing is restricted to the user's own files.
28
+ Please refer to [docs/Permission.md](./docs/Permission.md) for more details on the permission system.
docs/Known_issues.md ADDED
@@ -0,0 +1 @@
1
+ [Safari 中文输入法回车捕获](https://github.com/anse-app/anse/issues/127)
docs/Permission.md ADDED
@@ -0,0 +1,29 @@
1
+
2
+ # Permission System
3
+ There are two roles in the system: Admin and User (you can treat then as buckets).
4
+
5
+ ## `PUT` and `DELETE` permissions
6
+ Non-login user don't have `PUT/DELETE` permissions.
7
+ Every user can have `PUT/DELETE` permissions of files under its own `/<user>/` path.
8
+ The admin can have `PUT/DELETE` permissions of files of all users.
9
+
10
+ ## `GET` permissions
11
+ 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 `/`).
12
+
13
+ ### Path-listing
14
+ - Non-login users cannot list any files.
15
+ - All users can list the files under their own path
16
+ - Admins can list the files under other users' path.
17
+
18
+ ### File-access
19
+ 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.
20
+ (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.)
21
+
22
+ There are four types of permissions: `unset`, `public`, `protected`, `private`.
23
+ Non-admin users can access files based on:
24
+
25
+ - If the file is `public`, then all users can access it.
26
+ - If the file is `protected`, then only the logged-in user can access it.
27
+ - If the file is `private`, then only the owner can access it.
28
+ - If the file is `unset`, then the file's permission is inherited from the owner's permission.
29
+ - If both the owner and the file have `unset` permission, then the file is `public`.
frontend/api.js ADDED
@@ -0,0 +1,204 @@
1
+
2
+ /**
3
+ * @typedef Config
4
+ * @property {string} endpoint - the endpoint of the API
5
+ * @property {string} token - the token to authenticate the user
6
+ *
7
+ * Partially complete...
8
+ * @typedef {Object} UserRecord
9
+ * @property {number} id - the id of the user
10
+ * @property {string} username - the username of the user
11
+ * @property {boolean} is_admin - whether the user is an admin
12
+ * @property {string} create_time - the time the user was created
13
+ * @property {number} max_storage - the maximum storage the user can use
14
+ * @property {number} permission - the default read permission of the files owned by the user
15
+ *
16
+ * Partially complete...
17
+ * @typedef {Object} FileRecord
18
+ * @property {string} url - the url of the file
19
+ * @property {number} owner_id - the id of the owner of the file
20
+ * @property {number} file_size - the size of the file, in bytes
21
+ * @property {string} create_time - the time the file was created
22
+ * @property {string} access_time - the time the file was last accessed
23
+ *
24
+ * Partially complete...
25
+ * @typedef {Object} DirectoryRecord
26
+ * @property {string} url - the url of the directory
27
+ * @property {string} size - the size of the directory, in bytes
28
+ *
29
+ * @typedef {Object} PathListResponse
30
+ * @property {DirectoryRecord[]} dirs - the list of directories in the directory
31
+ * @property {FileRecord[]} files - the list of files in the directory
32
+ *
33
+ */
34
+
35
+ export const permMap = {
36
+ 0: 'unset',
37
+ 1: 'public',
38
+ 2: 'protected',
39
+ 3: 'private'
40
+ }
41
+
42
+ export default class Connector {
43
+
44
+ constructor(){
45
+ /** @type {Config} */
46
+ this.config = {
47
+ endpoint: 'http://localhost:8000',
48
+ token: ''
49
+ }
50
+ }
51
+
52
+ /**
53
+ * @param {string} path - the path to the file (url)
54
+ * @param {File} file - the file to upload
55
+ * @returns {Promise<string>} - the promise of the request, the url of the file
56
+ */
57
+ async put(path, file){
58
+ if (path.startsWith('/')){ path = path.slice(1); }
59
+ const fileBytes = await file.arrayBuffer();
60
+ const res = await fetch(this.config.endpoint + '/' + path, {
61
+ method: 'PUT',
62
+ headers: {
63
+ 'Authorization': 'Bearer ' + this.config.token,
64
+ 'Content-Type': 'application/octet-stream'
65
+ },
66
+ body: fileBytes
67
+ });
68
+ if (res.status != 200 && res.status != 201){
69
+ throw new Error(`Failed to upload file, status code: ${res.status}, message: ${await res.json()}`);
70
+ }
71
+ return (await res.json()).url;
72
+ }
73
+
74
+ /**
75
+ * @param {string} path - the path to the file (url), should end with .json
76
+ * @param {Objec} data - the data to upload
77
+ * @returns {Promise<string>} - the promise of the request, the url of the file
78
+ */
79
+ async putJson(path, data){
80
+ if (!path.endsWith('.json')){ throw new Error('Upload object must end with .json'); }
81
+ if (path.startsWith('/')){ path = path.slice(1); }
82
+ const res = await fetch(this.config.endpoint + '/' + path, {
83
+ method: 'PUT',
84
+ headers: {
85
+ 'Authorization': 'Bearer ' + this.config.token,
86
+ 'Content-Type': 'application/json'
87
+ },
88
+ body: JSON.stringify(data)
89
+ });
90
+ if (res.status != 200 && res.status != 201){
91
+ throw new Error(`Failed to upload object, status code: ${res.status}, message: ${await res.json()}`);
92
+ }
93
+ return (await res.json()).url;
94
+ }
95
+
96
+ async delete(path){
97
+ if (path.startsWith('/')){ path = path.slice(1); }
98
+ const res = await fetch(this.config.endpoint + '/' + path, {
99
+ method: 'DELETE',
100
+ headers: {
101
+ 'Authorization': 'Bearer ' + this.config.token
102
+ },
103
+ });
104
+ if (res.status == 200) return;
105
+ throw new Error(`Failed to delete file, status code: ${res.status}, message: ${await res.json()}`);
106
+ }
107
+
108
+ /**
109
+ * @param {string} path - the path to the file
110
+ * @returns {Promise<FileRecord | null>} - the promise of the request
111
+ */
112
+ async getMetadata(path){
113
+ if (path.startsWith('/')){ path = path.slice(1); }
114
+ const res = await fetch(this.config.endpoint + '/_api/fmeta?path=' + path, {
115
+ method: 'GET',
116
+ headers: {
117
+ 'Authorization': 'Bearer ' + this.config.token
118
+ },
119
+ });
120
+ if (res.status == 404){
121
+ return null;
122
+ }
123
+ return await res.json();
124
+ }
125
+
126
+ /**
127
+ * @param {string} path - the path to the file directory, should ends with '/'
128
+ * @returns {Promise<PathListResponse>} - the promise of the request
129
+ */
130
+ async listPath(path){
131
+ if (path.startsWith('/')){ path = path.slice(1); }
132
+ if (!path.endsWith('/')){ path += '/'; }
133
+ const res = await fetch(this.config.endpoint + '/' + path, {
134
+ method: 'GET',
135
+ headers: {
136
+ 'Authorization': 'Bearer ' + this.config.token
137
+ },
138
+ });
139
+ if (res.status == 403 || res.status == 401){
140
+ throw new Error(`Access denied to ${path}`);
141
+ }
142
+ return await res.json();
143
+ }
144
+
145
+ /**
146
+ * Check the user information by the token
147
+ * @returns {Promise<UserRecord>} - the promise of the request
148
+ */
149
+ async whoami(){
150
+ const res = await fetch(this.config.endpoint + '/_api/whoami', {
151
+ method: 'GET',
152
+ headers: {
153
+ 'Authorization': 'Bearer ' + this.config.token
154
+ },
155
+ });
156
+ if (res.status != 200){
157
+ throw new Error('Failed to get user info, status code: ' + res.status);
158
+ }
159
+ return await res.json();
160
+ };
161
+
162
+ /**
163
+ * @param {string} path - file path(url)
164
+ * @param {number} permission - please refer to the permMap
165
+ */
166
+ async setFilePermission(path, permission){
167
+ if (path.startsWith('/')){ path = path.slice(1); }
168
+ const dst = new URL(this.config.endpoint + '/_api/fmeta');
169
+ dst.searchParams.append('path', path);
170
+ dst.searchParams.append('perm', permission);
171
+ const res = await fetch(dst.toString(), {
172
+ method: 'POST',
173
+ headers: {
174
+ 'Authorization': 'Bearer ' + this.config.token
175
+ },
176
+ });
177
+ if (res.status != 200){
178
+ throw new Error(`Failed to set permission, status code: ${res.status}, message: ${await res.json()}`);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * @param {string} path - file path(url)
184
+ * @param {string} newPath - new file path(url)
185
+ */
186
+ async moveFile(path, newPath){
187
+ if (path.startsWith('/')){ path = path.slice(1); }
188
+ if (newPath.startsWith('/')){ newPath = newPath.slice(1); }
189
+ const dst = new URL(this.config.endpoint + '/_api/fmeta');
190
+ dst.searchParams.append('path', path);
191
+ dst.searchParams.append('new_path', newPath);
192
+ const res = await fetch(dst.toString(), {
193
+ method: 'POST',
194
+ headers: {
195
+ 'Authorization': 'Bearer ' + this.config.token,
196
+ 'Content-Type': 'application/www-form-urlencoded'
197
+ },
198
+ });
199
+ if (res.status != 200){
200
+ throw new Error(`Failed to move file, status code: ${res.status}, message: ${await res.json()}`);
201
+ }
202
+ }
203
+
204
+ }
frontend/index.html ADDED
@@ -0,0 +1,60 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LFSS</title>
7
+ <link rel="stylesheet" href="./styles.css">
8
+ </head>
9
+ <body>
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: 800px;">
17
+ <label for="token">Token</label>
18
+ <input type="text" id="token" placeholder="" autocomplete="off">
19
+ </div>
20
+ <div class="input-group">
21
+ <span id='back-btn'>⬅️</span>
22
+ <input type="text" id="path" placeholder="path/to/files/" autocomplete="off">
23
+ </div>
24
+ </div>
25
+
26
+ <div class="container content" id="view">
27
+ <div id="position-hint" class='disconnected'>
28
+ <span></span>
29
+ <label></label>
30
+ </div>
31
+ <table id="files">
32
+ <thead>
33
+ <tr>
34
+ <th>File</th>
35
+ <th>Size</th>
36
+ <th>Accessed</th>
37
+ <th>Created</th>
38
+ <th>Read access</th>
39
+ <th>Actions</th>
40
+ </tr>
41
+ </thead>
42
+ <tbody id="files-table-body">
43
+ </tbody>
44
+ </table>
45
+ </div>
46
+
47
+ <div class="container footer" id="upload">
48
+ <div class="input-group">
49
+ <input type="file" id="file-selector" accept="*">
50
+ <label for="file-name" id="upload-file-prefix"></label>
51
+ <input type="text" id="file-name" placeholder="Destination file path" autocomplete="off">
52
+ <span id='randomize-fname-btn'>🎲</span>
53
+ <button id="upload-btn">Upload</button>
54
+ </div>
55
+ </div>
56
+
57
+ </body>
58
+
59
+ <script src="./scripts.js" type="module" async defer></script>
60
+ </html>
frontend/popup.css ADDED
@@ -0,0 +1,30 @@
1
+
2
+ div.floating-window.blocker{
3
+ position: fixed;
4
+ top: 0;
5
+ left: 0;
6
+ width: 100%;
7
+ height: 100%;
8
+ background-color: rgba(0,0,0,0.25);
9
+ z-index: 100;
10
+ }
11
+
12
+ div.floating-window.window{
13
+ position: fixed;
14
+ top: 50%;
15
+ left: 50%;
16
+ transform: translate(-50%, -50%);
17
+ background-color: white;
18
+ box-shadow: 0 0 10px rgba(0,0,0,0.2);
19
+ border-radius: 0.5rem;
20
+ z-index: 101;
21
+ max-width: 80%;
22
+ max-height: 80%;
23
+ overflow: auto;
24
+ text-align: center;
25
+
26
+ display: flex;
27
+ flex-direction: column;
28
+ justify-content: center;
29
+ align-items: center;
30
+ }
frontend/popup.js ADDED
@@ -0,0 +1,89 @@
1
+
2
+
3
+ export function createFloatingWindow(innerHTML = '', {
4
+ onClose = () => {},
5
+ width = "auto",
6
+ height = "auto",
7
+ padding = "20px",
8
+ } = {}){
9
+ const blocker = document.createElement("div");
10
+ blocker.classList.add("floating-window", "blocker");
11
+
12
+ const floatingWindow = document.createElement("div");
13
+ floatingWindow.classList.add("floating-window", "window");
14
+ floatingWindow.id = "floatingWindow";
15
+ floatingWindow.innerHTML = innerHTML;
16
+ floatingWindow.style.width = width;
17
+ floatingWindow.style.height = height;
18
+ floatingWindow.style.padding = padding;
19
+
20
+ const container = document.createElement("div");
21
+ container.classList.add("floating-window", "container");
22
+
23
+ document.body.appendChild(blocker);
24
+ document.body.appendChild(floatingWindow);
25
+
26
+ function closeWindow(){
27
+ onClose();
28
+ if (blocker.parentNode) document.body.removeChild(blocker);
29
+ if (floatingWindow.parentNode) document.body.removeChild(floatingWindow);
30
+ window.removeEventListener("keydown", excapeEvListener);
31
+ }
32
+ blocker.onclick = closeWindow;
33
+
34
+ const excapeEvListener = (event) => {
35
+ event.stopPropagation();
36
+ if (event.key === "Escape") closeWindow();
37
+ }
38
+ window.addEventListener("keydown", excapeEvListener);
39
+
40
+ return [floatingWindow, closeWindow];
41
+ }
42
+
43
+ /* select can be "last-filename" */
44
+ export function showFloatingWindowLineInput(onSubmit = (v) => {}, {
45
+ text = "",
46
+ placeholder = "Enter text",
47
+ value = "",
48
+ select = ""
49
+ } = {}){
50
+ const [floatingWindow, closeWindow] = createFloatingWindow(`
51
+ <div style="margin-bottom: 0.5rem;width: 100%;text-align: left;">${text}</div>
52
+ <div style="display: flex; flex-direction: row; gap: 0.25rem;">
53
+ <input type="text" placeholder="${placeholder}" id="floatingWindowInput" value="${value}" style="min-width: 300px;"/>
54
+ <button id="floatingWindowSubmit">OK</button>
55
+ </div>
56
+ `);
57
+
58
+ /** @type {HTMLInputElement} */
59
+ const input = document.getElementById("floatingWindowInput");
60
+ const submit = document.getElementById("floatingWindowSubmit");
61
+
62
+ input.focus();
63
+ input.addEventListener("keydown", event => {
64
+ if(event.key === "Enter" && input.value && event.isComposing === false){
65
+ submit.click();
66
+ }
67
+ });
68
+
69
+ submit.onclick = () => {
70
+ onSubmit(input.value);
71
+ closeWindow();
72
+ };
73
+
74
+ if (select === "last-filename") {
75
+ const inputVal = input.value;
76
+ let lastSlash = inputVal.lastIndexOf("/");
77
+ if (lastSlash === -1) {
78
+ lastSlash = 0;
79
+ }
80
+ const fname = inputVal.slice(lastSlash + 1);
81
+ let lastDot = fname.lastIndexOf(".");
82
+ if (lastDot === -1) {
83
+ lastDot = fname.length;
84
+ }
85
+ input.setSelectionRange(lastSlash + 1, lastSlash + lastDot + 1);
86
+ }
87
+
88
+ return [floatingWindow, closeWindow];
89
+ }