lfss 0.12.3__py3-none-any.whl → 0.13.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.
docs/changelog.md CHANGED
@@ -1,3 +1,20 @@
1
+
2
+ ## 0.13
3
+
4
+ ### 0.13.0
5
+ - Break: Py client GET API not handle 404 as None.
6
+ - Break & Fix: Move will transfer ownership.
7
+ - Delete user transfer ownership of files outside their directory.
8
+ - In favor of `Client` instead of `Connector` for API client class.
9
+ - Add `/move` and `/set-perm` api for moving files and setting file permission.
10
+ - Add `/user` prefix for user related api.
11
+ - Add CLI command `mv`, `cp`, and `perm`.
12
+ - Add `name` method for record classes.
13
+ - Improve assertion error handling.
14
+ - Add user storage query api and query exists api.
15
+ - Non-exist user path will return 404 instead of 400.
16
+ - Frontend add editor page for text files.
17
+
1
18
  ## 0.12
2
19
 
3
20
  ### 0.12.3
frontend/api.js CHANGED
@@ -59,13 +59,51 @@ async function fmtFailedResponse(res){
59
59
  export default class Connector {
60
60
 
61
61
  constructor(){
62
+ // get default endpoint from url
63
+ const searchParams = (new URL(window.location.href)).searchParams;
64
+ const defaultToken = searchParams.get('lfss-token') || '';
65
+ const defaultEndpoint = searchParams.get('lfss-endpoint') ||
66
+ (searchParams.origin == 'null' ? 'http://localhost:8000' : searchParams.origin);
67
+
62
68
  /** @type {Config} */
63
69
  this.config = {
64
- endpoint: 'http://localhost:8000',
65
- token: ''
70
+ endpoint: defaultEndpoint,
71
+ token: defaultToken
66
72
  }
67
73
  }
68
74
 
75
+ async getText(path){
76
+ if (path.startsWith('/')){ path = path.slice(1); }
77
+ const res = await fetch(this.config.endpoint + '/' + path, {
78
+ method: 'GET',
79
+ headers: {
80
+ "Authorization": 'Bearer ' + this.config.token
81
+ },
82
+ });
83
+ if (res.status != 200){
84
+ throw new Error(`Failed to get file, status code: ${res.status}, message: ${await fmtFailedResponse(res)}`);
85
+ }
86
+ return await res.text();
87
+ }
88
+
89
+ /**
90
+ * @param {string} path - the path to the file (url)
91
+ * @param {string} text - the text content to upload
92
+ * @param {Object} [options] - Optional upload configuration.
93
+ * @param {'abort' | 'overwrite' | 'skip'} [options.conflict='abort'] - Conflict resolution strategy:
94
+ * @param {string} [options.type='text/plain'] - The MIME type of the text file.
95
+ * NOTE: type is only a hint, the backend may ignore it...
96
+ */
97
+ async putText(path, text, {
98
+ conflict = 'abort',
99
+ type = 'text/plain'
100
+ }){
101
+ const file = new Blob([text], { type: type });
102
+ return await this.put(path, file, {
103
+ conflict: conflict
104
+ });
105
+ }
106
+
69
107
  /**
70
108
  * @param {string} path - the path to the file (url)
71
109
  * @param {File} file - the file to upload
@@ -393,7 +431,7 @@ export default class Connector {
393
431
  * @returns {Promise<UserRecord>} - the promise of the request
394
432
  */
395
433
  async whoami(){
396
- const res = await fetch(this.config.endpoint + '/_api/whoami', {
434
+ const res = await fetch(this.config.endpoint + '/_api/user/whoami', {
397
435
  method: 'GET',
398
436
  headers: {
399
437
  'Authorization': 'Bearer ' + this.config.token
@@ -405,13 +443,36 @@ export default class Connector {
405
443
  return await res.json();
406
444
  };
407
445
 
446
+ /**
447
+ * List peer users
448
+ * @param {Object} [options] - Optional configuration.
449
+ * @param {number} [options.level=1] - The level of users to list. 1 for at leaset read permission
450
+ * @param {boolean} [options.incoming=false] - Whether to list incoming users (can access me) or outgoing users (I can access)
451
+ * @returns {Promise<UserRecord[]>} - the promise of the request
452
+ */
453
+ async listPeers({ level = 1, incoming = false } = {}){
454
+ const dst = new URL(this.config.endpoint + '/_api/user/list-peers');
455
+ dst.searchParams.append('level', level);
456
+ dst.searchParams.append('incoming', incoming);
457
+ const res = await fetch(dst.toString(), {
458
+ method: 'GET',
459
+ headers: {
460
+ 'Authorization': 'Bearer ' + this.config.token
461
+ },
462
+ });
463
+ if (res.status != 200){
464
+ throw new Error('Failed to list peer users, status code: ' + res.status);
465
+ }
466
+ return await res.json();
467
+ }
468
+
408
469
  /**
409
470
  * @param {string} path - file path(url)
410
471
  * @param {number} permission - please refer to the permMap
411
472
  */
412
473
  async setFilePermission(path, permission){
413
474
  if (path.startsWith('/')){ path = path.slice(1); }
414
- const dst = new URL(this.config.endpoint + '/_api/meta');
475
+ const dst = new URL(this.config.endpoint + '/_api/set-perm');
415
476
  dst.searchParams.append('path', path);
416
477
  dst.searchParams.append('perm', permission);
417
478
  const res = await fetch(dst.toString(), {
@@ -432,9 +493,9 @@ export default class Connector {
432
493
  async move(path, newPath){
433
494
  if (path.startsWith('/')){ path = path.slice(1); }
434
495
  if (newPath.startsWith('/')){ newPath = newPath.slice(1); }
435
- const dst = new URL(this.config.endpoint + '/_api/meta');
436
- dst.searchParams.append('path', path);
437
- dst.searchParams.append('new_path', newPath);
496
+ const dst = new URL(this.config.endpoint + '/_api/move');
497
+ dst.searchParams.append('src', path);
498
+ dst.searchParams.append('dst', newPath);
438
499
  const res = await fetch(dst.toString(), {
439
500
  method: 'POST',
440
501
  headers: {
@@ -496,8 +557,28 @@ export async function listPath(conn, path, {
496
557
  } = {}){
497
558
 
498
559
  if (path === '/' || path === ''){
499
- // this handles separate case for the root directory... please refer to the backend implementation
500
- return [await conn.listPath(''), {dirs: 0, files: 0}];
560
+ // this handles separate case for the root directory
561
+ const dirnames = [
562
+ (await conn.whoami()).username + '/'
563
+ ].concat(
564
+ (await conn.listPeers({ level: 1, incoming: false })).map(u => u.username + '/')
565
+ )
566
+ return [
567
+ {
568
+ dirs: dirnames.map(dirname => ({
569
+ url: dirname,
570
+ size: -1,
571
+ create_time: '',
572
+ update_time: '',
573
+ access_time: '',
574
+ n_files: -1
575
+ })),
576
+ files: []
577
+ }, {
578
+ dirs: dirnames.length,
579
+ files: 0
580
+ }
581
+ ]
501
582
  }
502
583
 
503
584
  orderBy = orderBy == 'none' ? '' : orderBy;
frontend/base.css ADDED
@@ -0,0 +1,33 @@
1
+
2
+
3
+ html {
4
+ overflow: -moz-scrollbars-vertical;
5
+ overflow-y: scroll;
6
+ }
7
+
8
+ body{
9
+ font-family: Arial, sans-serif;
10
+ background-color: #f1f1f1;
11
+ display: flex;
12
+ justify-content: center;
13
+ align-items: center;
14
+ }
15
+
16
+ input[type=button], button{
17
+ background-color: #195f8b;
18
+ color: white;
19
+ padding: 0.8rem;
20
+ margin: 0;
21
+ border: none;
22
+ border-radius: 0.25rem;
23
+ cursor: pointer;
24
+ }
25
+
26
+ input[type=text], input[type=password]
27
+ {
28
+ width: 100%;
29
+ padding: 0.75rem;
30
+ border: 1px solid #ccc;
31
+ border-radius: 0.25rem;
32
+ height: 1rem;
33
+ }
frontend/edit.css ADDED
@@ -0,0 +1,102 @@
1
+ @import url('./base.css');
2
+
3
+ body{
4
+ font-family: Arial, sans-serif;
5
+ background-color: #f1f1f1;
6
+ display: flex;
7
+ flex-direction: column;
8
+ align-items: center;
9
+ margin: 0;
10
+ padding: 0;
11
+ }
12
+
13
+ div#header {
14
+ top: 0;
15
+ left: 0;
16
+ right: 0;
17
+ width: 100%;
18
+
19
+
20
+ display: flex;
21
+ flex-direction: row;
22
+ align-items: center;
23
+ gap: 0.5rem;
24
+
25
+ background-color: white;
26
+ box-shadow: 0 0 10px rgba(0,0,0,0.1);
27
+ padding-block: 0.5rem;
28
+ padding-inline: 1rem;
29
+ z-index: 1000;
30
+ }
31
+
32
+ div#header .left {
33
+ display: flex;
34
+ flex-direction: row;
35
+ align-items: center;
36
+ gap: 0.5rem;
37
+ flex-grow: 1;
38
+ padding-inline: 0.5rem;
39
+ }
40
+
41
+ div#header .right {
42
+ display: flex;
43
+ flex-direction: row;
44
+ align-items: center;
45
+ gap: 0.5rem;
46
+ padding-inline: 0.5rem;
47
+ }
48
+
49
+ span.unicode-btn{
50
+ display: flex;
51
+ justify-content: center;
52
+ align-items: center;
53
+ left: 1.5rem;
54
+ width: 1rem;
55
+ padding: 0.25rem 0.5rem;
56
+ border-radius: 0.5rem;
57
+ background-color: rgb(189, 203, 211);
58
+ cursor: pointer;
59
+ }
60
+
61
+ #file-path-label {
62
+ font-weight: bold;
63
+ font-size: 1.2rem;
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 0.25rem;
67
+ }
68
+ #new-hint{
69
+ font-size: small;
70
+ font-weight: bold;
71
+ color: green;
72
+ margin-left: 0.25rem;
73
+ background-color: #e0ffe0;
74
+ border-radius: 0.25rem;
75
+ padding: 0.1rem 0.25rem;
76
+ user-select: none;
77
+ pointer-events: none;
78
+ }
79
+
80
+ textarea#editor{
81
+ width: max(90vw, 500px);
82
+ height: 80vh;
83
+ margin-top: 1rem;
84
+ padding: 1rem;
85
+ font-family: 'Courier New', Courier, monospace;
86
+ font-size: 1rem;
87
+ border: 1px solid #ccc;
88
+ border-radius: 0.25rem;
89
+ box-shadow: 0 0 10px rgba(0,0,0,0.1);
90
+ resize: none;
91
+ outline: none;
92
+ }
93
+
94
+ #save-hint{
95
+ font-size: 0.9rem;
96
+ color: white;
97
+ opacity: 0;
98
+ transition: opacity 0.3s ease-in-out;
99
+ background-color: green;
100
+ padding: 0.25rem 0.5rem;
101
+ border-radius: 0.5rem;
102
+ }
frontend/edit.html ADDED
@@ -0,0 +1,29 @@
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-Editor</title>
7
+ <link rel="stylesheet" href="./edit.css">
8
+ <link rel="stylesheet" href="./login.css">
9
+ </head>
10
+ <body>
11
+
12
+ <div id="header">
13
+ <div class="left">
14
+ <span id='home-btn' class="unicode-btn">⬅️</span>
15
+ <label id="file-path-label"></label>
16
+ </div>
17
+ <div class="right">
18
+ <span id='save-hint' class="hint">Saved</span>
19
+ <span id='save-btn' class="unicode-btn">💾</span>
20
+ </div>
21
+ </div>
22
+
23
+ <div class="container content" id="view">
24
+ <textarea id="editor"></textarea>
25
+ </div>
26
+
27
+ <script src="./edit.js" type="module"></script>
28
+ </body>
29
+ </html>
frontend/edit.js ADDED
@@ -0,0 +1,130 @@
1
+ import { store } from './state.js';
2
+ import { maybeShowLoginPanel } from './login.js';
3
+
4
+ const MAX_FILE_SIZE_MB = 5;
5
+
6
+ {
7
+ // initialization
8
+ store.init();
9
+ maybeShowLoginPanel(store, ).then(
10
+ (user) => {
11
+ console.log("User record", user);
12
+ }
13
+ )
14
+ }
15
+
16
+ const filePathLabel = document.getElementById('file-path-label');
17
+ const textArea = document.querySelector('#editor');
18
+ const backhomeBtn = document.getElementById('home-btn');
19
+ const saveBtn = document.getElementById('save-btn');
20
+ const saveHint = document.getElementById('save-hint');
21
+
22
+ // disable until file is loaded
23
+ saveBtn.disabled = true;
24
+ textArea.disabled = true;
25
+
26
+ backhomeBtn.addEventListener('click', () => {
27
+ const urlFrom = new URL(window.location.href);
28
+ if (urlFrom.searchParams.has('from')) {
29
+ const from = urlFrom.searchParams.get('from');
30
+ window.location.href = from;
31
+ return;
32
+ }
33
+ window.location.href = './index.html';
34
+ });
35
+
36
+ // make textarea tab insert spaces
37
+ textArea.addEventListener('keydown', (e) => {
38
+ const TAB_SIZE = 4;
39
+ if (e.key === 'Tab') {
40
+ e.preventDefault();
41
+ const start = textArea.selectionStart;
42
+ const end = textArea.selectionEnd;
43
+ textArea.value = textArea.value.substring(0, start) + ' '.repeat(TAB_SIZE) + textArea.value.substring(end);
44
+ textArea.selectionStart = textArea.selectionEnd = start + TAB_SIZE;
45
+ }
46
+ });
47
+
48
+ const urlParams = new URLSearchParams(window.location.search);
49
+ const filePath = urlParams.get('p') || urlParams.get('path');
50
+
51
+ function raiseError(msg) {
52
+ filePathLabel.textContent = `Error: ${msg}`;
53
+ filePathLabel.style.color = 'darkred';
54
+ textArea.disabled = true;
55
+ throw new Error(msg);
56
+ }
57
+
58
+
59
+ if (!filePath) {
60
+ raiseError('No file specified');
61
+ }
62
+ if (filePath.endsWith('/')) {
63
+ raiseError('Path cannot be a directory');
64
+ }
65
+
66
+ async function loadContent() {
67
+ content = await store.conn.getText(filePath).catch((e) => {
68
+ raiseError(`Failed to read file "${filePath}": ${e.message}`);
69
+ });
70
+ textArea.value = content;
71
+ return content;
72
+ }
73
+
74
+ // check existence
75
+ let ftype='';
76
+ let content = '';
77
+
78
+ const fmeta = await store.conn.getMetadata(filePath).catch((e) => {
79
+ raiseError(`File "${filePath}" does not exist or cannot be accessed.`);
80
+ });
81
+ filePathLabel.textContent = filePath;
82
+ if (fmeta != null) {
83
+ ftype = fmeta.mime_type || '';
84
+ saveHint.style.opacity = 1;
85
+ if (fmeta.file_size && fmeta.file_size > MAX_FILE_SIZE_MB * 1024 * 1024) {
86
+ raiseError(`File too large (${(fmeta.file_size / (1024 * 1024)).toFixed(2)} MB). Max allowed size is ${MAX_FILE_SIZE_MB} MB.`);
87
+ }
88
+ content = await loadContent();
89
+ }
90
+ else {
91
+ const newHint = document.createElement('span');
92
+ newHint.id = 'new-hint';
93
+ newHint.textContent = 'new';
94
+ filePathLabel.appendChild(newHint);
95
+ textArea.focus();
96
+ }
97
+
98
+ async function saveFile() {
99
+ const content = textArea.value;
100
+ try {
101
+ await store.conn.putText(filePath, content, {conflict: 'overwrite', type: ftype? ftype : 'text/plain'});
102
+ saveHint.style.opacity = 1;
103
+ // remove new file hint if exists
104
+ const newHint = document.getElementById('new-hint');
105
+ if (newHint) { newHint.remove(); }
106
+ }
107
+ catch (e) {
108
+ raiseError(`Failed to save file "${filePath}": ${e.message}`);
109
+ }
110
+ }
111
+
112
+ // unfreeze elements
113
+ saveBtn.disabled = false;
114
+ textArea.disabled = false;
115
+
116
+ saveBtn.addEventListener('click', saveFile);
117
+ textArea.addEventListener('input', () => {
118
+ saveHint.style.opacity = 0;
119
+ if (content == textArea.value){
120
+ saveHint.style.opacity = 1;
121
+ }
122
+ });
123
+
124
+ // bind Ctrl+S to save
125
+ document.addEventListener('keydown', (e) => {
126
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
127
+ e.preventDefault();
128
+ saveBtn.click();
129
+ }
130
+ });
frontend/login.css CHANGED
@@ -1,3 +1,4 @@
1
+ @import url('./popup.css');
1
2
 
2
3
  div#login-container{
3
4
  display: flex;
frontend/scripts.js CHANGED
@@ -84,10 +84,11 @@ pathBackButton.addEventListener('click', () => {
84
84
  function onFileNameInpuChange(){
85
85
  const fileName = uploadFileNameInput.value;
86
86
  if (fileName.endsWith('/')){
87
- uploadFileNameInput.classList.add('duplicate');
87
+ uploadFileNameInput.classList.add('red-bg');
88
88
  return;
89
89
  }
90
90
  if (fileName.length === 0){
91
+ uploadFileNameInput.classList.remove('red-bg');
91
92
  uploadFileNameInput.classList.remove('duplicate');
92
93
  }
93
94
  else {
@@ -95,9 +96,15 @@ function onFileNameInpuChange(){
95
96
  conn.getMetadata(p).then(
96
97
  (data) => {
97
98
  console.log("Got file meta", data);
98
- if (data===null) uploadFileNameInput.classList.remove('duplicate');
99
- else if (data.url) uploadFileNameInput.classList.add('duplicate');
99
+ if (data===null) {
100
+ uploadFileNameInput.classList.remove('red-bg');
101
+ uploadFileNameInput.classList.remove('duplicate');
102
+ }
103
+ else if (data.url){
104
+ uploadFileNameInput.classList.add('duplicate');
105
+ }
100
106
  else throw new Error('Invalid response');
107
+ updateFileUploadButton();
101
108
  }
102
109
  );
103
110
  }
@@ -117,11 +124,48 @@ randomizeFnameButton.addEventListener('click', () => {
117
124
  uploadFileNameInput.value = newName;
118
125
  onFileNameInpuChange();
119
126
  });
127
+ function updateFileUploadButton(){
128
+ if (uploadFileSelector.files.length === 0){
129
+ // change background color to indicate new/edit
130
+ if (!uploadButton.classList.contains('toedit')){
131
+ uploadButton.classList.add('toedit');
132
+ }
133
+ if (uploadFileNameInput.classList.contains('duplicate') && !uploadFileNameInput.value.endsWith('/')){
134
+ uploadButton.innerHTML = 'Edit';
135
+ uploadButton.title = 'Edit the existing file with the specified name';
136
+ }
137
+ else {
138
+ uploadButton.innerHTML = 'New';
139
+ uploadButton.title = 'Create a new empty file with the specified name';
140
+ }
141
+ }
142
+ else {
143
+ // change background color to indicate upload
144
+ if (uploadButton.classList.contains('toedit')){
145
+ uploadButton.classList.remove('toedit');
146
+ }
147
+ uploadButton.innerHTML = 'Upload';
148
+ uploadButton.title = 'Upload the selected file';
149
+ }
150
+ }
151
+ updateFileUploadButton();
120
152
  uploadFileSelector.addEventListener('change', () => {
153
+ updateFileUploadButton();
121
154
  uploadFileNameInput.value = uploadFileSelector.files[0].name;
122
155
  onFileNameInpuChange();
123
156
  });
124
157
  uploadButton.addEventListener('click', () => {
158
+ // create new empty file or edit existing file
159
+ if (uploadFileSelector.files.length === 0){
160
+ const newUrl = ensurePathURI(store.dirpath + uploadFileNameInput.value);
161
+ const thisUrl = new URL(window.location.href);
162
+ thisUrl.pathname = thisUrl.pathname.replace(/\/[^\/]*$/, '/edit.html');
163
+ thisUrl.searchParams.set('path', newUrl);
164
+ thisUrl.searchParams.set('from', window.location.href);
165
+ window.location.href = thisUrl.href;
166
+ return;
167
+ }
168
+
125
169
  const file = uploadFileSelector.files[0];
126
170
  let path = store.dirpath;
127
171
  let fileName = uploadFileNameInput.value;
@@ -167,6 +211,9 @@ uploadFileNameInput.addEventListener('input', debounce(onFileNameInpuChange, 500
167
211
  uploadFileSelector.files = e.dataTransfer.files;
168
212
  uploadFileNameInput.value = e.dataTransfer.files[0].name;
169
213
  uploadFileNameInput.focus();
214
+ // trigger change event
215
+ updateFileUploadButton();
216
+ onFileNameInpuChange();
170
217
  return;
171
218
  }
172
219
 
frontend/styles.css CHANGED
@@ -1,41 +1,9 @@
1
+ @import "./base.css";
1
2
  @import "./popup.css";
2
3
  @import "./info.css";
3
4
  @import "./thumb.css";
4
5
  @import "./login.css";
5
6
 
6
- html {
7
- overflow: -moz-scrollbars-vertical;
8
- overflow-y: scroll;
9
- }
10
-
11
- body{
12
- font-family: Arial, sans-serif;
13
- background-color: #f1f1f1;
14
- display: flex;
15
- justify-content: center;
16
- align-items: center;
17
- }
18
-
19
- input[type=button], button{
20
- background-color: #195f8b;
21
- color: white;
22
- padding: 0.8rem;
23
- margin: 0;
24
- border: none;
25
- border-radius: 0.25rem;
26
- cursor: pointer;
27
- }
28
-
29
- input[type=text], input[type=password]
30
- {
31
- width: 100%;
32
- padding: 0.75rem;
33
- border: 1px solid #ccc;
34
- border-radius: 0.25rem;
35
- height: 1rem;
36
- }
37
-
38
-
39
7
  div.container.header, div.container.footer{
40
8
  position: fixed;
41
9
  left: 0;
@@ -128,6 +96,11 @@ input#path{
128
96
  border-radius: 0.2rem;
129
97
  cursor: pointer;
130
98
  }
99
+ #upload-btn.toedit{
100
+ background-color: white;
101
+ color: #195f8b;
102
+ box-shadow: 2px 2px 10px #195f8b33;
103
+ }
131
104
 
132
105
  div#top-bar{
133
106
  color: grey;
@@ -217,9 +190,12 @@ label#upload-file-prefix{
217
190
  width: 800px;
218
191
  text-align: right;
219
192
  }
220
- input#file-name.duplicate{
193
+ input#file-name.red-bg{
221
194
  background-color: #ff001511
222
195
  }
196
+ input#file-name.duplicate{
197
+ background-color: #ffcc0011;
198
+ }
223
199
 
224
200
  .action-container{
225
201
  display: flex;