Compare commits
	
		
			25 Commits
		
	
	
		
			37167a41a6
			...
			v0.4.2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 61f9026e23 | ||
|   | 3e50149d4d | ||
|   | 7077b21159 | ||
|   | 938c5ca657 | ||
|   | e0aef07783 | ||
|   | 36826a83c1 | ||
|   | 6880f82c19 | ||
|   | 5dd1bd9bdc | ||
|   | 41e8c78ecd | ||
|   | dc4bb494f3 | ||
|   | 9b58b887b4 | ||
|   | 07848907f3 | ||
|   | 7a08f7cbe2 | ||
|   | dd37238510 | ||
|   | c8d5f335b1 | ||
|   | bb80b3ee54 | ||
|   | 06d860c601 | ||
|   | c321de13fd | ||
|   | 278e8303c4 | ||
|   | 9854dd01cc | ||
|   | fb03fa5430 | ||
|   | e26cb8f70a | ||
|   | 9bbbc829a1 | ||
|   | 876d76bc1f | ||
|   | 4a53d0b8e2 | 
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -3,4 +3,5 @@ | |||||||
| __pycache__/ | __pycache__/ | ||||||
| *.egg-info/ | *.egg-info/ | ||||||
| /cista/_version.py | /cista/_version.py | ||||||
|  | /cista/wwwroot/* | ||||||
| /dist | /dist | ||||||
|   | |||||||
							
								
								
									
										71
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,25 +1,78 @@ | |||||||
| # Web File Storage | # Web File Storage | ||||||
|  |  | ||||||
| Run directly from repository with Hatch (or use pip install as usual): | The Python package installs a `cista` executable. Use `hatch shell` to initiate and install in a virtual environment, or `pip install` it on your system. Alternatively `hatch run cista` may be used to skip the shell step but stay virtual. `pip install hatch` first if needed. | ||||||
| ```sh |  | ||||||
| hatch run cista -l :3000 /path/to/files |  | ||||||
| ``` |  | ||||||
|  |  | ||||||
| Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script). |  | ||||||
|  |  | ||||||
| Create your user account: | Create your user account: | ||||||
|  |  | ||||||
| ```sh | ```sh | ||||||
| hatch run cista --user admin --privileged | cista --user admin --privileged | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
|  | ## Running the server | ||||||
|  |  | ||||||
|  | Serve your files on localhost:8000: | ||||||
|  |  | ||||||
|  | ```sh | ||||||
|  | cista -l :8000 /path/to/files | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | The Git repository does not contain a frontend build, so you should first do that... | ||||||
|  |  | ||||||
| ## Build frontend | ## Build frontend | ||||||
|  |  | ||||||
| Prebuilt frontend is provided in repository but for any changes it will need to be manually rebuilt: | Frontend needs to be built before using and after any frontend changes: | ||||||
|  |  | ||||||
| ```sh | ```sh | ||||||
| cd cista-front | cd frontend | ||||||
| npm install | npm install | ||||||
| npm run build | npm run build | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`. | This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`. | ||||||
|  |  | ||||||
|  | ## Development setup | ||||||
|  |  | ||||||
|  | For rapid turnaround during development, you should run `npm run dev` Vite development server on the Vue frontend. While that is running, start the backend on another terminal `hatch run cista --dev -l :8000` and connect to the frontend. | ||||||
|  |  | ||||||
|  | The backend and the frontend will each reload automatically at any code or config changes. | ||||||
|  |  | ||||||
|  | ## System deployment | ||||||
|  |  | ||||||
|  | Clone the repository to `/srv/cista/cista-storage` or other suitable location accessible to the storage user account you plan to use. `sudo -u storage -s` and build the frontend if you hadn't already. | ||||||
|  |  | ||||||
|  | Create **/etc/systemd/system/cista@.service**: | ||||||
|  |  | ||||||
|  | ```ini | ||||||
|  | [Unit] | ||||||
|  | Description=Cista storage %i | ||||||
|  |  | ||||||
|  | [Service] | ||||||
|  | User=storage | ||||||
|  | WorkingDirectory=/srv/cista/cista-storage | ||||||
|  | ExecStart=hatch run cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/ | ||||||
|  | TimeoutStopSec=2 | ||||||
|  | Restart=always | ||||||
|  |  | ||||||
|  | [Install] | ||||||
|  | WantedBy=multi-user.target | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | This assumes you may want to run multiple separate storages, each having their files under `/media/storage/<domain>` and configuration under `/srv/cista/<domain>/`. Instead of numeric ports, we use UNIX sockets for convenience. | ||||||
|  |  | ||||||
|  | ```sh | ||||||
|  | systemctl daemon-reload | ||||||
|  | systemctl enable --now cista@foo.example.com | ||||||
|  | systemctl enable --now cista@bar.example.com | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | Exposing this publicly online is the most convenient using the [Caddy](https://caddyserver.com/) web server but you can of course use Nginx or others as well. Or even run the server with `-l domain.example.com` given TLS certificates in the config folder. | ||||||
|  |  | ||||||
|  | **/etc/caddy/Caddyfile**: | ||||||
|  |  | ||||||
|  | ```Caddyfile | ||||||
|  | foo.example.com, bar.example.com { | ||||||
|  |     reverse_proxy unix//srv/cista/{host}/socket | ||||||
|  | } | ||||||
|  | ``` | ||||||
|  |  | ||||||
|  | Using the `{host}` placeholder we can just put all the domains on the same block. That's the full server configuration you need. `systemctl enable --now caddy` or `systemctl restart caddy` for the config to take effect. | ||||||
|   | |||||||
| @@ -1,13 +0,0 @@ | |||||||
| <!DOCTYPE html> |  | ||||||
| <html lang="en"> |  | ||||||
|   <head> |  | ||||||
|     <meta charset="UTF-8"> |  | ||||||
|     <link rel="icon" href="/favicon.ico"> |  | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |  | ||||||
|     <title>Vite App</title> |  | ||||||
|   </head> |  | ||||||
|   <body> |  | ||||||
|     <div id="app"></div> |  | ||||||
|     <script type="module" src="/src/main.ts"></script> |  | ||||||
|   </body> |  | ||||||
| </html> |  | ||||||
							
								
								
									
										1458
									
								
								cista-front/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						| @@ -1,27 +0,0 @@ | |||||||
| { |  | ||||||
|   "name": "cista-front", |  | ||||||
|   "version": "0.0.0", |  | ||||||
|   "private": true, |  | ||||||
|   "scripts": { |  | ||||||
|     "dev": "vite", |  | ||||||
|     "build": "run-p type-check \"build-only {@}\" --", |  | ||||||
|     "preview": "vite preview", |  | ||||||
|     "build-only": "vite build", |  | ||||||
|     "type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false" |  | ||||||
|   }, |  | ||||||
|   "dependencies": { |  | ||||||
|     "pinia": "^2.1.7", |  | ||||||
|     "vue": "^3.3.4", |  | ||||||
|     "vue-router": "^4.2.5" |  | ||||||
|   }, |  | ||||||
|   "devDependencies": { |  | ||||||
|     "@tsconfig/node18": "^18.2.2", |  | ||||||
|     "@types/node": "^18.18.5", |  | ||||||
|     "@vitejs/plugin-vue": "^4.4.0", |  | ||||||
|     "@vue/tsconfig": "^0.4.0", |  | ||||||
|     "npm-run-all2": "^6.1.1", |  | ||||||
|     "typescript": "~5.2.0", |  | ||||||
|     "vite": "^4.4.11", |  | ||||||
|     "vue-tsc": "^1.8.19" |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| Before Width: | Height: | Size: 4.2 KiB | 
| @@ -1,241 +0,0 @@ | |||||||
| <!DOCTYPE html> |  | ||||||
| <title>Storage</title> |  | ||||||
| <style> |  | ||||||
|   body { |  | ||||||
|     font-family: sans-serif; |  | ||||||
|     max-width: 100ch; |  | ||||||
|     margin: 0 auto; |  | ||||||
|     padding: 1em; |  | ||||||
|     background-color: #333; |  | ||||||
|     color: #eee; |  | ||||||
|   } |  | ||||||
|   td { |  | ||||||
|     text-align: right; |  | ||||||
|     padding: .5em; |  | ||||||
|   } |  | ||||||
|   td:first-child { |  | ||||||
|     text-align: left; |  | ||||||
|   } |  | ||||||
|   a { |  | ||||||
|     color: inherit; |  | ||||||
|     text-decoration: none; |  | ||||||
|   } |  | ||||||
| </style> |  | ||||||
| <div> |  | ||||||
|   <h2>Quick file upload</h2> |  | ||||||
|   <p>Uses parallel WebSocket connections for increased bandwidth /api/upload</p> |  | ||||||
|   <input type=file id=fileInput> |  | ||||||
|   <progress id=progressBar value=0 max=1></progress> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| <div> |  | ||||||
|   <h2>Files</h2> |  | ||||||
|   <ul id=file_list></ul> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| <script> |  | ||||||
| let files = {} |  | ||||||
| let flatfiles = {} |  | ||||||
|  |  | ||||||
| function createWatchSocket() { |  | ||||||
|   const wsurl = new URL("/api/watch", location.href.replace(/^http/, 'ws')) |  | ||||||
|   const ws = new WebSocket(wsurl) |  | ||||||
|   ws.onmessage = event => { |  | ||||||
|     msg = JSON.parse(event.data) |  | ||||||
|     if (msg.update) { |  | ||||||
|       tree_update(msg.update) |  | ||||||
|       file_list(files) |  | ||||||
|     } else { |  | ||||||
|       console.log("Unkonwn message from watch socket", msg) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| createWatchSocket() |  | ||||||
|  |  | ||||||
| function tree_update(msg) { |  | ||||||
|   console.log("Tree update", msg) |  | ||||||
|   let node = files |  | ||||||
|   for (const elem of msg) { |  | ||||||
|     if (elem.deleted) { |  | ||||||
|       const p = node.dir[elem.name].path |  | ||||||
|       delete node.dir[elem.name] |  | ||||||
|       delete flatfiles[p] |  | ||||||
|       break |  | ||||||
|     } |  | ||||||
|     if (elem.name !== undefined) node = node.dir[elem.name] ||= {} |  | ||||||
|     if (elem.size !== undefined) node.size = elem.size |  | ||||||
|     if (elem.mtime !== undefined) node.mtime = elem.mtime |  | ||||||
|     if (elem.dir !== undefined) node.dir = elem.dir |  | ||||||
|   } |  | ||||||
|   // Update paths and flatfiles |  | ||||||
|   files.path = "/" |  | ||||||
|   const nodes = [files] |  | ||||||
|   flatfiles = {} |  | ||||||
|   while (node = nodes.pop()) { |  | ||||||
|     flatfiles[node.path] = node |  | ||||||
|     if (node.dir === undefined) continue |  | ||||||
|     for (const name of Object.keys(node.dir)) { |  | ||||||
|       const child = node.dir[name] |  | ||||||
|       child.path = node.path + name + (child.dir === undefined ? "" : "/") |  | ||||||
|       nodes.push(child) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}); |  | ||||||
|  |  | ||||||
| const compare_path = (a, b) => collator.compare(a.path, b.path) |  | ||||||
| const compare_time = (a, b) => a.mtime > b.mtime |  | ||||||
|  |  | ||||||
| function file_list(files) { |  | ||||||
|   const table = document.getElementById("file_list") |  | ||||||
|   const sorted = Object.values(flatfiles).sort(compare_time) |  | ||||||
|   table.innerHTML = "" |  | ||||||
|   for (const f of sorted) { |  | ||||||
|     const {path, size, mtime} = f |  | ||||||
|     const tr = document.createElement("tr") |  | ||||||
|     const name_td = document.createElement("td") |  | ||||||
|     const size_td = document.createElement("td") |  | ||||||
|     const mtime_td = document.createElement("td") |  | ||||||
|     const a = document.createElement("a") |  | ||||||
|     table.appendChild(tr) |  | ||||||
|     tr.appendChild(name_td) |  | ||||||
|     tr.appendChild(size_td) |  | ||||||
|     tr.appendChild(mtime_td) |  | ||||||
|     name_td.appendChild(a) |  | ||||||
|     size_td.textContent = size |  | ||||||
|     mtime_td.textContent = formatUnixDate(mtime) |  | ||||||
|     a.textContent = path |  | ||||||
|     a.href = `/files${path}` |  | ||||||
|     /*a.onclick = event => { |  | ||||||
|       if (window.showSaveFilePicker) { |  | ||||||
|         event.preventDefault() |  | ||||||
|         download_ws(name, size) |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|     a.download = ""*/ |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function formatUnixDate(t) { |  | ||||||
|     const date = new Date(t * 1000) |  | ||||||
|     const now = new Date() |  | ||||||
|     const diff = date - now |  | ||||||
|     const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) |  | ||||||
|  |  | ||||||
|     if (Math.abs(diff) <= 60000) { |  | ||||||
|         return formatter.format(Math.round(diff / 1000), 'second') |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (Math.abs(diff) <= 3600000) { |  | ||||||
|         return formatter.format(Math.round(diff / 60000), 'minute') |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (Math.abs(diff) <= 86400000) { |  | ||||||
|         return formatter.format(Math.round(diff / 3600000), 'hour') |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (Math.abs(diff) <= 604800000) { |  | ||||||
|         return formatter.format(Math.round(diff / 86400000), 'day') |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return date.toLocaleDateString() |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function download_ws(name, size) { |  | ||||||
|   const fh = await window.showSaveFilePicker({ |  | ||||||
|     suggestedName: name, |  | ||||||
|   }) |  | ||||||
|   const writer = await fh.createWritable() |  | ||||||
|   writer.truncate(size) |  | ||||||
|   const wsurl = new URL("/api/download", location.href.replace(/^http/, 'ws')) |  | ||||||
|   const ws = new WebSocket(wsurl) |  | ||||||
|   let pos = 0 |  | ||||||
|   ws.onopen = () => { |  | ||||||
|     console.log("Downloading over WebSocket", name, size) |  | ||||||
|     ws.send(JSON.stringify({name, start: 0, end: size, size})) |  | ||||||
|   } |  | ||||||
|   ws.onmessage = event => { |  | ||||||
|     if (typeof event.data === 'string') { |  | ||||||
|       const msg = JSON.parse(event.data) |  | ||||||
|       console.log("Download finished", msg) |  | ||||||
|       ws.close() |  | ||||||
|       return |  | ||||||
|     } |  | ||||||
|     console.log("Received chunk", name, pos, pos + event.data.size) |  | ||||||
|     pos += event.data.size |  | ||||||
|     writer.write(event.data) |  | ||||||
|   } |  | ||||||
|   ws.onclose = () => { |  | ||||||
|     if (pos < size) { |  | ||||||
|       console.log("Download aborted", name, pos) |  | ||||||
|       writer.truncate(pos) |  | ||||||
|     } |  | ||||||
|     writer.close() |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const fileInput = document.getElementById("fileInput") |  | ||||||
| const progress = document.getElementById("progressBar") |  | ||||||
| const numConnections = 2 |  | ||||||
| const chunkSize = 1<<20 |  | ||||||
| const wsConnections = new Set() |  | ||||||
|  |  | ||||||
| //for (let i = 0; i < numConnections; i++) createUploadWS() |  | ||||||
|  |  | ||||||
| function createUploadWS() { |  | ||||||
|   const wsurl = new URL("/api/upload", location.href.replace(/^http/, 'ws')) |  | ||||||
|   const ws = new WebSocket(wsurl) |  | ||||||
|   ws.binaryType = 'arraybuffer' |  | ||||||
|   ws.onopen = () => { |  | ||||||
|     wsConnections.add(ws) |  | ||||||
|     console.log("Upload socket connected") |  | ||||||
|   } |  | ||||||
|   ws.onmessage = event => { |  | ||||||
|     msg = JSON.parse(event.data) |  | ||||||
|     if (msg.written) progress.value += +msg.written |  | ||||||
|     else console.log(`Error: ${msg.error}`) |  | ||||||
|   } |  | ||||||
|   ws.onclose = () => { |  | ||||||
|     wsConnections.delete(ws) |  | ||||||
|     console.log("Upload socket disconnected, reconnecting...") |  | ||||||
|     setTimeout(createUploadWS, 1000) |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function load(file, start, end) { |  | ||||||
|   const reader = new FileReader() |  | ||||||
|   const load = new Promise(resolve => reader.onload = resolve) |  | ||||||
|   reader.readAsArrayBuffer(file.slice(start, end)) |  | ||||||
|   const event = await load |  | ||||||
|   return event.target.result |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function sendChunk(file, start, end, ws) { |  | ||||||
|   const chunk = await load(file, start, end) |  | ||||||
|   ws.send(JSON.stringify({ |  | ||||||
|     name: file.name, |  | ||||||
|     size: file.size, |  | ||||||
|     start: start, |  | ||||||
|     end: end |  | ||||||
|   })) |  | ||||||
|   ws.send(chunk) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| fileInput.addEventListener("change", async function() { |  | ||||||
|   const file = this.files[0] |  | ||||||
|   const numChunks = Math.ceil(file.size / chunkSize) |  | ||||||
|   progress.value = 0 |  | ||||||
|   progress.max = file.size |  | ||||||
|  |  | ||||||
|   console.log(wsConnections) |  | ||||||
|   for (let i = 0; i < numChunks; i++) { |  | ||||||
|     const ws = Array.from(wsConnections)[i % wsConnections.size] |  | ||||||
|     const start = i * chunkSize |  | ||||||
|     const end = Math.min(file.size, start + chunkSize) |  | ||||||
|     const res = await sendChunk(file, start, end, ws) |  | ||||||
|   } |  | ||||||
| }) |  | ||||||
|  |  | ||||||
| </script> |  | ||||||
| @@ -1,85 +0,0 @@ | |||||||
| <script setup lang="ts"> |  | ||||||
| import { RouterLink, RouterView } from 'vue-router' |  | ||||||
| import HelloWorld from './components/HelloWorld.vue' |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <header> |  | ||||||
|     <img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" /> |  | ||||||
|  |  | ||||||
|     <div class="wrapper"> |  | ||||||
|       <HelloWorld msg="You did it!" /> |  | ||||||
|  |  | ||||||
|       <nav> |  | ||||||
|         <RouterLink to="/">Home</RouterLink> |  | ||||||
|         <RouterLink to="/about">About</RouterLink> |  | ||||||
|       </nav> |  | ||||||
|     </div> |  | ||||||
|   </header> |  | ||||||
|  |  | ||||||
|   <RouterView /> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style scoped> |  | ||||||
| header { |  | ||||||
|   line-height: 1.5; |  | ||||||
|   max-height: 100vh; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .logo { |  | ||||||
|   display: block; |  | ||||||
|   margin: 0 auto 2rem; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| nav { |  | ||||||
|   width: 100%; |  | ||||||
|   font-size: 12px; |  | ||||||
|   text-align: center; |  | ||||||
|   margin-top: 2rem; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| nav a.router-link-exact-active { |  | ||||||
|   color: var(--color-text); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| nav a.router-link-exact-active:hover { |  | ||||||
|   background-color: transparent; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| nav a { |  | ||||||
|   display: inline-block; |  | ||||||
|   padding: 0 1rem; |  | ||||||
|   border-left: 1px solid var(--color-border); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| nav a:first-of-type { |  | ||||||
|   border: 0; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @media (min-width: 1024px) { |  | ||||||
|   header { |  | ||||||
|     display: flex; |  | ||||||
|     place-items: center; |  | ||||||
|     padding-right: calc(var(--section-gap) / 2); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .logo { |  | ||||||
|     margin: 0 2rem 0 0; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   header .wrapper { |  | ||||||
|     display: flex; |  | ||||||
|     place-items: flex-start; |  | ||||||
|     flex-wrap: wrap; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   nav { |  | ||||||
|     text-align: left; |  | ||||||
|     margin-left: -1rem; |  | ||||||
|     font-size: 1rem; |  | ||||||
|  |  | ||||||
|     padding: 1rem 0; |  | ||||||
|     margin-top: 1rem; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,86 +0,0 @@ | |||||||
| /* color palette from <https://github.com/vuejs/theme> */ |  | ||||||
| :root { |  | ||||||
|   --vt-c-white: #ffffff; |  | ||||||
|   --vt-c-white-soft: #f8f8f8; |  | ||||||
|   --vt-c-white-mute: #f2f2f2; |  | ||||||
|  |  | ||||||
|   --vt-c-black: #181818; |  | ||||||
|   --vt-c-black-soft: #222222; |  | ||||||
|   --vt-c-black-mute: #282828; |  | ||||||
|  |  | ||||||
|   --vt-c-indigo: #2c3e50; |  | ||||||
|  |  | ||||||
|   --vt-c-divider-light-1: rgba(60, 60, 60, 0.29); |  | ||||||
|   --vt-c-divider-light-2: rgba(60, 60, 60, 0.12); |  | ||||||
|   --vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); |  | ||||||
|   --vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); |  | ||||||
|  |  | ||||||
|   --vt-c-text-light-1: var(--vt-c-indigo); |  | ||||||
|   --vt-c-text-light-2: rgba(60, 60, 60, 0.66); |  | ||||||
|   --vt-c-text-dark-1: var(--vt-c-white); |  | ||||||
|   --vt-c-text-dark-2: rgba(235, 235, 235, 0.64); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| /* semantic color variables for this project */ |  | ||||||
| :root { |  | ||||||
|   --color-background: var(--vt-c-white); |  | ||||||
|   --color-background-soft: var(--vt-c-white-soft); |  | ||||||
|   --color-background-mute: var(--vt-c-white-mute); |  | ||||||
|  |  | ||||||
|   --color-border: var(--vt-c-divider-light-2); |  | ||||||
|   --color-border-hover: var(--vt-c-divider-light-1); |  | ||||||
|  |  | ||||||
|   --color-heading: var(--vt-c-text-light-1); |  | ||||||
|   --color-text: var(--vt-c-text-light-1); |  | ||||||
|  |  | ||||||
|   --section-gap: 160px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @media (prefers-color-scheme: dark) { |  | ||||||
|   :root { |  | ||||||
|     --color-background: var(--vt-c-black); |  | ||||||
|     --color-background-soft: var(--vt-c-black-soft); |  | ||||||
|     --color-background-mute: var(--vt-c-black-mute); |  | ||||||
|  |  | ||||||
|     --color-border: var(--vt-c-divider-dark-2); |  | ||||||
|     --color-border-hover: var(--vt-c-divider-dark-1); |  | ||||||
|  |  | ||||||
|     --color-heading: var(--vt-c-text-dark-1); |  | ||||||
|     --color-text: var(--vt-c-text-dark-2); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| *, |  | ||||||
| *::before, |  | ||||||
| *::after { |  | ||||||
|   box-sizing: border-box; |  | ||||||
|   margin: 0; |  | ||||||
|   font-weight: normal; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| body { |  | ||||||
|   min-height: 100vh; |  | ||||||
|   color: var(--color-text); |  | ||||||
|   background: var(--color-background); |  | ||||||
|   transition: |  | ||||||
|     color 0.5s, |  | ||||||
|     background-color 0.5s; |  | ||||||
|   line-height: 1.6; |  | ||||||
|   font-family: |  | ||||||
|     Inter, |  | ||||||
|     -apple-system, |  | ||||||
|     BlinkMacSystemFont, |  | ||||||
|     'Segoe UI', |  | ||||||
|     Roboto, |  | ||||||
|     Oxygen, |  | ||||||
|     Ubuntu, |  | ||||||
|     Cantarell, |  | ||||||
|     'Fira Sans', |  | ||||||
|     'Droid Sans', |  | ||||||
|     'Helvetica Neue', |  | ||||||
|     sans-serif; |  | ||||||
|   font-size: 15px; |  | ||||||
|   text-rendering: optimizeLegibility; |  | ||||||
|   -webkit-font-smoothing: antialiased; |  | ||||||
|   -moz-osx-font-smoothing: grayscale; |  | ||||||
| } |  | ||||||
| @@ -1 +0,0 @@ | |||||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg> |  | ||||||
| Before Width: | Height: | Size: 276 B | 
| @@ -1,35 +0,0 @@ | |||||||
| @import './base.css'; |  | ||||||
|  |  | ||||||
| #app { |  | ||||||
|   max-width: 1280px; |  | ||||||
|   margin: 0 auto; |  | ||||||
|   padding: 2rem; |  | ||||||
|  |  | ||||||
|   font-weight: normal; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| a, |  | ||||||
| .green { |  | ||||||
|   text-decoration: none; |  | ||||||
|   color: hsla(160, 100%, 37%, 1); |  | ||||||
|   transition: 0.4s; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @media (hover: hover) { |  | ||||||
|   a:hover { |  | ||||||
|     background-color: hsla(160, 100%, 37%, 0.2); |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @media (min-width: 1024px) { |  | ||||||
|   body { |  | ||||||
|     display: flex; |  | ||||||
|     place-items: center; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   #app { |  | ||||||
|     display: grid; |  | ||||||
|     grid-template-columns: 1fr 1fr; |  | ||||||
|     padding: 0 2rem; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| @@ -1,41 +0,0 @@ | |||||||
| <script setup lang="ts"> |  | ||||||
| defineProps<{ |  | ||||||
|   msg: string |  | ||||||
| }>() |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <div class="greetings"> |  | ||||||
|     <h1 class="green">{{ msg }}</h1> |  | ||||||
|     <h3> |  | ||||||
|       You’ve successfully created a project with |  | ||||||
|       <a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> + |  | ||||||
|       <a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next? |  | ||||||
|     </h3> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style scoped> |  | ||||||
| h1 { |  | ||||||
|   font-weight: 500; |  | ||||||
|   font-size: 2.6rem; |  | ||||||
|   position: relative; |  | ||||||
|   top: -10px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| h3 { |  | ||||||
|   font-size: 1.2rem; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .greetings h1, |  | ||||||
| .greetings h3 { |  | ||||||
|   text-align: center; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @media (min-width: 1024px) { |  | ||||||
|   .greetings h1, |  | ||||||
|   .greetings h3 { |  | ||||||
|     text-align: left; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,88 +0,0 @@ | |||||||
| <script setup lang="ts"> |  | ||||||
| import WelcomeItem from './WelcomeItem.vue' |  | ||||||
| import DocumentationIcon from './icons/IconDocumentation.vue' |  | ||||||
| import ToolingIcon from './icons/IconTooling.vue' |  | ||||||
| import EcosystemIcon from './icons/IconEcosystem.vue' |  | ||||||
| import CommunityIcon from './icons/IconCommunity.vue' |  | ||||||
| import SupportIcon from './icons/IconSupport.vue' |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <WelcomeItem> |  | ||||||
|     <template #icon> |  | ||||||
|       <DocumentationIcon /> |  | ||||||
|     </template> |  | ||||||
|     <template #heading>Documentation</template> |  | ||||||
|  |  | ||||||
|     Vue’s |  | ||||||
|     <a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a> |  | ||||||
|     provides you with all information you need to get started. |  | ||||||
|   </WelcomeItem> |  | ||||||
|  |  | ||||||
|   <WelcomeItem> |  | ||||||
|     <template #icon> |  | ||||||
|       <ToolingIcon /> |  | ||||||
|     </template> |  | ||||||
|     <template #heading>Tooling</template> |  | ||||||
|  |  | ||||||
|     This project is served and bundled with |  | ||||||
|     <a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The |  | ||||||
|     recommended IDE setup is |  | ||||||
|     <a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> + |  | ||||||
|     <a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If |  | ||||||
|     you need to test your components and web pages, check out |  | ||||||
|     <a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and |  | ||||||
|     <a href="https://on.cypress.io/component" target="_blank" rel="noopener" |  | ||||||
|       >Cypress Component Testing</a |  | ||||||
|     >. |  | ||||||
|  |  | ||||||
|     <br /> |  | ||||||
|  |  | ||||||
|     More instructions are available in <code>README.md</code>. |  | ||||||
|   </WelcomeItem> |  | ||||||
|  |  | ||||||
|   <WelcomeItem> |  | ||||||
|     <template #icon> |  | ||||||
|       <EcosystemIcon /> |  | ||||||
|     </template> |  | ||||||
|     <template #heading>Ecosystem</template> |  | ||||||
|  |  | ||||||
|     Get official tools and libraries for your project: |  | ||||||
|     <a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>, |  | ||||||
|     <a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>, |  | ||||||
|     <a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and |  | ||||||
|     <a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If |  | ||||||
|     you need more resources, we suggest paying |  | ||||||
|     <a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a> |  | ||||||
|     a visit. |  | ||||||
|   </WelcomeItem> |  | ||||||
|  |  | ||||||
|   <WelcomeItem> |  | ||||||
|     <template #icon> |  | ||||||
|       <CommunityIcon /> |  | ||||||
|     </template> |  | ||||||
|     <template #heading>Community</template> |  | ||||||
|  |  | ||||||
|     Got stuck? Ask your question on |  | ||||||
|     <a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official |  | ||||||
|     Discord server, or |  | ||||||
|     <a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener" |  | ||||||
|       >StackOverflow</a |  | ||||||
|     >. You should also subscribe to |  | ||||||
|     <a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow |  | ||||||
|     the official |  | ||||||
|     <a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a> |  | ||||||
|     twitter account for latest news in the Vue world. |  | ||||||
|   </WelcomeItem> |  | ||||||
|  |  | ||||||
|   <WelcomeItem> |  | ||||||
|     <template #icon> |  | ||||||
|       <SupportIcon /> |  | ||||||
|     </template> |  | ||||||
|     <template #heading>Support Vue</template> |  | ||||||
|  |  | ||||||
|     As an independent project, Vue relies on community backing for its sustainability. You can help |  | ||||||
|     us by |  | ||||||
|     <a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>. |  | ||||||
|   </WelcomeItem> |  | ||||||
| </template> |  | ||||||
| @@ -1,87 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="item"> |  | ||||||
|     <i> |  | ||||||
|       <slot name="icon"></slot> |  | ||||||
|     </i> |  | ||||||
|     <div class="details"> |  | ||||||
|       <h3> |  | ||||||
|         <slot name="heading"></slot> |  | ||||||
|       </h3> |  | ||||||
|       <slot></slot> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style scoped> |  | ||||||
| .item { |  | ||||||
|   margin-top: 2rem; |  | ||||||
|   display: flex; |  | ||||||
|   position: relative; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .details { |  | ||||||
|   flex: 1; |  | ||||||
|   margin-left: 1rem; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| i { |  | ||||||
|   display: flex; |  | ||||||
|   place-items: center; |  | ||||||
|   place-content: center; |  | ||||||
|   width: 32px; |  | ||||||
|   height: 32px; |  | ||||||
|  |  | ||||||
|   color: var(--color-text); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| h3 { |  | ||||||
|   font-size: 1.2rem; |  | ||||||
|   font-weight: 500; |  | ||||||
|   margin-bottom: 0.4rem; |  | ||||||
|   color: var(--color-heading); |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @media (min-width: 1024px) { |  | ||||||
|   .item { |  | ||||||
|     margin-top: 0; |  | ||||||
|     padding: 0.4rem 0 1rem calc(var(--section-gap) / 2); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   i { |  | ||||||
|     top: calc(50% - 25px); |  | ||||||
|     left: -26px; |  | ||||||
|     position: absolute; |  | ||||||
|     border: 1px solid var(--color-border); |  | ||||||
|     background: var(--color-background); |  | ||||||
|     border-radius: 8px; |  | ||||||
|     width: 50px; |  | ||||||
|     height: 50px; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .item:before { |  | ||||||
|     content: ' '; |  | ||||||
|     border-left: 1px solid var(--color-border); |  | ||||||
|     position: absolute; |  | ||||||
|     left: 0; |  | ||||||
|     bottom: calc(50% + 25px); |  | ||||||
|     height: calc(50% - 25px); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .item:after { |  | ||||||
|     content: ' '; |  | ||||||
|     border-left: 1px solid var(--color-border); |  | ||||||
|     position: absolute; |  | ||||||
|     left: 0; |  | ||||||
|     top: calc(50% + 25px); |  | ||||||
|     height: calc(50% - 25px); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .item:first-of-type:before { |  | ||||||
|     display: none; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   .item:last-of-type:after { |  | ||||||
|     display: none; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,7 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor"> |  | ||||||
|     <path |  | ||||||
|       d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z" |  | ||||||
|     /> |  | ||||||
|   </svg> |  | ||||||
| </template> |  | ||||||
| @@ -1,7 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor"> |  | ||||||
|     <path |  | ||||||
|       d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z" |  | ||||||
|     /> |  | ||||||
|   </svg> |  | ||||||
| </template> |  | ||||||
| @@ -1,7 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor"> |  | ||||||
|     <path |  | ||||||
|       d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z" |  | ||||||
|     /> |  | ||||||
|   </svg> |  | ||||||
| </template> |  | ||||||
| @@ -1,7 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor"> |  | ||||||
|     <path |  | ||||||
|       d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z" |  | ||||||
|     /> |  | ||||||
|   </svg> |  | ||||||
| </template> |  | ||||||
| @@ -1,19 +0,0 @@ | |||||||
| <!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license--> |  | ||||||
| <template> |  | ||||||
|   <svg |  | ||||||
|     xmlns="http://www.w3.org/2000/svg" |  | ||||||
|     xmlns:xlink="http://www.w3.org/1999/xlink" |  | ||||||
|     aria-hidden="true" |  | ||||||
|     role="img" |  | ||||||
|     class="iconify iconify--mdi" |  | ||||||
|     width="24" |  | ||||||
|     height="24" |  | ||||||
|     preserveAspectRatio="xMidYMid meet" |  | ||||||
|     viewBox="0 0 24 24" |  | ||||||
|   > |  | ||||||
|     <path |  | ||||||
|       d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z" |  | ||||||
|       fill="currentColor" |  | ||||||
|     ></path> |  | ||||||
|   </svg> |  | ||||||
| </template> |  | ||||||
| @@ -1,14 +0,0 @@ | |||||||
| import './assets/main.css' |  | ||||||
|  |  | ||||||
| import { createApp } from 'vue' |  | ||||||
| import { createPinia } from 'pinia' |  | ||||||
|  |  | ||||||
| import App from './App.vue' |  | ||||||
| import router from './router' |  | ||||||
|  |  | ||||||
| const app = createApp(App) |  | ||||||
|  |  | ||||||
| app.use(createPinia()) |  | ||||||
| app.use(router) |  | ||||||
|  |  | ||||||
| app.mount('#app') |  | ||||||
| @@ -1,23 +0,0 @@ | |||||||
| import { createRouter, createWebHistory } from 'vue-router' |  | ||||||
| import HomeView from '../views/HomeView.vue' |  | ||||||
|  |  | ||||||
| const router = createRouter({ |  | ||||||
|   history: createWebHistory(import.meta.env.BASE_URL), |  | ||||||
|   routes: [ |  | ||||||
|     { |  | ||||||
|       path: '/', |  | ||||||
|       name: 'home', |  | ||||||
|       component: HomeView |  | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       path: '/about', |  | ||||||
|       name: 'about', |  | ||||||
|       // route level code-splitting |  | ||||||
|       // this generates a separate chunk (About.[hash].js) for this route |  | ||||||
|       // which is lazy-loaded when the route is visited. |  | ||||||
|       component: () => import('../views/AboutView.vue') |  | ||||||
|     } |  | ||||||
|   ] |  | ||||||
| }) |  | ||||||
|  |  | ||||||
| export default router |  | ||||||
| @@ -1,12 +0,0 @@ | |||||||
| import { ref, computed } from 'vue' |  | ||||||
| import { defineStore } from 'pinia' |  | ||||||
|  |  | ||||||
| export const useCounterStore = defineStore('counter', () => { |  | ||||||
|   const count = ref(0) |  | ||||||
|   const doubleCount = computed(() => count.value * 2) |  | ||||||
|   function increment() { |  | ||||||
|     count.value++ |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return { count, doubleCount, increment } |  | ||||||
| }) |  | ||||||
| @@ -1,15 +0,0 @@ | |||||||
| <template> |  | ||||||
|   <div class="about"> |  | ||||||
|     <h1>This is an about page</h1> |  | ||||||
|   </div> |  | ||||||
| </template> |  | ||||||
|  |  | ||||||
| <style> |  | ||||||
| @media (min-width: 1024px) { |  | ||||||
|   .about { |  | ||||||
|     min-height: 100vh; |  | ||||||
|     display: flex; |  | ||||||
|     align-items: center; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| </style> |  | ||||||
| @@ -1,9 +0,0 @@ | |||||||
| <script setup lang="ts"> |  | ||||||
| import TheWelcome from '../components/TheWelcome.vue' |  | ||||||
| </script> |  | ||||||
|  |  | ||||||
| <template> |  | ||||||
|   <main> |  | ||||||
|     <TheWelcome /> |  | ||||||
|   </main> |  | ||||||
| </template> |  | ||||||
| @@ -1,20 +0,0 @@ | |||||||
| import { fileURLToPath, URL } from 'node:url' |  | ||||||
|  |  | ||||||
| import { defineConfig } from 'vite' |  | ||||||
| import vue from '@vitejs/plugin-vue' |  | ||||||
|  |  | ||||||
| // https://vitejs.dev/config/ |  | ||||||
| export default defineConfig({ |  | ||||||
|   plugins: [ |  | ||||||
|     vue(), |  | ||||||
|   ], |  | ||||||
|   resolve: { |  | ||||||
|     alias: { |  | ||||||
|       '@': fileURLToPath(new URL('./src', import.meta.url)) |  | ||||||
|     } |  | ||||||
|   }, |  | ||||||
|   build: { |  | ||||||
|     outDir: "../cista/wwwroot", |  | ||||||
|     emptyOutDir: true, |  | ||||||
|   } |  | ||||||
| }) |  | ||||||
							
								
								
									
										2
									
								
								cista/__init__.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -1 +1,3 @@ | |||||||
| from cista._version import __version__ | from cista._version import __version__ | ||||||
|  |  | ||||||
|  | __version__  # Public API | ||||||
|   | |||||||
							
								
								
									
										28
									
								
								cista/__main__.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -7,7 +7,7 @@ import cista | |||||||
| from cista import app, config, droppy, serve, server80 | from cista import app, config, droppy, serve, server80 | ||||||
| from cista.util import pwgen | from cista.util import pwgen | ||||||
|  |  | ||||||
| del app, server80.app   # Only import needed, for Sanic multiprocessing | del app, server80.app  # Only import needed, for Sanic multiprocessing | ||||||
|  |  | ||||||
| doc = f"""Cista {cista.__version__} - A file storage for the web. | doc = f"""Cista {cista.__version__} - A file storage for the web. | ||||||
|  |  | ||||||
| @@ -34,6 +34,7 @@ User management: | |||||||
|   --password        Reset password |   --password        Reset password | ||||||
| """ | """ | ||||||
|  |  | ||||||
|  |  | ||||||
| def main(): | def main(): | ||||||
|     # Dev mode doesn't catch exceptions |     # Dev mode doesn't catch exceptions | ||||||
|     if "--dev" in sys.argv: |     if "--dev" in sys.argv: | ||||||
| @@ -45,6 +46,7 @@ def main(): | |||||||
|         print("Error:", e) |         print("Error:", e) | ||||||
|         return 1 |         return 1 | ||||||
|  |  | ||||||
|  |  | ||||||
| def _main(): | def _main(): | ||||||
|     args = docopt(doc) |     args = docopt(doc) | ||||||
|     if args["--user"]: |     if args["--user"]: | ||||||
| @@ -64,19 +66,25 @@ def _main(): | |||||||
|     if not necessary_opts: |     if not necessary_opts: | ||||||
|         # Maybe run without arguments |         # Maybe run without arguments | ||||||
|         print(doc) |         print(doc) | ||||||
|         print("No config file found! Get started with:\n  cista -l :8000 /path/to/files, or\n  cista -l example.com --import-droppy  # Uses Droppy files\n") |         print( | ||||||
|  |             "No config file found! Get started with:\n  cista -l :8000 /path/to/files, or\n  cista -l example.com --import-droppy  # Uses Droppy files\n", | ||||||
|  |         ) | ||||||
|         return 1 |         return 1 | ||||||
|     settings = {} |     settings = {} | ||||||
|     if import_droppy: |     if import_droppy: | ||||||
|         if exists: |         if exists: | ||||||
|             raise ValueError(f"Importing Droppy: First remove the existing configuration:\n  rm {config.conffile}") |             raise ValueError( | ||||||
|  |                 f"Importing Droppy: First remove the existing configuration:\n  rm {config.conffile}", | ||||||
|  |             ) | ||||||
|         settings = droppy.readconf() |         settings = droppy.readconf() | ||||||
|     if path: settings["path"] = path |     if path: | ||||||
|     if listen: settings["listen"] = listen |         settings["path"] = path | ||||||
|  |     if listen: | ||||||
|  |         settings["listen"] = listen | ||||||
|     operation = config.update_config(settings) |     operation = config.update_config(settings) | ||||||
|     print(f"Config {operation}: {config.conffile}") |     print(f"Config {operation}: {config.conffile}") | ||||||
|     # Prepare to serve |     # Prepare to serve | ||||||
|     domain = unix = port = None |     unix = None | ||||||
|     url, _ = serve.parse_listen(config.config.listen) |     url, _ = serve.parse_listen(config.config.listen) | ||||||
|     if not config.config.path.is_dir(): |     if not config.config.path.is_dir(): | ||||||
|         raise ValueError(f"No such directory: {config.config.path}") |         raise ValueError(f"No such directory: {config.config.path}") | ||||||
| @@ -87,6 +95,8 @@ def _main(): | |||||||
|     print(f"Serving {config.config.path} at {url}{extra}") |     print(f"Serving {config.config.path} at {url}{extra}") | ||||||
|     # Run the server |     # Run the server | ||||||
|     serve.run(dev=dev) |     serve.run(dev=dev) | ||||||
|  |     return 0 | ||||||
|  |  | ||||||
|  |  | ||||||
| def _confdir(args): | def _confdir(args): | ||||||
|     if args["-c"]: |     if args["-c"]: | ||||||
| @@ -95,9 +105,10 @@ def _confdir(args): | |||||||
|         if confdir.exists() and not confdir.is_dir(): |         if confdir.exists() and not confdir.is_dir(): | ||||||
|             if confdir.name != config.conffile.name: |             if confdir.name != config.conffile.name: | ||||||
|                 raise ValueError("Config path is not a directory") |                 raise ValueError("Config path is not a directory") | ||||||
|             # Accidentally pointed to the cista.toml, use parent |             # Accidentally pointed to the db.toml, use parent | ||||||
|             confdir = confdir.parent |             confdir = confdir.parent | ||||||
|         config.conffile = config.conffile.with_parent(confdir) |         config.conffile = confdir / config.conffile.name | ||||||
|  |  | ||||||
|  |  | ||||||
| def _user(args): | def _user(args): | ||||||
|     _confdir(args) |     _confdir(args) | ||||||
| @@ -123,5 +134,6 @@ def _user(args): | |||||||
|     if res == "read": |     if res == "read": | ||||||
|         print("  No changes") |         print("  No changes") | ||||||
|  |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|     sys.exit(main()) |     sys.exit(main()) | ||||||
|   | |||||||
							
								
								
									
										74
									
								
								cista/api.py
									
									
									
									
									
								
							
							
						
						| @@ -1,26 +1,30 @@ | |||||||
| import asyncio | import asyncio | ||||||
| import typing | import typing | ||||||
|  | from secrets import token_bytes | ||||||
|  |  | ||||||
| import msgspec | import msgspec | ||||||
| from sanic import Blueprint | from sanic import Blueprint | ||||||
|  |  | ||||||
| from cista import watching | from cista import __version__, config, watching | ||||||
| from cista.fileio import FileServer | from cista.fileio import FileServer | ||||||
| from cista.protocol import ControlBase, FileRange, StatusMsg | from cista.protocol import ControlTypes, FileRange, StatusMsg | ||||||
| from cista.util.apphelpers import asend, websocket_wrapper | from cista.util.apphelpers import asend, websocket_wrapper | ||||||
|  |  | ||||||
| bp = Blueprint("api", url_prefix="/api") | bp = Blueprint("api", url_prefix="/api") | ||||||
| fileserver = FileServer() | fileserver = FileServer() | ||||||
|  |  | ||||||
|  |  | ||||||
| @bp.before_server_start | @bp.before_server_start | ||||||
| async def start_fileserver(app, _): | async def start_fileserver(app, _): | ||||||
|     await fileserver.start() |     await fileserver.start() | ||||||
|  |  | ||||||
|  |  | ||||||
| @bp.after_server_stop | @bp.after_server_stop | ||||||
| async def stop_fileserver(app, _): | async def stop_fileserver(app, _): | ||||||
|     await fileserver.stop() |     await fileserver.stop() | ||||||
|  |  | ||||||
| @bp.websocket('upload') |  | ||||||
|  | @bp.websocket("upload") | ||||||
| @websocket_wrapper | @websocket_wrapper | ||||||
| async def upload(req, ws): | async def upload(req, ws): | ||||||
|     alink = fileserver.alink |     alink = fileserver.alink | ||||||
| @@ -28,22 +32,32 @@ async def upload(req, ws): | |||||||
|         req = None |         req = None | ||||||
|         text = await ws.recv() |         text = await ws.recv() | ||||||
|         if not isinstance(text, str): |         if not isinstance(text, str): | ||||||
|             raise ValueError(f"Expected JSON control, got binary len(data) = {len(text)}") |             raise ValueError( | ||||||
|  |                 f"Expected JSON control, got binary len(data) = {len(text)}", | ||||||
|  |             ) | ||||||
|         req = msgspec.json.decode(text, type=FileRange) |         req = msgspec.json.decode(text, type=FileRange) | ||||||
|         pos = req.start |         pos = req.start | ||||||
|         data = None |         while True: | ||||||
|         while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes): |             data = await ws.recv() | ||||||
|  |             if not isinstance(data, bytes): | ||||||
|  |                 break | ||||||
|  |             if len(data) > req.end - pos: | ||||||
|  |                 raise ValueError( | ||||||
|  |                     f"Expected up to {req.end - pos} bytes, got {len(data)} bytes" | ||||||
|  |                 ) | ||||||
|             sentsize = await alink(("upload", req.name, pos, data, req.size)) |             sentsize = await alink(("upload", req.name, pos, data, req.size)) | ||||||
|             pos += typing.cast(int, sentsize) |             pos += typing.cast(int, sentsize) | ||||||
|  |             if pos >= req.end: | ||||||
|  |                 break | ||||||
|         if pos != req.end: |         if pos != req.end: | ||||||
|             d = f"{len(data)} bytes" if isinstance(data, bytes) else data |             d = f"{len(data)} bytes" if isinstance(data, bytes) else data | ||||||
|             raise ValueError(f"Expected {req.end - pos} more bytes, got {d}") |             raise ValueError(f"Expected {req.end - pos} more bytes, got {d}") | ||||||
|         # Report success |         # Report success | ||||||
|         res = StatusMsg(status="ack", req=req) |         res = StatusMsg(status="ack", req=req) | ||||||
|         await asend(ws, res) |         await asend(ws, res) | ||||||
|         #await ws.drain() |  | ||||||
|  |  | ||||||
| @bp.websocket('download') |  | ||||||
|  | @bp.websocket("download") | ||||||
| @websocket_wrapper | @websocket_wrapper | ||||||
| async def download(req, ws): | async def download(req, ws): | ||||||
|     alink = fileserver.alink |     alink = fileserver.alink | ||||||
| @@ -51,37 +65,59 @@ async def download(req, ws): | |||||||
|         req = None |         req = None | ||||||
|         text = await ws.recv() |         text = await ws.recv() | ||||||
|         if not isinstance(text, str): |         if not isinstance(text, str): | ||||||
|             raise ValueError(f"Expected JSON control, got binary len(data) = {len(text)}") |             raise ValueError( | ||||||
|  |                 f"Expected JSON control, got binary len(data) = {len(text)}", | ||||||
|  |             ) | ||||||
|         req = msgspec.json.decode(text, type=FileRange) |         req = msgspec.json.decode(text, type=FileRange) | ||||||
|         pos = req.start |         pos = req.start | ||||||
|         while pos < req.end: |         while pos < req.end: | ||||||
|             end = min(req.end, pos + (1<<20)) |             end = min(req.end, pos + (1 << 20)) | ||||||
|             data = typing.cast(bytes, await alink(("download", req.name, pos, end))) |             data = typing.cast(bytes, await alink(("download", req.name, pos, end))) | ||||||
|             await asend(ws, data) |             await asend(ws, data) | ||||||
|             pos += len(data) |             pos += len(data) | ||||||
|         # Report success |         # Report success | ||||||
|         res = StatusMsg(status="ack", req=req) |         res = StatusMsg(status="ack", req=req) | ||||||
|         await asend(ws, res) |         await asend(ws, res) | ||||||
|         #await ws.drain() |  | ||||||
|  |  | ||||||
| @bp.websocket("control") | @bp.websocket("control") | ||||||
| @websocket_wrapper | @websocket_wrapper | ||||||
| async def control(req, ws): | async def control(req, ws): | ||||||
|     cmd = msgspec.json.decode(await ws.recv(), type=ControlBase) |     while True: | ||||||
|     await asyncio.to_thread(cmd) |         cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes) | ||||||
|     await asend(ws, StatusMsg(status="ack", req=cmd)) |         await asyncio.to_thread(cmd) | ||||||
|  |         await asend(ws, StatusMsg(status="ack", req=cmd)) | ||||||
|  |  | ||||||
|  |  | ||||||
| @bp.websocket("watch") | @bp.websocket("watch") | ||||||
| @websocket_wrapper | @websocket_wrapper | ||||||
| async def watch(req, ws): | async def watch(req, ws): | ||||||
|  |     await ws.send( | ||||||
|  |         msgspec.json.encode( | ||||||
|  |             { | ||||||
|  |                 "server": { | ||||||
|  |                     "name": config.config.name or config.config.path.name, | ||||||
|  |                     "version": __version__, | ||||||
|  |                     "public": config.config.public, | ||||||
|  |                 }, | ||||||
|  |                 "user": { | ||||||
|  |                     "username": req.ctx.username, | ||||||
|  |                     "privileged": req.ctx.user.privileged, | ||||||
|  |                 } | ||||||
|  |                 if req.ctx.user | ||||||
|  |                 else None, | ||||||
|  |             } | ||||||
|  |         ).decode() | ||||||
|  |     ) | ||||||
|  |     uuid = token_bytes(16) | ||||||
|     try: |     try: | ||||||
|         with watching.tree_lock: |         with watching.state.lock: | ||||||
|             q = watching.pubsub[ws] = asyncio.Queue() |             q = watching.pubsub[uuid] = asyncio.Queue() | ||||||
|             # Init with disk usage and full tree |             # Init with disk usage and full tree | ||||||
|             await ws.send(watching.format_du()) |             await ws.send(watching.format_space(watching.state.space)) | ||||||
|             await ws.send(watching.format_tree()) |             await ws.send(watching.format_root(watching.state.root)) | ||||||
|         # Send updates |         # Send updates | ||||||
|         while True: |         while True: | ||||||
|             await ws.send(await q.get()) |             await ws.send(await q.get()) | ||||||
|     finally: |     finally: | ||||||
|         del watching.pubsub[ws] |         del watching.pubsub[uuid] | ||||||
|   | |||||||
							
								
								
									
										233
									
								
								cista/app.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -1,36 +1,56 @@ | |||||||
|  | import asyncio | ||||||
|  | import datetime | ||||||
| import mimetypes | import mimetypes | ||||||
| from importlib.resources import files | from concurrent.futures import ThreadPoolExecutor | ||||||
|  | from pathlib import Path, PurePath, PurePosixPath | ||||||
|  | from stat import S_IFDIR, S_IFREG | ||||||
| from urllib.parse import unquote | from urllib.parse import unquote | ||||||
|  | from wsgiref.handlers import format_date_time | ||||||
|  |  | ||||||
| from html5tagger import E | import brotli | ||||||
| from sanic import Blueprint, Sanic, raw | import sanic.helpers | ||||||
| from sanic.exceptions import Forbidden, NotFound | from blake3 import blake3 | ||||||
|  | from sanic import Blueprint, Sanic, empty, raw | ||||||
|  | from sanic.exceptions import Forbidden, NotFound, ServerError | ||||||
|  | from sanic.log import logging | ||||||
|  | from stream_zip import ZIP_AUTO, stream_zip | ||||||
|  |  | ||||||
| from cista import auth, config, session, watching | from cista import auth, config, session, watching | ||||||
| from cista.api import bp | from cista.api import bp | ||||||
| from cista.util import filename |  | ||||||
| from cista.util.apphelpers import handle_sanic_exception | from cista.util.apphelpers import handle_sanic_exception | ||||||
|  |  | ||||||
|  | # Workaround until Sanic PR #2824 is merged | ||||||
|  | sanic.helpers._ENTITY_HEADERS = frozenset() | ||||||
|  |  | ||||||
| app = Sanic("cista", strict_slashes=True) | app = Sanic("cista", strict_slashes=True) | ||||||
| app.blueprint(auth.bp) | app.blueprint(auth.bp) | ||||||
| app.blueprint(bp) | app.blueprint(bp) | ||||||
| app.exception(Exception)(handle_sanic_exception) | app.exception(Exception)(handle_sanic_exception) | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.before_server_start | @app.before_server_start | ||||||
| async def main_start(app, loop): | async def main_start(app, loop): | ||||||
|     config.load_config() |     config.load_config() | ||||||
|     await watching.start(app, loop) |     await watching.start(app, loop) | ||||||
|  |     app.ctx.threadexec = ThreadPoolExecutor( | ||||||
|  |         max_workers=8, thread_name_prefix="cista-ioworker" | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.after_server_stop | @app.after_server_stop | ||||||
| async def main_stop(app, loop): | async def main_stop(app, loop): | ||||||
|     await watching.stop(app, loop) |     await watching.stop(app, loop) | ||||||
|  |     app.ctx.threadexec.shutdown() | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.on_request | @app.on_request | ||||||
| async def use_session(req): | async def use_session(req): | ||||||
|     req.ctx.session = session.get(req) |     req.ctx.session = session.get(req) | ||||||
|     try: |     try: | ||||||
|         req.ctx.user = config.config.users[req.ctx.session["username"]]  # type: ignore |         req.ctx.username = req.ctx.session["username"]  # type: ignore | ||||||
|  |         req.ctx.user = config.config.users[req.ctx.username] | ||||||
|     except (AttributeError, KeyError, TypeError): |     except (AttributeError, KeyError, TypeError): | ||||||
|  |         req.ctx.username = None | ||||||
|         req.ctx.user = None |         req.ctx.user = None | ||||||
|     # CSRF protection |     # CSRF protection | ||||||
|     if req.method == "GET" and req.headers.upgrade != "websocket": |     if req.method == "GET" and req.headers.upgrade != "websocket": | ||||||
| @@ -41,20 +61,203 @@ async def use_session(req): | |||||||
|     if origin and origin.split("//", 1)[1] != req.host: |     if origin and origin.split("//", 1)[1] != req.host: | ||||||
|         raise Forbidden("Invalid origin: Cross-Site requests not permitted") |         raise Forbidden("Invalid origin: Cross-Site requests not permitted") | ||||||
|  |  | ||||||
|  |  | ||||||
| @app.before_server_start | @app.before_server_start | ||||||
| def http_fileserver(app, _): | def http_fileserver(app, _): | ||||||
|     bp = Blueprint("fileserver") |     bp = Blueprint("fileserver") | ||||||
|     bp.on_request(auth.verify) |     bp.on_request(auth.verify) | ||||||
|     bp.static("/files/", config.config.path, use_content_range=True, stream_large_files=True, directory_view=True) |     bp.static( | ||||||
|  |         "/files/", | ||||||
|  |         config.config.path, | ||||||
|  |         use_content_range=True, | ||||||
|  |         stream_large_files=True, | ||||||
|  |         directory_view=True, | ||||||
|  |     ) | ||||||
|     app.blueprint(bp) |     app.blueprint(bp) | ||||||
|  |  | ||||||
| @app.get("/<path:path>", static=True) |  | ||||||
|  | www = {} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _load_wwwroot(www): | ||||||
|  |     wwwnew = {} | ||||||
|  |     base = Path(__file__).with_name("wwwroot") | ||||||
|  |     paths = [PurePath()] | ||||||
|  |     while paths: | ||||||
|  |         path = paths.pop(0) | ||||||
|  |         current = base / path | ||||||
|  |         for p in current.iterdir(): | ||||||
|  |             if p.is_dir(): | ||||||
|  |                 paths.append(p.relative_to(base)) | ||||||
|  |                 continue | ||||||
|  |             name = p.relative_to(base).as_posix() | ||||||
|  |             mime = mimetypes.guess_type(name)[0] or "application/octet-stream" | ||||||
|  |             mtime = p.stat().st_mtime | ||||||
|  |             data = p.read_bytes() | ||||||
|  |             etag = blake3(data).hexdigest(length=8) | ||||||
|  |             if name == "index.html": | ||||||
|  |                 name = "" | ||||||
|  |             # Use old data if not changed | ||||||
|  |             if name in www and www[name][2]["etag"] == etag: | ||||||
|  |                 wwwnew[name] = www[name] | ||||||
|  |                 continue | ||||||
|  |             # Add charset definition | ||||||
|  |             if mime.startswith("text/"): | ||||||
|  |                 mime = f"{mime}; charset=UTF-8" | ||||||
|  |             # Asset files names will change whenever the content changes | ||||||
|  |             cached = name.startswith("assets/") | ||||||
|  |             headers = { | ||||||
|  |                 "etag": etag, | ||||||
|  |                 "last-modified": format_date_time(mtime), | ||||||
|  |                 "cache-control": "max-age=31536000, immutable" | ||||||
|  |                 if cached | ||||||
|  |                 else "no-cache", | ||||||
|  |                 "content-type": mime, | ||||||
|  |             } | ||||||
|  |             # Precompress with Brotli | ||||||
|  |             br = brotli.compress(data) | ||||||
|  |             if len(br) >= len(data): | ||||||
|  |                 br = False | ||||||
|  |             wwwnew[name] = data, br, headers | ||||||
|  |     if not wwwnew: | ||||||
|  |         raise ServerError( | ||||||
|  |             "Web frontend missing. Did you forget npm run build?", | ||||||
|  |             extra={"wwwroot": str(base)}, | ||||||
|  |             quiet=True, | ||||||
|  |         ) | ||||||
|  |     return wwwnew | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.before_server_start | ||||||
|  | async def start(app): | ||||||
|  |     await load_wwwroot(app) | ||||||
|  |     if app.debug: | ||||||
|  |         app.add_task(refresh_wwwroot()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def load_wwwroot(app): | ||||||
|  |     global www | ||||||
|  |     www = await asyncio.get_event_loop().run_in_executor( | ||||||
|  |         app.ctx.threadexec, _load_wwwroot, www | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def refresh_wwwroot(): | ||||||
|  |     while True: | ||||||
|  |         await asyncio.sleep(0.5) | ||||||
|  |         try: | ||||||
|  |             wwwold = www | ||||||
|  |             await load_wwwroot(app) | ||||||
|  |             changes = "" | ||||||
|  |             for name in sorted(www): | ||||||
|  |                 attr = www[name] | ||||||
|  |                 if wwwold.get(name) == attr: | ||||||
|  |                     continue | ||||||
|  |                 headers = attr[2] | ||||||
|  |                 changes += f"{headers['last-modified']} {headers['etag']} /{name}\n" | ||||||
|  |             for name in sorted(set(wwwold) - set(www)): | ||||||
|  |                 changes += f"Deleted /{name}\n" | ||||||
|  |             if changes: | ||||||
|  |                 print(f"Updated wwwroot:\n{changes}", end="", flush=True) | ||||||
|  |         except Exception as e: | ||||||
|  |             print("Error loading wwwroot", e) | ||||||
|  |         if not app.debug: | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.route("/<path:path>", methods=["GET", "HEAD"]) | ||||||
| async def wwwroot(req, path=""): | async def wwwroot(req, path=""): | ||||||
|     """Frontend files only""" |     """Frontend files only""" | ||||||
|     name = filename.sanitize(unquote(path)) if path else "index.html" |     name = unquote(path) | ||||||
|     try: |     if name not in www: | ||||||
|         index = files("cista").joinpath("wwwroot", name).read_bytes() |         raise NotFound(f"File not found: /{path}", extra={"name": name}) | ||||||
|     except OSError as e: |     data, br, headers = www[name] | ||||||
|         raise NotFound(f"File not found: /{path}", extra={"name": name, "exception": repr(e)}) |     if req.headers.if_none_match == headers["etag"]: | ||||||
|     mime = mimetypes.guess_type(name)[0] or "application/octet-stream" |         # The client has it cached, respond 304 Not Modified | ||||||
|     return raw(index, content_type=mime) |         return empty(304, headers=headers) | ||||||
|  |     # Brotli compressed? | ||||||
|  |     if br and "br" in req.headers.accept_encoding.split(", "): | ||||||
|  |         headers = {**headers, "content-encoding": "br"} | ||||||
|  |         data = br | ||||||
|  |     return raw(data, headers=headers) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]: | ||||||
|  |     loc = PurePosixPath() | ||||||
|  |     idx = 0 | ||||||
|  |     ret = [] | ||||||
|  |     level: int | None = None | ||||||
|  |     parent: PurePosixPath | None = None | ||||||
|  |     with watching.state.lock: | ||||||
|  |         root = watching.state.root | ||||||
|  |         while idx < len(root): | ||||||
|  |             f = root[idx] | ||||||
|  |             loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name | ||||||
|  |             if parent is not None and f.level <= level: | ||||||
|  |                 level = parent = None | ||||||
|  |             if f.key in wanted: | ||||||
|  |                 level, parent = f.level, loc.parent | ||||||
|  |             if parent is not None: | ||||||
|  |                 wanted.discard(f.key) | ||||||
|  |                 ret.append((loc.relative_to(parent), watching.rootpath / loc)) | ||||||
|  |             idx += 1 | ||||||
|  |     return ret | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.get("/zip/<keys>/<zipfile:ext=zip>") | ||||||
|  | async def zip_download(req, keys, zipfile, ext): | ||||||
|  |     """Download a zip archive of the given keys""" | ||||||
|  |  | ||||||
|  |     wanted = set(keys.split("+")) | ||||||
|  |     files = get_files(wanted) | ||||||
|  |  | ||||||
|  |     if not files: | ||||||
|  |         raise NotFound( | ||||||
|  |             "No files found", | ||||||
|  |             context={"keys": keys, "zipfile": f"{zipfile}.{ext}", "wanted": wanted}, | ||||||
|  |         ) | ||||||
|  |     if wanted: | ||||||
|  |         raise NotFound("Files not found", context={"missing": wanted}) | ||||||
|  |  | ||||||
|  |     def local_files(files): | ||||||
|  |         for rel, p in files: | ||||||
|  |             s = p.stat() | ||||||
|  |             size = s.st_size | ||||||
|  |             modified = datetime.datetime.fromtimestamp(s.st_mtime, datetime.UTC) | ||||||
|  |             name = rel.as_posix() | ||||||
|  |             if p.is_dir(): | ||||||
|  |                 yield f"{name}/", modified, S_IFDIR | 0o755, ZIP_AUTO(size), iter(b"") | ||||||
|  |             else: | ||||||
|  |                 yield name, modified, S_IFREG | 0o644, ZIP_AUTO(size), contents(p, size) | ||||||
|  |  | ||||||
|  |     def contents(name, size): | ||||||
|  |         with name.open("rb") as f: | ||||||
|  |             while size > 0 and (chunk := f.read(min(size, 1 << 20))): | ||||||
|  |                 size -= len(chunk) | ||||||
|  |                 yield chunk | ||||||
|  |         assert size == 0 | ||||||
|  |  | ||||||
|  |     def worker(): | ||||||
|  |         try: | ||||||
|  |             for chunk in stream_zip(local_files(files)): | ||||||
|  |                 asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result() | ||||||
|  |         except Exception: | ||||||
|  |             logging.exception("Error streaming ZIP") | ||||||
|  |             raise | ||||||
|  |         finally: | ||||||
|  |             asyncio.run_coroutine_threadsafe(queue.put(None), loop) | ||||||
|  |  | ||||||
|  |     # Don't block the event loop: run in a thread | ||||||
|  |     queue = asyncio.Queue(maxsize=1) | ||||||
|  |     loop = asyncio.get_event_loop() | ||||||
|  |     thread = loop.run_in_executor(app.ctx.threadexec, worker) | ||||||
|  |  | ||||||
|  |     # Stream the response | ||||||
|  |     res = await req.respond( | ||||||
|  |         content_type="application/zip", | ||||||
|  |         headers={"cache-control": "no-store"}, | ||||||
|  |     ) | ||||||
|  |     while chunk := await queue.get(): | ||||||
|  |         await res.send(chunk) | ||||||
|  |  | ||||||
|  |     await thread  # If it raises, the response will fail download | ||||||
|   | |||||||
							
								
								
									
										58
									
								
								cista/auth.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -12,10 +12,12 @@ from sanic.exceptions import BadRequest, Forbidden, Unauthorized | |||||||
| from cista import config, session | from cista import config, session | ||||||
|  |  | ||||||
| _argon = argon2.PasswordHasher() | _argon = argon2.PasswordHasher() | ||||||
| _droppyhash = re.compile(r'^([a-f0-9]{64})\$([a-f0-9]{8})$') | _droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$") | ||||||
|  |  | ||||||
|  |  | ||||||
| def _pwnorm(password): | def _pwnorm(password): | ||||||
|     return normalize('NFC', password).strip().encode() |     return normalize("NFC", password).strip().encode() | ||||||
|  |  | ||||||
|  |  | ||||||
| def login(username: str, password: str): | def login(username: str, password: str): | ||||||
|     un = _pwnorm(username) |     un = _pwnorm(username) | ||||||
| @@ -23,7 +25,7 @@ def login(username: str, password: str): | |||||||
|     try: |     try: | ||||||
|         u = config.config.users[un.decode()] |         u = config.config.users[un.decode()] | ||||||
|     except KeyError: |     except KeyError: | ||||||
|         raise ValueError("Invalid username") |         raise ValueError("Invalid username") from None | ||||||
|     # Verify password |     # Verify password | ||||||
|     need_rehash = False |     need_rehash = False | ||||||
|     if not u.hash: |     if not u.hash: | ||||||
| @@ -39,7 +41,7 @@ def login(username: str, password: str): | |||||||
|         try: |         try: | ||||||
|             _argon.verify(u.hash, pw) |             _argon.verify(u.hash, pw) | ||||||
|         except Exception: |         except Exception: | ||||||
|             raise ValueError("Invalid password") |             raise ValueError("Invalid password") from None | ||||||
|         if _argon.check_needs_rehash(u.hash): |         if _argon.check_needs_rehash(u.hash): | ||||||
|             need_rehash = True |             need_rehash = True | ||||||
|     # Login successful |     # Login successful | ||||||
| @@ -49,33 +51,51 @@ def login(username: str, password: str): | |||||||
|     u.lastSeen = now |     u.lastSeen = now | ||||||
|     return u |     return u | ||||||
|  |  | ||||||
|  |  | ||||||
| def set_password(user: config.User, password: str): | def set_password(user: config.User, password: str): | ||||||
|     user.hash = _argon.hash(_pwnorm(password)) |     user.hash = _argon.hash(_pwnorm(password)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class LoginResponse(msgspec.Struct): | class LoginResponse(msgspec.Struct): | ||||||
|     user: str = "" |     user: str = "" | ||||||
|     privileged: bool = False |     privileged: bool = False | ||||||
|     error: str = "" |     error: str = "" | ||||||
|  |  | ||||||
| def verify(request, privileged=False): |  | ||||||
|  | def verify(request, *, privileged=False): | ||||||
|     """Raise Unauthorized or Forbidden if the request is not authorized""" |     """Raise Unauthorized or Forbidden if the request is not authorized""" | ||||||
|     if privileged: |     if privileged: | ||||||
|         if request.ctx.user: |         if request.ctx.user: | ||||||
|             if request.ctx.user.privileged: return |             if request.ctx.user.privileged: | ||||||
|             raise Forbidden("Access Forbidden: Only for privileged users") |                 return | ||||||
|     elif config.config.public or request.ctx.user: return |             raise Forbidden("Access Forbidden: Only for privileged users", quiet=True) | ||||||
|     raise Unauthorized("Login required", "cookie", context={"redirect": "/login"}) |     elif config.config.public or request.ctx.user: | ||||||
|  |         return | ||||||
|  |     raise Unauthorized("Login required", "cookie", quiet=True) | ||||||
|  |  | ||||||
|  |  | ||||||
| bp = Blueprint("auth") | bp = Blueprint("auth") | ||||||
|  |  | ||||||
|  |  | ||||||
| @bp.get("/login") | @bp.get("/login") | ||||||
| async def login_page(request): | async def login_page(request): | ||||||
|     doc = Document("Cista Login") |     doc = Document("Cista Login") | ||||||
|     with doc.div(id="login"): |     with doc.div(id="login"): | ||||||
|         with doc.form(method="POST", autocomplete="on"): |         with doc.form(method="POST", autocomplete="on"): | ||||||
|             doc.h1("Login") |             doc.h1("Login") | ||||||
|             doc.input(name="username", placeholder="Username", autocomplete="username", required=True).br |             doc.input( | ||||||
|             doc.input(type="password", name="password", placeholder="Password", autocomplete="current-password", required=True).br |                 name="username", | ||||||
|  |                 placeholder="Username", | ||||||
|  |                 autocomplete="username", | ||||||
|  |                 required=True, | ||||||
|  |             ).br | ||||||
|  |             doc.input( | ||||||
|  |                 type="password", | ||||||
|  |                 name="password", | ||||||
|  |                 placeholder="Password", | ||||||
|  |                 autocomplete="current-password", | ||||||
|  |                 required=True, | ||||||
|  |             ).br | ||||||
|             doc.input(type="submit", value="Login") |             doc.input(type="submit", value="Login") | ||||||
|         s = session.get(request) |         s = session.get(request) | ||||||
|         if s: |         if s: | ||||||
| @@ -84,7 +104,12 @@ async def login_page(request): | |||||||
|                 doc.input(type="submit", value=f"Logout {name}") |                 doc.input(type="submit", value=f"Logout {name}") | ||||||
|     flash = request.cookies.message |     flash = request.cookies.message | ||||||
|     if flash: |     if flash: | ||||||
|         doc.dialog(flash, id="flash", open=True, style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8") |         doc.dialog( | ||||||
|  |             flash, | ||||||
|  |             id="flash", | ||||||
|  |             open=True, | ||||||
|  |             style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8", | ||||||
|  |         ) | ||||||
|     res = html(doc) |     res = html(doc) | ||||||
|     if flash: |     if flash: | ||||||
|         res.cookies.delete_cookie("flash") |         res.cookies.delete_cookie("flash") | ||||||
| @@ -92,6 +117,7 @@ async def login_page(request): | |||||||
|         session.delete(res) |         session.delete(res) | ||||||
|     return res |     return res | ||||||
|  |  | ||||||
|  |  | ||||||
| @bp.post("/login") | @bp.post("/login") | ||||||
| async def login_post(request): | async def login_post(request): | ||||||
|     try: |     try: | ||||||
| @@ -104,11 +130,14 @@ async def login_post(request): | |||||||
|         if not username or not password: |         if not username or not password: | ||||||
|             raise KeyError |             raise KeyError | ||||||
|     except KeyError: |     except KeyError: | ||||||
|         raise BadRequest("Missing username or password", context={"redirect": "/login"}) |         raise BadRequest( | ||||||
|  |             "Missing username or password", | ||||||
|  |             context={"redirect": "/login"}, | ||||||
|  |         ) from None | ||||||
|     try: |     try: | ||||||
|         user = login(username, password) |         user = login(username, password) | ||||||
|     except ValueError as e: |     except ValueError as e: | ||||||
|         raise Forbidden(str(e), context={"redirect": "/login"}) |         raise Forbidden(str(e), context={"redirect": "/login"}) from e | ||||||
|  |  | ||||||
|     if "text/html" in request.headers.accept: |     if "text/html" in request.headers.accept: | ||||||
|         res = redirect("/") |         res = redirect("/") | ||||||
| @@ -118,6 +147,7 @@ async def login_post(request): | |||||||
|     session.create(res, username) |     session.create(res, username) | ||||||
|     return res |     return res | ||||||
|  |  | ||||||
|  |  | ||||||
| @bp.post("/logout") | @bp.post("/logout") | ||||||
| async def logout_post(request): | async def logout_post(request): | ||||||
|     s = request.ctx.session |     s = request.ctx.session | ||||||
|   | |||||||
							
								
								
									
										28
									
								
								cista/config.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -1,6 +1,7 @@ | |||||||
| from __future__ import annotations | from __future__ import annotations | ||||||
|  |  | ||||||
| import secrets | import secrets | ||||||
|  | import sys | ||||||
| from functools import wraps | from functools import wraps | ||||||
| from hashlib import sha256 | from hashlib import sha256 | ||||||
| from pathlib import Path, PurePath | from pathlib import Path, PurePath | ||||||
| @@ -14,29 +15,32 @@ class Config(msgspec.Struct): | |||||||
|     listen: str |     listen: str | ||||||
|     secret: str = secrets.token_hex(12) |     secret: str = secrets.token_hex(12) | ||||||
|     public: bool = False |     public: bool = False | ||||||
|  |     name: str = "" | ||||||
|     users: dict[str, User] = {} |     users: dict[str, User] = {} | ||||||
|     links: dict[str, Link] = {} |     links: dict[str, Link] = {} | ||||||
|  |  | ||||||
|  |  | ||||||
| class User(msgspec.Struct, omit_defaults=True): | class User(msgspec.Struct, omit_defaults=True): | ||||||
|     privileged: bool = False |     privileged: bool = False | ||||||
|     hash: str = "" |     hash: str = "" | ||||||
|     lastSeen: int = 0 |     lastSeen: int = 0  # noqa: N815 | ||||||
|  |  | ||||||
|  |  | ||||||
| class Link(msgspec.Struct, omit_defaults=True): | class Link(msgspec.Struct, omit_defaults=True): | ||||||
|     location: str |     location: str | ||||||
|     creator: str = "" |     creator: str = "" | ||||||
|     expires: int = 0 |     expires: int = 0 | ||||||
|  |  | ||||||
|  |  | ||||||
| config = None | config = None | ||||||
| conffile = Path.home() / ".local/share/cista/db.toml" | conffile = Path.home() / ".local/share/cista/db.toml" | ||||||
|  |  | ||||||
|  |  | ||||||
| def derived_secret(*params, len=8) -> bytes: | def derived_secret(*params, len=8) -> bytes: | ||||||
|     """Used to derive secret keys from the main secret""" |     """Used to derive secret keys from the main secret""" | ||||||
|     # Each part is made the same length by hashing first |     # Each part is made the same length by hashing first | ||||||
|     combined = b"".join( |     combined = b"".join( | ||||||
|         sha256( |         sha256(p if isinstance(p, bytes) else f"{p}".encode()).digest() | ||||||
|             p if isinstance(p, bytes) else f"{p}".encode() |  | ||||||
|         ).digest() |  | ||||||
|         for p in [config.secret, *params] |         for p in [config.secret, *params] | ||||||
|     ) |     ) | ||||||
|     # Output a bytes of the desired length |     # Output a bytes of the desired length | ||||||
| @@ -48,11 +52,13 @@ def enc_hook(obj): | |||||||
|         return obj.as_posix() |         return obj.as_posix() | ||||||
|     raise TypeError |     raise TypeError | ||||||
|  |  | ||||||
|  |  | ||||||
| def dec_hook(typ, obj): | def dec_hook(typ, obj): | ||||||
|     if typ is Path: |     if typ is Path: | ||||||
|         return Path(obj) |         return Path(obj) | ||||||
|     raise TypeError |     raise TypeError | ||||||
|  |  | ||||||
|  |  | ||||||
| def config_update(modify): | def config_update(modify): | ||||||
|     global config |     global config | ||||||
|     if not conffile.exists(): |     if not conffile.exists(): | ||||||
| @@ -85,6 +91,8 @@ def config_update(modify): | |||||||
|             return "read" |             return "read" | ||||||
|         f.write(new) |         f.write(new) | ||||||
|         f.close() |         f.close() | ||||||
|  |         if sys.platform == "win32": | ||||||
|  |             conffile.unlink()  # Windows doesn't support atomic replace | ||||||
|         tmpname.rename(conffile)  # Atomic replace |         tmpname.rename(conffile)  # Atomic replace | ||||||
|     except: |     except: | ||||||
|         f.close() |         f.close() | ||||||
| @@ -93,21 +101,28 @@ def config_update(modify): | |||||||
|     config = c |     config = c | ||||||
|     return "modified" if old else "created" |     return "modified" if old else "created" | ||||||
|  |  | ||||||
|  |  | ||||||
| def modifies_config(modify): | def modifies_config(modify): | ||||||
|     """Decorator for functions that modify the config file""" |     """Decorator for functions that modify the config file""" | ||||||
|  |  | ||||||
|     @wraps(modify) |     @wraps(modify) | ||||||
|     def wrapper(*args, **kwargs): |     def wrapper(*args, **kwargs): | ||||||
|         m = lambda c: modify(c, *args, **kwargs) |         def m(c): | ||||||
|  |             return modify(c, *args, **kwargs) | ||||||
|  |  | ||||||
|         # Retry modification in case of write collision |         # Retry modification in case of write collision | ||||||
|         while (c := config_update(m)) == "collision": |         while (c := config_update(m)) == "collision": | ||||||
|             time.sleep(0.01) |             time.sleep(0.01) | ||||||
|         return c |         return c | ||||||
|  |  | ||||||
|     return wrapper |     return wrapper | ||||||
|  |  | ||||||
|  |  | ||||||
| def load_config(): | def load_config(): | ||||||
|     global config |     global config | ||||||
|     config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook) |     config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook) | ||||||
|  |  | ||||||
|  |  | ||||||
| @modifies_config | @modifies_config | ||||||
| def update_config(conf: Config, changes: dict) -> Config: | def update_config(conf: Config, changes: dict) -> Config: | ||||||
|     """Create/update the config with new values, respecting changes done by others.""" |     """Create/update the config with new values, respecting changes done by others.""" | ||||||
| @@ -116,6 +131,7 @@ def update_config(conf: Config, changes: dict) -> Config: | |||||||
|     settings.update(changes) |     settings.update(changes) | ||||||
|     return msgspec.convert(settings, Config, dec_hook=dec_hook) |     return msgspec.convert(settings, Config, dec_hook=dec_hook) | ||||||
|  |  | ||||||
|  |  | ||||||
| @modifies_config | @modifies_config | ||||||
| def update_user(conf: Config, name: str, changes: dict) -> Config: | def update_user(conf: Config, name: str, changes: dict) -> Config: | ||||||
|     """Create/update a user with new values, respecting changes done by others.""" |     """Create/update a user with new values, respecting changes done by others.""" | ||||||
| @@ -126,6 +142,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config: | |||||||
|         u = User() |         u = User() | ||||||
|     if "password" in changes: |     if "password" in changes: | ||||||
|         from . import auth |         from . import auth | ||||||
|  |  | ||||||
|         auth.set_password(u, changes["password"]) |         auth.set_password(u, changes["password"]) | ||||||
|         del changes["password"] |         del changes["password"] | ||||||
|     udict = msgspec.to_builtins(u, enc_hook=enc_hook) |     udict = msgspec.to_builtins(u, enc_hook=enc_hook) | ||||||
| @@ -134,6 +151,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config: | |||||||
|     settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook) |     settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook) | ||||||
|     return msgspec.convert(settings, Config, dec_hook=dec_hook) |     return msgspec.convert(settings, Config, dec_hook=dec_hook) | ||||||
|  |  | ||||||
|  |  | ||||||
| @modifies_config | @modifies_config | ||||||
| def del_user(conf: Config, name: str) -> Config: | def del_user(conf: Config, name: str) -> Config: | ||||||
|     """Delete named user account.""" |     """Delete named user account.""" | ||||||
|   | |||||||
							
								
								
									
										19
									
								
								cista/droppy.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -11,6 +11,7 @@ def readconf() -> dict: | |||||||
|     cf["listen"] = _droppy_listeners(cf) |     cf["listen"] = _droppy_listeners(cf) | ||||||
|     return cf | db |     return cf | db | ||||||
|  |  | ||||||
|  |  | ||||||
| def _droppy_listeners(cf): | def _droppy_listeners(cf): | ||||||
|     """Convert Droppy listeners to our format, for typical cases but not in full.""" |     """Convert Droppy listeners to our format, for typical cases but not in full.""" | ||||||
|     for listener in cf["listeners"]: |     for listener in cf["listeners"]: | ||||||
| @@ -20,15 +21,21 @@ def _droppy_listeners(cf): | |||||||
|                 continue |                 continue | ||||||
|             socket = listener.get("socket") |             socket = listener.get("socket") | ||||||
|             if socket: |             if socket: | ||||||
|                 if isinstance(socket, list): socket = socket[0] |                 if isinstance(socket, list): | ||||||
|  |                     socket = socket[0] | ||||||
|                 return f"{socket}" |                 return f"{socket}" | ||||||
|             port = listener["port"] |             port = listener["port"] | ||||||
|             if isinstance(port, list): port = port[0] |             if isinstance(port, list): | ||||||
|  |                 port = port[0] | ||||||
|             host = listener["host"] |             host = listener["host"] | ||||||
|             if isinstance(host, list): host = host[0] |             if isinstance(host, list): | ||||||
|             if host in ("127.0.0.1", "::", "localhost"): return f":{port}" |                 host = host[0] | ||||||
|             return f"{host}:{port}" |  | ||||||
|         except (KeyError, IndexError): |         except (KeyError, IndexError): | ||||||
|             continue |             continue | ||||||
|  |         else: | ||||||
|  |             if host in ("127.0.0.1", "::", "localhost"): | ||||||
|  |                 return f":{port}" | ||||||
|  |             return f"{host}:{port}" | ||||||
|  |  | ||||||
|     # If none matched, fallback to Droppy default |     # If none matched, fallback to Droppy default | ||||||
|     return f"0.0.0.0:8989" |     return "0.0.0.0:8989" | ||||||
|   | |||||||
							
								
								
									
										29
									
								
								cista/fileio.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -1,7 +1,5 @@ | |||||||
| import asyncio | import asyncio | ||||||
| import os | import os | ||||||
| import unicodedata |  | ||||||
| from pathlib import PurePosixPath |  | ||||||
|  |  | ||||||
| from cista import config | from cista import config | ||||||
| from cista.util import filename | from cista.util import filename | ||||||
| @@ -13,6 +11,7 @@ def fuid(stat) -> str: | |||||||
|     """Unique file ID. Stays the same on renames and modification.""" |     """Unique file ID. Stays the same on renames and modification.""" | ||||||
|     return config.derived_secret("filekey-inode", stat.st_dev, stat.st_ino).hex() |     return config.derived_secret("filekey-inode", stat.st_dev, stat.st_ino).hex() | ||||||
|  |  | ||||||
|  |  | ||||||
| class File: | class File: | ||||||
|     def __init__(self, filename): |     def __init__(self, filename): | ||||||
|         self.path = config.config.path / filename |         self.path = config.config.path / filename | ||||||
| @@ -30,23 +29,26 @@ class File: | |||||||
|         self.writable = True |         self.writable = True | ||||||
|  |  | ||||||
|     def write(self, pos, buffer, *, file_size=None): |     def write(self, pos, buffer, *, file_size=None): | ||||||
|         assert self.fd is not None |  | ||||||
|         if not self.writable: |         if not self.writable: | ||||||
|             # Create/open file |             # Create/open file | ||||||
|             self.open_rw() |             self.open_rw() | ||||||
|  |         assert self.fd is not None | ||||||
|         if file_size is not None: |         if file_size is not None: | ||||||
|  |             assert pos + len(buffer) <= file_size | ||||||
|             os.ftruncate(self.fd, file_size) |             os.ftruncate(self.fd, file_size) | ||||||
|         os.lseek(self.fd, pos, os.SEEK_SET) |         if buffer: | ||||||
|         os.write(self.fd, buffer) |             os.lseek(self.fd, pos, os.SEEK_SET) | ||||||
|  |             os.write(self.fd, buffer) | ||||||
|  |  | ||||||
|     def __getitem__(self, slice): |     def __getitem__(self, slice): | ||||||
|         assert self.fd is not None |  | ||||||
|         if self.fd is None: |         if self.fd is None: | ||||||
|             self.open_ro() |             self.open_ro() | ||||||
|  |         assert self.fd is not None | ||||||
|         os.lseek(self.fd, slice.start, os.SEEK_SET) |         os.lseek(self.fd, slice.start, os.SEEK_SET) | ||||||
|         l = slice.stop - slice.start |         size = slice.stop - slice.start | ||||||
|         data = os.read(self.fd, l) |         data = os.read(self.fd, size) | ||||||
|         if len(data) < l: raise EOFError("Error reading requested range") |         if len(data) < size: | ||||||
|  |             raise EOFError("Error reading requested range") | ||||||
|         return data |         return data | ||||||
|  |  | ||||||
|     def close(self): |     def close(self): | ||||||
| @@ -59,10 +61,13 @@ class File: | |||||||
|  |  | ||||||
|  |  | ||||||
| class FileServer: | class FileServer: | ||||||
|  |  | ||||||
|     async def start(self): |     async def start(self): | ||||||
|         self.alink = AsyncLink() |         self.alink = AsyncLink() | ||||||
|         self.worker = asyncio.get_event_loop().run_in_executor(None, self.worker_thread, self.alink.to_sync) |         self.worker = asyncio.get_event_loop().run_in_executor( | ||||||
|  |             None, | ||||||
|  |             self.worker_thread, | ||||||
|  |             self.alink.to_sync, | ||||||
|  |         ) | ||||||
|         self.cache = LRUCache(File, capacity=10, maxage=5.0) |         self.cache = LRUCache(File, capacity=10, maxage=5.0) | ||||||
|  |  | ||||||
|     async def stop(self): |     async def stop(self): | ||||||
| @@ -91,4 +96,4 @@ class FileServer: | |||||||
|     def download(self, name, start, end): |     def download(self, name, start, end): | ||||||
|         name = filename.sanitize(name) |         name = filename.sanitize(name) | ||||||
|         f = self.cache[name] |         f = self.cache[name] | ||||||
|         return f[start: end] |         return f[start:end] | ||||||
|   | |||||||
							
								
								
									
										114
									
								
								cista/protocol.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -11,19 +11,24 @@ from cista.util import filename | |||||||
|  |  | ||||||
| ## Control commands | ## Control commands | ||||||
|  |  | ||||||
|  |  | ||||||
| class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower): | class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower): | ||||||
|     def __call__(self): |     def __call__(self): | ||||||
|         raise NotImplementedError |         raise NotImplementedError | ||||||
|  |  | ||||||
|  |  | ||||||
| class MkDir(ControlBase): | class MkDir(ControlBase): | ||||||
|     path: str |     path: str | ||||||
|  |  | ||||||
|     def __call__(self): |     def __call__(self): | ||||||
|         path = config.config.path / filename.sanitize(self.path) |         path = config.config.path / filename.sanitize(self.path) | ||||||
|         path.mkdir(parents=False, exist_ok=False) |         path.mkdir(parents=True, exist_ok=False) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Rename(ControlBase): | class Rename(ControlBase): | ||||||
|     path: str |     path: str | ||||||
|     to: str |     to: str | ||||||
|  |  | ||||||
|     def __call__(self): |     def __call__(self): | ||||||
|         to = filename.sanitize(self.to) |         to = filename.sanitize(self.to) | ||||||
|         if "/" in to: |         if "/" in to: | ||||||
| @@ -31,17 +36,24 @@ class Rename(ControlBase): | |||||||
|         path = config.config.path / filename.sanitize(self.path) |         path = config.config.path / filename.sanitize(self.path) | ||||||
|         path.rename(path.with_name(to)) |         path.rename(path.with_name(to)) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Rm(ControlBase): | class Rm(ControlBase): | ||||||
|     sel: list[str] |     sel: list[str] | ||||||
|  |  | ||||||
|     def __call__(self): |     def __call__(self): | ||||||
|         root = config.config.path |         root = config.config.path | ||||||
|         sel = [root / filename.sanitize(p) for p in self.sel] |         sel = [root / filename.sanitize(p) for p in self.sel] | ||||||
|         for p in sel: |         for p in sel: | ||||||
|             shutil.rmtree(p, ignore_errors=True) |             if p.is_dir(): | ||||||
|  |                 shutil.rmtree(p) | ||||||
|  |             else: | ||||||
|  |                 p.unlink() | ||||||
|  |  | ||||||
|  |  | ||||||
| class Mv(ControlBase): | class Mv(ControlBase): | ||||||
|     sel: list[str] |     sel: list[str] | ||||||
|     dst: str |     dst: str | ||||||
|  |  | ||||||
|     def __call__(self): |     def __call__(self): | ||||||
|         root = config.config.path |         root = config.config.path | ||||||
|         sel = [root / filename.sanitize(p) for p in self.sel] |         sel = [root / filename.sanitize(p) for p in self.sel] | ||||||
| @@ -51,9 +63,11 @@ class Mv(ControlBase): | |||||||
|         for p in sel: |         for p in sel: | ||||||
|             shutil.move(p, dst) |             shutil.move(p, dst) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Cp(ControlBase): | class Cp(ControlBase): | ||||||
|     sel: list[str] |     sel: list[str] | ||||||
|     dst: str |     dst: str | ||||||
|  |  | ||||||
|     def __call__(self): |     def __call__(self): | ||||||
|         root = config.config.path |         root = config.config.path | ||||||
|         sel = [root / filename.sanitize(p) for p in self.sel] |         sel = [root / filename.sanitize(p) for p in self.sel] | ||||||
| @@ -61,73 +75,77 @@ class Cp(ControlBase): | |||||||
|         if not dst.is_dir(): |         if not dst.is_dir(): | ||||||
|             raise BadRequest("The destination must be a directory") |             raise BadRequest("The destination must be a directory") | ||||||
|         for p in sel: |         for p in sel: | ||||||
|             # Note: copies as dst rather than in dst unless name is appended. |             if p.is_dir(): | ||||||
|             shutil.copytree(p, dst / p.name, dirs_exist_ok=True, ignore_dangling_symlinks=True) |                 # Note: copies as dst rather than in dst unless name is appended. | ||||||
|  |                 shutil.copytree( | ||||||
|  |                     p, | ||||||
|  |                     dst / p.name, | ||||||
|  |                     dirs_exist_ok=True, | ||||||
|  |                     ignore_dangling_symlinks=True, | ||||||
|  |                 ) | ||||||
|  |             else: | ||||||
|  |                 shutil.copy2(p, dst) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ControlTypes = MkDir | Rename | Rm | Mv | Cp | ||||||
|  |  | ||||||
|  |  | ||||||
| ## File uploads and downloads | ## File uploads and downloads | ||||||
|  |  | ||||||
|  |  | ||||||
| class FileRange(msgspec.Struct): | class FileRange(msgspec.Struct): | ||||||
|     name: str |     name: str | ||||||
|     size: int |     size: int | ||||||
|     start: int |     start: int | ||||||
|     end: int |     end: int | ||||||
|  |  | ||||||
|  |  | ||||||
| class StatusMsg(msgspec.Struct): | class StatusMsg(msgspec.Struct): | ||||||
|     status: str |     status: str | ||||||
|     req: FileRange |     req: FileRange | ||||||
|  |  | ||||||
|  |  | ||||||
| class ErrorMsg(msgspec.Struct): | class ErrorMsg(msgspec.Struct): | ||||||
|     error: dict[str, Any] |     error: dict[str, Any] | ||||||
|  |  | ||||||
|  |  | ||||||
| ## Directory listings | ## Directory listings | ||||||
|  |  | ||||||
| class FileEntry(msgspec.Struct): |  | ||||||
|     size: int | class FileEntry(msgspec.Struct, array_like=True): | ||||||
|  |     level: int | ||||||
|  |     name: str | ||||||
|  |     key: str | ||||||
|     mtime: int |     mtime: int | ||||||
|  |  | ||||||
| class DirEntry(msgspec.Struct): |  | ||||||
|     size: int |     size: int | ||||||
|     mtime: int |     isfile: int | ||||||
|     dir: DirList |  | ||||||
|  |  | ||||||
|     def __getitem__(self, name): |     def __repr__(self): | ||||||
|         return self.dir[name] |         return self.key or "FileEntry()" | ||||||
|  |  | ||||||
|     def __setitem__(self, name, value): |  | ||||||
|         self.dir[name] = value |  | ||||||
|  |  | ||||||
|     def __contains__(self, name): |  | ||||||
|         return name in self.dir |  | ||||||
|  |  | ||||||
|     def __delitem__(self, name): |  | ||||||
|         del self.dir[name] |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def props(self): |  | ||||||
|         return { |  | ||||||
|             k: v |  | ||||||
|             for k, v in self.__struct_fields__ |  | ||||||
|             if k != "dir" |  | ||||||
|         } |  | ||||||
|  |  | ||||||
| DirList = dict[str, FileEntry | DirEntry] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class UpdateEntry(msgspec.Struct, omit_defaults=True): | class Update(msgspec.Struct, array_like=True): | ||||||
|     """Updates the named entry in the tree. Fields that are set replace old values. A list of entries recurses directories.""" |     ... | ||||||
|     name: str = "" |  | ||||||
|     deleted: bool = False |  | ||||||
|     size: int | None = None |  | ||||||
|     mtime: int | None = None |  | ||||||
|     dir: DirList | None = None |  | ||||||
|  |  | ||||||
| def make_dir_data(root): |  | ||||||
|     if len(root) == 2: | class UpdKeep(Update, tag="k"): | ||||||
|         return FileEntry(*root) |     count: int | ||||||
|     size, mtime, listing = root |  | ||||||
|     converted = {} |  | ||||||
|     for name, data in listing.items(): | class UpdDel(Update, tag="d"): | ||||||
|         converted[name] = make_dir_data(data) |     count: int | ||||||
|     sz = sum(x.size for x in converted.values()) |  | ||||||
|     mt = max(x.mtime for x in converted.values()) |  | ||||||
|     return DirEntry(sz, max(mt, mtime), converted) | class UpdIns(Update, tag="i"): | ||||||
|  |     items: list[FileEntry] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class UpdateMessage(msgspec.Struct): | ||||||
|  |     update: list[UpdKeep | UpdDel | UpdIns] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Space(msgspec.Struct): | ||||||
|  |     disk: int | ||||||
|  |     free: int | ||||||
|  |     usage: int | ||||||
|  |     storage: int | ||||||
|   | |||||||
							
								
								
									
										45
									
								
								cista/serve.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -1,47 +1,62 @@ | |||||||
| import os | import os | ||||||
| import re | import re | ||||||
| from pathlib import Path, PurePath | from pathlib import Path | ||||||
|  |  | ||||||
| from sanic import Sanic | from sanic import Sanic | ||||||
|  |  | ||||||
| from cista import config, server80 | from cista import config, server80 | ||||||
|  |  | ||||||
|  |  | ||||||
| def run(dev=False): | def run(*, dev=False): | ||||||
|     """Run Sanic main process that spawns worker processes to serve HTTP requests.""" |     """Run Sanic main process that spawns worker processes to serve HTTP requests.""" | ||||||
|     from .app import app |     from .app import app | ||||||
|  |  | ||||||
|     url, opts = parse_listen(config.config.listen) |     url, opts = parse_listen(config.config.listen) | ||||||
|     # Silence Sanic's warning about running in production rather than debug |     # Silence Sanic's warning about running in production rather than debug | ||||||
|     os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" |     os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" | ||||||
|     confdir = config.conffile.parent |     confdir = config.conffile.parent | ||||||
|     wwwroot = PurePath(__file__).parent / "wwwroot" |  | ||||||
|     if opts.get("ssl"): |     if opts.get("ssl"): | ||||||
|         # Run plain HTTP redirect/acme server on port 80 |         # Run plain HTTP redirect/acme server on port 80 | ||||||
|         server80.app.prepare(port=80, motd=False) |         server80.app.prepare(port=80, motd=False) | ||||||
|         domain = opts["host"] |         domain = opts["host"] | ||||||
|         check_cert(confdir / domain, domain) |         check_cert(confdir / domain, domain) | ||||||
|         opts["ssl"] = str(confdir / domain)  # type: ignore |         opts["ssl"] = str(confdir / domain)  # type: ignore | ||||||
|     app.prepare(**opts, motd=False, dev=dev, auto_reload=dev, reload_dir={confdir, wwwroot}, access_log=True)  # type: ignore |     app.prepare( | ||||||
|     Sanic.serve() |         **opts, | ||||||
|  |         motd=False, | ||||||
|  |         dev=dev, | ||||||
|  |         auto_reload=dev, | ||||||
|  |         reload_dir={confdir}, | ||||||
|  |         access_log=True, | ||||||
|  |     )  # type: ignore | ||||||
|  |     if dev: | ||||||
|  |         Sanic.serve() | ||||||
|  |     else: | ||||||
|  |         Sanic.serve_single() | ||||||
|  |  | ||||||
|  |  | ||||||
| def check_cert(certdir, domain): | def check_cert(certdir, domain): | ||||||
|     if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists(): |     if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists(): | ||||||
|         return |         return | ||||||
|     # TODO: Use certbot to fetch a cert |     # TODO: Use certbot to fetch a cert | ||||||
|     raise ValueError(f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}") |     raise ValueError( | ||||||
|  |         f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}", | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def parse_listen(listen): | def parse_listen(listen): | ||||||
|     if listen.startswith("/"): |     if listen.startswith("/"): | ||||||
|         unix = Path(listen).resolve() |         unix = Path(listen).resolve() | ||||||
|         if not unix.parent.exists(): |         if not unix.parent.exists(): | ||||||
|             raise ValueError(f"Directory for unix socket does not exist: {unix.parent}/") |             raise ValueError( | ||||||
|  |                 f"Directory for unix socket does not exist: {unix.parent}/", | ||||||
|  |             ) | ||||||
|         return "http://localhost", {"unix": unix} |         return "http://localhost", {"unix": unix} | ||||||
|     elif re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE): |     if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE): | ||||||
|         return f"https://{listen}", {"host": listen, "port": 443, "ssl": True} |         return f"https://{listen}", {"host": listen, "port": 443, "ssl": True} | ||||||
|     else: |     try: | ||||||
|         try: |         addr, _port = listen.split(":", 1) | ||||||
|             addr, _port = listen.split(":", 1) |         port = int(_port) | ||||||
|             port = int(_port) |     except Exception: | ||||||
|         except Exception: |         raise ValueError(f"Invalid listen address: {listen}") from None | ||||||
|             raise ValueError(f"Invalid listen address: {listen}") |     return f"http://localhost:{port}", {"host": addr, "port": port} | ||||||
|         return f"http://localhost:{port}", {"host": addr, "port": port} |  | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ from sanic import Sanic, exceptions, response | |||||||
|  |  | ||||||
| app = Sanic("server80") | app = Sanic("server80") | ||||||
|  |  | ||||||
|  |  | ||||||
| # Send all HTTP users to HTTPS | # Send all HTTP users to HTTPS | ||||||
| @app.exception(exceptions.NotFound, exceptions.MethodNotSupported) | @app.exception(exceptions.NotFound, exceptions.MethodNotSupported) | ||||||
| def redirect_everything_else(request, exception): | def redirect_everything_else(request, exception): | ||||||
| @@ -10,6 +11,7 @@ def redirect_everything_else(request, exception): | |||||||
|         return response.redirect(f"https://{server}{path}", status=308) |         return response.redirect(f"https://{server}{path}", status=308) | ||||||
|     return response.text("Bad Request. Please use HTTPS!", status=400) |     return response.text("Bad Request. Please use HTTPS!", status=400) | ||||||
|  |  | ||||||
|  |  | ||||||
| # ACME challenge for LetsEncrypt | # ACME challenge for LetsEncrypt | ||||||
| @app.get("/.well-known/acme-challenge/<challenge>") | @app.get("/.well-known/acme-challenge/<challenge>") | ||||||
| async def letsencrypt(request, challenge): | async def letsencrypt(request, challenge): | ||||||
| @@ -18,4 +20,5 @@ async def letsencrypt(request, challenge): | |||||||
|     except KeyError: |     except KeyError: | ||||||
|         return response.text(f"ACME challenge not found: {challenge}", status=404) |         return response.text(f"ACME challenge not found: {challenge}", status=404) | ||||||
|  |  | ||||||
|  |  | ||||||
| acme_challenges = {} | acme_challenges = {} | ||||||
|   | |||||||
							
								
								
									
										13
									
								
								cista/session.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -4,16 +4,21 @@ import jwt | |||||||
|  |  | ||||||
| from cista.config import derived_secret | from cista.config import derived_secret | ||||||
|  |  | ||||||
| session_secret = lambda: derived_secret("session") |  | ||||||
|  | def session_secret(): | ||||||
|  |     return derived_secret("session") | ||||||
|  |  | ||||||
|  |  | ||||||
| max_age = 365 * 86400  # Seconds since last login | max_age = 365 * 86400  # Seconds since last login | ||||||
|  |  | ||||||
|  |  | ||||||
| def get(request): | def get(request): | ||||||
|     try: |     try: | ||||||
|         return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"]) |         return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"]) | ||||||
|     except Exception as e: |     except Exception: | ||||||
|         s = None |  | ||||||
|         return False if "s" in request.cookies else None |         return False if "s" in request.cookies else None | ||||||
|  |  | ||||||
|  |  | ||||||
| def create(res, username, **kwargs): | def create(res, username, **kwargs): | ||||||
|     data = { |     data = { | ||||||
|         "exp": int(time()) + max_age, |         "exp": int(time()) + max_age, | ||||||
| @@ -23,12 +28,14 @@ def create(res, username, **kwargs): | |||||||
|     s = jwt.encode(data, session_secret()) |     s = jwt.encode(data, session_secret()) | ||||||
|     res.cookies.add_cookie("s", s, httponly=True, max_age=max_age) |     res.cookies.add_cookie("s", s, httponly=True, max_age=max_age) | ||||||
|  |  | ||||||
|  |  | ||||||
| def update(res, s, **kwargs): | def update(res, s, **kwargs): | ||||||
|     s.update(kwargs) |     s.update(kwargs) | ||||||
|     s = jwt.encode(s, session_secret()) |     s = jwt.encode(s, session_secret()) | ||||||
|     max_age = max(1, s["exp"] - int(time()))  # type: ignore |     max_age = max(1, s["exp"] - int(time()))  # type: ignore | ||||||
|     res.cookies.add_cookie("s", s, httponly=True, max_age=max_age) |     res.cookies.add_cookie("s", s, httponly=True, max_age=max_age) | ||||||
|  |  | ||||||
|  |  | ||||||
| def delete(res): | def delete(res): | ||||||
|     res.cookies.delete_cookie("s") |     res.cookies.delete_cookie("s") | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								cista/util/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -14,10 +14,12 @@ def asend(ws, msg): | |||||||
|     """Send JSON message or bytes to a websocket""" |     """Send JSON message or bytes to a websocket""" | ||||||
|     return ws.send(msg if isinstance(msg, bytes) else msgspec.json.encode(msg).decode()) |     return ws.send(msg if isinstance(msg, bytes) else msgspec.json.encode(msg).decode()) | ||||||
|  |  | ||||||
|  |  | ||||||
| def jres(data, **kwargs): | def jres(data, **kwargs): | ||||||
|     """JSON Sanic response, using msgspec encoding""" |     """JSON Sanic response, using msgspec encoding""" | ||||||
|     return raw(msgspec.json.encode(data), content_type="application/json", **kwargs) |     return raw(msgspec.json.encode(data), content_type="application/json", **kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
| async def handle_sanic_exception(request, e): | async def handle_sanic_exception(request, e): | ||||||
|     logger.exception(e) |     logger.exception(e) | ||||||
|     context, code = {}, 500 |     context, code = {}, 500 | ||||||
| @@ -30,7 +32,10 @@ async def handle_sanic_exception(request, e): | |||||||
|     message = f"⚠️ {message}" if code < 500 else f"🛑 {message}" |     message = f"⚠️ {message}" if code < 500 else f"🛑 {message}" | ||||||
|     # Non-browsers get JSON errors |     # Non-browsers get JSON errors | ||||||
|     if "text/html" not in request.headers.accept: |     if "text/html" not in request.headers.accept: | ||||||
|         return jres(ErrorMsg({"code": code, "message": message, **context}), status=code) |         return jres( | ||||||
|  |             ErrorMsg({"code": code, "message": message, **context}), | ||||||
|  |             status=code, | ||||||
|  |         ) | ||||||
|     # Redirections flash the error message via cookies |     # Redirections flash the error message via cookies | ||||||
|     if "redirect" in context: |     if "redirect" in context: | ||||||
|         res = redirect(context["redirect"]) |         res = redirect(context["redirect"]) | ||||||
| @@ -39,8 +44,10 @@ async def handle_sanic_exception(request, e): | |||||||
|     # Otherwise use Sanic's default error page |     # Otherwise use Sanic's default error page | ||||||
|     return errorpages.HTMLRenderer(request, e, debug=request.app.debug).full() |     return errorpages.HTMLRenderer(request, e, debug=request.app.debug).full() | ||||||
|  |  | ||||||
|  |  | ||||||
| def websocket_wrapper(handler): | def websocket_wrapper(handler): | ||||||
|     """Decorator for websocket handlers that catches exceptions and sends them back to the client""" |     """Decorator for websocket handlers that catches exceptions and sends them back to the client""" | ||||||
|  |  | ||||||
|     @wraps(handler) |     @wraps(handler) | ||||||
|     async def wrapper(request, ws, *args, **kwargs): |     async def wrapper(request, ws, *args, **kwargs): | ||||||
|         try: |         try: | ||||||
| @@ -55,4 +62,5 @@ def websocket_wrapper(handler): | |||||||
|             message = f"⚠️ {message}" if code < 500 else f"🛑 {message}" |             message = f"⚠️ {message}" if code < 500 else f"🛑 {message}" | ||||||
|             await asend(ws, ErrorMsg({"code": code, "message": message, **context})) |             await asend(ws, ErrorMsg({"code": code, "message": message, **context})) | ||||||
|             raise |             raise | ||||||
|  |  | ||||||
|     return wrapper |     return wrapper | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								cista/util/asynclink.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -80,8 +80,9 @@ class SyncRequest: | |||||||
|         if exc: |         if exc: | ||||||
|             self.set_exception(exc) |             self.set_exception(exc) | ||||||
|             return True |             return True | ||||||
|         elif not self.done: |         if not self.done: | ||||||
|             self.set_result(None) |             self.set_result(None) | ||||||
|  |         return None | ||||||
|  |  | ||||||
|     def set_result(self, value): |     def set_result(self, value): | ||||||
|         """Set result value; mark as done.""" |         """Set result value; mark as done.""" | ||||||
|   | |||||||
| @@ -10,4 +10,7 @@ def sanitize(filename: str) -> str: | |||||||
|     filename = filename.replace("\\", "-") |     filename = filename.replace("\\", "-") | ||||||
|     filename = sanitize_filepath(filename) |     filename = sanitize_filepath(filename) | ||||||
|     filename = filename.strip("/") |     filename = filename.strip("/") | ||||||
|     return PurePosixPath(filename).as_posix() |     p = PurePosixPath(filename) | ||||||
|  |     if any(n.startswith(".") for n in p.parts): | ||||||
|  |         raise ValueError("Filenames starting with dot are not allowed") | ||||||
|  |     return p.as_posix() | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								cista/util/lrucache.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -11,6 +11,7 @@ class LRUCache: | |||||||
|         maxage (float): Max age for items in cache in seconds. |         maxage (float): Max age for items in cache in seconds. | ||||||
|         cache (list): Internal list storing the cache items. |         cache (list): Internal list storing the cache items. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def __init__(self, open: callable, *, capacity: int, maxage: float): |     def __init__(self, open: callable, *, capacity: int, maxage: float): | ||||||
|         """ |         """ | ||||||
|         Initialize LRUCache. |         Initialize LRUCache. | ||||||
| @@ -40,7 +41,7 @@ class LRUCache: | |||||||
|             The corresponding item's handle. |             The corresponding item's handle. | ||||||
|         """ |         """ | ||||||
|         # Take from cache or open a new one |         # Take from cache or open a new one | ||||||
|         for i, (k, f, ts) in enumerate(self.cache): |         for i, (k, f, _ts) in enumerate(self.cache):  # noqa: B007 | ||||||
|             if k == key: |             if k == key: | ||||||
|                 self.cache.pop(i) |                 self.cache.pop(i) | ||||||
|                 break |                 break | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ def generate(n=4): | |||||||
|     wl = list(words) |     wl = list(words) | ||||||
|     return ".".join(wl.pop(secrets.randbelow(len(wl))) for i in range(n)) |     return ".".join(wl.pop(secrets.randbelow(len(wl))) for i in range(n)) | ||||||
|  |  | ||||||
|  |  | ||||||
| # A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words, entropy 2.1b/letter 10b/word | # A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words, entropy 2.1b/letter 10b/word | ||||||
| words: list = """ | words: list = """ | ||||||
| able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead | able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead | ||||||
|   | |||||||
							
								
								
									
										388
									
								
								cista/watching.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -1,51 +1,163 @@ | |||||||
| import asyncio | import asyncio | ||||||
| import shutil | import shutil | ||||||
|  | import stat | ||||||
|  | import sys | ||||||
| import threading | import threading | ||||||
| import time | import time | ||||||
|  | from os import stat_result | ||||||
| from pathlib import Path, PurePosixPath | from pathlib import Path, PurePosixPath | ||||||
|  |  | ||||||
| import inotify.adapters |  | ||||||
| import msgspec | import msgspec | ||||||
|  | from natsort import humansorted, natsort_keygen, ns | ||||||
|  | from sanic.log import logging | ||||||
|  |  | ||||||
| from cista import config | from cista import config | ||||||
| from cista.protocol import DirEntry, FileEntry, UpdateEntry | from cista.fileio import fuid | ||||||
|  | from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep | ||||||
|  |  | ||||||
| pubsub = {} | pubsub = {} | ||||||
| tree = {"": None} | sortkey = natsort_keygen(alg=ns.LOCALE) | ||||||
| tree_lock = threading.Lock() |  | ||||||
|  |  | ||||||
|  | class State: | ||||||
|  |     def __init__(self): | ||||||
|  |         self.lock = threading.RLock() | ||||||
|  |         self._space = Space(0, 0, 0, 0) | ||||||
|  |         self._listing: list[FileEntry] = [] | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def space(self): | ||||||
|  |         with self.lock: | ||||||
|  |             return self._space | ||||||
|  |  | ||||||
|  |     @space.setter | ||||||
|  |     def space(self, space): | ||||||
|  |         with self.lock: | ||||||
|  |             self._space = space | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def root(self) -> list[FileEntry]: | ||||||
|  |         with self.lock: | ||||||
|  |             return self._listing[:] | ||||||
|  |  | ||||||
|  |     @root.setter | ||||||
|  |     def root(self, listing: list[FileEntry]): | ||||||
|  |         with self.lock: | ||||||
|  |             self._listing = listing | ||||||
|  |  | ||||||
|  |     def _slice(self, idx: PurePosixPath | tuple[PurePosixPath, int]): | ||||||
|  |         relpath, relfile = idx if isinstance(idx, tuple) else (idx, 0) | ||||||
|  |         begin, end = 0, len(self._listing) | ||||||
|  |         level = 0 | ||||||
|  |         isfile = 0 | ||||||
|  |  | ||||||
|  |         # Special case for root | ||||||
|  |         if not relpath.parts: | ||||||
|  |             return slice(begin, end) | ||||||
|  |  | ||||||
|  |         begin += 1 | ||||||
|  |         for part in relpath.parts: | ||||||
|  |             level += 1 | ||||||
|  |             found = False | ||||||
|  |  | ||||||
|  |             while begin < end: | ||||||
|  |                 entry = self._listing[begin] | ||||||
|  |  | ||||||
|  |                 if entry.level < level: | ||||||
|  |                     break | ||||||
|  |  | ||||||
|  |                 if entry.level == level: | ||||||
|  |                     if entry.name == part: | ||||||
|  |                         found = True | ||||||
|  |                         if level == len(relpath.parts): | ||||||
|  |                             isfile = relfile | ||||||
|  |                         else: | ||||||
|  |                             begin += 1 | ||||||
|  |                         break | ||||||
|  |                     cmp = entry.isfile - isfile or sortkey(entry.name) > sortkey(part) | ||||||
|  |                     if cmp > 0: | ||||||
|  |                         break | ||||||
|  |  | ||||||
|  |                 begin += 1 | ||||||
|  |  | ||||||
|  |             if not found: | ||||||
|  |                 return slice(begin, begin) | ||||||
|  |  | ||||||
|  |         # Found the starting point, now find the end of the slice | ||||||
|  |         for end in range(begin + 1, len(self._listing) + 1): | ||||||
|  |             if end == len(self._listing) or self._listing[end].level <= level: | ||||||
|  |                 break | ||||||
|  |         return slice(begin, end) | ||||||
|  |  | ||||||
|  |     def __getitem__(self, index: PurePosixPath | tuple[PurePosixPath, int]): | ||||||
|  |         with self.lock: | ||||||
|  |             return self._listing[self._slice(index)] | ||||||
|  |  | ||||||
|  |     def __setitem__( | ||||||
|  |         self, index: tuple[PurePosixPath, int], value: list[FileEntry] | ||||||
|  |     ) -> None: | ||||||
|  |         rel, isfile = index | ||||||
|  |         with self.lock: | ||||||
|  |             if rel.parts: | ||||||
|  |                 parent = self._slice(rel.parent) | ||||||
|  |                 if parent.start == parent.stop: | ||||||
|  |                     raise ValueError( | ||||||
|  |                         f"Parent folder {rel.as_posix()} is missing for {rel.name}" | ||||||
|  |                     ) | ||||||
|  |             self._listing[self._slice(index)] = value | ||||||
|  |  | ||||||
|  |     def __delitem__(self, relpath: PurePosixPath): | ||||||
|  |         with self.lock: | ||||||
|  |             del self._listing[self._slice(relpath)] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | state = State() | ||||||
| rootpath: Path = None  # type: ignore | rootpath: Path = None  # type: ignore | ||||||
| quit = False | quit = False | ||||||
| modified_flags = "IN_CREATE", "IN_DELETE", "IN_DELETE_SELF", "IN_MODIFY", "IN_MOVE_SELF", "IN_MOVED_FROM", "IN_MOVED_TO" | modified_flags = ( | ||||||
| disk_usage = None |     "IN_CREATE", | ||||||
|  |     "IN_DELETE", | ||||||
|  |     "IN_DELETE_SELF", | ||||||
|  |     "IN_MODIFY", | ||||||
|  |     "IN_MOVE_SELF", | ||||||
|  |     "IN_MOVED_FROM", | ||||||
|  |     "IN_MOVED_TO", | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def watcher_thread(loop): | def watcher_thread(loop): | ||||||
|     global disk_usage |     global rootpath | ||||||
|  |     import inotify.adapters | ||||||
|  |  | ||||||
|     while True: |     while not quit: | ||||||
|         rootpath = config.config.path |         rootpath = config.config.path | ||||||
|         i = inotify.adapters.InotifyTree(rootpath.as_posix()) |         i = inotify.adapters.InotifyTree(rootpath.as_posix()) | ||||||
|         old = format_tree() if tree[""] else None |         # Initialize the tree from filesystem | ||||||
|         with tree_lock: |         new = walk() | ||||||
|             # Initialize the tree from filesystem |         with state.lock: | ||||||
|             tree[""] = walk(rootpath) |             old = state.root | ||||||
|         msg = format_tree() |             if old != new: | ||||||
|         if msg != old: |                 state.root = new | ||||||
|             asyncio.run_coroutine_threadsafe(broadcast(msg), loop) |                 broadcast(format_update(old, new), loop) | ||||||
|  |  | ||||||
|         # The watching is not entirely reliable, so do a full refresh every minute |         # The watching is not entirely reliable, so do a full refresh every 30 seconds | ||||||
|         refreshdl = time.monotonic() + 60.0 |         refreshdl = time.monotonic() + 30.0 | ||||||
|  |  | ||||||
|         for event in i.event_gen(): |         for event in i.event_gen(): | ||||||
|             if quit: return |             if quit: | ||||||
|  |                 return | ||||||
|             # Disk usage update |             # Disk usage update | ||||||
|             du = shutil.disk_usage(rootpath) |             du = shutil.disk_usage(rootpath) | ||||||
|             if du != disk_usage: |             space = Space(*du, storage=state.root[0].size) | ||||||
|                 disk_usage = du |             if space != state.space: | ||||||
|                 asyncio.run_coroutine_threadsafe(broadcast(format_du()), loop) |                 state.space = space | ||||||
|  |                 broadcast(format_space(space), loop) | ||||||
|                 break |                 break | ||||||
|             # Do a full refresh? |             # Do a full refresh? | ||||||
|             if time.monotonic() > refreshdl: break |             if time.monotonic() > refreshdl: | ||||||
|             if event is None: continue |                 break | ||||||
|  |             if event is None: | ||||||
|  |                 continue | ||||||
|             _, flags, path, filename = event |             _, flags, path, filename = event | ||||||
|             if not any(f in modified_flags for f in flags): |             if not any(f in modified_flags for f in flags): | ||||||
|                 continue |                 continue | ||||||
| @@ -54,113 +166,169 @@ def watcher_thread(loop): | |||||||
|             try: |             try: | ||||||
|                 update(path.relative_to(rootpath), loop) |                 update(path.relative_to(rootpath), loop) | ||||||
|             except Exception as e: |             except Exception as e: | ||||||
|                 print("Watching error", e) |                 print("Watching error", e, path, rootpath) | ||||||
|                 break |                 raise | ||||||
|         i = None  # Free the inotify object |         i = None  # Free the inotify object | ||||||
|  |  | ||||||
| def format_du(): |  | ||||||
|     return msgspec.json.encode({"space": { |  | ||||||
|         "disk": disk_usage.total, |  | ||||||
|         "used": disk_usage.used, |  | ||||||
|         "free": disk_usage.free, |  | ||||||
|         "storage": tree[""].size, |  | ||||||
|     }}).decode() |  | ||||||
|  |  | ||||||
| def format_tree(): | def watcher_thread_poll(loop): | ||||||
|     root = tree[""] |     global rootpath | ||||||
|     return msgspec.json.encode({"update": [ |  | ||||||
|         UpdateEntry(size=root.size, mtime=root.mtime, dir=root.dir) |  | ||||||
|     ]}).decode() |  | ||||||
|  |  | ||||||
| def walk(path: Path) -> DirEntry | FileEntry | None: |     while not quit: | ||||||
|  |         rootpath = config.config.path | ||||||
|  |         new = walk() | ||||||
|  |         with state.lock: | ||||||
|  |             old = state.root | ||||||
|  |             if old != new: | ||||||
|  |                 state.root = new | ||||||
|  |                 broadcast(format_update(old, new), loop) | ||||||
|  |  | ||||||
|  |         # Disk usage update | ||||||
|  |         du = shutil.disk_usage(rootpath) | ||||||
|  |         space = Space(*du, storage=state.root[0].size) | ||||||
|  |         if space != state.space: | ||||||
|  |             state.space = space | ||||||
|  |             broadcast(format_space(space), loop) | ||||||
|  |  | ||||||
|  |         time.sleep(2.0) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def walk(rel=PurePosixPath()) -> list[FileEntry]:  # noqa: B008 | ||||||
|  |     path = rootpath / rel | ||||||
|     try: |     try: | ||||||
|         s = path.stat() |         st = path.stat() | ||||||
|         mtime = int(s.st_mtime) |     except OSError: | ||||||
|         if path.is_file(): |         return [] | ||||||
|             return FileEntry(s.st_size, mtime) |     return _walk(rel, int(not stat.S_ISDIR(st.st_mode)), st) | ||||||
|  |  | ||||||
|         tree = {p.name: v for p in path.iterdir() if not p.name.startswith('.') if (v := walk(p)) is not None} |  | ||||||
|         if tree: | def _walk(rel: PurePosixPath, isfile: int, st: stat_result) -> list[FileEntry]: | ||||||
|             size = sum(v.size for v in tree.values()) |     entry = FileEntry( | ||||||
|             mtime = max(mtime, max(v.mtime for v in tree.values())) |         level=len(rel.parts), | ||||||
|         else: |         name=rel.name, | ||||||
|             size = 0 |         key=fuid(st), | ||||||
|         return DirEntry(size, mtime, tree) |         mtime=int(st.st_mtime), | ||||||
|  |         size=st.st_size if isfile else 0, | ||||||
|  |         isfile=isfile, | ||||||
|  |     ) | ||||||
|  |     if isfile: | ||||||
|  |         return [entry] | ||||||
|  |     ret = [entry] | ||||||
|  |     path = rootpath / rel | ||||||
|  |     try: | ||||||
|  |         li = [] | ||||||
|  |         for f in path.iterdir(): | ||||||
|  |             if quit: | ||||||
|  |                 raise SystemExit("quit") | ||||||
|  |             if f.name.startswith("."): | ||||||
|  |                 continue  # No dotfiles | ||||||
|  |             s = f.stat() | ||||||
|  |             li.append((int(not stat.S_ISDIR(s.st_mode)), f.name, s)) | ||||||
|  |         for [isfile, name, s] in humansorted(li): | ||||||
|  |             if quit: | ||||||
|  |                 raise SystemExit("quit") | ||||||
|  |             subtree = _walk(rel / name, isfile, s) | ||||||
|  |             child = subtree[0] | ||||||
|  |             entry.mtime = max(entry.mtime, child.mtime) | ||||||
|  |             entry.size += child.size | ||||||
|  |             ret.extend(subtree) | ||||||
|     except FileNotFoundError: |     except FileNotFoundError: | ||||||
|         return None |         pass  # Things may be rapidly in motion | ||||||
|     except OSError as e: |     except OSError as e: | ||||||
|         print("OS error walking path", path, e) |         print("OS error walking path", path, e) | ||||||
|         return None |     return ret | ||||||
|  |  | ||||||
|  |  | ||||||
| def update(relpath: PurePosixPath, loop): | def update(relpath: PurePosixPath, loop): | ||||||
|     """Called by inotify updates, check the filesystem and broadcast any changes.""" |     """Called by inotify updates, check the filesystem and broadcast any changes.""" | ||||||
|     new = walk(rootpath / relpath) |     if rootpath is None or relpath is None: | ||||||
|     with tree_lock: |         print("ERROR", rootpath, relpath) | ||||||
|         update = update_internal(relpath, new) |     new = walk(relpath) | ||||||
|         if not update: return  # No changes |     with state.lock: | ||||||
|         msg = msgspec.json.encode({"update": update}).decode() |         old = state[relpath] | ||||||
|         asyncio.run_coroutine_threadsafe(broadcast(msg), loop) |         if old == new: | ||||||
|  |             return | ||||||
|  |         old = state.root | ||||||
|  |         if new: | ||||||
|  |             state[relpath, new[0].isfile] = new | ||||||
|  |         else: | ||||||
|  |             del state[relpath] | ||||||
|  |         broadcast(format_update(old, state.root), loop) | ||||||
|  |  | ||||||
| def update_internal(relpath: PurePosixPath, new: DirEntry | FileEntry | None) -> list[UpdateEntry]: |  | ||||||
|     path = "", *relpath.parts | def format_update(old, new): | ||||||
|     old = tree |     # Make keep/del/insert diff until one of the lists ends | ||||||
|     elems = [] |     oidx, nidx = 0, 0 | ||||||
|     for name in path: |  | ||||||
|         if name not in old: |  | ||||||
|             # File or folder created |  | ||||||
|             old = None |  | ||||||
|             elems.append((name, None)) |  | ||||||
|             if len(elems) < len(path): |  | ||||||
|                 # We got a notify for an item whose parent is not in tree |  | ||||||
|                 print("Tree out of sync DEBUG", relpath) |  | ||||||
|                 print(elems) |  | ||||||
|                 print("Current tree:") |  | ||||||
|                 print(tree[""]) |  | ||||||
|                 print("Walking all:") |  | ||||||
|                 print(walk(rootpath)) |  | ||||||
|                 raise ValueError("Tree out of sync") |  | ||||||
|             break |  | ||||||
|         old = old[name] |  | ||||||
|         elems.append((name, old)) |  | ||||||
|     if old == new: |  | ||||||
|         return [] |  | ||||||
|     mt = new.mtime if new else 0 |  | ||||||
|     szdiff = (new.size if new else 0) - (old.size if old else 0) |  | ||||||
|     # Update parents |  | ||||||
|     update = [] |     update = [] | ||||||
|     for name, entry in elems[:-1]: |     keep_count = 0 | ||||||
|         u = UpdateEntry(name) |     while oidx < len(old) and nidx < len(new): | ||||||
|         if szdiff: |         if old[oidx] == new[nidx]: | ||||||
|             entry.size += szdiff |             keep_count += 1 | ||||||
|             u.size = entry.size |             oidx += 1 | ||||||
|         if mt > entry.mtime: |             nidx += 1 | ||||||
|             u.mtime = entry.mtime = mt |             continue | ||||||
|         update.append(u) |         if keep_count > 0: | ||||||
|     # The last element is the one that changed |             update.append(UpdKeep(keep_count)) | ||||||
|     name, entry = elems[-1] |             keep_count = 0 | ||||||
|     parent = elems[-2][1] if len(elems) > 1 else tree |  | ||||||
|     u = UpdateEntry(name) |         del_count = 0 | ||||||
|     if new: |         rest = new[nidx:] | ||||||
|         parent[name] = new |         while oidx < len(old) and old[oidx] not in rest: | ||||||
|         if u.size != new.size: u.size = new.size |             del_count += 1 | ||||||
|         if u.mtime != new.mtime: u.mtime = new.mtime |             oidx += 1 | ||||||
|         if isinstance(new, DirEntry): |         if del_count: | ||||||
|             if u.dir == new.dir: u.dir = new.dir |             update.append(UpdDel(del_count)) | ||||||
|     else: |             continue | ||||||
|         del parent[name] |  | ||||||
|         u.deleted = True |         insert_items = [] | ||||||
|     update.append(u) |         rest = old[oidx:] | ||||||
|     return update |         while nidx < len(new) and new[nidx] not in rest: | ||||||
|  |             insert_items.append(new[nidx]) | ||||||
|  |             nidx += 1 | ||||||
|  |         update.append(UpdIns(insert_items)) | ||||||
|  |  | ||||||
|  |     # Diff any remaining | ||||||
|  |     if keep_count > 0: | ||||||
|  |         update.append(UpdKeep(keep_count)) | ||||||
|  |     if oidx < len(old): | ||||||
|  |         update.append(UpdDel(len(old) - oidx)) | ||||||
|  |     elif nidx < len(new): | ||||||
|  |         update.append(UpdIns(new[nidx:])) | ||||||
|  |  | ||||||
|  |     return msgspec.json.encode({"update": update}).decode() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def format_space(usage): | ||||||
|  |     return msgspec.json.encode({"space": usage}).decode() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def format_root(root): | ||||||
|  |     return msgspec.json.encode({"root": root}).decode() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def broadcast(msg, loop): | ||||||
|  |     return asyncio.run_coroutine_threadsafe(abroadcast(msg), loop).result() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def abroadcast(msg): | ||||||
|  |     try: | ||||||
|  |         for queue in pubsub.values(): | ||||||
|  |             queue.put_nowait(msg) | ||||||
|  |     except Exception: | ||||||
|  |         # Log because asyncio would silently eat the error | ||||||
|  |         logging.exception("Broadcast error") | ||||||
|  |  | ||||||
| async def broadcast(msg): |  | ||||||
|     for queue in pubsub.values(): |  | ||||||
|         await queue.put_nowait(msg) |  | ||||||
|  |  | ||||||
| async def start(app, loop): | async def start(app, loop): | ||||||
|     config.load_config() |     config.load_config() | ||||||
|     app.ctx.watcher = threading.Thread(target=watcher_thread, args=[loop]) |     use_inotify = sys.platform == "linux" | ||||||
|  |     app.ctx.watcher = threading.Thread( | ||||||
|  |         target=watcher_thread if use_inotify else watcher_thread_poll, | ||||||
|  |         args=[loop], | ||||||
|  |     ) | ||||||
|     app.ctx.watcher.start() |     app.ctx.watcher.start() | ||||||
|  |  | ||||||
|  |  | ||||||
| async def stop(app, loop): | async def stop(app, loop): | ||||||
|     global quit |     global quit | ||||||
|     quit = True |     quit = True | ||||||
|   | |||||||
| @@ -1 +0,0 @@ | |||||||
| @media (min-width: 1024px){.about{min-height:100vh;display:flex;align-items:center}} |  | ||||||
| @@ -1 +0,0 @@ | |||||||
| import{_ as e,o as t,c as o,a as s}from"./index-689b26c8.js";const _={},c={class:"about"},a=s("h1",null,"This is an about page",-1),n=[a];function i(r,u){return t(),o("div",c,n)}const l=e(_,[["render",i]]);export{l as default}; |  | ||||||
| @@ -1 +0,0 @@ | |||||||
| :root{--vt-c-white: #ffffff;--vt-c-white-soft: #f8f8f8;--vt-c-white-mute: #f2f2f2;--vt-c-black: #181818;--vt-c-black-soft: #222222;--vt-c-black-mute: #282828;--vt-c-indigo: #2c3e50;--vt-c-divider-light-1: rgba(60, 60, 60, .29);--vt-c-divider-light-2: rgba(60, 60, 60, .12);--vt-c-divider-dark-1: rgba(84, 84, 84, .65);--vt-c-divider-dark-2: rgba(84, 84, 84, .48);--vt-c-text-light-1: var(--vt-c-indigo);--vt-c-text-light-2: rgba(60, 60, 60, .66);--vt-c-text-dark-1: var(--vt-c-white);--vt-c-text-dark-2: rgba(235, 235, 235, .64)}:root{--color-background: var(--vt-c-white);--color-background-soft: var(--vt-c-white-soft);--color-background-mute: var(--vt-c-white-mute);--color-border: var(--vt-c-divider-light-2);--color-border-hover: var(--vt-c-divider-light-1);--color-heading: var(--vt-c-text-light-1);--color-text: var(--vt-c-text-light-1);--section-gap: 160px}@media (prefers-color-scheme: dark){:root{--color-background: var(--vt-c-black);--color-background-soft: var(--vt-c-black-soft);--color-background-mute: var(--vt-c-black-mute);--color-border: var(--vt-c-divider-dark-2);--color-border-hover: var(--vt-c-divider-dark-1);--color-heading: var(--vt-c-text-dark-1);--color-text: var(--vt-c-text-dark-2)}}*,*:before,*:after{box-sizing:border-box;margin:0;font-weight:400}body{min-height:100vh;color:var(--color-text);background:var(--color-background);transition:color .5s,background-color .5s;line-height:1.6;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;font-size:15px;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app{max-width:1280px;margin:0 auto;padding:2rem;font-weight:400}a,.green{text-decoration:none;color:#00bd7e;transition:.4s}@media (hover: hover){a:hover{background-color:#00bd7e33}}@media (min-width: 1024px){body{display:flex;place-items:center}#app{display:grid;grid-template-columns:1fr 1fr;padding:0 2rem}}h1[data-v-a47c673d]{font-weight:500;font-size:2.6rem;position:relative;top:-10px}h3[data-v-a47c673d]{font-size:1.2rem}.greetings h1[data-v-a47c673d],.greetings h3[data-v-a47c673d]{text-align:center}@media (min-width: 1024px){.greetings h1[data-v-a47c673d],.greetings h3[data-v-a47c673d]{text-align:left}}header[data-v-85852c48]{line-height:1.5;max-height:100vh}.logo[data-v-85852c48]{display:block;margin:0 auto 2rem}nav[data-v-85852c48]{width:100%;font-size:12px;text-align:center;margin-top:2rem}nav a.router-link-exact-active[data-v-85852c48]{color:var(--color-text)}nav a.router-link-exact-active[data-v-85852c48]:hover{background-color:transparent}nav a[data-v-85852c48]{display:inline-block;padding:0 1rem;border-left:1px solid var(--color-border)}nav a[data-v-85852c48]:first-of-type{border:0}@media (min-width: 1024px){header[data-v-85852c48]{display:flex;place-items:center;padding-right:calc(var(--section-gap) / 2)}.logo[data-v-85852c48]{margin:0 2rem 0 0}header .wrapper[data-v-85852c48]{display:flex;place-items:flex-start;flex-wrap:wrap}nav[data-v-85852c48]{text-align:left;margin-left:-1rem;font-size:1rem;padding:1rem 0;margin-top:1rem}}.item[data-v-fd0742eb]{margin-top:2rem;display:flex;position:relative}.details[data-v-fd0742eb]{flex:1;margin-left:1rem}i[data-v-fd0742eb]{display:flex;place-items:center;place-content:center;width:32px;height:32px;color:var(--color-text)}h3[data-v-fd0742eb]{font-size:1.2rem;font-weight:500;margin-bottom:.4rem;color:var(--color-heading)}@media (min-width: 1024px){.item[data-v-fd0742eb]{margin-top:0;padding:.4rem 0 1rem calc(var(--section-gap) / 2)}i[data-v-fd0742eb]{top:calc(50% - 25px);left:-26px;position:absolute;border:1px solid var(--color-border);background:var(--color-background);border-radius:8px;width:50px;height:50px}.item[data-v-fd0742eb]:before{content:" ";border-left:1px solid var(--color-border);position:absolute;left:0;bottom:calc(50% + 25px);height:calc(50% - 25px)}.item[data-v-fd0742eb]:after{content:" ";border-left:1px solid var(--color-border);position:absolute;left:0;top:calc(50% + 25px);height:calc(50% - 25px)}.item[data-v-fd0742eb]:first-of-type:before{display:none}.item[data-v-fd0742eb]:last-of-type:after{display:none}} |  | ||||||
| @@ -1 +0,0 @@ | |||||||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg> |  | ||||||
| Before Width: | Height: | Size: 276 B | 
| Before Width: | Height: | Size: 4.2 KiB | 
| @@ -1,15 +0,0 @@ | |||||||
| <!DOCTYPE html> |  | ||||||
| <html lang="en"> |  | ||||||
|   <head> |  | ||||||
|     <meta charset="UTF-8"> |  | ||||||
|     <link rel="icon" href="/favicon.ico"> |  | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |  | ||||||
|     <title>Vite App</title> |  | ||||||
|     <script type="module" crossorigin src="/assets/index-689b26c8.js"></script> |  | ||||||
|     <link rel="stylesheet" href="/assets/index-9f680dd7.css"> |  | ||||||
|   </head> |  | ||||||
|   <body> |  | ||||||
|     <div id="app"></div> |  | ||||||
|      |  | ||||||
|   </body> |  | ||||||
| </html> |  | ||||||
| @@ -1,241 +0,0 @@ | |||||||
| <!DOCTYPE html> |  | ||||||
| <title>Storage</title> |  | ||||||
| <style> |  | ||||||
|   body { |  | ||||||
|     font-family: sans-serif; |  | ||||||
|     max-width: 100ch; |  | ||||||
|     margin: 0 auto; |  | ||||||
|     padding: 1em; |  | ||||||
|     background-color: #333; |  | ||||||
|     color: #eee; |  | ||||||
|   } |  | ||||||
|   td { |  | ||||||
|     text-align: right; |  | ||||||
|     padding: .5em; |  | ||||||
|   } |  | ||||||
|   td:first-child { |  | ||||||
|     text-align: left; |  | ||||||
|   } |  | ||||||
|   a { |  | ||||||
|     color: inherit; |  | ||||||
|     text-decoration: none; |  | ||||||
|   } |  | ||||||
| </style> |  | ||||||
| <div> |  | ||||||
|   <h2>Quick file upload</h2> |  | ||||||
|   <p>Uses parallel WebSocket connections for increased bandwidth /api/upload</p> |  | ||||||
|   <input type=file id=fileInput> |  | ||||||
|   <progress id=progressBar value=0 max=1></progress> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| <div> |  | ||||||
|   <h2>Files</h2> |  | ||||||
|   <ul id=file_list></ul> |  | ||||||
| </div> |  | ||||||
|  |  | ||||||
| <script> |  | ||||||
| let files = {} |  | ||||||
| let flatfiles = {} |  | ||||||
|  |  | ||||||
| function createWatchSocket() { |  | ||||||
|   const wsurl = new URL("/api/watch", location.href.replace(/^http/, 'ws')) |  | ||||||
|   const ws = new WebSocket(wsurl) |  | ||||||
|   ws.onmessage = event => { |  | ||||||
|     msg = JSON.parse(event.data) |  | ||||||
|     if (msg.update) { |  | ||||||
|       tree_update(msg.update) |  | ||||||
|       file_list(files) |  | ||||||
|     } else { |  | ||||||
|       console.log("Unkonwn message from watch socket", msg) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| createWatchSocket() |  | ||||||
|  |  | ||||||
| function tree_update(msg) { |  | ||||||
|   console.log("Tree update", msg) |  | ||||||
|   let node = files |  | ||||||
|   for (const elem of msg) { |  | ||||||
|     if (elem.deleted) { |  | ||||||
|       const p = node.dir[elem.name].path |  | ||||||
|       delete node.dir[elem.name] |  | ||||||
|       delete flatfiles[p] |  | ||||||
|       break |  | ||||||
|     } |  | ||||||
|     if (elem.name !== undefined) node = node.dir[elem.name] ||= {} |  | ||||||
|     if (elem.size !== undefined) node.size = elem.size |  | ||||||
|     if (elem.mtime !== undefined) node.mtime = elem.mtime |  | ||||||
|     if (elem.dir !== undefined) node.dir = elem.dir |  | ||||||
|   } |  | ||||||
|   // Update paths and flatfiles |  | ||||||
|   files.path = "/" |  | ||||||
|   const nodes = [files] |  | ||||||
|   flatfiles = {} |  | ||||||
|   while (node = nodes.pop()) { |  | ||||||
|     flatfiles[node.path] = node |  | ||||||
|     if (node.dir === undefined) continue |  | ||||||
|     for (const name of Object.keys(node.dir)) { |  | ||||||
|       const child = node.dir[name] |  | ||||||
|       child.path = node.path + name + (child.dir === undefined ? "" : "/") |  | ||||||
|       nodes.push(child) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'}); |  | ||||||
|  |  | ||||||
| const compare_path = (a, b) => collator.compare(a.path, b.path) |  | ||||||
| const compare_time = (a, b) => a.mtime > b.mtime |  | ||||||
|  |  | ||||||
| function file_list(files) { |  | ||||||
|   const table = document.getElementById("file_list") |  | ||||||
|   const sorted = Object.values(flatfiles).sort(compare_time) |  | ||||||
|   table.innerHTML = "" |  | ||||||
|   for (const f of sorted) { |  | ||||||
|     const {path, size, mtime} = f |  | ||||||
|     const tr = document.createElement("tr") |  | ||||||
|     const name_td = document.createElement("td") |  | ||||||
|     const size_td = document.createElement("td") |  | ||||||
|     const mtime_td = document.createElement("td") |  | ||||||
|     const a = document.createElement("a") |  | ||||||
|     table.appendChild(tr) |  | ||||||
|     tr.appendChild(name_td) |  | ||||||
|     tr.appendChild(size_td) |  | ||||||
|     tr.appendChild(mtime_td) |  | ||||||
|     name_td.appendChild(a) |  | ||||||
|     size_td.textContent = size |  | ||||||
|     mtime_td.textContent = formatUnixDate(mtime) |  | ||||||
|     a.textContent = path |  | ||||||
|     a.href = `/files${path}` |  | ||||||
|     /*a.onclick = event => { |  | ||||||
|       if (window.showSaveFilePicker) { |  | ||||||
|         event.preventDefault() |  | ||||||
|         download_ws(name, size) |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|     a.download = ""*/ |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| function formatUnixDate(t) { |  | ||||||
|     const date = new Date(t * 1000) |  | ||||||
|     const now = new Date() |  | ||||||
|     const diff = date - now |  | ||||||
|     const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) |  | ||||||
|  |  | ||||||
|     if (Math.abs(diff) <= 60000) { |  | ||||||
|         return formatter.format(Math.round(diff / 1000), 'second') |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (Math.abs(diff) <= 3600000) { |  | ||||||
|         return formatter.format(Math.round(diff / 60000), 'minute') |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (Math.abs(diff) <= 86400000) { |  | ||||||
|         return formatter.format(Math.round(diff / 3600000), 'hour') |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (Math.abs(diff) <= 604800000) { |  | ||||||
|         return formatter.format(Math.round(diff / 86400000), 'day') |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return date.toLocaleDateString() |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function download_ws(name, size) { |  | ||||||
|   const fh = await window.showSaveFilePicker({ |  | ||||||
|     suggestedName: name, |  | ||||||
|   }) |  | ||||||
|   const writer = await fh.createWritable() |  | ||||||
|   writer.truncate(size) |  | ||||||
|   const wsurl = new URL("/api/download", location.href.replace(/^http/, 'ws')) |  | ||||||
|   const ws = new WebSocket(wsurl) |  | ||||||
|   let pos = 0 |  | ||||||
|   ws.onopen = () => { |  | ||||||
|     console.log("Downloading over WebSocket", name, size) |  | ||||||
|     ws.send(JSON.stringify({name, start: 0, end: size, size})) |  | ||||||
|   } |  | ||||||
|   ws.onmessage = event => { |  | ||||||
|     if (typeof event.data === 'string') { |  | ||||||
|       const msg = JSON.parse(event.data) |  | ||||||
|       console.log("Download finished", msg) |  | ||||||
|       ws.close() |  | ||||||
|       return |  | ||||||
|     } |  | ||||||
|     console.log("Received chunk", name, pos, pos + event.data.size) |  | ||||||
|     pos += event.data.size |  | ||||||
|     writer.write(event.data) |  | ||||||
|   } |  | ||||||
|   ws.onclose = () => { |  | ||||||
|     if (pos < size) { |  | ||||||
|       console.log("Download aborted", name, pos) |  | ||||||
|       writer.truncate(pos) |  | ||||||
|     } |  | ||||||
|     writer.close() |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| const fileInput = document.getElementById("fileInput") |  | ||||||
| const progress = document.getElementById("progressBar") |  | ||||||
| const numConnections = 2 |  | ||||||
| const chunkSize = 1<<20 |  | ||||||
| const wsConnections = new Set() |  | ||||||
|  |  | ||||||
| //for (let i = 0; i < numConnections; i++) createUploadWS() |  | ||||||
|  |  | ||||||
| function createUploadWS() { |  | ||||||
|   const wsurl = new URL("/api/upload", location.href.replace(/^http/, 'ws')) |  | ||||||
|   const ws = new WebSocket(wsurl) |  | ||||||
|   ws.binaryType = 'arraybuffer' |  | ||||||
|   ws.onopen = () => { |  | ||||||
|     wsConnections.add(ws) |  | ||||||
|     console.log("Upload socket connected") |  | ||||||
|   } |  | ||||||
|   ws.onmessage = event => { |  | ||||||
|     msg = JSON.parse(event.data) |  | ||||||
|     if (msg.written) progress.value += +msg.written |  | ||||||
|     else console.log(`Error: ${msg.error}`) |  | ||||||
|   } |  | ||||||
|   ws.onclose = () => { |  | ||||||
|     wsConnections.delete(ws) |  | ||||||
|     console.log("Upload socket disconnected, reconnecting...") |  | ||||||
|     setTimeout(createUploadWS, 1000) |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function load(file, start, end) { |  | ||||||
|   const reader = new FileReader() |  | ||||||
|   const load = new Promise(resolve => reader.onload = resolve) |  | ||||||
|   reader.readAsArrayBuffer(file.slice(start, end)) |  | ||||||
|   const event = await load |  | ||||||
|   return event.target.result |  | ||||||
| } |  | ||||||
|  |  | ||||||
| async function sendChunk(file, start, end, ws) { |  | ||||||
|   const chunk = await load(file, start, end) |  | ||||||
|   ws.send(JSON.stringify({ |  | ||||||
|     name: file.name, |  | ||||||
|     size: file.size, |  | ||||||
|     start: start, |  | ||||||
|     end: end |  | ||||||
|   })) |  | ||||||
|   ws.send(chunk) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| fileInput.addEventListener("change", async function() { |  | ||||||
|   const file = this.files[0] |  | ||||||
|   const numChunks = Math.ceil(file.size / chunkSize) |  | ||||||
|   progress.value = 0 |  | ||||||
|   progress.max = file.size |  | ||||||
|  |  | ||||||
|   console.log(wsConnections) |  | ||||||
|   for (let i = 0; i < numChunks; i++) { |  | ||||||
|     const ws = Array.from(wsConnections)[i % wsConnections.size] |  | ||||||
|     const start = i * chunkSize |  | ||||||
|     const end = Math.min(file.size, start + chunkSize) |  | ||||||
|     const res = await sendChunk(file, start, end, ws) |  | ||||||
|   } |  | ||||||
| }) |  | ||||||
|  |  | ||||||
| </script> |  | ||||||
							
								
								
									
										5
									
								
								cista-front/.gitignore → frontend/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -7,12 +7,17 @@ yarn-error.log* | |||||||
| pnpm-debug.log* | pnpm-debug.log* | ||||||
| lerna-debug.log* | lerna-debug.log* | ||||||
| 
 | 
 | ||||||
|  | # No locking | ||||||
|  | package-lock.json | ||||||
|  | yarn.lock | ||||||
|  | 
 | ||||||
| node_modules | node_modules | ||||||
| .DS_Store | .DS_Store | ||||||
| dist | dist | ||||||
| dist-ssr | dist-ssr | ||||||
| coverage | coverage | ||||||
| *.local | *.local | ||||||
|  | components.d.ts | ||||||
| 
 | 
 | ||||||
| /cypress/videos/ | /cypress/videos/ | ||||||
| /cypress/screenshots/ | /cypress/screenshots/ | ||||||
							
								
								
									
										12
									
								
								frontend/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,12 @@ | |||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang=en> | ||||||
|  | <meta charset=UTF-8> | ||||||
|  | <title>Cista Storage</title> | ||||||
|  | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | ||||||
|  | <link rel="icon" href="/src/assets/logo.svg"> | ||||||
|  | <link rel="preconnect" href="https://fonts.googleapis.com"> | ||||||
|  | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | ||||||
|  | <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet"> | ||||||
|  | <script type="module" src="/src/main.ts"></script> | ||||||
|  |  | ||||||
|  | <div id="app"></div> | ||||||
							
								
								
									
										59
									
								
								frontend/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,59 @@ | |||||||
|  | { | ||||||
|  |   "name": "cista-frontend", | ||||||
|  |   "version": "0.0.0", | ||||||
|  |   "private": true, | ||||||
|  |   "scripts": { | ||||||
|  |     "dev": "vite", | ||||||
|  |     "build": "run-p type-check \"build-only {@}\" --", | ||||||
|  |     "preview": "vite preview", | ||||||
|  |     "test:unit": "vitest", | ||||||
|  |     "build-only": "vite build", | ||||||
|  |     "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", | ||||||
|  |     "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", | ||||||
|  |     "format": "prettier --write src/" | ||||||
|  |   }, | ||||||
|  |   "dependencies": { | ||||||
|  |     "@imengyu/vue3-context-menu": "^1.3.3", | ||||||
|  |     "@vueuse/core": "^10.4.1", | ||||||
|  |     "esbuild": "^0.19.5", | ||||||
|  |     "lodash": "^4.17.21", | ||||||
|  |     "lodash-es": "^4.17.21", | ||||||
|  |     "pinia": "^2.1.6", | ||||||
|  |     "pinia-plugin-persistedstate": "^3.2.0", | ||||||
|  |     "unplugin-vue-components": "^0.25.2", | ||||||
|  |     "vite-plugin-rewrite-all": "^1.0.1", | ||||||
|  |     "vite-svg-loader": "^4.0.0", | ||||||
|  |     "vue": "^3.3.4", | ||||||
|  |     "vue-router": "^4.2.4" | ||||||
|  |   }, | ||||||
|  |   "devDependencies": { | ||||||
|  |     "@rushstack/eslint-patch": "^1.3.3", | ||||||
|  |     "@tsconfig/node18": "^18.2.2", | ||||||
|  |     "@types/jsdom": "^21.1.3", | ||||||
|  |     "@types/lodash-es": "^4.17.10", | ||||||
|  |     "@types/node": "^18.17.17", | ||||||
|  |     "@vitejs/plugin-vue": "^4.3.4", | ||||||
|  |     "@vue/eslint-config-prettier": "^8.0.0", | ||||||
|  |     "@vue/eslint-config-typescript": "^12.0.0", | ||||||
|  |     "@vue/test-utils": "^2.4.1", | ||||||
|  |     "@vue/tsconfig": "^0.4.0", | ||||||
|  |     "babel-eslint": "^10.1.0", | ||||||
|  |     "eslint": "^8.52.0", | ||||||
|  |     "eslint-plugin-vue": "^9.18.1", | ||||||
|  |     "jsdom": "^22.1.0", | ||||||
|  |     "npm-run-all2": "^6.0.6", | ||||||
|  |     "prettier": "^3.0.3", | ||||||
|  |     "typescript": "~5.2.0", | ||||||
|  |     "vite": "^4.4.9", | ||||||
|  |     "vitest": "^0.34.4", | ||||||
|  |     "vue-tsc": "^1.8.11" | ||||||
|  |   }, | ||||||
|  |   "prettier": { | ||||||
|  |     "semi": false, | ||||||
|  |     "singleQuote": true, | ||||||
|  |     "trailingComma": "none", | ||||||
|  |     "arrowParens": "avoid", | ||||||
|  |     "endOfLine": "lf", | ||||||
|  |     "printWidth": 88 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										2
									
								
								frontend/public/robots.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | |||||||
|  | User-agent: * | ||||||
|  | Disallow: / | ||||||
							
								
								
									
										127
									
								
								frontend/src/App.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,127 @@ | |||||||
|  | <template> | ||||||
|  |   <LoginModal /> | ||||||
|  |   <header> | ||||||
|  |     <HeaderMain ref="headerMain" :path="path.pathList" :query="path.query"> | ||||||
|  |       <HeaderSelected :path="path.pathList" /> | ||||||
|  |     </HeaderMain> | ||||||
|  |     <BreadCrumb :path="path.pathList" tabindex="-1"/> | ||||||
|  |   </header> | ||||||
|  |   <main> | ||||||
|  |     <RouterView :path="path.pathList" :query="path.query" /> | ||||||
|  |   </main> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <script setup lang="ts"> | ||||||
|  | import { RouterView } from 'vue-router' | ||||||
|  | import type { ComputedRef } from 'vue' | ||||||
|  | import type HeaderMain from '@/components/HeaderMain.vue' | ||||||
|  | import { onMounted, onUnmounted, ref, watchEffect } from 'vue' | ||||||
|  | import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS' | ||||||
|  | import { useMainStore } from '@/stores/main' | ||||||
|  |  | ||||||
|  | import { computed } from 'vue' | ||||||
|  | import Router from '@/router/index' | ||||||
|  |  | ||||||
|  | interface Path { | ||||||
|  |   path: string | ||||||
|  |   pathList: string[] | ||||||
|  |   query: string | ||||||
|  | } | ||||||
|  | const store = useMainStore() | ||||||
|  | const path: ComputedRef<Path> = computed(() => { | ||||||
|  |   const p = decodeURIComponent(Router.currentRoute.value.path).split('//') | ||||||
|  |   const pathList = p[0].split('/').filter(value => value !== '') | ||||||
|  |   const query = p.slice(1).join('//') | ||||||
|  |   return { | ||||||
|  |     path: p[0], | ||||||
|  |     pathList, | ||||||
|  |     query | ||||||
|  |   } | ||||||
|  | }) | ||||||
|  | watchEffect(() => { | ||||||
|  |   document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage' | ||||||
|  | }) | ||||||
|  | onMounted(loadSession) | ||||||
|  | onMounted(watchConnect) | ||||||
|  | onUnmounted(watchDisconnect) | ||||||
|  | const headerMain = ref<typeof HeaderMain | null>(null) | ||||||
|  | let vert = 0 | ||||||
|  | let timer: any = null | ||||||
|  | const globalShortcutHandler = (event: KeyboardEvent) => { | ||||||
|  |   const fileExplorer = store.fileExplorer as any | ||||||
|  |   if (!fileExplorer) return | ||||||
|  |   const c = fileExplorer.isCursor() | ||||||
|  |   const keyup = event.type === 'keyup' | ||||||
|  |   if (event.repeat) { | ||||||
|  |     if ( | ||||||
|  |       event.key === 'ArrowUp' || | ||||||
|  |       event.key === 'ArrowDown' || | ||||||
|  |       (c && event.code === 'Space') | ||||||
|  |     ) { | ||||||
|  |       event.preventDefault() | ||||||
|  |     } | ||||||
|  |     return | ||||||
|  |   } | ||||||
|  |   //console.log("key pressed", event) | ||||||
|  |   // For up/down implement custom fast repeat | ||||||
|  |   if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1 | ||||||
|  |   else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1 | ||||||
|  |   // Find: process on keydown so that we can bypass the built-in search hotkey | ||||||
|  |   else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) { | ||||||
|  |     headerMain.value!.toggleSearchInput() | ||||||
|  |   } | ||||||
|  |   // Select all (toggle); keydown to prevent builtin | ||||||
|  |   else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) { | ||||||
|  |     fileExplorer.toggleSelectAll() | ||||||
|  |   } | ||||||
|  |   // Keys 1-3 to sort columns | ||||||
|  |   else if ( | ||||||
|  |     c && | ||||||
|  |     keyup && | ||||||
|  |     (event.key === '1' || event.key === '2' || event.key === '3') | ||||||
|  |   ) { | ||||||
|  |     fileExplorer.toggleSortColumn(+event.key) | ||||||
|  |   } | ||||||
|  |   // Rename | ||||||
|  |   else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) { | ||||||
|  |     fileExplorer.cursorRename() | ||||||
|  |   } | ||||||
|  |   // Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey | ||||||
|  |   else if (c && event.code === 'Space') { | ||||||
|  |     if (keyup && !event.altKey && !event.ctrlKey) | ||||||
|  |       fileExplorer.cursorSelect() | ||||||
|  |   } else return | ||||||
|  |   event.preventDefault() | ||||||
|  |   if (!vert) { | ||||||
|  |     if (timer) { | ||||||
|  |       clearTimeout(timer) // Good for either timeout or interval | ||||||
|  |       timer = null | ||||||
|  |     } | ||||||
|  |     return | ||||||
|  |   } | ||||||
|  |   if (!timer) { | ||||||
|  |     // Initial move, then t0 delay until repeats at tr intervals | ||||||
|  |     const select = event.shiftKey | ||||||
|  |     fileExplorer.cursorMove(vert, select) | ||||||
|  |     const t0 = 200, | ||||||
|  |       tr = 30 | ||||||
|  |     timer = setTimeout( | ||||||
|  |       () => | ||||||
|  |         (timer = setInterval(() => { | ||||||
|  |           fileExplorer.cursorMove(vert, select) | ||||||
|  |         }, tr)), | ||||||
|  |       t0 - tr | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | onMounted(() => { | ||||||
|  |   window.addEventListener('keydown', globalShortcutHandler) | ||||||
|  |   window.addEventListener('keyup', globalShortcutHandler) | ||||||
|  | }) | ||||||
|  | onUnmounted(() => { | ||||||
|  |   window.removeEventListener('keydown', globalShortcutHandler) | ||||||
|  |   window.removeEventListener('keyup', globalShortcutHandler) | ||||||
|  | }) | ||||||
|  | export type { Path } | ||||||
|  | </script> | ||||||
|  | @/stores/main | ||||||
							
								
								
									
										1
									
								
								frontend/src/assets/logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#f80" rx="64" ry="64"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg> | ||||||
| After Width: | Height: | Size: 258 B | 
							
								
								
									
										268
									
								
								frontend/src/assets/main.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,268 @@ | |||||||
|  | @charset "UTF-8"; | ||||||
|  |  | ||||||
|  | :root { | ||||||
|  |   --primary-color: #000; | ||||||
|  |   --primary-background: #ddd; | ||||||
|  |   --header-background: var(--soft-color); | ||||||
|  |   --header-color: #ccc; | ||||||
|  |   --input-background: #fff; | ||||||
|  |   --input-color: #000; | ||||||
|  |   --primary-color: #000; | ||||||
|  |   --soft-color: #146; | ||||||
|  |   --accent-color: #f80; | ||||||
|  |   --transition-time: 0.2s; | ||||||
|  |   /* The following are overridden by responsive layouts */ | ||||||
|  |   --root-font-size: 1rem; | ||||||
|  |   --header-font-size: 1rem; | ||||||
|  |   --header-height: calc(6.5 * var(--header-font-size)); | ||||||
|  | } | ||||||
|  | @media (prefers-color-scheme: dark) { | ||||||
|  |   :root { | ||||||
|  |     --primary-color: #ddd; | ||||||
|  |     --primary-background: var(--soft-color); | ||||||
|  |     --header-background: #000; | ||||||
|  |     --header-color: #ccc; | ||||||
|  |     --input-background: var(--soft-color); | ||||||
|  |     --input-color: #ddd; | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @media screen and (max-width: 600px) { | ||||||
|  |   .size, | ||||||
|  |   .modified, | ||||||
|  |   .summary { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @media screen and (min-width: 1000px) { | ||||||
|  |   :root { | ||||||
|  |     --root-font-size: calc(8px + 8 * 100vw / 1000); | ||||||
|  |   } | ||||||
|  |   header .buttons:has(input[type='search']) > div { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  |   header .buttons > div:has(input[type='search']) { | ||||||
|  |     display: inherit; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @media screen and (min-width: 2000px) { | ||||||
|  |   :root { | ||||||
|  |     --root-font-size: 1.5rem; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | /* Low (landscape) screens: smaller header */ | ||||||
|  | @media screen and (max-height: 600px) { | ||||||
|  |   :root { | ||||||
|  |     --header-font-size: calc(10px + 10 * 100vh / 600);  /* 20px at 600px height */ | ||||||
|  |     --root-font-size: 0.8rem; | ||||||
|  |   } | ||||||
|  |   header .breadcrumb > * { | ||||||
|  |     padding-top: calc(8 + 8 * 100vh / 600) !important; | ||||||
|  |     padding-bottom: calc(8 + 8 * 100vh / 600) !important; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @media screen and (max-height: 300px) { | ||||||
|  |   :root { | ||||||
|  |     --header-font-size: 15px;  /* Don't go smaller than this, no benefit */ | ||||||
|  |     --header-height: calc(1.75 * 16px); | ||||||
|  |     --root-font-size: 0.6rem; | ||||||
|  |   } | ||||||
|  |   header .breadcrumb > * { | ||||||
|  |     padding-top: 14px !important; | ||||||
|  |     padding-bottom: 14px !important; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @media screen and (orientation: landscape) and (min-width: 700px) { | ||||||
|  |   /* Breadcrumbs and buttons side by side */ | ||||||
|  |   :root { | ||||||
|  |     --header-font-size: calc(8px + 8 * 100vh / 600);  /* 16px (1rem nominal) at 600px height */ | ||||||
|  |   } | ||||||
|  |   header { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: row-reverse; | ||||||
|  |     justify-content: space-between; | ||||||
|  |     align-items: end; | ||||||
|  |   } | ||||||
|  |   header .breadcrumb { | ||||||
|  |     flex-shrink: 1; | ||||||
|  |   } | ||||||
|  |   header .breadcrumb > * { | ||||||
|  |     flex-shrink: 1; | ||||||
|  |     padding-top: 1rem !important; | ||||||
|  |     padding-bottom: 1rem !important; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @media print { | ||||||
|  |   :root { | ||||||
|  |     --primary-color: black; | ||||||
|  |     --primary-background: none; | ||||||
|  |     --header-background: none; | ||||||
|  |     --header-color: black; | ||||||
|  |   } | ||||||
|  |   nav, | ||||||
|  |   .menu, | ||||||
|  |   .rename-button { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  |   .breadcrumb > a { | ||||||
|  |     color: black !important; | ||||||
|  |     background: none !important; | ||||||
|  |     padding: 0 !important; | ||||||
|  |     margin: 0 !important; | ||||||
|  |     clip-path: none !important; | ||||||
|  |     max-width: none !important; | ||||||
|  |   } | ||||||
|  |   .breadcrumb > a::after { | ||||||
|  |     content: '/'; | ||||||
|  |   } | ||||||
|  |   .breadcrumb svg { | ||||||
|  |     fill: black !important; | ||||||
|  |   } | ||||||
|  |   main { | ||||||
|  |     height: auto !important; | ||||||
|  |     padding-bottom: 0 !important; | ||||||
|  |   } | ||||||
|  |   thead tr { | ||||||
|  |     position: static !important; | ||||||
|  |     background: none !important; | ||||||
|  |     border-bottom: 1pt solid black !important; | ||||||
|  |   } | ||||||
|  |   .selection { | ||||||
|  |     min-width: 0 !important; | ||||||
|  |     padding: 0 !important; | ||||||
|  |   } | ||||||
|  |   .selection input { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  |   .selection input:checked { | ||||||
|  |     display: inherit; | ||||||
|  |   } | ||||||
|  |   tbody .selection input:checked { | ||||||
|  |     opacity: 1 !important; | ||||||
|  |     transform: scale(0.5); | ||||||
|  |     left: 0; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | html { | ||||||
|  |   font-size: var(--root-font-size); | ||||||
|  |   overflow: hidden; | ||||||
|  | } | ||||||
|  | /* Hide scrollbar for all browsers */ | ||||||
|  | main::-webkit-scrollbar { | ||||||
|  |   display: none; | ||||||
|  | } | ||||||
|  | main { | ||||||
|  |   -ms-overflow-style: none; /* IE and Edge */ | ||||||
|  |   scrollbar-width: none; /* Firefox */ | ||||||
|  | } | ||||||
|  | body { | ||||||
|  |   background-color: var(--primary-background); | ||||||
|  |   font-size: 1rem; | ||||||
|  |   font-family: 'Roboto'; | ||||||
|  |   color: var(--primary-color); | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  | tbody .size, | ||||||
|  | tbody .modified { | ||||||
|  |   font-family: 'Roboto Mono'; | ||||||
|  | } | ||||||
|  | header { | ||||||
|  |   background-color: var(--header-background); | ||||||
|  |   color: var(--header-color); | ||||||
|  |   font-size: var(--header-font-size); | ||||||
|  | } | ||||||
|  | main { | ||||||
|  |   height: 100%; | ||||||
|  | } | ||||||
|  | ::selection { | ||||||
|  |   color: #000; | ||||||
|  |   background: yellow !important; | ||||||
|  | } | ||||||
|  | button { | ||||||
|  |   font: inherit; | ||||||
|  |   color: inherit; | ||||||
|  |   margin: 0; | ||||||
|  |   border: 0; | ||||||
|  |   padding: 0; | ||||||
|  |   background: none; | ||||||
|  |   cursor: pointer; | ||||||
|  |   min-width: 1rem; | ||||||
|  |   min-height: 1rem; | ||||||
|  | } | ||||||
|  | input { | ||||||
|  |   margin: 0; | ||||||
|  | } | ||||||
|  | :focus { | ||||||
|  |   outline: none; | ||||||
|  | } | ||||||
|  | a:link, | ||||||
|  | a:visited, | ||||||
|  | a:active, | ||||||
|  | a:hover { | ||||||
|  |   color: var(--primary-color); | ||||||
|  |   text-decoration: none; | ||||||
|  | } | ||||||
|  | table { | ||||||
|  |   border-collapse: collapse; | ||||||
|  |   border-spacing: 0; | ||||||
|  |   border: 0; | ||||||
|  |   gap: 0; | ||||||
|  | } | ||||||
|  | #app { | ||||||
|  |   height: 100%; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  | } | ||||||
|  | header nav.headermain { | ||||||
|  |   /* Position so that tooltips can appear on top of other positioned elements */ | ||||||
|  |   position: relative; | ||||||
|  |   z-index: 100; | ||||||
|  | } | ||||||
|  | main { | ||||||
|  |   height: calc(100svh - var(--header-height)); | ||||||
|  |   padding-bottom: 3em; /* convenience space on the bottom */ | ||||||
|  |   overflow-y: scroll; | ||||||
|  | } | ||||||
|  | .spacer { flex-grow: 1 } | ||||||
|  | .smallgap { flex-shrink: 1; width: 2em } | ||||||
|  |  | ||||||
|  | [data-tooltip]:hover:after { | ||||||
|  |   z-index: 101; | ||||||
|  |   content: attr(data-tooltip); | ||||||
|  |   position: absolute; | ||||||
|  |   font-size: 1rem; | ||||||
|  |   text-align: center; | ||||||
|  |   padding: .5rem 1rem; | ||||||
|  |   border-radius: 3rem 0 3rem 0; | ||||||
|  |   box-shadow: 0 0 1rem var(--accent-color); | ||||||
|  |   transform: translate(calc(1rem + -50%), 150%); | ||||||
|  |   background-color: var(--accent-color); | ||||||
|  |   color: var(--primary-color); | ||||||
|  |   white-space: pre; | ||||||
|  |   animation: appearbriefly calc(10 * var(--transition-time)) linear forwards; | ||||||
|  | } | ||||||
|  | .modified [data-tooltip]:hover:after { | ||||||
|  |   transform: translate(calc(1rem + 1ex + -100%), calc(-1.5rem + 100%)); | ||||||
|  | } | ||||||
|  | @keyframes appearbriefly { | ||||||
|  |   from { | ||||||
|  |     opacity: 0; | ||||||
|  |   } | ||||||
|  |   30% { | ||||||
|  |     opacity: 0; | ||||||
|  |   } | ||||||
|  |   40% { | ||||||
|  |     opacity: 1; | ||||||
|  |   } | ||||||
|  |   90% { | ||||||
|  |     opacity: 1; | ||||||
|  |   } | ||||||
|  |   to { | ||||||
|  |     opacity: 0; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | .error-message { | ||||||
|  |   padding: .5em; | ||||||
|  |   font-weight: bold; | ||||||
|  |   background: var(--accent-color); | ||||||
|  |   color: #000; | ||||||
|  | } | ||||||
							
								
								
									
										1
									
								
								frontend/src/assets/svg/add-file.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM18.5 16v7.1h-5.3V16H8.7l7.1-7.1L23 16h-4.5z"/></svg> | ||||||
| After Width: | Height: | Size: 158 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/add-folder.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20H0zm18.3 9.5V27h-5.6v-7.5H8l7.5-7.5 7.5 7.5h-4.7z"/></svg> | ||||||
| After Width: | Height: | Size: 168 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/arrow.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 -32 640 640"><path d="M495.46 365.98c-13.03-13.37-150.24-144.06-150.24-144.06A35.16 35.16 0 0 0 320 211.2a35.06 35.06 0 0 0-25.22 10.72s-137.2 130.7-150.27 144.06c-13 13.38-13.9 37.44 0 51.72 14 14.24 33.4 15.4 50.48 0L320 297.8l125.02 119.9c17.1 15.4 36.55 14.24 50.44 0 13.95-14.3 13.08-38.37 0-51.72z"/></svg> | ||||||
| After Width: | Height: | Size: 388 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/arrows-h.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="-6 -2 44 36"><path d="M12 18H6v4l-6-6 6-6v4h6zm8-4h6v-4l6 6-6 6v-4h-6z"/></svg> | ||||||
| After Width: | Height: | Size: 128 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/arrows-v.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -6 16 44"><path d="M8 20v6h4l-6 6-6-6h4v-6zm-4-8V6H0l6-6 6 6H8v6z"/></svg> | ||||||
| After Width: | Height: | Size: 126 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/check.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="-48 0 512 512"><path d="M320 96L128 288l-64-64-64 64 128 128 256-256-64-64z"/></svg> | ||||||
| After Width: | Height: | Size: 158 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/code.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="-24 8 512 512"><path d="M304 96l-48 48 112 112-112 112 48 48 144-160L304 96zm-160 0L0 256l144 160 48-48L80 256l112-112-48-48z"/></svg> | ||||||
| After Width: | Height: | Size: 208 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/cog.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M223.97 175A81 81 0 0 0 143 256c0 44.7 36.27 81.03 80.97 81.03 44.72 0 80.72-36.34 80.72-81.03 0-44.73-36-81-80.8-81zM386.3 302.53l-14.58 35.16 29.47 57.8-36.1 36.1-59.3-28-35.2 14.4-17.87 54.6-2.28 7.24h-51L177.4 418.2l-35.17-14.5-57.9 29.4-36.1-36.1 27.97-59.2-14.47-35.12L0 282.6v-51l61.7-22.1 14.5-35.1-25.96-51.23-3.43-6.72 36.1-36.03 59.3 27.92 35.1-14.5 17.9-54.6 2.3-7.24h51l22.1 61.73 35.07 14.52 58.04-29.4 36.06 36.03-27.96 59.2 14.42 35.17 61.8 20.13v50.97l-61.67 22.18z"/></svg> | ||||||
| After Width: | Height: | Size: 563 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/copy.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 36 36"><path d="M26 8h-6V6l-6-6H0v24h12v8h20V14l-6-6zm0 2.83L29.17 14H26v-3.17zm-12-8L17.17 6H14V2.83zM2 2h10v6h6v14H2V2zm28 28H14v-6h6V10h4v6h6v14z"/></svg> | ||||||
| After Width: | Height: | Size: 212 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/create-file.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4v2.4z"/></svg> | ||||||
| After Width: | Height: | Size: 293 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/create-folder.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M29 6H16l-1-2H4L2 8h28zM0 10l2 20h28l2-20H0zm22.8 11.2c0 .3-.2.5-.5.5h-5.2v5.2c0 .3-.2.5-.5.5h-2.8c-.3 0-.5-.2-.5-.5v-5.2H8.1c-.3 0-.5-.2-.5-.5v-2.8c0-.3.2-.5.5-.5h5.2v-5.2c0-.3.2-.5.5-.5h2.8c.3 0 .5.2.5.5v5.2h5.2c.3 0 .5.2.5.5v2.8z"/></svg> | ||||||
| After Width: | Height: | Size: 310 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/cross.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M25.3 8.56L17.88 16l7.44 7.44-1.86 1.87L16 17.9l-7.44 7.4-1.86-1.85L14.12 16 6.68 8.56 8.55 6.7 16 14.12l7.44-7.44z"/></svg> | ||||||
| After Width: | Height: | Size: 193 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/disk.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M24.27 3.2H6.4a3.2 3.2 0 0 0-3.2 3.2v19.2a3.2 3.2 0 0 0 3.2 3.2h19.2a3.2 3.2 0 0 0 3.2-3.2V8.2l-4.53-5zm-1.87 9.6c0 .88-.72 1.6-1.6 1.6h-9.6a1.6 1.6 0 0 1-1.6-1.6v-8h12.8v8zm-1.6-6.4h-3.2v6.4h3.2V6.4z"/></svg> | ||||||
| After Width: | Height: | Size: 278 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/download.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30"><path d="M23 25.9c0-.3-.1-.6-.3-.8s-.5-.3-.8-.3c-.3 0-.6.1-.8.3-.2.2-.3.5-.3.8s.1.6.3.8c.2.2.5.3.8.3.3 0 .6-.1.8-.3.2-.3.3-.5.3-.8zm4.6 0c0-.3-.1-.6-.3-.8s-.5-.3-.8-.3c-.3 0-.6.1-.8.3-.2.2-.3.5-.3.8s.1.6.3.8c.2.2.5.3.8.3.3 0 .6-.1.8-.3.2-.3.3-.5.3-.8zm2.3-4v5.7c0 .5-.2.9-.5 1.2-.3.3-.7.5-1.2.5H1.9c-.5 0-.9-.2-1.2-.5s-.5-.7-.5-1.2v-5.7c0-.5.2-.9.5-1.2.3-.3.7-.5 1.2-.5h8.3l2.4 2.4c.7.7 1.5 1 2.4 1 .9 0 1.7-.3 2.4-1l2.4-2.4h8.3c.5 0 .9.2 1.2.5.4.3.6.7.6 1.2zm-5.8-10.2c.2.5.1.9-.3 1.3l-8 8c-.2.2-.5.3-.8.3-.3 0-.6-.1-.8-.3l-8-8c-.4-.3-.5-.8-.3-1.3S6.5 11 7 11h4.6V3c0-.3.1-.6.3-.8s.5-.3.8-.3h4.6c.3 0 .6.1.8.3s.3.5.3.8v8H23c.5 0 .8.2 1.1.7z"/></svg> | ||||||
| After Width: | Height: | Size: 711 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/exclamation.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="448" height="448" viewBox="-136 0 448 448"><path d="M128 312v56q0 6.5-4.75 11.25T112 384H48q-6.5 0-11.25-4.75T32 368v-56q0-6.5 4.75-11.25T48 296h64q6.5 0 11.25 4.75T128 312zm7.5-264l-7 192q-.25 6.5-5.13 11.25T112 256H48q-6.5 0-11.38-4.75T31.5 240l-7-192q-.25-6.5 4.38-11.25T40 32h80q6.5 0 11.13 4.75T135.5 48z"/></svg> | ||||||
| After Width: | Height: | Size: 365 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/eye.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 36 36"><path d="M29.715 16c-1.696-2.625-4.018-4.875-6.804-6.304 0.714 1.214 1.089 2.607 1.089 4.018 0 4.411-3.589 8-8 8s-8-3.589-8-8c0-1.411 0.375-2.804 1.089-4.018-2.786 1.429-5.107 3.679-6.804 6.304 3.054 4.714 7.982 8 13.714 8s10.661-3.286 13.714-8zM16.858 9.143c0-0.464-0.393-0.857-0.857-0.857-2.982 0-5.429 2.446-5.429 5.429 0 0.464 0.393 0.857 0.857 0.857s0.857-0.393 0.857-0.857c0-2.036 1.679-3.714 3.714-3.714 0.464 0 0.857-0.393 0.857-0.857zM32 16c0 0.446-0.143 0.857-0.357 1.232-3.286 5.411-9.304 9.054-15.643 9.054s-12.357-3.661-15.643-9.054c-0.214-0.375-0.357-0.786-0.357-1.232s0.143-0.857 0.357-1.232c3.286-5.393 9.304-9.054 15.643-9.054s12.357 3.661 15.643 9.054c0.214 0.375 0.357 0.786 0.357 1.232z"></path></svg> | ||||||
| After Width: | Height: | Size: 783 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/find.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="-12 -12 512 512"><path d="M480 416L355.44 291.44C373.22 262.4 384 228.58 384 192 384 85.98 298 0 192 0 85.98 0 0 85.98 0 192c0 106 85.98 192 192 192 36.58 0 70.4-10.78 99.44-28.5L416 480c8.75 8.75 23.25 8.7 32 0l32-32a22.8 22.8 0 0 0 0-32zm-288-96c-70.7 0-128-57.3-128-128S121.3 64 192 64s128 57.3 128 128-57.3 128-128 128z"/></svg> | ||||||
| After Width: | Height: | Size: 382 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/fullscreen.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M18.7 6.7h6.6v6.6h-2.6v-4h-4V6.7zm4 16v-4h2.6v6.6h-6.6v-2.6h4zm-16-9.4V6.7h6.6v2.6h-4v4H6.7zm2.6 5.4v4h4v2.6H6.7v-6.6h2.6z"/></svg> | ||||||
| After Width: | Height: | Size: 200 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/github.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path d="M256 6.3C114.6 6.3 0 121 0 262.3c0 113 73.4 209 175 243 13 2.3 17.6-5.6 17.6-12.4l-.4-48C121 460.5 106 415 106 415c-11.7-29.5-28.5-37.4-28.5-37.4-23.2-16 1.8-15.6 1.8-15.6 25.7 1.8 39.2 26.4 39.2 26.4 23 39.2 60 27.8 74.5 21.3 2.3-16.5 9-27.8 16.3-34.2C152.3 369 92.6 347 92.6 249c0-28 10-50.8 26.4-68.8-2.6-6.4-11.4-32.5 2.5-67.7 0 0 21.5-7 70.4 26.2 20-5.6 42-8.5 64-8.6 21.3.7 43.2 3 64 9 49-33 70-26 70-26 14 35.3 5 61.4 2.4 67.8 16.3 18 26.2 40.8 26.2 68.7 0 98.4-60 120-117 126.4 9.2 8 17.4 23.4 17.4 47.3l-.2 70.2c0 6.6 4.7 14.6 17.7 12 101.7-34 175-129.7 175-243C512 121 397.5 6 256 6z"/></svg> | ||||||
| After Width: | Height: | Size: 698 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/home.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M32 18.45L16 6.03 0 18.45V13.4L16 .96 32 13.4zM28 18v12h-8v-8h-8v8H4V18l12-9z"/></svg> | ||||||
| After Width: | Height: | Size: 156 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/info.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 34 34"><path d="M16 .6C7.5.6.6 7.6.6 16c0 8.5 7 15.4 15.4 15.4 8.5 0 15.4-7 15.4-15.4C31.4 7.5 24.4.6 16 .6zm1.4 5.6c1.5 0 2 1 2 1.8 0 1.3-1 2.4-2.7 2.4-1.4 0-2-.7-2-2 0-.8.8-2.2 2.7-2.2zm-3.8 19c-1 0-1.8-.6-1-3.4l1-4.8c.3-.8.4-1 0-1-.2 0-1.5.5-2.3 1l-.5-1c2.5-2 5.3-3 6.6-3 1 0 1.2 1 .6 3l-1.3 5c-.2 1 0 1.2 0 1.2.4 0 1.4-.3 2.4-1l1 .7c-2.4 2-5 3-6 3z"/></svg> | ||||||
| After Width: | Height: | Size: 416 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/link.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg> | ||||||
| After Width: | Height: | Size: 517 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#26b" rx="64" ry="64"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg> | ||||||
| After Width: | Height: | Size: 257 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/loop.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 36 36"><path d="M23.53 8.44l3.13-3.13v9.4h-9.38l4.3-4.3C20.18 8.94 18.18 8 16 8c-4.45 0-8 3.56-8 8s3.55 8 8 8c3.5 0 6.5-2.2 7.55-5.3h2.75c-1.2 4.6-5.3 8-10.3 8-5.9 0-10.64-4.83-10.64-10.7S10.1 5.3 15.96 5.3c2.95 0 5.63 1.2 7.57 3.14z"/></svg> | ||||||
| After Width: | Height: | Size: 297 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/menu.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="4 0 24 32"><path d="M16 21.3c1.4 0 2.7 1.3 2.7 2.7s-1.3 2.7-2.7 2.7-2.7-1.3-2.7-2.7 1.3-2.7 2.7-2.7zm0-8c1.4 0 2.7 1.3 2.7 2.7s-1.3 2.7-2.7 2.7-2.7-1.3-2.7-2.7 1.3-2.7 2.7-2.7zm0-2.6c-1.4 0-2.7-1.3-2.7-2.7s1.3-2.7 2.7-2.7 2.7 1.3 2.7 2.7-1.3 2.7-2.7 2.7z"/></svg> | ||||||
| After Width: | Height: | Size: 312 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/next.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="2 0 32 32"><path d="M24 4v24h-4V17L10 27V5l10 10V4z"/></svg> | ||||||
| After Width: | Height: | Size: 109 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/open.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640"><path d="M576 32H64C28.8 32 0 60.8 0 96v384c0 35.2 28.8 63.36 64 63.36h127.36v-62.72h-128V185.6h513.28v295.04h-128v62.75H576c35.23 0 64-28.2 64-63.4V96c0-35.2-28.77-64-64-64zM83.23 138.56c-13.28 0-24-10.46-24-23.36s10.72-23.36 24-23.36c13.25 0 24 10.46 24 23.36s-10.75 23.36-24 23.36zm64 0c-13.28 0-24-10.46-24-23.36s10.72-23.36 24-23.36c13.25 0 24 10.46 24 23.36s-10.75 23.36-24 23.36zm429.44-3.52h-385.3V95.36h385.27v39.68zM318.34 261.57l-155.27 154.3h96V608H377.6V415.87h96l-155.26-154.3z"/></svg> | ||||||
| After Width: | Height: | Size: 587 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/paste.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M26 10V5a1 1 0 0 0-1-1h-7V2a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2H3a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h9v6h14l6-6V10h-6zM12 2h4v2h-4V2zM6 8V6h16v2H6zm20 21.17V26h3.17L26 29.17zM30 24h-6v6H14V12h16v12z"/></svg> | ||||||
| After Width: | Height: | Size: 269 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/pause.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M4 4h10v24H4zm14 0h10v24H18z"/></svg> | ||||||
| After Width: | Height: | Size: 106 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/pencil.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="-2.5 0 32 32"><path d="M6.5 27.4l1.6-1.6-4.2-4.2-1.6 1.6v1.9h2.3v2.3h1.9zm9.3-16.5c0-.3-.1-.4-.4-.4-.1 0-.2 0-.3.1l-9.7 9.7c-.1.1-.1.2-.1.3 0 .3.1.4.4.4.1 0 .2 0 .3-.1l9.7-9.7c.1-.1.1-.2.1-.3zm-.9-3.5l7.4 7.4L7.4 29.7H0v-7.4L14.9 7.4zm12.2 1.7a2 2 0 0 1-.7 1.6l-3 3L16 6.3l3-2.9a2 2 0 0 1 1.6-.7 2 2 0 0 1 1.6.7l4.2 4.2c.4.4.7.9.7 1.5z"/></svg> | ||||||
| After Width: | Height: | Size: 393 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/play.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M6 4l20 12L6 28z"/></svg> | ||||||
| After Width: | Height: | Size: 94 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/plus.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M31 12H20V1a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v11H1a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h11v11a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V20h11a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1z"/></svg> | ||||||
| After Width: | Height: | Size: 229 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/previous.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M8 28V4h4v11L22 5v22L12 17v11z"/></svg> | ||||||
| After Width: | Height: | Size: 108 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/reload.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M24.48 14.8c.37 2.55-.4 5.24-2.4 7.2-2.94 2.9-7.48 3.26-10.82 1.08l2.34-2.28L5 19.6 6.2 28l2.62-2.52c4.72 3.48 11.4 3.15 15.7-1.08 2.48-2.45 3.6-5.7 3.47-8.9l-3.53-.7zM9.92 10c2.94-2.9 7.48-3.26 10.82-1.08L18.4 11.2l8.6 1.2L25.8 4l-2.63 2.52C18.47 3.04 11.77 3.37 7.5 7.6 5 10.05 3.86 13.3 4 16.5l3.52.7c-.37-2.55.4-5.24 2.4-7.2z"/></svg> | ||||||
| After Width: | Height: | Size: 407 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/rename.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M26.67 4V1.33h-8V4h2.66v24h-2.66v2.67h8V28H24V4zm-8 3.12c0-.14-.26-.3-.43-.42a5.8 5.8 0 0 0-2.45-1.07c-.9-.17-2-.25-3.2-.25-.9 0-1.8.14-2.7.42-.9.28-1.7.62-2.4 1.03a5.7 5.7 0 0 0-1.8 1.62c-.5.6-.7 1.24-.7 1.9 0 .64.1 1.2.5 1.72s.8.77 1.6.77 1.4-.2 1.9-.63c.4-.4.7-.8.7-1.3s-.1-1-.2-1.5c-.2-.5-.2-1-.2-1.3.2-.2.6-.5 1.2-.7.5-.2 1.2-.3 1.8-.3.9 0 1.7.2 2.2.6.5.4.9.9 1.2 1.4.2.5.2 1.6.2 1.6v3.2c0 .36-1.8.9-3.8 1.54s-3.2 1-3.8 1.27c-.5.2-1 .48-1.6.8a5.54 5.54 0 0 0-2.4 2.9c-.2.65-.3 1.36-.3 2.2 0 1.58.5 2.87 1.5 3.85S7.82 27.9 9.4 27.9c1.5 0 2.8-.6 3.8-1.13 1.1-.5 2.1-1.3 3-2.7h.1c.2 1.4.6 2.1 1.26 2.7l.87.07V7.1zm-2.32 15.85a7.96 7.96 0 0 1-1.97 1.76 4.9 4.9 0 0 1-2.7.75c-.97 0-1.76-.28-2.4-.85-.62-.56-.93-1.44-.93-2.64 0-1 .2-1.8.63-2.4.4-.7 1-1.3 1.7-1.8.8-.5 1.65-1 2.58-1.3.92-.4 1.86-.7 3.1-1.1v7.4z"/></svg> | ||||||
| After Width: | Height: | Size: 887 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/scissors.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M27.84 22.16A7.15 7.15 0 0 0 22.9 20H22l-2-2 7.98-8c2-2 2-6 0-8L16 14 4.02 2c-2 2-2 6 0 8l8 8-2 2H9.1c-1.7 0-3.5.74-4.94 2.16-2.55 2.55-2.9 6.33-.77 8.45.9 1 2.2 1.4 3.5 1.4a7 7 0 0 0 4.9-2.1 6.86 6.86 0 0 0 2.1-5.8L16 22l2.04 2.05a6.87 6.87 0 0 0 2.1 5.8A7.2 7.2 0 0 0 25.07 32c1.34 0 2.6-.46 3.53-1.4 2.13-2.1 1.8-5.9-.77-8.44zm-16.8 4.26A4.95 4.95 0 0 1 8.44 29c-.5.22-1.02.33-1.5.33-.5 0-1.16-.1-1.67-.6a2.3 2.3 0 0 1-.6-1.64A4 4 0 0 1 5 25.5a3.9 3.9 0 0 1 1-1.53c.44-.46 1-.8 1.52-1.05.5-.23 1.03-.34 1.52-.34.46 0 1.13.1 1.64.6.5.5.6 1.2.6 1.65 0 .5-.1 1.02-.3 1.52zm4.96-5.6a2.83 2.83 0 1 1 0-5.67 2.83 2.83 0 0 1 0 5.68zm10.73 7.9c-.5.5-1.18.6-1.66.6-.5 0-1-.1-1.52-.32a4.92 4.92 0 0 1-2.9-4.08c0-.47.1-1.13.6-1.64.5-.5 1.2-.6 1.65-.6.5 0 1.02.1 1.52.32a5.08 5.08 0 0 1 2.6 2.58c.25.5.37 1.02.37 1.5 0 .47-.1 1.13-.6 1.64z"/></svg> | ||||||
| After Width: | Height: | Size: 908 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/shuffle.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M32 8l-8-8v6c-4.1 0-7.2.97-9.55 2.98l-.48.43A29.5 29.5 0 0 1 16.1 13c1.5-1.8 3.63-3 7.9-3v12c-6.8 0-8.3-3-10.2-6.9-1.1-2.1-2.2-4.3-4.3-6.1C7.2 7 4.1 6 0 6v4c6.76 0 8.28 3.04 10.2 6.9 1.08 2.14 2.2 4.36 4.25 6.12C16.8 25.02 19.9 26 24 26v6l8-8-8-8 8-8zM0 22v4c4.1 0 7.2-.97 9.55-2.98l.48-.43C9.17 21.4 8.5 20.1 7.9 19c-1.5 1.8-3.67 3-7.9 3z"/></svg> | ||||||
| After Width: | Height: | Size: 417 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/signin.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 2 28 28"><path d="M21.1 16c0 .3-.1.6-.3.8l-9.7 9.7c-.2.2-.5.3-.8.3s-.6-.1-.8-.3c-.2-.2-.3-.5-.3-.8v-5.1h-8c-.3 0-.6-.1-.8-.3-.3-.3-.4-.6-.4-.9v-6.9c0-.3.1-.6.3-.8s.5-.3.8-.3h8V6.3c0-.3.1-.6.3-.8s.5-.3.8-.3.6.1.8.3l9.7 9.7c.3.2.4.5.4.8zm6.3-6.3v12.6c0 1.4-.5 2.6-1.5 3.6s-2.2 1.5-3.6 1.5h-5.7c-.2 0-.3-.1-.4-.2s-.2-.2-.2-.3V26l.1-.4.2-.3.4-.1h5.7c.8 0 1.5-.3 2-.8.6-.6.8-1.2.8-2V9.7c0-.8-.3-1.5-.8-2-.6-.6-1.2-.8-2-.8h-5.8l-.2-.1-.1-.1-.3-.2V5l.2-.3.4-.1h5.7c1.4 0 2.6.5 3.6 1.5s1.5 2.2 1.5 3.6z"/></svg> | ||||||
| After Width: | Height: | Size: 554 B | 
							
								
								
									
										1
									
								
								frontend/src/assets/svg/signout.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | |||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" viewBox="1 1 28 28"><path d="M12.4 25.7V27l-.2.3-.4.1H6.1c-1.4 0-2.6-.5-3.6-1.5S1 23.7 1 22.3V9.7c0-1.4.5-2.6 1.5-3.6s2.2-1.5 3.6-1.5h5.7c.2 0 .3.1.4.2.1.1.2.2.2.4v.9l-.1.4-.2.3-.4.1H6.1c-.8 0-1.5.3-2 .8-.6.6-.8 1.2-.8 2v12.6c0 .8.3 1.5.8 2 .6.6 1.2.8 2 .8h5.8l.2.1.1.1.1.2.1.2zM29 16c0 .3-.1.6-.3.8L19 26.5c-.2.2-.5.3-.8.3-.3 0-.6-.1-.8-.3a.9.9 0 0 1-.4-.8v-5.1H9c-.3 0-.6-.1-.8-.3-.2-.3-.3-.6-.3-.9v-6.9c0-.3.1-.6.3-.8.2-.2.5-.3.8-.3h8V6.3c0-.3.1-.6.3-.8s.5-.3.8-.3c.3 0 .6.1.8.3l9.7 9.7c.3.2.4.5.4.8z"/></svg> | ||||||
| After Width: | Height: | Size: 552 B |