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__/ | ||||
| *.egg-info/ | ||||
| /cista/_version.py | ||||
| /cista/wwwroot/* | ||||
| /dist | ||||
|   | ||||
							
								
								
									
										71
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -1,25 +1,78 @@ | ||||
| # Web File Storage | ||||
|  | ||||
| Run directly from repository with Hatch (or use pip install as usual): | ||||
| ```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). | ||||
| 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. | ||||
|  | ||||
| Create your user account: | ||||
|  | ||||
| ```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 | ||||
|  | ||||
| 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 | ||||
| cd cista-front | ||||
| cd frontend | ||||
| npm install | ||||
| 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`. | ||||
|  | ||||
| ## 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__ | ||||
|  | ||||
| __version__  # Public API | ||||
|   | ||||
							
								
								
									
										26
									
								
								cista/__main__.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -34,6 +34,7 @@ User management: | ||||
|   --password        Reset password | ||||
| """ | ||||
|  | ||||
|  | ||||
| def main(): | ||||
|     # Dev mode doesn't catch exceptions | ||||
|     if "--dev" in sys.argv: | ||||
| @@ -45,6 +46,7 @@ def main(): | ||||
|         print("Error:", e) | ||||
|         return 1 | ||||
|  | ||||
|  | ||||
| def _main(): | ||||
|     args = docopt(doc) | ||||
|     if args["--user"]: | ||||
| @@ -64,19 +66,25 @@ def _main(): | ||||
|     if not necessary_opts: | ||||
|         # Maybe run without arguments | ||||
|         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 | ||||
|     settings = {} | ||||
|     if import_droppy: | ||||
|         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() | ||||
|     if path: settings["path"] = path | ||||
|     if listen: settings["listen"] = listen | ||||
|     if path: | ||||
|         settings["path"] = path | ||||
|     if listen: | ||||
|         settings["listen"] = listen | ||||
|     operation = config.update_config(settings) | ||||
|     print(f"Config {operation}: {config.conffile}") | ||||
|     # Prepare to serve | ||||
|     domain = unix = port = None | ||||
|     unix = None | ||||
|     url, _ = serve.parse_listen(config.config.listen) | ||||
|     if not config.config.path.is_dir(): | ||||
|         raise ValueError(f"No such directory: {config.config.path}") | ||||
| @@ -87,6 +95,8 @@ def _main(): | ||||
|     print(f"Serving {config.config.path} at {url}{extra}") | ||||
|     # Run the server | ||||
|     serve.run(dev=dev) | ||||
|     return 0 | ||||
|  | ||||
|  | ||||
| def _confdir(args): | ||||
|     if args["-c"]: | ||||
| @@ -95,9 +105,10 @@ def _confdir(args): | ||||
|         if confdir.exists() and not confdir.is_dir(): | ||||
|             if confdir.name != config.conffile.name: | ||||
|                 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 | ||||
|         config.conffile = config.conffile.with_parent(confdir) | ||||
|         config.conffile = confdir / config.conffile.name | ||||
|  | ||||
|  | ||||
| def _user(args): | ||||
|     _confdir(args) | ||||
| @@ -123,5 +134,6 @@ def _user(args): | ||||
|     if res == "read": | ||||
|         print("  No changes") | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     sys.exit(main()) | ||||
|   | ||||
							
								
								
									
										70
									
								
								cista/api.py
									
									
									
									
									
								
							
							
						
						| @@ -1,26 +1,30 @@ | ||||
| import asyncio | ||||
| import typing | ||||
| from secrets import token_bytes | ||||
|  | ||||
| import msgspec | ||||
| from sanic import Blueprint | ||||
|  | ||||
| from cista import watching | ||||
| from cista import __version__, config, watching | ||||
| 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 | ||||
|  | ||||
| bp = Blueprint("api", url_prefix="/api") | ||||
| fileserver = FileServer() | ||||
|  | ||||
|  | ||||
| @bp.before_server_start | ||||
| async def start_fileserver(app, _): | ||||
|     await fileserver.start() | ||||
|  | ||||
|  | ||||
| @bp.after_server_stop | ||||
| async def stop_fileserver(app, _): | ||||
|     await fileserver.stop() | ||||
|  | ||||
| @bp.websocket('upload') | ||||
|  | ||||
| @bp.websocket("upload") | ||||
| @websocket_wrapper | ||||
| async def upload(req, ws): | ||||
|     alink = fileserver.alink | ||||
| @@ -28,22 +32,32 @@ async def upload(req, ws): | ||||
|         req = None | ||||
|         text = await ws.recv() | ||||
|         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) | ||||
|         pos = req.start | ||||
|         data = None | ||||
|         while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes): | ||||
|         while True: | ||||
|             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)) | ||||
|             pos += typing.cast(int, sentsize) | ||||
|             if pos >= req.end: | ||||
|                 break | ||||
|         if pos != req.end: | ||||
|             d = f"{len(data)} bytes" if isinstance(data, bytes) else data | ||||
|             raise ValueError(f"Expected {req.end - pos} more bytes, got {d}") | ||||
|         # Report success | ||||
|         res = StatusMsg(status="ack", req=req) | ||||
|         await asend(ws, res) | ||||
|         #await ws.drain() | ||||
|  | ||||
| @bp.websocket('download') | ||||
|  | ||||
| @bp.websocket("download") | ||||
| @websocket_wrapper | ||||
| async def download(req, ws): | ||||
|     alink = fileserver.alink | ||||
| @@ -51,37 +65,59 @@ async def download(req, ws): | ||||
|         req = None | ||||
|         text = await ws.recv() | ||||
|         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) | ||||
|         pos = req.start | ||||
|         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))) | ||||
|             await asend(ws, data) | ||||
|             pos += len(data) | ||||
|         # Report success | ||||
|         res = StatusMsg(status="ack", req=req) | ||||
|         await asend(ws, res) | ||||
|         #await ws.drain() | ||||
|  | ||||
|  | ||||
| @bp.websocket("control") | ||||
| @websocket_wrapper | ||||
| async def control(req, ws): | ||||
|     cmd = msgspec.json.decode(await ws.recv(), type=ControlBase) | ||||
|     while True: | ||||
|         cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes) | ||||
|         await asyncio.to_thread(cmd) | ||||
|         await asend(ws, StatusMsg(status="ack", req=cmd)) | ||||
|  | ||||
|  | ||||
| @bp.websocket("watch") | ||||
| @websocket_wrapper | ||||
| 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: | ||||
|         with watching.tree_lock: | ||||
|             q = watching.pubsub[ws] = asyncio.Queue() | ||||
|         with watching.state.lock: | ||||
|             q = watching.pubsub[uuid] = asyncio.Queue() | ||||
|             # Init with disk usage and full tree | ||||
|             await ws.send(watching.format_du()) | ||||
|             await ws.send(watching.format_tree()) | ||||
|             await ws.send(watching.format_space(watching.state.space)) | ||||
|             await ws.send(watching.format_root(watching.state.root)) | ||||
|         # Send updates | ||||
|         while True: | ||||
|             await ws.send(await q.get()) | ||||
|     finally: | ||||
|         del watching.pubsub[ws] | ||||
|         del watching.pubsub[uuid] | ||||
|   | ||||
							
								
								
									
										231
									
								
								cista/app.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -1,36 +1,56 @@ | ||||
| import asyncio | ||||
| import datetime | ||||
| 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 wsgiref.handlers import format_date_time | ||||
|  | ||||
| from html5tagger import E | ||||
| from sanic import Blueprint, Sanic, raw | ||||
| from sanic.exceptions import Forbidden, NotFound | ||||
| import brotli | ||||
| import sanic.helpers | ||||
| 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.api import bp | ||||
| from cista.util import filename | ||||
| 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.blueprint(auth.bp) | ||||
| app.blueprint(bp) | ||||
| app.exception(Exception)(handle_sanic_exception) | ||||
|  | ||||
|  | ||||
| @app.before_server_start | ||||
| async def main_start(app, loop): | ||||
|     config.load_config() | ||||
|     await watching.start(app, loop) | ||||
|     app.ctx.threadexec = ThreadPoolExecutor( | ||||
|         max_workers=8, thread_name_prefix="cista-ioworker" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @app.after_server_stop | ||||
| async def main_stop(app, loop): | ||||
|     await watching.stop(app, loop) | ||||
|     app.ctx.threadexec.shutdown() | ||||
|  | ||||
|  | ||||
| @app.on_request | ||||
| async def use_session(req): | ||||
|     req.ctx.session = session.get(req) | ||||
|     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): | ||||
|         req.ctx.username = None | ||||
|         req.ctx.user = None | ||||
|     # CSRF protection | ||||
|     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: | ||||
|         raise Forbidden("Invalid origin: Cross-Site requests not permitted") | ||||
|  | ||||
|  | ||||
| @app.before_server_start | ||||
| def http_fileserver(app, _): | ||||
|     bp = Blueprint("fileserver") | ||||
|     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.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=""): | ||||
|     """Frontend files only""" | ||||
|     name = filename.sanitize(unquote(path)) if path else "index.html" | ||||
|     name = unquote(path) | ||||
|     if name not in www: | ||||
|         raise NotFound(f"File not found: /{path}", extra={"name": name}) | ||||
|     data, br, headers = www[name] | ||||
|     if req.headers.if_none_match == headers["etag"]: | ||||
|         # The client has it cached, respond 304 Not Modified | ||||
|         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: | ||||
|         index = files("cista").joinpath("wwwroot", name).read_bytes() | ||||
|     except OSError as e: | ||||
|         raise NotFound(f"File not found: /{path}", extra={"name": name, "exception": repr(e)}) | ||||
|     mime = mimetypes.guess_type(name)[0] or "application/octet-stream" | ||||
|     return raw(index, content_type=mime) | ||||
|             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 | ||||
|  | ||||
| _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): | ||||
|     return normalize('NFC', password).strip().encode() | ||||
|     return normalize("NFC", password).strip().encode() | ||||
|  | ||||
|  | ||||
| def login(username: str, password: str): | ||||
|     un = _pwnorm(username) | ||||
| @@ -23,7 +25,7 @@ def login(username: str, password: str): | ||||
|     try: | ||||
|         u = config.config.users[un.decode()] | ||||
|     except KeyError: | ||||
|         raise ValueError("Invalid username") | ||||
|         raise ValueError("Invalid username") from None | ||||
|     # Verify password | ||||
|     need_rehash = False | ||||
|     if not u.hash: | ||||
| @@ -39,7 +41,7 @@ def login(username: str, password: str): | ||||
|         try: | ||||
|             _argon.verify(u.hash, pw) | ||||
|         except Exception: | ||||
|             raise ValueError("Invalid password") | ||||
|             raise ValueError("Invalid password") from None | ||||
|         if _argon.check_needs_rehash(u.hash): | ||||
|             need_rehash = True | ||||
|     # Login successful | ||||
| @@ -49,33 +51,51 @@ def login(username: str, password: str): | ||||
|     u.lastSeen = now | ||||
|     return u | ||||
|  | ||||
|  | ||||
| def set_password(user: config.User, password: str): | ||||
|     user.hash = _argon.hash(_pwnorm(password)) | ||||
|  | ||||
|  | ||||
| class LoginResponse(msgspec.Struct): | ||||
|     user: str = "" | ||||
|     privileged: bool = False | ||||
|     error: str = "" | ||||
|  | ||||
| def verify(request, privileged=False): | ||||
|  | ||||
| def verify(request, *, privileged=False): | ||||
|     """Raise Unauthorized or Forbidden if the request is not authorized""" | ||||
|     if privileged: | ||||
|         if request.ctx.user: | ||||
|             if request.ctx.user.privileged: return | ||||
|             raise Forbidden("Access Forbidden: Only for privileged users") | ||||
|     elif config.config.public or request.ctx.user: return | ||||
|     raise Unauthorized("Login required", "cookie", context={"redirect": "/login"}) | ||||
|             if request.ctx.user.privileged: | ||||
|                 return | ||||
|             raise Forbidden("Access Forbidden: Only for privileged users", quiet=True) | ||||
|     elif config.config.public or request.ctx.user: | ||||
|         return | ||||
|     raise Unauthorized("Login required", "cookie", quiet=True) | ||||
|  | ||||
|  | ||||
| bp = Blueprint("auth") | ||||
|  | ||||
|  | ||||
| @bp.get("/login") | ||||
| async def login_page(request): | ||||
|     doc = Document("Cista Login") | ||||
|     with doc.div(id="login"): | ||||
|         with doc.form(method="POST", autocomplete="on"): | ||||
|             doc.h1("Login") | ||||
|             doc.input(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( | ||||
|                 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") | ||||
|         s = session.get(request) | ||||
|         if s: | ||||
| @@ -84,7 +104,12 @@ async def login_page(request): | ||||
|                 doc.input(type="submit", value=f"Logout {name}") | ||||
|     flash = request.cookies.message | ||||
|     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) | ||||
|     if flash: | ||||
|         res.cookies.delete_cookie("flash") | ||||
| @@ -92,6 +117,7 @@ async def login_page(request): | ||||
|         session.delete(res) | ||||
|     return res | ||||
|  | ||||
|  | ||||
| @bp.post("/login") | ||||
| async def login_post(request): | ||||
|     try: | ||||
| @@ -104,11 +130,14 @@ async def login_post(request): | ||||
|         if not username or not password: | ||||
|             raise KeyError | ||||
|     except KeyError: | ||||
|         raise BadRequest("Missing username or password", context={"redirect": "/login"}) | ||||
|         raise BadRequest( | ||||
|             "Missing username or password", | ||||
|             context={"redirect": "/login"}, | ||||
|         ) from None | ||||
|     try: | ||||
|         user = login(username, password) | ||||
|     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: | ||||
|         res = redirect("/") | ||||
| @@ -118,6 +147,7 @@ async def login_post(request): | ||||
|     session.create(res, username) | ||||
|     return res | ||||
|  | ||||
|  | ||||
| @bp.post("/logout") | ||||
| async def logout_post(request): | ||||
|     s = request.ctx.session | ||||
|   | ||||
							
								
								
									
										28
									
								
								cista/config.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -1,6 +1,7 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import secrets | ||||
| import sys | ||||
| from functools import wraps | ||||
| from hashlib import sha256 | ||||
| from pathlib import Path, PurePath | ||||
| @@ -14,29 +15,32 @@ class Config(msgspec.Struct): | ||||
|     listen: str | ||||
|     secret: str = secrets.token_hex(12) | ||||
|     public: bool = False | ||||
|     name: str = "" | ||||
|     users: dict[str, User] = {} | ||||
|     links: dict[str, Link] = {} | ||||
|  | ||||
|  | ||||
| class User(msgspec.Struct, omit_defaults=True): | ||||
|     privileged: bool = False | ||||
|     hash: str = "" | ||||
|     lastSeen: int = 0 | ||||
|     lastSeen: int = 0  # noqa: N815 | ||||
|  | ||||
|  | ||||
| class Link(msgspec.Struct, omit_defaults=True): | ||||
|     location: str | ||||
|     creator: str = "" | ||||
|     expires: int = 0 | ||||
|  | ||||
|  | ||||
| config = None | ||||
| conffile = Path.home() / ".local/share/cista/db.toml" | ||||
|  | ||||
|  | ||||
| def derived_secret(*params, len=8) -> bytes: | ||||
|     """Used to derive secret keys from the main secret""" | ||||
|     # Each part is made the same length by hashing first | ||||
|     combined = b"".join( | ||||
|         sha256( | ||||
|             p if isinstance(p, bytes) else f"{p}".encode() | ||||
|         ).digest() | ||||
|         sha256(p if isinstance(p, bytes) else f"{p}".encode()).digest() | ||||
|         for p in [config.secret, *params] | ||||
|     ) | ||||
|     # Output a bytes of the desired length | ||||
| @@ -48,11 +52,13 @@ def enc_hook(obj): | ||||
|         return obj.as_posix() | ||||
|     raise TypeError | ||||
|  | ||||
|  | ||||
| def dec_hook(typ, obj): | ||||
|     if typ is Path: | ||||
|         return Path(obj) | ||||
|     raise TypeError | ||||
|  | ||||
|  | ||||
| def config_update(modify): | ||||
|     global config | ||||
|     if not conffile.exists(): | ||||
| @@ -85,6 +91,8 @@ def config_update(modify): | ||||
|             return "read" | ||||
|         f.write(new) | ||||
|         f.close() | ||||
|         if sys.platform == "win32": | ||||
|             conffile.unlink()  # Windows doesn't support atomic replace | ||||
|         tmpname.rename(conffile)  # Atomic replace | ||||
|     except: | ||||
|         f.close() | ||||
| @@ -93,21 +101,28 @@ def config_update(modify): | ||||
|     config = c | ||||
|     return "modified" if old else "created" | ||||
|  | ||||
|  | ||||
| def modifies_config(modify): | ||||
|     """Decorator for functions that modify the config file""" | ||||
|  | ||||
|     @wraps(modify) | ||||
|     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 | ||||
|         while (c := config_update(m)) == "collision": | ||||
|             time.sleep(0.01) | ||||
|         return c | ||||
|  | ||||
|     return wrapper | ||||
|  | ||||
|  | ||||
| def load_config(): | ||||
|     global config | ||||
|     config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook) | ||||
|  | ||||
|  | ||||
| @modifies_config | ||||
| def update_config(conf: Config, changes: dict) -> Config: | ||||
|     """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) | ||||
|     return msgspec.convert(settings, Config, dec_hook=dec_hook) | ||||
|  | ||||
|  | ||||
| @modifies_config | ||||
| def update_user(conf: Config, name: str, changes: dict) -> Config: | ||||
|     """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() | ||||
|     if "password" in changes: | ||||
|         from . import auth | ||||
|  | ||||
|         auth.set_password(u, changes["password"]) | ||||
|         del changes["password"] | ||||
|     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) | ||||
|     return msgspec.convert(settings, Config, dec_hook=dec_hook) | ||||
|  | ||||
|  | ||||
| @modifies_config | ||||
| def del_user(conf: Config, name: str) -> Config: | ||||
|     """Delete named user account.""" | ||||
|   | ||||
							
								
								
									
										19
									
								
								cista/droppy.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -11,6 +11,7 @@ def readconf() -> dict: | ||||
|     cf["listen"] = _droppy_listeners(cf) | ||||
|     return cf | db | ||||
|  | ||||
|  | ||||
| def _droppy_listeners(cf): | ||||
|     """Convert Droppy listeners to our format, for typical cases but not in full.""" | ||||
|     for listener in cf["listeners"]: | ||||
| @@ -20,15 +21,21 @@ def _droppy_listeners(cf): | ||||
|                 continue | ||||
|             socket = listener.get("socket") | ||||
|             if socket: | ||||
|                 if isinstance(socket, list): socket = socket[0] | ||||
|                 if isinstance(socket, list): | ||||
|                     socket = socket[0] | ||||
|                 return f"{socket}" | ||||
|             port = listener["port"] | ||||
|             if isinstance(port, list): port = port[0] | ||||
|             if isinstance(port, list): | ||||
|                 port = port[0] | ||||
|             host = listener["host"] | ||||
|             if isinstance(host, list): host = host[0] | ||||
|             if host in ("127.0.0.1", "::", "localhost"): return f":{port}" | ||||
|             return f"{host}:{port}" | ||||
|             if isinstance(host, list): | ||||
|                 host = host[0] | ||||
|         except (KeyError, IndexError): | ||||
|             continue | ||||
|         else: | ||||
|             if host in ("127.0.0.1", "::", "localhost"): | ||||
|                 return f":{port}" | ||||
|             return f"{host}:{port}" | ||||
|  | ||||
|     # If none matched, fallback to Droppy default | ||||
|     return f"0.0.0.0:8989" | ||||
|     return "0.0.0.0:8989" | ||||
|   | ||||
							
								
								
									
										25
									
								
								cista/fileio.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -1,7 +1,5 @@ | ||||
| import asyncio | ||||
| import os | ||||
| import unicodedata | ||||
| from pathlib import PurePosixPath | ||||
|  | ||||
| from cista import config | ||||
| from cista.util import filename | ||||
| @@ -13,6 +11,7 @@ def fuid(stat) -> str: | ||||
|     """Unique file ID. Stays the same on renames and modification.""" | ||||
|     return config.derived_secret("filekey-inode", stat.st_dev, stat.st_ino).hex() | ||||
|  | ||||
|  | ||||
| class File: | ||||
|     def __init__(self, filename): | ||||
|         self.path = config.config.path / filename | ||||
| @@ -30,23 +29,26 @@ class File: | ||||
|         self.writable = True | ||||
|  | ||||
|     def write(self, pos, buffer, *, file_size=None): | ||||
|         assert self.fd is not None | ||||
|         if not self.writable: | ||||
|             # Create/open file | ||||
|             self.open_rw() | ||||
|         assert self.fd is not None | ||||
|         if file_size is not None: | ||||
|             assert pos + len(buffer) <= file_size | ||||
|             os.ftruncate(self.fd, file_size) | ||||
|         if buffer: | ||||
|             os.lseek(self.fd, pos, os.SEEK_SET) | ||||
|             os.write(self.fd, buffer) | ||||
|  | ||||
|     def __getitem__(self, slice): | ||||
|         assert self.fd is not None | ||||
|         if self.fd is None: | ||||
|             self.open_ro() | ||||
|         assert self.fd is not None | ||||
|         os.lseek(self.fd, slice.start, os.SEEK_SET) | ||||
|         l = slice.stop - slice.start | ||||
|         data = os.read(self.fd, l) | ||||
|         if len(data) < l: raise EOFError("Error reading requested range") | ||||
|         size = slice.stop - slice.start | ||||
|         data = os.read(self.fd, size) | ||||
|         if len(data) < size: | ||||
|             raise EOFError("Error reading requested range") | ||||
|         return data | ||||
|  | ||||
|     def close(self): | ||||
| @@ -59,10 +61,13 @@ class File: | ||||
|  | ||||
|  | ||||
| class FileServer: | ||||
|  | ||||
|     async def start(self): | ||||
|         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) | ||||
|  | ||||
|     async def stop(self): | ||||
| @@ -91,4 +96,4 @@ class FileServer: | ||||
|     def download(self, name, start, end): | ||||
|         name = filename.sanitize(name) | ||||
|         f = self.cache[name] | ||||
|         return f[start: end] | ||||
|         return f[start:end] | ||||
|   | ||||
							
								
								
									
										112
									
								
								cista/protocol.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -11,19 +11,24 @@ from cista.util import filename | ||||
|  | ||||
| ## Control commands | ||||
|  | ||||
|  | ||||
| class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower): | ||||
|     def __call__(self): | ||||
|         raise NotImplementedError | ||||
|  | ||||
|  | ||||
| class MkDir(ControlBase): | ||||
|     path: str | ||||
|  | ||||
|     def __call__(self): | ||||
|         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): | ||||
|     path: str | ||||
|     to: str | ||||
|  | ||||
|     def __call__(self): | ||||
|         to = filename.sanitize(self.to) | ||||
|         if "/" in to: | ||||
| @@ -31,17 +36,24 @@ class Rename(ControlBase): | ||||
|         path = config.config.path / filename.sanitize(self.path) | ||||
|         path.rename(path.with_name(to)) | ||||
|  | ||||
|  | ||||
| class Rm(ControlBase): | ||||
|     sel: list[str] | ||||
|  | ||||
|     def __call__(self): | ||||
|         root = config.config.path | ||||
|         sel = [root / filename.sanitize(p) for p in self.sel] | ||||
|         for p in sel: | ||||
|             shutil.rmtree(p, ignore_errors=True) | ||||
|             if p.is_dir(): | ||||
|                 shutil.rmtree(p) | ||||
|             else: | ||||
|                 p.unlink() | ||||
|  | ||||
|  | ||||
| class Mv(ControlBase): | ||||
|     sel: list[str] | ||||
|     dst: str | ||||
|  | ||||
|     def __call__(self): | ||||
|         root = config.config.path | ||||
|         sel = [root / filename.sanitize(p) for p in self.sel] | ||||
| @@ -51,9 +63,11 @@ class Mv(ControlBase): | ||||
|         for p in sel: | ||||
|             shutil.move(p, dst) | ||||
|  | ||||
|  | ||||
| class Cp(ControlBase): | ||||
|     sel: list[str] | ||||
|     dst: str | ||||
|  | ||||
|     def __call__(self): | ||||
|         root = config.config.path | ||||
|         sel = [root / filename.sanitize(p) for p in self.sel] | ||||
| @@ -61,73 +75,77 @@ class Cp(ControlBase): | ||||
|         if not dst.is_dir(): | ||||
|             raise BadRequest("The destination must be a directory") | ||||
|         for p in sel: | ||||
|             if p.is_dir(): | ||||
|                 # 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) | ||||
|                 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 | ||||
|  | ||||
|  | ||||
| class FileRange(msgspec.Struct): | ||||
|     name: str | ||||
|     size: int | ||||
|     start: int | ||||
|     end: int | ||||
|  | ||||
|  | ||||
| class StatusMsg(msgspec.Struct): | ||||
|     status: str | ||||
|     req: FileRange | ||||
|  | ||||
|  | ||||
| class ErrorMsg(msgspec.Struct): | ||||
|     error: dict[str, Any] | ||||
|  | ||||
|  | ||||
| ## Directory listings | ||||
|  | ||||
| class FileEntry(msgspec.Struct): | ||||
|     size: int | ||||
|  | ||||
| class FileEntry(msgspec.Struct, array_like=True): | ||||
|     level: int | ||||
|     name: str | ||||
|     key: str | ||||
|     mtime: int | ||||
|  | ||||
| class DirEntry(msgspec.Struct): | ||||
|     size: int | ||||
|     mtime: int | ||||
|     dir: DirList | ||||
|     isfile: int | ||||
|  | ||||
|     def __getitem__(self, name): | ||||
|         return self.dir[name] | ||||
|  | ||||
|     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] | ||||
|     def __repr__(self): | ||||
|         return self.key or "FileEntry()" | ||||
|  | ||||
|  | ||||
| class UpdateEntry(msgspec.Struct, omit_defaults=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 | ||||
| class Update(msgspec.Struct, array_like=True): | ||||
|     ... | ||||
|  | ||||
| def make_dir_data(root): | ||||
|     if len(root) == 2: | ||||
|         return FileEntry(*root) | ||||
|     size, mtime, listing = root | ||||
|     converted = {} | ||||
|     for name, data in listing.items(): | ||||
|         converted[name] = make_dir_data(data) | ||||
|     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 UpdKeep(Update, tag="k"): | ||||
|     count: int | ||||
|  | ||||
|  | ||||
| class UpdDel(Update, tag="d"): | ||||
|     count: int | ||||
|  | ||||
|  | ||||
| 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 | ||||
|   | ||||
							
								
								
									
										33
									
								
								cista/serve.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -1,47 +1,62 @@ | ||||
| import os | ||||
| import re | ||||
| from pathlib import Path, PurePath | ||||
| from pathlib import Path | ||||
|  | ||||
| from sanic import Sanic | ||||
|  | ||||
| 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.""" | ||||
|     from .app import app | ||||
|  | ||||
|     url, opts = parse_listen(config.config.listen) | ||||
|     # Silence Sanic's warning about running in production rather than debug | ||||
|     os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1" | ||||
|     confdir = config.conffile.parent | ||||
|     wwwroot = PurePath(__file__).parent / "wwwroot" | ||||
|     if opts.get("ssl"): | ||||
|         # Run plain HTTP redirect/acme server on port 80 | ||||
|         server80.app.prepare(port=80, motd=False) | ||||
|         domain = opts["host"] | ||||
|         check_cert(confdir / domain, domain) | ||||
|         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( | ||||
|         **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): | ||||
|     if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists(): | ||||
|         return | ||||
|     # 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): | ||||
|     if listen.startswith("/"): | ||||
|         unix = Path(listen).resolve() | ||||
|         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} | ||||
|     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} | ||||
|     else: | ||||
|     try: | ||||
|         addr, _port = listen.split(":", 1) | ||||
|         port = int(_port) | ||||
|     except Exception: | ||||
|             raise ValueError(f"Invalid listen address: {listen}") | ||||
|         raise ValueError(f"Invalid listen address: {listen}") from None | ||||
|     return f"http://localhost:{port}", {"host": addr, "port": port} | ||||
|   | ||||
| @@ -2,6 +2,7 @@ from sanic import Sanic, exceptions, response | ||||
|  | ||||
| app = Sanic("server80") | ||||
|  | ||||
|  | ||||
| # Send all HTTP users to HTTPS | ||||
| @app.exception(exceptions.NotFound, exceptions.MethodNotSupported) | ||||
| 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.text("Bad Request. Please use HTTPS!", status=400) | ||||
|  | ||||
|  | ||||
| # ACME challenge for LetsEncrypt | ||||
| @app.get("/.well-known/acme-challenge/<challenge>") | ||||
| async def letsencrypt(request, challenge): | ||||
| @@ -18,4 +20,5 @@ async def letsencrypt(request, challenge): | ||||
|     except KeyError: | ||||
|         return response.text(f"ACME challenge not found: {challenge}", status=404) | ||||
|  | ||||
|  | ||||
| acme_challenges = {} | ||||
|   | ||||
							
								
								
									
										13
									
								
								cista/session.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -4,16 +4,21 @@ import jwt | ||||
|  | ||||
| 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 | ||||
|  | ||||
|  | ||||
| def get(request): | ||||
|     try: | ||||
|         return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"]) | ||||
|     except Exception as e: | ||||
|         s = None | ||||
|     except Exception: | ||||
|         return False if "s" in request.cookies else None | ||||
|  | ||||
|  | ||||
| def create(res, username, **kwargs): | ||||
|     data = { | ||||
|         "exp": int(time()) + max_age, | ||||
| @@ -23,12 +28,14 @@ def create(res, username, **kwargs): | ||||
|     s = jwt.encode(data, session_secret()) | ||||
|     res.cookies.add_cookie("s", s, httponly=True, max_age=max_age) | ||||
|  | ||||
|  | ||||
| def update(res, s, **kwargs): | ||||
|     s.update(kwargs) | ||||
|     s = jwt.encode(s, session_secret()) | ||||
|     max_age = max(1, s["exp"] - int(time()))  # type: ignore | ||||
|     res.cookies.add_cookie("s", s, httponly=True, max_age=max_age) | ||||
|  | ||||
|  | ||||
| def delete(res): | ||||
|     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""" | ||||
|     return ws.send(msg if isinstance(msg, bytes) else msgspec.json.encode(msg).decode()) | ||||
|  | ||||
|  | ||||
| def jres(data, **kwargs): | ||||
|     """JSON Sanic response, using msgspec encoding""" | ||||
|     return raw(msgspec.json.encode(data), content_type="application/json", **kwargs) | ||||
|  | ||||
|  | ||||
| async def handle_sanic_exception(request, e): | ||||
|     logger.exception(e) | ||||
|     context, code = {}, 500 | ||||
| @@ -30,7 +32,10 @@ async def handle_sanic_exception(request, e): | ||||
|     message = f"⚠️ {message}" if code < 500 else f"🛑 {message}" | ||||
|     # Non-browsers get JSON errors | ||||
|     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 | ||||
|     if "redirect" in context: | ||||
|         res = redirect(context["redirect"]) | ||||
| @@ -39,8 +44,10 @@ async def handle_sanic_exception(request, e): | ||||
|     # Otherwise use Sanic's default error page | ||||
|     return errorpages.HTMLRenderer(request, e, debug=request.app.debug).full() | ||||
|  | ||||
|  | ||||
| def websocket_wrapper(handler): | ||||
|     """Decorator for websocket handlers that catches exceptions and sends them back to the client""" | ||||
|  | ||||
|     @wraps(handler) | ||||
|     async def wrapper(request, ws, *args, **kwargs): | ||||
|         try: | ||||
| @@ -55,4 +62,5 @@ def websocket_wrapper(handler): | ||||
|             message = f"⚠️ {message}" if code < 500 else f"🛑 {message}" | ||||
|             await asend(ws, ErrorMsg({"code": code, "message": message, **context})) | ||||
|             raise | ||||
|  | ||||
|     return wrapper | ||||
|   | ||||
							
								
								
									
										3
									
								
								cista/util/asynclink.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -80,8 +80,9 @@ class SyncRequest: | ||||
|         if exc: | ||||
|             self.set_exception(exc) | ||||
|             return True | ||||
|         elif not self.done: | ||||
|         if not self.done: | ||||
|             self.set_result(None) | ||||
|         return None | ||||
|  | ||||
|     def set_result(self, value): | ||||
|         """Set result value; mark as done.""" | ||||
|   | ||||
| @@ -10,4 +10,7 @@ def sanitize(filename: str) -> str: | ||||
|     filename = filename.replace("\\", "-") | ||||
|     filename = sanitize_filepath(filename) | ||||
|     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. | ||||
|         cache (list): Internal list storing the cache items. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, open: callable, *, capacity: int, maxage: float): | ||||
|         """ | ||||
|         Initialize LRUCache. | ||||
| @@ -40,7 +41,7 @@ class LRUCache: | ||||
|             The corresponding item's handle. | ||||
|         """ | ||||
|         # 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: | ||||
|                 self.cache.pop(i) | ||||
|                 break | ||||
|   | ||||
| @@ -6,6 +6,7 @@ def generate(n=4): | ||||
|     wl = list(words) | ||||
|     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 | ||||
| words: list = """ | ||||
| able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead | ||||
|   | ||||
							
								
								
									
										382
									
								
								cista/watching.py
									
									
									
									
									
										
										
										Executable file → Normal file
									
								
							
							
						
						| @@ -1,51 +1,163 @@ | ||||
| import asyncio | ||||
| import shutil | ||||
| import stat | ||||
| import sys | ||||
| import threading | ||||
| import time | ||||
| from os import stat_result | ||||
| from pathlib import Path, PurePosixPath | ||||
|  | ||||
| import inotify.adapters | ||||
| import msgspec | ||||
| from natsort import humansorted, natsort_keygen, ns | ||||
| from sanic.log import logging | ||||
|  | ||||
| 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 = {} | ||||
| tree = {"": None} | ||||
| tree_lock = threading.Lock() | ||||
| sortkey = natsort_keygen(alg=ns.LOCALE) | ||||
|  | ||||
|  | ||||
| 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 | ||||
| quit = False | ||||
| modified_flags = "IN_CREATE", "IN_DELETE", "IN_DELETE_SELF", "IN_MODIFY", "IN_MOVE_SELF", "IN_MOVED_FROM", "IN_MOVED_TO" | ||||
| disk_usage = None | ||||
| modified_flags = ( | ||||
|     "IN_CREATE", | ||||
|     "IN_DELETE", | ||||
|     "IN_DELETE_SELF", | ||||
|     "IN_MODIFY", | ||||
|     "IN_MOVE_SELF", | ||||
|     "IN_MOVED_FROM", | ||||
|     "IN_MOVED_TO", | ||||
| ) | ||||
|  | ||||
|  | ||||
| def watcher_thread(loop): | ||||
|     global disk_usage | ||||
|     global rootpath | ||||
|     import inotify.adapters | ||||
|  | ||||
|     while True: | ||||
|     while not quit: | ||||
|         rootpath = config.config.path | ||||
|         i = inotify.adapters.InotifyTree(rootpath.as_posix()) | ||||
|         old = format_tree() if tree[""] else None | ||||
|         with tree_lock: | ||||
|         # Initialize the tree from filesystem | ||||
|             tree[""] = walk(rootpath) | ||||
|         msg = format_tree() | ||||
|         if msg != old: | ||||
|             asyncio.run_coroutine_threadsafe(broadcast(msg), loop) | ||||
|         new = walk() | ||||
|         with state.lock: | ||||
|             old = state.root | ||||
|             if old != new: | ||||
|                 state.root = new | ||||
|                 broadcast(format_update(old, new), loop) | ||||
|  | ||||
|         # The watching is not entirely reliable, so do a full refresh every minute | ||||
|         refreshdl = time.monotonic() + 60.0 | ||||
|         # The watching is not entirely reliable, so do a full refresh every 30 seconds | ||||
|         refreshdl = time.monotonic() + 30.0 | ||||
|  | ||||
|         for event in i.event_gen(): | ||||
|             if quit: return | ||||
|             if quit: | ||||
|                 return | ||||
|             # Disk usage update | ||||
|             du = shutil.disk_usage(rootpath) | ||||
|             if du != disk_usage: | ||||
|                 disk_usage = du | ||||
|                 asyncio.run_coroutine_threadsafe(broadcast(format_du()), loop) | ||||
|             space = Space(*du, storage=state.root[0].size) | ||||
|             if space != state.space: | ||||
|                 state.space = space | ||||
|                 broadcast(format_space(space), loop) | ||||
|                 break | ||||
|             # Do a full refresh? | ||||
|             if time.monotonic() > refreshdl: break | ||||
|             if event is None: continue | ||||
|             if time.monotonic() > refreshdl: | ||||
|                 break | ||||
|             if event is None: | ||||
|                 continue | ||||
|             _, flags, path, filename = event | ||||
|             if not any(f in modified_flags for f in flags): | ||||
|                 continue | ||||
| @@ -54,113 +166,169 @@ def watcher_thread(loop): | ||||
|             try: | ||||
|                 update(path.relative_to(rootpath), loop) | ||||
|             except Exception as e: | ||||
|                 print("Watching error", e) | ||||
|                 break | ||||
|                 print("Watching error", e, path, rootpath) | ||||
|                 raise | ||||
|         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(): | ||||
|     root = tree[""] | ||||
|     return msgspec.json.encode({"update": [ | ||||
|         UpdateEntry(size=root.size, mtime=root.mtime, dir=root.dir) | ||||
|     ]}).decode() | ||||
| def watcher_thread_poll(loop): | ||||
|     global rootpath | ||||
|  | ||||
| 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: | ||||
|         s = path.stat() | ||||
|         mtime = int(s.st_mtime) | ||||
|         if path.is_file(): | ||||
|             return FileEntry(s.st_size, mtime) | ||||
|         st = path.stat() | ||||
|     except OSError: | ||||
|         return [] | ||||
|     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: | ||||
|             size = sum(v.size for v in tree.values()) | ||||
|             mtime = max(mtime, max(v.mtime for v in tree.values())) | ||||
|         else: | ||||
|             size = 0 | ||||
|         return DirEntry(size, mtime, tree) | ||||
|  | ||||
| def _walk(rel: PurePosixPath, isfile: int, st: stat_result) -> list[FileEntry]: | ||||
|     entry = FileEntry( | ||||
|         level=len(rel.parts), | ||||
|         name=rel.name, | ||||
|         key=fuid(st), | ||||
|         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: | ||||
|         return None | ||||
|         pass  # Things may be rapidly in motion | ||||
|     except OSError as e: | ||||
|         print("OS error walking path", path, e) | ||||
|         return None | ||||
|     return ret | ||||
|  | ||||
|  | ||||
| def update(relpath: PurePosixPath, loop): | ||||
|     """Called by inotify updates, check the filesystem and broadcast any changes.""" | ||||
|     new = walk(rootpath / relpath) | ||||
|     with tree_lock: | ||||
|         update = update_internal(relpath, new) | ||||
|         if not update: return  # No changes | ||||
|         msg = msgspec.json.encode({"update": update}).decode() | ||||
|         asyncio.run_coroutine_threadsafe(broadcast(msg), loop) | ||||
|  | ||||
| def update_internal(relpath: PurePosixPath, new: DirEntry | FileEntry | None) -> list[UpdateEntry]: | ||||
|     path = "", *relpath.parts | ||||
|     old = tree | ||||
|     elems = [] | ||||
|     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 rootpath is None or relpath is None: | ||||
|         print("ERROR", rootpath, relpath) | ||||
|     new = walk(relpath) | ||||
|     with state.lock: | ||||
|         old = state[relpath] | ||||
|         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 = [] | ||||
|     for name, entry in elems[:-1]: | ||||
|         u = UpdateEntry(name) | ||||
|         if szdiff: | ||||
|             entry.size += szdiff | ||||
|             u.size = entry.size | ||||
|         if mt > entry.mtime: | ||||
|             u.mtime = entry.mtime = mt | ||||
|         update.append(u) | ||||
|     # The last element is the one that changed | ||||
|     name, entry = elems[-1] | ||||
|     parent = elems[-2][1] if len(elems) > 1 else tree | ||||
|     u = UpdateEntry(name) | ||||
|             return | ||||
|         old = state.root | ||||
|         if new: | ||||
|         parent[name] = new | ||||
|         if u.size != new.size: u.size = new.size | ||||
|         if u.mtime != new.mtime: u.mtime = new.mtime | ||||
|         if isinstance(new, DirEntry): | ||||
|             if u.dir == new.dir: u.dir = new.dir | ||||
|             state[relpath, new[0].isfile] = new | ||||
|         else: | ||||
|         del parent[name] | ||||
|         u.deleted = True | ||||
|     update.append(u) | ||||
|     return update | ||||
|             del state[relpath] | ||||
|         broadcast(format_update(old, state.root), loop) | ||||
|  | ||||
| async def broadcast(msg): | ||||
|  | ||||
| def format_update(old, new): | ||||
|     # Make keep/del/insert diff until one of the lists ends | ||||
|     oidx, nidx = 0, 0 | ||||
|     update = [] | ||||
|     keep_count = 0 | ||||
|     while oidx < len(old) and nidx < len(new): | ||||
|         if old[oidx] == new[nidx]: | ||||
|             keep_count += 1 | ||||
|             oidx += 1 | ||||
|             nidx += 1 | ||||
|             continue | ||||
|         if keep_count > 0: | ||||
|             update.append(UpdKeep(keep_count)) | ||||
|             keep_count = 0 | ||||
|  | ||||
|         del_count = 0 | ||||
|         rest = new[nidx:] | ||||
|         while oidx < len(old) and old[oidx] not in rest: | ||||
|             del_count += 1 | ||||
|             oidx += 1 | ||||
|         if del_count: | ||||
|             update.append(UpdDel(del_count)) | ||||
|             continue | ||||
|  | ||||
|         insert_items = [] | ||||
|         rest = old[oidx:] | ||||
|         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(): | ||||
|         await queue.put_nowait(msg) | ||||
|             queue.put_nowait(msg) | ||||
|     except Exception: | ||||
|         # Log because asyncio would silently eat the error | ||||
|         logging.exception("Broadcast error") | ||||
|  | ||||
|  | ||||
| async def start(app, loop): | ||||
|     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() | ||||
|  | ||||
|  | ||||
| async def stop(app, loop): | ||||
|     global quit | ||||
|     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* | ||||
| lerna-debug.log* | ||||
| 
 | ||||
| # No locking | ||||
| package-lock.json | ||||
| yarn.lock | ||||
| 
 | ||||
| node_modules | ||||
| .DS_Store | ||||
| dist | ||||
| dist-ssr | ||||
| coverage | ||||
| *.local | ||||
| components.d.ts | ||||
| 
 | ||||
| /cypress/videos/ | ||||
| /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 |