Compare commits
53 Commits
37167a41a6
...
v0.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62388eb555 | ||
|
|
53778543bf | ||
|
|
8dda230510 | ||
|
|
696e3ab568 | ||
|
|
85ac12ad33 | ||
|
|
e56cc47105 | ||
|
|
ebbd96bc94 | ||
|
|
a9b6d04361 | ||
|
|
5808fe17ad | ||
|
|
671359e327 | ||
|
|
ba9495eb65 | ||
|
|
de482afd60 | ||
|
|
a547052e29 | ||
|
|
07c2ff4c15 | ||
|
|
e20b04189f | ||
|
|
8da141744e | ||
|
|
11887edde3 | ||
|
|
034c6fdea9 | ||
|
|
c5083f0f2b | ||
|
|
f8a9197474 | ||
|
|
5285cb2fb5 | ||
|
|
b6b387d09b | ||
|
|
669762dfe7 | ||
|
|
51fd07d4fa | ||
|
|
c40c245ce6 | ||
|
|
1fdd00b833 | ||
|
|
520a9dff47 | ||
|
|
c5c65d136a | ||
|
|
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
|
||||
|
||||
143
README.md
@@ -1,25 +1,118 @@
|
||||
# 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).
|
||||
|
||||
Create your user account:
|
||||
```sh
|
||||
hatch run cista --user admin --privileged
|
||||
```
|
||||
|
||||
## Build frontend
|
||||
|
||||
Prebuilt frontend is provided in repository but for any changes it will need to be manually rebuilt:
|
||||
|
||||
```sh
|
||||
cd cista-front
|
||||
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`.
|
||||
# Cista Web Storage
|
||||
|
||||
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
|
||||
|
||||
Cista takes its name from the ancient *cistae*, metal containers used by Greeks and Egyptians to safeguard valuable items. This modern application provides a browser interface for secure and accessible file storage, echoing the trust and reliability of its historical namesake.
|
||||
|
||||
This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**.
|
||||
|
||||
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter.
|
||||
|
||||
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
|
||||
|
||||
Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something...
|
||||
|
||||
|
||||
## Getting Started
|
||||
### Installation
|
||||
|
||||
To install the cista application, use:
|
||||
|
||||
```fish
|
||||
pip install cista
|
||||
```
|
||||
|
||||
Note: Some Linux distributions might need `--break-system-packages` to install Python packages, which are safely installed in the user's home folder. As an alternative to avoid installation, run it with command `pipx run cista`
|
||||
|
||||
### Running the Server
|
||||
|
||||
Create an account: (or run a public server without authentication)
|
||||
```fish
|
||||
cista --user yourname --privileged
|
||||
```
|
||||
|
||||
Serve your files at http://localhost:8000:
|
||||
```fish
|
||||
cista -l :8000 /path/to/files
|
||||
```
|
||||
|
||||
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
||||
|
||||
### Internet Access
|
||||
|
||||
To use your own TLS certificates, place them in the config folder and run:
|
||||
|
||||
```fish
|
||||
cista -l cista.example.com
|
||||
```
|
||||
|
||||
Most admins instead find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
|
||||
|
||||
`/etc/caddy/Caddyfile`:
|
||||
|
||||
```Caddyfile
|
||||
cista.example.com {
|
||||
reverse_proxy :8000
|
||||
}
|
||||
```
|
||||
|
||||
## Development setup
|
||||
|
||||
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
|
||||
|
||||
```fish
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Concurrently, start the backend on another terminal:
|
||||
|
||||
```fish
|
||||
hatch shell
|
||||
pip install -e '.[dev]'
|
||||
cista --dev -l :8000 /path/to/files
|
||||
```
|
||||
|
||||
We use `hatch shell` for installing on a virtual environment, to avoid disturbing the rest of the system with our hacking.
|
||||
|
||||
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. Running `hatch build` builds the frontend and creates a NodeJS-independent Python package.
|
||||
|
||||
## System Deployment
|
||||
|
||||
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
||||
|
||||
Assuming a restricted user account `storage` for serving files and that cista is installed system-wide or on this account (check with `sudo -u storage -s`). Alternatively, use `pipx run cista` or `hatch run cista` as the ExecStart command.
|
||||
|
||||
Create `/etc/systemd/system/cista@.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Cista storage %i
|
||||
|
||||
[Service]
|
||||
User=storage
|
||||
ExecStart=cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience.
|
||||
|
||||
```fish
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now cista@foo.example.com
|
||||
systemctl enable --now cista@bar.example.com
|
||||
```
|
||||
|
||||
Public exposure is easiest using the Caddy web server.
|
||||
|
||||
`/etc/caddy/Caddyfile`:
|
||||
|
||||
```Caddyfile
|
||||
foo.example.com, bar.example.com {
|
||||
reverse_proxy unix//srv/cista/{host}/socket
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
65
cista/__main__.py
Executable file → Normal file
@@ -7,7 +7,7 @@ import cista
|
||||
from cista import app, config, droppy, serve, server80
|
||||
from cista.util import pwgen
|
||||
|
||||
del app, server80.app # Only import needed, for Sanic multiprocessing
|
||||
del app, server80.app # Only import needed, for Sanic multiprocessing
|
||||
|
||||
doc = f"""Cista {cista.__version__} - A file storage for the web.
|
||||
|
||||
@@ -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"]:
|
||||
@@ -60,23 +62,39 @@ def _main():
|
||||
_confdir(args)
|
||||
exists = config.conffile.exists()
|
||||
import_droppy = args["--import-droppy"]
|
||||
necessary_opts = exists or import_droppy or path and listen
|
||||
necessary_opts = exists or import_droppy or path
|
||||
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 one of:\n"
|
||||
" cista --user yourname --privileged\n"
|
||||
" cista --import-droppy\n"
|
||||
" cista -l :8000 /path/to/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
|
||||
elif not exists:
|
||||
settings["path"] = Path.home() / "Downloads"
|
||||
if listen:
|
||||
settings["listen"] = listen
|
||||
elif not exists:
|
||||
settings["listen"] = ":8000"
|
||||
if not exists and not import_droppy:
|
||||
# We have no users, so make it public
|
||||
settings["public"] = True
|
||||
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 +105,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,17 +115,30 @@ 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)
|
||||
config.load_config()
|
||||
if config.conffile.exists():
|
||||
config.load_config()
|
||||
operation = False
|
||||
else:
|
||||
# Defaults for new config when user is created
|
||||
operation = config.update_config(
|
||||
{
|
||||
"listen": ":8000",
|
||||
"path": Path.home() / "Downloads",
|
||||
"public": False,
|
||||
}
|
||||
)
|
||||
print(f"Config {operation}: {config.conffile}\n")
|
||||
|
||||
name = args["--user"]
|
||||
if not name or not name.isidentifier():
|
||||
raise ValueError("Invalid username")
|
||||
config.load_config()
|
||||
u = config.config.users.get(name)
|
||||
info = f"User {name}" if u else f"New user {name}"
|
||||
changes = {}
|
||||
@@ -117,11 +150,17 @@ def _user(args):
|
||||
info += " (admin)" if oldadmin else ""
|
||||
if args["--password"] or not u:
|
||||
changes["password"] = pw = pwgen.generate()
|
||||
info += f"\n Password: {pw}"
|
||||
res = config.update_user(args["--user"], changes)
|
||||
info += f"\n Password: {pw}\n"
|
||||
res = config.update_user(name, changes)
|
||||
print(info)
|
||||
if res == "read":
|
||||
print(" No changes")
|
||||
|
||||
if operation == "created":
|
||||
print(
|
||||
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
87
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,70 @@ 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)
|
||||
await asyncio.to_thread(cmd)
|
||||
await asend(ws, StatusMsg(status="ack", req=cmd))
|
||||
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()
|
||||
# Init with disk usage and full tree
|
||||
await ws.send(watching.format_du())
|
||||
await ws.send(watching.format_tree())
|
||||
q, space, root = await asyncio.get_event_loop().run_in_executor(
|
||||
req.app.ctx.threadexec, subscribe, uuid, ws
|
||||
)
|
||||
await ws.send(space)
|
||||
await ws.send(root)
|
||||
# Send updates
|
||||
while True:
|
||||
await ws.send(await q.get())
|
||||
finally:
|
||||
del watching.pubsub[ws]
|
||||
del watching.pubsub[uuid]
|
||||
|
||||
|
||||
def subscribe(uuid, ws):
|
||||
with watching.state.lock:
|
||||
q = watching.pubsub[uuid] = asyncio.Queue()
|
||||
# Init with disk usage and full tree
|
||||
return (
|
||||
q,
|
||||
watching.format_space(watching.state.space),
|
||||
watching.format_root(watching.state.root),
|
||||
)
|
||||
|
||||
249
cista/app.py
Executable file → Normal file
@@ -1,36 +1,59 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import mimetypes
|
||||
from importlib.resources import files
|
||||
import threading
|
||||
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
|
||||
import brotli
|
||||
import sanic.helpers
|
||||
from blake3 import blake3
|
||||
from sanic import Blueprint, Sanic, empty, raw
|
||||
from sanic.exceptions import Forbidden, NotFound
|
||||
from sanic.log import logger
|
||||
from stream_zip import ZIP_AUTO, stream_zip
|
||||
|
||||
from cista import auth, config, session, watching
|
||||
from cista import auth, config, preview, 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(preview.bp)
|
||||
app.blueprint(bp)
|
||||
app.exception(Exception)(handle_sanic_exception)
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
async def main_start(app, loop):
|
||||
config.load_config()
|
||||
app.ctx.threadexec = ThreadPoolExecutor(
|
||||
max_workers=8, thread_name_prefix="cista-ioworker"
|
||||
)
|
||||
await watching.start(app, loop)
|
||||
|
||||
|
||||
@app.after_server_stop
|
||||
async def main_stop(app, loop):
|
||||
quit.set()
|
||||
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 +64,216 @@ 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:
|
||||
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
|
||||
if not www:
|
||||
logger.warning(msg)
|
||||
if not app.debug:
|
||||
msg = "Web frontend missing. Cista installation is broken.\n"
|
||||
wwwnew[""] = (
|
||||
msg.encode(),
|
||||
False,
|
||||
{
|
||||
"etag": "error",
|
||||
"content-type": "text/plain",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
)
|
||||
return wwwnew
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
async def start(app):
|
||||
await load_wwwroot(app)
|
||||
if app.debug:
|
||||
app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
|
||||
|
||||
|
||||
async def load_wwwroot(app):
|
||||
global www
|
||||
www = await asyncio.get_event_loop().run_in_executor(
|
||||
app.ctx.threadexec, _load_wwwroot, www
|
||||
)
|
||||
|
||||
|
||||
quit = threading.Event()
|
||||
|
||||
|
||||
async def refresh_wwwroot():
|
||||
try:
|
||||
while not quit.is_set():
|
||||
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(f"Error loading wwwroot: {e!r}")
|
||||
await asyncio.sleep(0.5)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
@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"
|
||||
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)
|
||||
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:
|
||||
for chunk in stream_zip(local_files(files)):
|
||||
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
|
||||
except Exception:
|
||||
logger.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(f"Login required for {request.path}", "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
|
||||
|
||||
32
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,24 +131,27 @@ 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."""
|
||||
# Encode into dict, update values with new, convert to Config
|
||||
try:
|
||||
u = conf.users[name].__copy__()
|
||||
except KeyError:
|
||||
except (KeyError, AttributeError):
|
||||
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)
|
||||
udict.update(changes)
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}}
|
||||
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"
|
||||
|
||||
29
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)
|
||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||
os.write(self.fd, buffer)
|
||||
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]
|
||||
|
||||
52
cista/preview.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import asyncio
|
||||
import io
|
||||
from pathlib import PurePosixPath
|
||||
from urllib.parse import unquote
|
||||
|
||||
from PIL import Image
|
||||
from sanic import Blueprint, raw
|
||||
from sanic.exceptions import Forbidden, NotFound
|
||||
from sanic.log import logger
|
||||
|
||||
from cista import config
|
||||
from cista.util.filename import sanitize
|
||||
|
||||
bp = Blueprint("preview", url_prefix="/preview")
|
||||
|
||||
|
||||
@bp.get("/<path:path>")
|
||||
async def preview(req, path):
|
||||
"""Preview a file"""
|
||||
width = int(req.query_string) if req.query_string else 1024
|
||||
rel = PurePosixPath(sanitize(unquote(path)))
|
||||
path = config.config.path / rel
|
||||
if not path.is_file():
|
||||
raise NotFound("File not found")
|
||||
size = path.lstat().st_size
|
||||
if size > 20 * 10**6:
|
||||
raise Forbidden("File too large")
|
||||
img = await asyncio.get_event_loop().run_in_executor(
|
||||
req.app.ctx.threadexec, process_image, path, width
|
||||
)
|
||||
return raw(img, content_type="image/webp")
|
||||
|
||||
|
||||
def process_image(path, maxsize):
|
||||
img = Image.open(path)
|
||||
w, h = img.size
|
||||
img.thumbnail((min(w, maxsize), min(h, maxsize)))
|
||||
# Fix rotation based on EXIF data
|
||||
try:
|
||||
rotate_values = {3: 180, 6: 270, 8: 90}
|
||||
exif = img._getexif()
|
||||
if exif:
|
||||
orientation = exif.get(274)
|
||||
if orientation in rotate_values:
|
||||
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
|
||||
img = img.rotate(rotate_values[orientation], expand=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error rotating preview image: {e}")
|
||||
# Save as webp
|
||||
imgdata = io.BytesIO()
|
||||
img.save(imgdata, format="webp", quality=70, method=6)
|
||||
return imgdata.getvalue()
|
||||
114
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:
|
||||
# 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)
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
47
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
|
||||
Sanic.serve()
|
||||
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}/")
|
||||
return "http://localhost", {"unix": unix}
|
||||
elif re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
|
||||
raise ValueError(
|
||||
f"Directory for unix socket does not exist: {unix.parent}/",
|
||||
)
|
||||
return "http://localhost", {"unix": unix.as_posix()}
|
||||
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}")
|
||||
return f"http://localhost:{port}", {"host": addr, "port": port}
|
||||
try:
|
||||
addr, _port = listen.split(":", 1)
|
||||
port = int(_port)
|
||||
except Exception:
|
||||
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,12 +14,13 @@ 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
|
||||
message = str(e)
|
||||
if isinstance(e, SanicException):
|
||||
@@ -30,29 +31,36 @@ 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"])
|
||||
res.cookies.add_cookie("message", message, max_age=5)
|
||||
return res
|
||||
# Otherwise use Sanic's default error page
|
||||
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).full()
|
||||
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).render()
|
||||
|
||||
|
||||
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:
|
||||
auth.verify(request)
|
||||
await handler(request, ws, *args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
||||
if isinstance(e, SanicException):
|
||||
context = e.context or {}
|
||||
code = e.status_code
|
||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
||||
if not getattr(e, "quiet", False):
|
||||
logger.exception(f"{code} {e!r}")
|
||||
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
|
||||
|
||||
452
cista/watching.py
Executable file → Normal file
@@ -1,167 +1,343 @@
|
||||
import asyncio
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from os import stat_result
|
||||
from pathlib import Path, PurePosixPath
|
||||
from stat import S_ISDIR, S_ISREG
|
||||
|
||||
import inotify.adapters
|
||||
import msgspec
|
||||
from natsort import humansorted, natsort_keygen, ns
|
||||
from sanic.log import logger
|
||||
|
||||
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
|
||||
quit = threading.Event()
|
||||
|
||||
def watcher_thread(loop):
|
||||
global disk_usage
|
||||
## Filesystem scanning
|
||||
|
||||
while True:
|
||||
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)
|
||||
|
||||
# The watching is not entirely reliable, so do a full refresh every minute
|
||||
refreshdl = time.monotonic() + 60.0
|
||||
|
||||
for event in i.event_gen():
|
||||
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)
|
||||
break
|
||||
# Do a full refresh?
|
||||
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
|
||||
# Update modified path
|
||||
path = PurePosixPath(path) / filename
|
||||
try:
|
||||
update(path.relative_to(rootpath), loop)
|
||||
except Exception as e:
|
||||
print("Watching error", e)
|
||||
break
|
||||
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 walk(path: Path) -> DirEntry | FileEntry | None:
|
||||
def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]:
|
||||
path = rootpath / rel
|
||||
ret = []
|
||||
try:
|
||||
s = path.stat()
|
||||
mtime = int(s.st_mtime)
|
||||
if path.is_file():
|
||||
return FileEntry(s.st_size, mtime)
|
||||
|
||||
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)
|
||||
st = stat or path.stat()
|
||||
isfile = not S_ISDIR(st.st_mode)
|
||||
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]
|
||||
# Walk all entries of the directory
|
||||
ret = [entry]
|
||||
li = []
|
||||
for f in path.iterdir():
|
||||
if quit.is_set():
|
||||
raise SystemExit("quit")
|
||||
if f.name.startswith("."):
|
||||
continue # No dotfiles
|
||||
with suppress(FileNotFoundError):
|
||||
s = f.lstat()
|
||||
isfile = S_ISREG(s.st_mode)
|
||||
isdir = S_ISDIR(s.st_mode)
|
||||
if not isfile and not isdir:
|
||||
continue
|
||||
li.append((int(isfile), f.name, s))
|
||||
# Build the tree as a list of FileEntries
|
||||
for [_, name, s] in humansorted(li):
|
||||
sub = walk(rel / name, stat=s)
|
||||
ret.extend(sub)
|
||||
child = sub[0]
|
||||
entry.mtime = max(entry.mtime, child.mtime)
|
||||
entry.size += child.size
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
pass # Things may be rapidly in motion
|
||||
except OSError as e:
|
||||
print("OS error walking path", path, e)
|
||||
return None
|
||||
if e.errno == 13: # Permission denied
|
||||
pass
|
||||
logger.error(f"Watching {path=}: {e!r}")
|
||||
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 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
|
||||
def update_root(loop):
|
||||
"""Full filesystem scan"""
|
||||
new = walk(PurePosixPath())
|
||||
with state.lock:
|
||||
old = state.root
|
||||
if old != new:
|
||||
state.root = new
|
||||
broadcast(format_update(old, new), loop)
|
||||
|
||||
|
||||
def update_path(relpath: PurePosixPath, loop):
|
||||
"""Called on FS updates, check the filesystem and broadcast any changes."""
|
||||
new = walk(relpath)
|
||||
with state.lock:
|
||||
old = state[relpath]
|
||||
if old == new:
|
||||
return
|
||||
old = state.root
|
||||
if new:
|
||||
state[relpath, new[0].isfile] = new
|
||||
else:
|
||||
del state[relpath]
|
||||
broadcast(format_update(old, state.root), loop)
|
||||
|
||||
|
||||
def update_space(loop):
|
||||
"""Called periodically to update the disk usage."""
|
||||
du = shutil.disk_usage(rootpath)
|
||||
space = Space(*du, storage=state.root[0].size)
|
||||
# Update only on difference above 1 MB
|
||||
tol = 10**6
|
||||
old = msgspec.structs.astuple(state.space)
|
||||
new = msgspec.structs.astuple(space)
|
||||
if any(abs(o - n) > tol for o, n in zip(old, new, strict=True)):
|
||||
state.space = space
|
||||
broadcast(format_space(space), loop)
|
||||
|
||||
|
||||
## Messaging
|
||||
|
||||
|
||||
def format_update(old, new):
|
||||
# Make keep/del/insert diff until one of the lists ends
|
||||
oidx, nidx = 0, 0
|
||||
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)
|
||||
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
|
||||
else:
|
||||
del parent[name]
|
||||
u.deleted = True
|
||||
update.append(u)
|
||||
return 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():
|
||||
queue.put_nowait(msg)
|
||||
except Exception:
|
||||
# Log because asyncio would silently eat the error
|
||||
logger.exception("Broadcast error")
|
||||
|
||||
|
||||
## Watcher thread
|
||||
|
||||
|
||||
def watcher_inotify(loop):
|
||||
"""Inotify watcher thread (Linux only)"""
|
||||
import inotify.adapters
|
||||
|
||||
modified_flags = (
|
||||
"IN_CREATE",
|
||||
"IN_DELETE",
|
||||
"IN_DELETE_SELF",
|
||||
"IN_MODIFY",
|
||||
"IN_MOVE_SELF",
|
||||
"IN_MOVED_FROM",
|
||||
"IN_MOVED_TO",
|
||||
)
|
||||
while not quit.is_set():
|
||||
i = inotify.adapters.InotifyTree(rootpath.as_posix())
|
||||
# Initialize the tree from filesystem
|
||||
update_root(loop)
|
||||
trefresh = time.monotonic() + 30.0
|
||||
tspace = time.monotonic() + 5.0
|
||||
# Watch for changes (frequent wakeups needed for quiting)
|
||||
for event in i.event_gen(timeout_s=0.1):
|
||||
if quit.is_set():
|
||||
break
|
||||
t = time.monotonic()
|
||||
# The watching is not entirely reliable, so do a full refresh every 30 seconds
|
||||
if t >= trefresh:
|
||||
break
|
||||
# Disk usage update
|
||||
if t >= tspace:
|
||||
tspace = time.monotonic() + 5.0
|
||||
update_space(loop)
|
||||
# Inotify event, update the tree
|
||||
if event and any(f in modified_flags for f in event[1]):
|
||||
# Update modified path
|
||||
update_path(PurePosixPath(event[2]) / event[3], loop)
|
||||
|
||||
del i # Free the inotify object
|
||||
|
||||
|
||||
def watcher_poll(loop):
|
||||
"""Polling version of the watcher thread."""
|
||||
while not quit.is_set():
|
||||
t0 = time.perf_counter()
|
||||
update_root(loop)
|
||||
update_space(loop)
|
||||
dur = time.perf_counter() - t0
|
||||
if dur > 1.0:
|
||||
logger.debug(f"Reading the full file list took {dur:.1f}s")
|
||||
quit.wait(0.1 + 8 * dur)
|
||||
|
||||
async def broadcast(msg):
|
||||
for queue in pubsub.values():
|
||||
await queue.put_nowait(msg)
|
||||
|
||||
async def start(app, loop):
|
||||
global rootpath
|
||||
config.load_config()
|
||||
app.ctx.watcher = threading.Thread(target=watcher_thread, args=[loop])
|
||||
rootpath = config.config.path
|
||||
use_inotify = sys.platform == "linux"
|
||||
app.ctx.watcher = threading.Thread(
|
||||
target=watcher_inotify if use_inotify else watcher_poll,
|
||||
args=[loop],
|
||||
# Descriptive name for system monitoring
|
||||
name=f"cista-watcher {rootpath}",
|
||||
)
|
||||
app.ctx.watcher.start()
|
||||
|
||||
|
||||
async def stop(app, loop):
|
||||
global quit
|
||||
quit = True
|
||||
quit.set()
|
||||
app.ctx.watcher.join()
|
||||
|
||||
@@ -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>
|
||||
BIN
docs/cista.webp
Normal file
|
After Width: | Height: | Size: 363 KiB |
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/
|
||||
@@ -1,40 +1,46 @@
|
||||
# cista-front
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||
|
||||
1. Disable the built-in TypeScript Extension
|
||||
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
# Cista Vue Frontend
|
||||
|
||||
The frontend is a Single-Page App implemented with Vue 3. Development uses the Vite server together with the main Python backend, but in production the latter also serves the prebuilt frontend files.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||
|
||||
1. Disable the built-in TypeScript Extension
|
||||
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
## Hot-Reload for Development
|
||||
|
||||
### Run the backend
|
||||
|
||||
```fish
|
||||
hatch shell
|
||||
cista --dev -l :8000
|
||||
```
|
||||
|
||||
### And the Vite server (in another terminal)
|
||||
|
||||
```fish
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Browse to Vite, which will proxy API requests to port 8000. Both servers live reload changes.
|
||||
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
This is also called by `hatch build` during Python packaging:
|
||||
|
||||
```fish
|
||||
npm run build
|
||||
```
|
||||
|
||||
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: /
|
||||
145
frontend/src/App.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<LoginModal />
|
||||
<header>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
||||
<HeaderSelected :path="path.pathList" />
|
||||
</HeaderMain>
|
||||
<BreadCrumb :path="path.pathList" primary />
|
||||
</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'
|
||||
import type { SortOrder } from './utils/docsort'
|
||||
|
||||
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 input = (event.target as HTMLElement).tagName === 'INPUT'
|
||||
const keyup = event.type === 'keyup'
|
||||
if (event.repeat) {
|
||||
if (
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
(c && event.code === 'Space')
|
||||
) {
|
||||
if (!input) event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
//console.log("key pressed", event)
|
||||
/// Long if-else machina for all keys we handle here
|
||||
let arrow = ''
|
||||
if (event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
|
||||
// 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()
|
||||
}
|
||||
// Search also on / (UNIX style)
|
||||
else if (keyup && !input && event.key === '/') {
|
||||
headerMain.value!.toggleSearchInput()
|
||||
}
|
||||
// Globally close search, clear errors on Escape
|
||||
else if (keyup && event.key === 'Escape') {
|
||||
store.error = ''
|
||||
headerMain.value!.closeSearch(event)
|
||||
store.focusBreadcrumb()
|
||||
}
|
||||
else if (!input && keyup && event.key === 'Backspace') {
|
||||
Router.back()
|
||||
}
|
||||
// Select all (toggle); keydown to precede and prevent builtin
|
||||
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
|
||||
fileExplorer.toggleSelectAll()
|
||||
}
|
||||
// G toggles Gallery
|
||||
else if (!input && keyup && event.key === 'g') {
|
||||
store.prefs.gallery = !store.prefs.gallery
|
||||
}
|
||||
// Keys Backquote-1-2-3 to sort columns
|
||||
else if (
|
||||
!input &&
|
||||
keyup &&
|
||||
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3')
|
||||
) {
|
||||
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
|
||||
}
|
||||
// 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
|
||||
/// We are handling this!
|
||||
event.preventDefault()
|
||||
if (timer) {
|
||||
clearTimeout(timer) // Good for either timeout or interval
|
||||
timer = null
|
||||
}
|
||||
let f: any
|
||||
switch (arrow) {
|
||||
case 'up': f = () => fileExplorer.up(event); break
|
||||
case 'down': f = () => fileExplorer.down(event); break
|
||||
case 'left': f = () => fileExplorer.left(event); break
|
||||
case 'right': f = () => fileExplorer.right(event); break
|
||||
}
|
||||
if (f && !keyup) {
|
||||
// Initial move, then t0 delay until repeats at tr intervals
|
||||
const t0 = 200, tr = event.altKey ? 20 : 100
|
||||
f()
|
||||
timer = setTimeout(() => { timer = setInterval(f, 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>
|
||||
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 |
263
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,263 @@
|
||||
@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: 4rem;
|
||||
}
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@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-height: 2rem;
|
||||
}
|
||||
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;
|
||||
justify-content: space-between;
|
||||
}
|
||||
header .headermain { order: 1; }
|
||||
header .breadcrumb { align-self: stretch; }
|
||||
header .action-button {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
:root {
|
||||
--primary-color: black;
|
||||
--primary-background: none;
|
||||
--header-background: none;
|
||||
--header-color: black;
|
||||
}
|
||||
nav,
|
||||
.menu,
|
||||
.rename-button {
|
||||
display: none !important;
|
||||
}
|
||||
.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 {
|
||||
font-size: 1rem !important;
|
||||
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;
|
||||
}
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
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 |