Compare commits
88 Commits
37167a41a6
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091d57dba7 | ||
|
|
69a897cfec | ||
|
|
33db2c01b4 | ||
|
|
26addb2f7b | ||
|
|
ce6d60e588 | ||
| 073f1a8707 | |||
|
|
f38bb4bab9 | ||
|
|
26f9bef087 | ||
|
|
634dabe52d | ||
|
|
a383358369 | ||
|
|
369dc3ecaf | ||
|
|
0cf9c254e5 | ||
|
|
58b9dd3dd4 | ||
|
|
0965a56204 | ||
|
|
52beedcef0 | ||
|
|
8592d462f2 | ||
|
|
b2a24fca57 | ||
|
|
7cc7e32c33 | ||
|
|
fa98cb9177 | ||
|
|
b3ab09a614 | ||
|
|
a49dd2f111 | ||
|
|
dbb06e111c | ||
|
|
667e31aa08 | ||
|
|
007f021d6f | ||
|
|
b2188eee68 | ||
|
|
7311ffdff1 | ||
|
|
102a970174 | ||
|
|
a9d713dbd0 | ||
|
|
434e55f303 | ||
|
|
be9772c90e | ||
|
|
bb2448dc24 | ||
|
|
115f3e20d0 | ||
|
|
a366a0bcc6 | ||
|
|
2ff0f78759 | ||
|
|
9a2d6e8065 | ||
|
|
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 |
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
|||||||
.*
|
.*
|
||||||
|
*.lock
|
||||||
!.gitignore
|
!.gitignore
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
/cista/_version.py
|
/cista/_version.py
|
||||||
|
/cista/wwwroot/*
|
||||||
/dist
|
/dist
|
||||||
|
|||||||
132
README.md
@@ -1,25 +1,123 @@
|
|||||||
# Web File Storage
|
# Cista Web Storage
|
||||||
|
|
||||||
Run directly from repository with Hatch (or use pip install as usual):
|
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
|
||||||
```sh
|
|
||||||
hatch run cista -l :3000 /path/to/files
|
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**.
|
||||||
|
|
||||||
|
**Built-in document and media previews** let you quickly view files without downloading them. Cista shows PDF and other documents, video and image thumbnails, with **HDR10 support** video previews and image formats, including HEIC and AVIF. It also has a player for music and video files.
|
||||||
|
|
||||||
|
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
|
||||||
|
### Running the Server
|
||||||
|
|
||||||
|
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
|
||||||
|
|
||||||
|
Create an account: (otherwise the server is public for all)
|
||||||
|
```fish
|
||||||
|
uvx cista --user yourname --privileged
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
Serve your files at http://localhost:8000:
|
||||||
|
```fish
|
||||||
Create your user account:
|
uvx cista -l :8000 /path/to/files
|
||||||
```sh
|
|
||||||
hatch run cista --user admin --privileged
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build frontend
|
Alternatively, you can install with `pip` or `uv pip`. This enables using the `cista` command directly without `uvx` or `uv run`.
|
||||||
|
|
||||||
Prebuilt frontend is provided in repository but for any changes it will need to be manually rebuilt:
|
```fish
|
||||||
|
pip install cista --break-system-packages
|
||||||
```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`.
|
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
|
||||||
|
|
||||||
|
Most admins 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nxing or other proxy may be similarly used, or alternatively you can place cert and key in cista config dir and run `cista -l cista.example.com`
|
||||||
|
|
||||||
|
## 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 UV is installed system-wide or on this account. Only UV is required: this does not use git or bun/npm.
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/cista@.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Cista storage %i
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=storage
|
||||||
|
ExecStart=uvx 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
Make sure you have git, uv and bun (or npm) installed.
|
||||||
|
|
||||||
|
Backend (Python) – setup and run:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
git clone https://git.zi.fi/Vasanko/cista-storage.git
|
||||||
|
cd cista-storage
|
||||||
|
uv sync --dev
|
||||||
|
uv run cista --dev -l :8000 /path/to/files
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend (Vue/Vite) – run the dev server in another terminal:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
cd frontend
|
||||||
|
bun install
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Building the package for release (frontend + Python wheel/sdist):
|
||||||
|
|
||||||
|
```fish
|
||||||
|
uv build
|
||||||
|
```
|
||||||
|
|
||||||
|
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<link rel="icon" href="/favicon.ico">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Vite App</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
1458
cista-front/package-lock.json
generated
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "cista-front",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "run-p type-check \"build-only {@}\" --",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"build-only": "vite build",
|
|
||||||
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"pinia": "^2.1.7",
|
|
||||||
"vue": "^3.3.4",
|
|
||||||
"vue-router": "^4.2.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tsconfig/node18": "^18.2.2",
|
|
||||||
"@types/node": "^18.18.5",
|
|
||||||
"@vitejs/plugin-vue": "^4.4.0",
|
|
||||||
"@vue/tsconfig": "^0.4.0",
|
|
||||||
"npm-run-all2": "^6.1.1",
|
|
||||||
"typescript": "~5.2.0",
|
|
||||||
"vite": "^4.4.11",
|
|
||||||
"vue-tsc": "^1.8.19"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,241 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<title>Storage</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: sans-serif;
|
|
||||||
max-width: 100ch;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1em;
|
|
||||||
background-color: #333;
|
|
||||||
color: #eee;
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
text-align: right;
|
|
||||||
padding: .5em;
|
|
||||||
}
|
|
||||||
td:first-child {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<div>
|
|
||||||
<h2>Quick file upload</h2>
|
|
||||||
<p>Uses parallel WebSocket connections for increased bandwidth /api/upload</p>
|
|
||||||
<input type=file id=fileInput>
|
|
||||||
<progress id=progressBar value=0 max=1></progress>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2>Files</h2>
|
|
||||||
<ul id=file_list></ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let files = {}
|
|
||||||
let flatfiles = {}
|
|
||||||
|
|
||||||
function createWatchSocket() {
|
|
||||||
const wsurl = new URL("/api/watch", location.href.replace(/^http/, 'ws'))
|
|
||||||
const ws = new WebSocket(wsurl)
|
|
||||||
ws.onmessage = event => {
|
|
||||||
msg = JSON.parse(event.data)
|
|
||||||
if (msg.update) {
|
|
||||||
tree_update(msg.update)
|
|
||||||
file_list(files)
|
|
||||||
} else {
|
|
||||||
console.log("Unkonwn message from watch socket", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
createWatchSocket()
|
|
||||||
|
|
||||||
function tree_update(msg) {
|
|
||||||
console.log("Tree update", msg)
|
|
||||||
let node = files
|
|
||||||
for (const elem of msg) {
|
|
||||||
if (elem.deleted) {
|
|
||||||
const p = node.dir[elem.name].path
|
|
||||||
delete node.dir[elem.name]
|
|
||||||
delete flatfiles[p]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (elem.name !== undefined) node = node.dir[elem.name] ||= {}
|
|
||||||
if (elem.size !== undefined) node.size = elem.size
|
|
||||||
if (elem.mtime !== undefined) node.mtime = elem.mtime
|
|
||||||
if (elem.dir !== undefined) node.dir = elem.dir
|
|
||||||
}
|
|
||||||
// Update paths and flatfiles
|
|
||||||
files.path = "/"
|
|
||||||
const nodes = [files]
|
|
||||||
flatfiles = {}
|
|
||||||
while (node = nodes.pop()) {
|
|
||||||
flatfiles[node.path] = node
|
|
||||||
if (node.dir === undefined) continue
|
|
||||||
for (const name of Object.keys(node.dir)) {
|
|
||||||
const child = node.dir[name]
|
|
||||||
child.path = node.path + name + (child.dir === undefined ? "" : "/")
|
|
||||||
nodes.push(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var collator = new Intl.Collator(undefined, {numeric: true, sensitivity: 'base'});
|
|
||||||
|
|
||||||
const compare_path = (a, b) => collator.compare(a.path, b.path)
|
|
||||||
const compare_time = (a, b) => a.mtime > b.mtime
|
|
||||||
|
|
||||||
function file_list(files) {
|
|
||||||
const table = document.getElementById("file_list")
|
|
||||||
const sorted = Object.values(flatfiles).sort(compare_time)
|
|
||||||
table.innerHTML = ""
|
|
||||||
for (const f of sorted) {
|
|
||||||
const {path, size, mtime} = f
|
|
||||||
const tr = document.createElement("tr")
|
|
||||||
const name_td = document.createElement("td")
|
|
||||||
const size_td = document.createElement("td")
|
|
||||||
const mtime_td = document.createElement("td")
|
|
||||||
const a = document.createElement("a")
|
|
||||||
table.appendChild(tr)
|
|
||||||
tr.appendChild(name_td)
|
|
||||||
tr.appendChild(size_td)
|
|
||||||
tr.appendChild(mtime_td)
|
|
||||||
name_td.appendChild(a)
|
|
||||||
size_td.textContent = size
|
|
||||||
mtime_td.textContent = formatUnixDate(mtime)
|
|
||||||
a.textContent = path
|
|
||||||
a.href = `/files${path}`
|
|
||||||
/*a.onclick = event => {
|
|
||||||
if (window.showSaveFilePicker) {
|
|
||||||
event.preventDefault()
|
|
||||||
download_ws(name, size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.download = ""*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUnixDate(t) {
|
|
||||||
const date = new Date(t * 1000)
|
|
||||||
const now = new Date()
|
|
||||||
const diff = date - now
|
|
||||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
|
|
||||||
|
|
||||||
if (Math.abs(diff) <= 60000) {
|
|
||||||
return formatter.format(Math.round(diff / 1000), 'second')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(diff) <= 3600000) {
|
|
||||||
return formatter.format(Math.round(diff / 60000), 'minute')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(diff) <= 86400000) {
|
|
||||||
return formatter.format(Math.round(diff / 3600000), 'hour')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(diff) <= 604800000) {
|
|
||||||
return formatter.format(Math.round(diff / 86400000), 'day')
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toLocaleDateString()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function download_ws(name, size) {
|
|
||||||
const fh = await window.showSaveFilePicker({
|
|
||||||
suggestedName: name,
|
|
||||||
})
|
|
||||||
const writer = await fh.createWritable()
|
|
||||||
writer.truncate(size)
|
|
||||||
const wsurl = new URL("/api/download", location.href.replace(/^http/, 'ws'))
|
|
||||||
const ws = new WebSocket(wsurl)
|
|
||||||
let pos = 0
|
|
||||||
ws.onopen = () => {
|
|
||||||
console.log("Downloading over WebSocket", name, size)
|
|
||||||
ws.send(JSON.stringify({name, start: 0, end: size, size}))
|
|
||||||
}
|
|
||||||
ws.onmessage = event => {
|
|
||||||
if (typeof event.data === 'string') {
|
|
||||||
const msg = JSON.parse(event.data)
|
|
||||||
console.log("Download finished", msg)
|
|
||||||
ws.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.log("Received chunk", name, pos, pos + event.data.size)
|
|
||||||
pos += event.data.size
|
|
||||||
writer.write(event.data)
|
|
||||||
}
|
|
||||||
ws.onclose = () => {
|
|
||||||
if (pos < size) {
|
|
||||||
console.log("Download aborted", name, pos)
|
|
||||||
writer.truncate(pos)
|
|
||||||
}
|
|
||||||
writer.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileInput = document.getElementById("fileInput")
|
|
||||||
const progress = document.getElementById("progressBar")
|
|
||||||
const numConnections = 2
|
|
||||||
const chunkSize = 1<<20
|
|
||||||
const wsConnections = new Set()
|
|
||||||
|
|
||||||
//for (let i = 0; i < numConnections; i++) createUploadWS()
|
|
||||||
|
|
||||||
function createUploadWS() {
|
|
||||||
const wsurl = new URL("/api/upload", location.href.replace(/^http/, 'ws'))
|
|
||||||
const ws = new WebSocket(wsurl)
|
|
||||||
ws.binaryType = 'arraybuffer'
|
|
||||||
ws.onopen = () => {
|
|
||||||
wsConnections.add(ws)
|
|
||||||
console.log("Upload socket connected")
|
|
||||||
}
|
|
||||||
ws.onmessage = event => {
|
|
||||||
msg = JSON.parse(event.data)
|
|
||||||
if (msg.written) progress.value += +msg.written
|
|
||||||
else console.log(`Error: ${msg.error}`)
|
|
||||||
}
|
|
||||||
ws.onclose = () => {
|
|
||||||
wsConnections.delete(ws)
|
|
||||||
console.log("Upload socket disconnected, reconnecting...")
|
|
||||||
setTimeout(createUploadWS, 1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(file, start, end) {
|
|
||||||
const reader = new FileReader()
|
|
||||||
const load = new Promise(resolve => reader.onload = resolve)
|
|
||||||
reader.readAsArrayBuffer(file.slice(start, end))
|
|
||||||
const event = await load
|
|
||||||
return event.target.result
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendChunk(file, start, end, ws) {
|
|
||||||
const chunk = await load(file, start, end)
|
|
||||||
ws.send(JSON.stringify({
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
start: start,
|
|
||||||
end: end
|
|
||||||
}))
|
|
||||||
ws.send(chunk)
|
|
||||||
}
|
|
||||||
|
|
||||||
fileInput.addEventListener("change", async function() {
|
|
||||||
const file = this.files[0]
|
|
||||||
const numChunks = Math.ceil(file.size / chunkSize)
|
|
||||||
progress.value = 0
|
|
||||||
progress.max = file.size
|
|
||||||
|
|
||||||
console.log(wsConnections)
|
|
||||||
for (let i = 0; i < numChunks; i++) {
|
|
||||||
const ws = Array.from(wsConnections)[i % wsConnections.size]
|
|
||||||
const start = i * chunkSize
|
|
||||||
const end = Math.min(file.size, start + chunkSize)
|
|
||||||
const res = await sendChunk(file, start, end, ws)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
|
||||||
import HelloWorld from './components/HelloWorld.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<header>
|
|
||||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
|
||||||
|
|
||||||
<div class="wrapper">
|
|
||||||
<HelloWorld msg="You did it!" />
|
|
||||||
|
|
||||||
<nav>
|
|
||||||
<RouterLink to="/">Home</RouterLink>
|
|
||||||
<RouterLink to="/about">About</RouterLink>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<RouterView />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
header {
|
|
||||||
line-height: 1.5;
|
|
||||||
max-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.router-link-exact-active {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.router-link-exact-active:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0 1rem;
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a:first-of-type {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
padding-right: calc(var(--section-gap) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
margin: 0 2rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header .wrapper {
|
|
||||||
display: flex;
|
|
||||||
place-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
text-align: left;
|
|
||||||
margin-left: -1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
padding: 1rem 0;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/* color palette from <https://github.com/vuejs/theme> */
|
|
||||||
:root {
|
|
||||||
--vt-c-white: #ffffff;
|
|
||||||
--vt-c-white-soft: #f8f8f8;
|
|
||||||
--vt-c-white-mute: #f2f2f2;
|
|
||||||
|
|
||||||
--vt-c-black: #181818;
|
|
||||||
--vt-c-black-soft: #222222;
|
|
||||||
--vt-c-black-mute: #282828;
|
|
||||||
|
|
||||||
--vt-c-indigo: #2c3e50;
|
|
||||||
|
|
||||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
|
||||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
|
||||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
|
||||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
|
||||||
|
|
||||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
|
||||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
|
||||||
--vt-c-text-dark-1: var(--vt-c-white);
|
|
||||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* semantic color variables for this project */
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-white);
|
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-light-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-light-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-light-1);
|
|
||||||
--color-text: var(--vt-c-text-light-1);
|
|
||||||
|
|
||||||
--section-gap: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--color-background: var(--vt-c-black);
|
|
||||||
--color-background-soft: var(--vt-c-black-soft);
|
|
||||||
--color-background-mute: var(--vt-c-black-mute);
|
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-dark-2);
|
|
||||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
min-height: 100vh;
|
|
||||||
color: var(--color-text);
|
|
||||||
background: var(--color-background);
|
|
||||||
transition:
|
|
||||||
color 0.5s,
|
|
||||||
background-color 0.5s;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-family:
|
|
||||||
Inter,
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
Oxygen,
|
|
||||||
Ubuntu,
|
|
||||||
Cantarell,
|
|
||||||
'Fira Sans',
|
|
||||||
'Droid Sans',
|
|
||||||
'Helvetica Neue',
|
|
||||||
sans-serif;
|
|
||||||
font-size: 15px;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 276 B |
@@ -1,35 +0,0 @@
|
|||||||
@import './base.css';
|
|
||||||
|
|
||||||
#app {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
a,
|
|
||||||
.green {
|
|
||||||
text-decoration: none;
|
|
||||||
color: hsla(160, 100%, 37%, 1);
|
|
||||||
transition: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: hover) {
|
|
||||||
a:hover {
|
|
||||||
background-color: hsla(160, 100%, 37%, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
padding: 0 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
msg: string
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="greetings">
|
|
||||||
<h1 class="green">{{ msg }}</h1>
|
|
||||||
<h3>
|
|
||||||
You’ve successfully created a project with
|
|
||||||
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
|
|
||||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
h1 {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 2.6rem;
|
|
||||||
position: relative;
|
|
||||||
top: -10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.greetings h1,
|
|
||||||
.greetings h3 {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.greetings h1,
|
|
||||||
.greetings h3 {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import WelcomeItem from './WelcomeItem.vue'
|
|
||||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
|
||||||
import ToolingIcon from './icons/IconTooling.vue'
|
|
||||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
|
||||||
import CommunityIcon from './icons/IconCommunity.vue'
|
|
||||||
import SupportIcon from './icons/IconSupport.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<DocumentationIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Documentation</template>
|
|
||||||
|
|
||||||
Vue’s
|
|
||||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
|
||||||
provides you with all information you need to get started.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<ToolingIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Tooling</template>
|
|
||||||
|
|
||||||
This project is served and bundled with
|
|
||||||
<a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
|
||||||
recommended IDE setup is
|
|
||||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
|
|
||||||
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
|
|
||||||
you need to test your components and web pages, check out
|
|
||||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
|
|
||||||
<a href="https://on.cypress.io/component" target="_blank" rel="noopener"
|
|
||||||
>Cypress Component Testing</a
|
|
||||||
>.
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
More instructions are available in <code>README.md</code>.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<EcosystemIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Ecosystem</template>
|
|
||||||
|
|
||||||
Get official tools and libraries for your project:
|
|
||||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
|
||||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
|
||||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
|
||||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
|
||||||
you need more resources, we suggest paying
|
|
||||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
|
||||||
a visit.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<CommunityIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Community</template>
|
|
||||||
|
|
||||||
Got stuck? Ask your question on
|
|
||||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
|
|
||||||
Discord server, or
|
|
||||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
|
||||||
>StackOverflow</a
|
|
||||||
>. You should also subscribe to
|
|
||||||
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
|
|
||||||
the official
|
|
||||||
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
|
||||||
twitter account for latest news in the Vue world.
|
|
||||||
</WelcomeItem>
|
|
||||||
|
|
||||||
<WelcomeItem>
|
|
||||||
<template #icon>
|
|
||||||
<SupportIcon />
|
|
||||||
</template>
|
|
||||||
<template #heading>Support Vue</template>
|
|
||||||
|
|
||||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
|
||||||
us by
|
|
||||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
|
||||||
</WelcomeItem>
|
|
||||||
</template>
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="item">
|
|
||||||
<i>
|
|
||||||
<slot name="icon"></slot>
|
|
||||||
</i>
|
|
||||||
<div class="details">
|
|
||||||
<h3>
|
|
||||||
<slot name="heading"></slot>
|
|
||||||
</h3>
|
|
||||||
<slot></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.item {
|
|
||||||
margin-top: 2rem;
|
|
||||||
display: flex;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.details {
|
|
||||||
flex: 1;
|
|
||||||
margin-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
place-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
color: var(--color-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.item {
|
|
||||||
margin-top: 0;
|
|
||||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
top: calc(50% - 25px);
|
|
||||||
left: -26px;
|
|
||||||
position: absolute;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: var(--color-background);
|
|
||||||
border-radius: 8px;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:before {
|
|
||||||
content: ' ';
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
bottom: calc(50% + 25px);
|
|
||||||
height: calc(50% - 25px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:after {
|
|
||||||
content: ' ';
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: calc(50% + 25px);
|
|
||||||
height: calc(50% - 25px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:first-of-type:before {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item:last-of-type:after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
|
||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
aria-hidden="true"
|
|
||||||
role="img"
|
|
||||||
class="iconify iconify--mdi"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
|
||||||
fill="currentColor"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import './assets/main.css'
|
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
|
|
||||||
import App from './App.vue'
|
|
||||||
import router from './router'
|
|
||||||
|
|
||||||
const app = createApp(App)
|
|
||||||
|
|
||||||
app.use(createPinia())
|
|
||||||
app.use(router)
|
|
||||||
|
|
||||||
app.mount('#app')
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
|
||||||
import HomeView from '../views/HomeView.vue'
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
name: 'home',
|
|
||||||
component: HomeView
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/about',
|
|
||||||
name: 'about',
|
|
||||||
// route level code-splitting
|
|
||||||
// this generates a separate chunk (About.[hash].js) for this route
|
|
||||||
// which is lazy-loaded when the route is visited.
|
|
||||||
component: () => import('../views/AboutView.vue')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { ref, computed } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
export const useCounterStore = defineStore('counter', () => {
|
|
||||||
const count = ref(0)
|
|
||||||
const doubleCount = computed(() => count.value * 2)
|
|
||||||
function increment() {
|
|
||||||
count.value++
|
|
||||||
}
|
|
||||||
|
|
||||||
return { count, doubleCount, increment }
|
|
||||||
})
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="about">
|
|
||||||
<h1>This is an about page</h1>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.about {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import TheWelcome from '../components/TheWelcome.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main>
|
|
||||||
<TheWelcome />
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
vue(),
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: "../cista/wwwroot",
|
|
||||||
emptyOutDir: true,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
2
cista/__init__.py
Executable file → Normal file
@@ -1 +1,3 @@
|
|||||||
from cista._version import __version__
|
from cista._version import __version__
|
||||||
|
|
||||||
|
__version__ # Public API
|
||||||
|
|||||||
107
cista/__main__.py
Executable file → Normal file
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -7,10 +8,26 @@ import cista
|
|||||||
from cista import app, config, droppy, serve, server80
|
from cista import app, config, droppy, serve, server80
|
||||||
from cista.util import pwgen
|
from cista.util import pwgen
|
||||||
|
|
||||||
del app, server80.app # Only import needed, for Sanic multiprocessing
|
del app, server80.app # Only import needed, for Sanic multiprocessing
|
||||||
|
|
||||||
doc = f"""Cista {cista.__version__} - A file storage for the web.
|
|
||||||
|
|
||||||
|
def create_banner():
|
||||||
|
"""Create a framed banner with the Cista version."""
|
||||||
|
title = f"Cista {cista.__version__}"
|
||||||
|
subtitle = "A file storage for the web"
|
||||||
|
width = max(len(title), len(subtitle)) + 4
|
||||||
|
|
||||||
|
return f"""\
|
||||||
|
╭{"─" * width}╮
|
||||||
|
│{title:^{width}}│
|
||||||
|
│{subtitle:^{width}}│
|
||||||
|
╰{"─" * width}╯
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
banner = create_banner()
|
||||||
|
|
||||||
|
doc = """\
|
||||||
Usage:
|
Usage:
|
||||||
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
||||||
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
||||||
@@ -34,6 +51,15 @@ User management:
|
|||||||
--password Reset password
|
--password Reset password
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
first_time_help = """\
|
||||||
|
No config file found! Get started with:
|
||||||
|
cista --user yourname --privileged # If you want user accounts
|
||||||
|
cista -l :8000 /path/to/files # Run the server on localhost:8000
|
||||||
|
|
||||||
|
See cista --help for other options!
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Dev mode doesn't catch exceptions
|
# Dev mode doesn't catch exceptions
|
||||||
if "--dev" in sys.argv:
|
if "--dev" in sys.argv:
|
||||||
@@ -42,10 +68,19 @@ def main():
|
|||||||
try:
|
try:
|
||||||
return _main()
|
return _main()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("Error:", e)
|
sys.stderr.write(f"Error: {e}\n")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def _main():
|
def _main():
|
||||||
|
# The banner printing differs by mode, and needs to be done before docopt() printing its messages
|
||||||
|
if any(arg in sys.argv for arg in ("--help", "-h")):
|
||||||
|
sys.stdout.write(banner)
|
||||||
|
elif "--version" in sys.argv:
|
||||||
|
sys.stdout.write(f"cista {cista.__version__}\n")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
sys.stderr.write(banner)
|
||||||
args = docopt(doc)
|
args = docopt(doc)
|
||||||
if args["--user"]:
|
if args["--user"]:
|
||||||
return _user(args)
|
return _user(args)
|
||||||
@@ -60,23 +95,33 @@ def _main():
|
|||||||
_confdir(args)
|
_confdir(args)
|
||||||
exists = config.conffile.exists()
|
exists = config.conffile.exists()
|
||||||
import_droppy = args["--import-droppy"]
|
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:
|
if not necessary_opts:
|
||||||
# Maybe run without arguments
|
# Maybe run without arguments
|
||||||
print(doc)
|
sys.stderr.write(first_time_help)
|
||||||
print("No config file found! Get started with:\n cista -l :8000 /path/to/files, or\n cista -l example.com --import-droppy # Uses Droppy files\n")
|
|
||||||
return 1
|
return 1
|
||||||
settings = {}
|
settings = {}
|
||||||
if import_droppy:
|
if import_droppy:
|
||||||
if exists:
|
if exists:
|
||||||
raise ValueError(f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}")
|
raise ValueError(
|
||||||
|
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}",
|
||||||
|
)
|
||||||
settings = droppy.readconf()
|
settings = droppy.readconf()
|
||||||
if path: settings["path"] = path
|
if path:
|
||||||
if listen: settings["listen"] = listen
|
settings["path"] = path
|
||||||
|
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)
|
operation = config.update_config(settings)
|
||||||
print(f"Config {operation}: {config.conffile}")
|
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
|
||||||
# Prepare to serve
|
# Prepare to serve
|
||||||
domain = unix = port = None
|
unix = None
|
||||||
url, _ = serve.parse_listen(config.config.listen)
|
url, _ = serve.parse_listen(config.config.listen)
|
||||||
if not config.config.path.is_dir():
|
if not config.config.path.is_dir():
|
||||||
raise ValueError(f"No such directory: {config.config.path}")
|
raise ValueError(f"No such directory: {config.config.path}")
|
||||||
@@ -84,9 +129,11 @@ def _main():
|
|||||||
dev = args["--dev"]
|
dev = args["--dev"]
|
||||||
if dev:
|
if dev:
|
||||||
extra += " (dev mode)"
|
extra += " (dev mode)"
|
||||||
print(f"Serving {config.config.path} at {url}{extra}")
|
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
|
||||||
# Run the server
|
# Run the server
|
||||||
serve.run(dev=dev)
|
serve.run(dev=dev)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _confdir(args):
|
def _confdir(args):
|
||||||
if args["-c"]:
|
if args["-c"]:
|
||||||
@@ -95,17 +142,31 @@ def _confdir(args):
|
|||||||
if confdir.exists() and not confdir.is_dir():
|
if confdir.exists() and not confdir.is_dir():
|
||||||
if confdir.name != config.conffile.name:
|
if confdir.name != config.conffile.name:
|
||||||
raise ValueError("Config path is not a directory")
|
raise ValueError("Config path is not a directory")
|
||||||
# Accidentally pointed to the cista.toml, use parent
|
# Accidentally pointed to the db.toml, use parent
|
||||||
confdir = confdir.parent
|
confdir = confdir.parent
|
||||||
config.conffile = config.conffile.with_parent(confdir)
|
os.environ["CISTA_HOME"] = confdir.as_posix()
|
||||||
|
config.init_confdir() # Uses environ if available
|
||||||
|
|
||||||
|
|
||||||
def _user(args):
|
def _user(args):
|
||||||
_confdir(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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sys.stderr.write(f"Config {operation}: {config.conffile}\n\n")
|
||||||
|
|
||||||
name = args["--user"]
|
name = args["--user"]
|
||||||
if not name or not name.isidentifier():
|
if not name or not name.isidentifier():
|
||||||
raise ValueError("Invalid username")
|
raise ValueError("Invalid username")
|
||||||
config.load_config()
|
|
||||||
u = config.config.users.get(name)
|
u = config.config.users.get(name)
|
||||||
info = f"User {name}" if u else f"New user {name}"
|
info = f"User {name}" if u else f"New user {name}"
|
||||||
changes = {}
|
changes = {}
|
||||||
@@ -117,11 +178,17 @@ def _user(args):
|
|||||||
info += " (admin)" if oldadmin else ""
|
info += " (admin)" if oldadmin else ""
|
||||||
if args["--password"] or not u:
|
if args["--password"] or not u:
|
||||||
changes["password"] = pw = pwgen.generate()
|
changes["password"] = pw = pwgen.generate()
|
||||||
info += f"\n Password: {pw}"
|
info += f"\n Password: {pw}\n"
|
||||||
res = config.update_user(args["--user"], changes)
|
res = config.update_user(name, changes)
|
||||||
print(info)
|
sys.stderr.write(f"{info}\n")
|
||||||
if res == "read":
|
if res == "read":
|
||||||
print(" No changes")
|
sys.stderr.write(" No changes\n")
|
||||||
|
|
||||||
|
if operation == "created":
|
||||||
|
sys.stderr.write(
|
||||||
|
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|||||||
91
cista/api.py
@@ -1,26 +1,30 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
|
from secrets import token_bytes
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from sanic import Blueprint
|
from sanic import Blueprint
|
||||||
|
|
||||||
from cista import watching
|
from cista import __version__, config, watching
|
||||||
from cista.fileio import FileServer
|
from cista.fileio import FileServer
|
||||||
from cista.protocol import ControlBase, FileRange, StatusMsg
|
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
||||||
from cista.util.apphelpers import asend, websocket_wrapper
|
from cista.util.apphelpers import asend, websocket_wrapper
|
||||||
|
|
||||||
bp = Blueprint("api", url_prefix="/api")
|
bp = Blueprint("api", url_prefix="/api")
|
||||||
fileserver = FileServer()
|
fileserver = FileServer()
|
||||||
|
|
||||||
|
|
||||||
@bp.before_server_start
|
@bp.before_server_start
|
||||||
async def start_fileserver(app, _):
|
async def start_fileserver(app, _):
|
||||||
await fileserver.start()
|
await fileserver.start()
|
||||||
|
|
||||||
|
|
||||||
@bp.after_server_stop
|
@bp.after_server_stop
|
||||||
async def stop_fileserver(app, _):
|
async def stop_fileserver(app, _):
|
||||||
await fileserver.stop()
|
await fileserver.stop()
|
||||||
|
|
||||||
@bp.websocket('upload')
|
|
||||||
|
@bp.websocket("upload")
|
||||||
@websocket_wrapper
|
@websocket_wrapper
|
||||||
async def upload(req, ws):
|
async def upload(req, ws):
|
||||||
alink = fileserver.alink
|
alink = fileserver.alink
|
||||||
@@ -28,22 +32,32 @@ async def upload(req, ws):
|
|||||||
req = None
|
req = None
|
||||||
text = await ws.recv()
|
text = await ws.recv()
|
||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
raise ValueError(f"Expected JSON control, got binary len(data) = {len(text)}")
|
raise ValueError(
|
||||||
|
f"Expected JSON control, got binary len(data) = {len(text)}",
|
||||||
|
)
|
||||||
req = msgspec.json.decode(text, type=FileRange)
|
req = msgspec.json.decode(text, type=FileRange)
|
||||||
pos = req.start
|
pos = req.start
|
||||||
data = None
|
while True:
|
||||||
while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes):
|
data = await ws.recv()
|
||||||
|
if not isinstance(data, bytes):
|
||||||
|
break
|
||||||
|
if len(data) > req.end - pos:
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected up to {req.end - pos} bytes, got {len(data)} bytes"
|
||||||
|
)
|
||||||
sentsize = await alink(("upload", req.name, pos, data, req.size))
|
sentsize = await alink(("upload", req.name, pos, data, req.size))
|
||||||
pos += typing.cast(int, sentsize)
|
pos += typing.cast(int, sentsize)
|
||||||
|
if pos >= req.end:
|
||||||
|
break
|
||||||
if pos != req.end:
|
if pos != req.end:
|
||||||
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
||||||
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
|
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
|
||||||
# Report success
|
# Report success
|
||||||
res = StatusMsg(status="ack", req=req)
|
res = StatusMsg(status="ack", req=req)
|
||||||
await asend(ws, res)
|
await asend(ws, res)
|
||||||
#await ws.drain()
|
|
||||||
|
|
||||||
@bp.websocket('download')
|
|
||||||
|
@bp.websocket("download")
|
||||||
@websocket_wrapper
|
@websocket_wrapper
|
||||||
async def download(req, ws):
|
async def download(req, ws):
|
||||||
alink = fileserver.alink
|
alink = fileserver.alink
|
||||||
@@ -51,37 +65,74 @@ async def download(req, ws):
|
|||||||
req = None
|
req = None
|
||||||
text = await ws.recv()
|
text = await ws.recv()
|
||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
raise ValueError(f"Expected JSON control, got binary len(data) = {len(text)}")
|
raise ValueError(
|
||||||
|
f"Expected JSON control, got binary len(data) = {len(text)}",
|
||||||
|
)
|
||||||
req = msgspec.json.decode(text, type=FileRange)
|
req = msgspec.json.decode(text, type=FileRange)
|
||||||
pos = req.start
|
pos = req.start
|
||||||
while pos < req.end:
|
while pos < req.end:
|
||||||
end = min(req.end, pos + (1<<20))
|
end = min(req.end, pos + (1 << 20))
|
||||||
data = typing.cast(bytes, await alink(("download", req.name, pos, end)))
|
data = typing.cast(bytes, await alink(("download", req.name, pos, end)))
|
||||||
await asend(ws, data)
|
await asend(ws, data)
|
||||||
pos += len(data)
|
pos += len(data)
|
||||||
# Report success
|
# Report success
|
||||||
res = StatusMsg(status="ack", req=req)
|
res = StatusMsg(status="ack", req=req)
|
||||||
await asend(ws, res)
|
await asend(ws, res)
|
||||||
#await ws.drain()
|
|
||||||
|
|
||||||
@bp.websocket("control")
|
@bp.websocket("control")
|
||||||
@websocket_wrapper
|
@websocket_wrapper
|
||||||
async def control(req, ws):
|
async def control(req, ws):
|
||||||
cmd = msgspec.json.decode(await ws.recv(), type=ControlBase)
|
while True:
|
||||||
await asyncio.to_thread(cmd)
|
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
|
||||||
await asend(ws, StatusMsg(status="ack", req=cmd))
|
await asyncio.to_thread(cmd)
|
||||||
|
await asend(ws, StatusMsg(status="ack", req=cmd))
|
||||||
|
|
||||||
|
|
||||||
@bp.websocket("watch")
|
@bp.websocket("watch")
|
||||||
@websocket_wrapper
|
@websocket_wrapper
|
||||||
async def watch(req, ws):
|
async def watch(req, ws):
|
||||||
|
await ws.send(
|
||||||
|
msgspec.json.encode(
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"name": config.config.name or config.config.path.name,
|
||||||
|
"version": __version__,
|
||||||
|
"public": config.config.public,
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"username": req.ctx.username,
|
||||||
|
"privileged": req.ctx.user.privileged,
|
||||||
|
}
|
||||||
|
if req.ctx.user
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
).decode()
|
||||||
|
)
|
||||||
|
uuid = token_bytes(16)
|
||||||
try:
|
try:
|
||||||
with watching.tree_lock:
|
q, space, root = await asyncio.get_event_loop().run_in_executor(
|
||||||
q = watching.pubsub[ws] = asyncio.Queue()
|
req.app.ctx.threadexec, subscribe, uuid, ws
|
||||||
# Init with disk usage and full tree
|
)
|
||||||
await ws.send(watching.format_du())
|
await ws.send(space)
|
||||||
await ws.send(watching.format_tree())
|
await ws.send(root)
|
||||||
# Send updates
|
# Send updates
|
||||||
while True:
|
while True:
|
||||||
await ws.send(await q.get())
|
await ws.send(await q.get())
|
||||||
|
except RuntimeError as e:
|
||||||
|
if str(e) == "cannot schedule new futures after shutdown":
|
||||||
|
return # Server shutting down, drop the WebSocket
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
del watching.pubsub[ws]
|
watching.pubsub.pop(uuid, None) # Remove whether it got added yet or not
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|||||||
270
cista/app.py
Executable file → Normal file
@@ -1,36 +1,68 @@
|
|||||||
|
import asyncio
|
||||||
|
import datetime
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from importlib.resources import files
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from multiprocessing import cpu_count
|
||||||
|
from pathlib import Path, PurePath, PurePosixPath
|
||||||
|
from stat import S_IFDIR, S_IFREG
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
from html5tagger import E
|
import brotli
|
||||||
from sanic import Blueprint, Sanic, raw
|
import sanic.helpers
|
||||||
|
from blake3 import blake3
|
||||||
|
from sanic import Blueprint, Sanic, empty, raw, redirect
|
||||||
from sanic.exceptions import Forbidden, NotFound
|
from sanic.exceptions import Forbidden, NotFound
|
||||||
|
from sanic.log import logger
|
||||||
|
from setproctitle import setproctitle
|
||||||
|
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.api import bp
|
||||||
from cista.util import filename
|
|
||||||
from cista.util.apphelpers import handle_sanic_exception
|
from cista.util.apphelpers import handle_sanic_exception
|
||||||
|
|
||||||
|
# Workaround until Sanic PR #2824 is merged
|
||||||
|
sanic.helpers._ENTITY_HEADERS = frozenset()
|
||||||
|
|
||||||
app = Sanic("cista", strict_slashes=True)
|
app = Sanic("cista", strict_slashes=True)
|
||||||
app.blueprint(auth.bp)
|
app.blueprint(auth.bp)
|
||||||
|
app.blueprint(preview.bp)
|
||||||
app.blueprint(bp)
|
app.blueprint(bp)
|
||||||
app.exception(Exception)(handle_sanic_exception)
|
app.exception(Exception)(handle_sanic_exception)
|
||||||
|
|
||||||
|
|
||||||
|
setproctitle("cista-main")
|
||||||
|
|
||||||
|
|
||||||
@app.before_server_start
|
@app.before_server_start
|
||||||
async def main_start(app, loop):
|
async def main_start(app, loop):
|
||||||
config.load_config()
|
config.load_config()
|
||||||
await watching.start(app, loop)
|
setproctitle(f"cista {config.config.path.name}")
|
||||||
|
workers = max(2, min(8, cpu_count()))
|
||||||
|
app.ctx.threadexec = ThreadPoolExecutor(
|
||||||
|
max_workers=workers, thread_name_prefix="cista-ioworker"
|
||||||
|
)
|
||||||
|
watching.start(app, loop)
|
||||||
|
|
||||||
@app.after_server_stop
|
|
||||||
|
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
||||||
|
@app.before_server_stop
|
||||||
async def main_stop(app, loop):
|
async def main_stop(app, loop):
|
||||||
await watching.stop(app, loop)
|
quit.set()
|
||||||
|
watching.stop(app)
|
||||||
|
app.ctx.threadexec.shutdown()
|
||||||
|
logger.debug("Cista worker threads all finished")
|
||||||
|
|
||||||
|
|
||||||
@app.on_request
|
@app.on_request
|
||||||
async def use_session(req):
|
async def use_session(req):
|
||||||
req.ctx.session = session.get(req)
|
req.ctx.session = session.get(req)
|
||||||
try:
|
try:
|
||||||
req.ctx.user = config.config.users[req.ctx.session["username"]] # type: ignore
|
req.ctx.username = req.ctx.session["username"] # type: ignore
|
||||||
|
req.ctx.user = config.config.users[req.ctx.username]
|
||||||
except (AttributeError, KeyError, TypeError):
|
except (AttributeError, KeyError, TypeError):
|
||||||
|
req.ctx.username = None
|
||||||
req.ctx.user = None
|
req.ctx.user = None
|
||||||
# CSRF protection
|
# CSRF protection
|
||||||
if req.method == "GET" and req.headers.upgrade != "websocket":
|
if req.method == "GET" and req.headers.upgrade != "websocket":
|
||||||
@@ -41,20 +73,222 @@ async def use_session(req):
|
|||||||
if origin and origin.split("//", 1)[1] != req.host:
|
if origin and origin.split("//", 1)[1] != req.host:
|
||||||
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
||||||
|
|
||||||
|
|
||||||
@app.before_server_start
|
@app.before_server_start
|
||||||
def http_fileserver(app, _):
|
def http_fileserver(app, _):
|
||||||
bp = Blueprint("fileserver")
|
bp = Blueprint("fileserver")
|
||||||
bp.on_request(auth.verify)
|
bp.on_request(auth.verify)
|
||||||
bp.static("/files/", config.config.path, use_content_range=True, stream_large_files=True, directory_view=True)
|
bp.static(
|
||||||
|
"/files/",
|
||||||
|
config.config.path,
|
||||||
|
use_content_range=True,
|
||||||
|
stream_large_files=True,
|
||||||
|
directory_view=True,
|
||||||
|
)
|
||||||
app.blueprint(bp)
|
app.blueprint(bp)
|
||||||
|
|
||||||
@app.get("/<path:path>", static=True)
|
|
||||||
|
www = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_wwwroot(www):
|
||||||
|
wwwnew = {}
|
||||||
|
base = Path(__file__).with_name("wwwroot")
|
||||||
|
paths = [PurePath()]
|
||||||
|
while paths:
|
||||||
|
path = paths.pop(0)
|
||||||
|
current = base / path
|
||||||
|
for p in current.iterdir():
|
||||||
|
if p.is_dir():
|
||||||
|
paths.append(p.relative_to(base))
|
||||||
|
continue
|
||||||
|
name = p.relative_to(base).as_posix()
|
||||||
|
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||||
|
mtime = p.stat().st_mtime
|
||||||
|
data = p.read_bytes()
|
||||||
|
etag = blake3(data).hexdigest(length=8)
|
||||||
|
if name == "index.html":
|
||||||
|
name = ""
|
||||||
|
# Use old data if not changed
|
||||||
|
if name in www and www[name][2]["etag"] == etag:
|
||||||
|
wwwnew[name] = www[name]
|
||||||
|
continue
|
||||||
|
# Add charset definition
|
||||||
|
if mime.startswith("text/"):
|
||||||
|
mime = f"{mime}; charset=UTF-8"
|
||||||
|
# Asset files names will change whenever the content changes
|
||||||
|
cached = name.startswith("assets/")
|
||||||
|
headers = {
|
||||||
|
"etag": etag,
|
||||||
|
"last-modified": format_date_time(mtime),
|
||||||
|
"cache-control": "max-age=31536000, immutable"
|
||||||
|
if cached
|
||||||
|
else "no-cache",
|
||||||
|
"content-type": mime,
|
||||||
|
}
|
||||||
|
# Precompress with Brotli
|
||||||
|
br = brotli.compress(data)
|
||||||
|
if len(br) >= len(data):
|
||||||
|
br = False
|
||||||
|
wwwnew[name] = data, br, headers
|
||||||
|
if not wwwnew:
|
||||||
|
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=""):
|
async def wwwroot(req, path=""):
|
||||||
"""Frontend files only"""
|
"""Frontend files only"""
|
||||||
name = filename.sanitize(unquote(path)) if path else "index.html"
|
name = unquote(path)
|
||||||
try:
|
if name not in www:
|
||||||
index = files("cista").joinpath("wwwroot", name).read_bytes()
|
raise NotFound(f"File not found: /{path}", extra={"name": name})
|
||||||
except OSError as e:
|
data, br, headers = www[name]
|
||||||
raise NotFound(f"File not found: /{path}", extra={"name": name, "exception": repr(e)})
|
if req.headers.if_none_match == headers["etag"]:
|
||||||
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
# The client has it cached, respond 304 Not Modified
|
||||||
return raw(index, content_type=mime)
|
return empty(304, headers=headers)
|
||||||
|
# Brotli compressed?
|
||||||
|
if br and "br" in req.headers.accept_encoding.split(", "):
|
||||||
|
headers = {**headers, "content-encoding": "br"}
|
||||||
|
data = br
|
||||||
|
return raw(data, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/favicon.ico", methods=["GET", "HEAD"])
|
||||||
|
async def favicon(req):
|
||||||
|
# Browsers keep asking for it when viewing files (not HTML with icon link)
|
||||||
|
return redirect("/assets/logo-97d1d7eb.svg", status=308)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
90
cista/auth.py
Executable file → Normal file
@@ -12,10 +12,12 @@ from sanic.exceptions import BadRequest, Forbidden, Unauthorized
|
|||||||
from cista import config, session
|
from cista import config, session
|
||||||
|
|
||||||
_argon = argon2.PasswordHasher()
|
_argon = argon2.PasswordHasher()
|
||||||
_droppyhash = re.compile(r'^([a-f0-9]{64})\$([a-f0-9]{8})$')
|
_droppyhash = re.compile(r"^([a-f0-9]{64})\$([a-f0-9]{8})$")
|
||||||
|
|
||||||
|
|
||||||
def _pwnorm(password):
|
def _pwnorm(password):
|
||||||
return normalize('NFC', password).strip().encode()
|
return normalize("NFC", password).strip().encode()
|
||||||
|
|
||||||
|
|
||||||
def login(username: str, password: str):
|
def login(username: str, password: str):
|
||||||
un = _pwnorm(username)
|
un = _pwnorm(username)
|
||||||
@@ -23,7 +25,7 @@ def login(username: str, password: str):
|
|||||||
try:
|
try:
|
||||||
u = config.config.users[un.decode()]
|
u = config.config.users[un.decode()]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise ValueError("Invalid username")
|
raise ValueError("Invalid username") from None
|
||||||
# Verify password
|
# Verify password
|
||||||
need_rehash = False
|
need_rehash = False
|
||||||
if not u.hash:
|
if not u.hash:
|
||||||
@@ -39,7 +41,7 @@ def login(username: str, password: str):
|
|||||||
try:
|
try:
|
||||||
_argon.verify(u.hash, pw)
|
_argon.verify(u.hash, pw)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise ValueError("Invalid password")
|
raise ValueError("Invalid password") from None
|
||||||
if _argon.check_needs_rehash(u.hash):
|
if _argon.check_needs_rehash(u.hash):
|
||||||
need_rehash = True
|
need_rehash = True
|
||||||
# Login successful
|
# Login successful
|
||||||
@@ -49,33 +51,51 @@ def login(username: str, password: str):
|
|||||||
u.lastSeen = now
|
u.lastSeen = now
|
||||||
return u
|
return u
|
||||||
|
|
||||||
|
|
||||||
def set_password(user: config.User, password: str):
|
def set_password(user: config.User, password: str):
|
||||||
user.hash = _argon.hash(_pwnorm(password))
|
user.hash = _argon.hash(_pwnorm(password))
|
||||||
|
|
||||||
|
|
||||||
class LoginResponse(msgspec.Struct):
|
class LoginResponse(msgspec.Struct):
|
||||||
user: str = ""
|
user: str = ""
|
||||||
privileged: bool = False
|
privileged: bool = False
|
||||||
error: str = ""
|
error: str = ""
|
||||||
|
|
||||||
def verify(request, privileged=False):
|
|
||||||
|
def verify(request, *, privileged=False):
|
||||||
"""Raise Unauthorized or Forbidden if the request is not authorized"""
|
"""Raise Unauthorized or Forbidden if the request is not authorized"""
|
||||||
if privileged:
|
if privileged:
|
||||||
if request.ctx.user:
|
if request.ctx.user:
|
||||||
if request.ctx.user.privileged: return
|
if request.ctx.user.privileged:
|
||||||
raise Forbidden("Access Forbidden: Only for privileged users")
|
return
|
||||||
elif config.config.public or request.ctx.user: return
|
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
|
||||||
raise Unauthorized("Login required", "cookie", context={"redirect": "/login"})
|
elif config.config.public or request.ctx.user:
|
||||||
|
return
|
||||||
|
raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True)
|
||||||
|
|
||||||
|
|
||||||
bp = Blueprint("auth")
|
bp = Blueprint("auth")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/login")
|
@bp.get("/login")
|
||||||
async def login_page(request):
|
async def login_page(request):
|
||||||
doc = Document("Cista Login")
|
doc = Document("Cista Login")
|
||||||
with doc.div(id="login"):
|
with doc.div(id="login"):
|
||||||
with doc.form(method="POST", autocomplete="on"):
|
with doc.form(method="POST", autocomplete="on"):
|
||||||
doc.h1("Login")
|
doc.h1("Login")
|
||||||
doc.input(name="username", placeholder="Username", autocomplete="username", required=True).br
|
doc.input(
|
||||||
doc.input(type="password", name="password", placeholder="Password", autocomplete="current-password", required=True).br
|
name="username",
|
||||||
|
placeholder="Username",
|
||||||
|
autocomplete="username",
|
||||||
|
required=True,
|
||||||
|
).br
|
||||||
|
doc.input(
|
||||||
|
type="password",
|
||||||
|
name="password",
|
||||||
|
placeholder="Password",
|
||||||
|
autocomplete="current-password",
|
||||||
|
required=True,
|
||||||
|
).br
|
||||||
doc.input(type="submit", value="Login")
|
doc.input(type="submit", value="Login")
|
||||||
s = session.get(request)
|
s = session.get(request)
|
||||||
if s:
|
if s:
|
||||||
@@ -84,7 +104,12 @@ async def login_page(request):
|
|||||||
doc.input(type="submit", value=f"Logout {name}")
|
doc.input(type="submit", value=f"Logout {name}")
|
||||||
flash = request.cookies.message
|
flash = request.cookies.message
|
||||||
if flash:
|
if flash:
|
||||||
doc.dialog(flash, id="flash", open=True, style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8")
|
doc.dialog(
|
||||||
|
flash,
|
||||||
|
id="flash",
|
||||||
|
open=True,
|
||||||
|
style="position: fixed; top: 0; left: 0; width: 100%; opacity: .8",
|
||||||
|
)
|
||||||
res = html(doc)
|
res = html(doc)
|
||||||
if flash:
|
if flash:
|
||||||
res.cookies.delete_cookie("flash")
|
res.cookies.delete_cookie("flash")
|
||||||
@@ -92,6 +117,7 @@ async def login_page(request):
|
|||||||
session.delete(res)
|
session.delete(res)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/login")
|
@bp.post("/login")
|
||||||
async def login_post(request):
|
async def login_post(request):
|
||||||
try:
|
try:
|
||||||
@@ -104,11 +130,14 @@ async def login_post(request):
|
|||||||
if not username or not password:
|
if not username or not password:
|
||||||
raise KeyError
|
raise KeyError
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise BadRequest("Missing username or password", context={"redirect": "/login"})
|
raise BadRequest(
|
||||||
|
"Missing username or password",
|
||||||
|
context={"redirect": "/login"},
|
||||||
|
) from None
|
||||||
try:
|
try:
|
||||||
user = login(username, password)
|
user = login(username, password)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise Forbidden(str(e), context={"redirect": "/login"})
|
raise Forbidden(str(e), context={"redirect": "/login"}) from e
|
||||||
|
|
||||||
if "text/html" in request.headers.accept:
|
if "text/html" in request.headers.accept:
|
||||||
res = redirect("/")
|
res = redirect("/")
|
||||||
@@ -118,6 +147,7 @@ async def login_post(request):
|
|||||||
session.create(res, username)
|
session.create(res, username)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/logout")
|
@bp.post("/logout")
|
||||||
async def logout_post(request):
|
async def logout_post(request):
|
||||||
s = request.ctx.session
|
s = request.ctx.session
|
||||||
@@ -129,3 +159,35 @@ async def logout_post(request):
|
|||||||
res = json({"message": msg})
|
res = json({"message": msg})
|
||||||
session.delete(res)
|
session.delete(res)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/password-change")
|
||||||
|
async def change_password(request):
|
||||||
|
try:
|
||||||
|
if request.headers.content_type == "application/json":
|
||||||
|
username = request.json["username"]
|
||||||
|
pwchange = request.json["passwordChange"]
|
||||||
|
password = request.json["password"]
|
||||||
|
else:
|
||||||
|
username = request.form["username"][0]
|
||||||
|
pwchange = request.form["passwordChange"][0]
|
||||||
|
password = request.form["password"][0]
|
||||||
|
if not username or not password:
|
||||||
|
raise KeyError
|
||||||
|
except KeyError:
|
||||||
|
raise BadRequest(
|
||||||
|
"Missing username, passwordChange or password",
|
||||||
|
) from None
|
||||||
|
try:
|
||||||
|
user = login(username, password)
|
||||||
|
set_password(user, pwchange)
|
||||||
|
except ValueError as e:
|
||||||
|
raise Forbidden(str(e), context={"redirect": "/login"}) from e
|
||||||
|
|
||||||
|
if "text/html" in request.headers.accept:
|
||||||
|
res = redirect("/")
|
||||||
|
session.flash(res, "Password updated")
|
||||||
|
else:
|
||||||
|
res = json({"message": "Password updated"})
|
||||||
|
session.create(res, username)
|
||||||
|
return res
|
||||||
|
|||||||
106
cista/config.py
Executable file → Normal file
@@ -1,12 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path, PurePath
|
||||||
from time import time
|
from time import sleep, time
|
||||||
|
from typing import Callable, Concatenate, Literal, ParamSpec
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
import msgspec.toml
|
||||||
|
|
||||||
|
|
||||||
class Config(msgspec.Struct):
|
class Config(msgspec.Struct):
|
||||||
@@ -14,29 +19,55 @@ class Config(msgspec.Struct):
|
|||||||
listen: str
|
listen: str
|
||||||
secret: str = secrets.token_hex(12)
|
secret: str = secrets.token_hex(12)
|
||||||
public: bool = False
|
public: bool = False
|
||||||
|
name: str = ""
|
||||||
users: dict[str, User] = {}
|
users: dict[str, User] = {}
|
||||||
links: dict[str, Link] = {}
|
links: dict[str, Link] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# Typing: arguments for config-modifying functions
|
||||||
|
P = ParamSpec("P")
|
||||||
|
ResultStr = Literal["modified", "created", "read"]
|
||||||
|
RawModifyFunc = Callable[Concatenate[Config, P], Config]
|
||||||
|
ModifyPublic = Callable[P, ResultStr]
|
||||||
|
|
||||||
|
|
||||||
class User(msgspec.Struct, omit_defaults=True):
|
class User(msgspec.Struct, omit_defaults=True):
|
||||||
privileged: bool = False
|
privileged: bool = False
|
||||||
hash: str = ""
|
hash: str = ""
|
||||||
lastSeen: int = 0
|
lastSeen: int = 0 # noqa: N815
|
||||||
|
|
||||||
|
|
||||||
class Link(msgspec.Struct, omit_defaults=True):
|
class Link(msgspec.Struct, omit_defaults=True):
|
||||||
location: str
|
location: str
|
||||||
creator: str = ""
|
creator: str = ""
|
||||||
expires: int = 0
|
expires: int = 0
|
||||||
|
|
||||||
config = None
|
|
||||||
conffile = Path.home() / ".local/share/cista/db.toml"
|
# Global variables - initialized during application startup
|
||||||
|
config: Config
|
||||||
|
conffile: Path
|
||||||
|
|
||||||
|
|
||||||
|
def init_confdir() -> None:
|
||||||
|
global conffile
|
||||||
|
if p := os.environ.get("CISTA_HOME"):
|
||||||
|
home = Path(p)
|
||||||
|
else:
|
||||||
|
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
home = (
|
||||||
|
Path(xdg).expanduser() / "cista" if xdg else Path.home() / ".config/cista"
|
||||||
|
)
|
||||||
|
if not home.is_dir():
|
||||||
|
home.mkdir(parents=True, exist_ok=True)
|
||||||
|
home.chmod(0o700)
|
||||||
|
conffile = home / "db.toml"
|
||||||
|
|
||||||
|
|
||||||
def derived_secret(*params, len=8) -> bytes:
|
def derived_secret(*params, len=8) -> bytes:
|
||||||
"""Used to derive secret keys from the main secret"""
|
"""Used to derive secret keys from the main secret"""
|
||||||
# Each part is made the same length by hashing first
|
# Each part is made the same length by hashing first
|
||||||
combined = b"".join(
|
combined = b"".join(
|
||||||
sha256(
|
sha256(p if isinstance(p, bytes) else f"{p}".encode()).digest()
|
||||||
p if isinstance(p, bytes) else f"{p}".encode()
|
|
||||||
).digest()
|
|
||||||
for p in [config.secret, *params]
|
for p in [config.secret, *params]
|
||||||
)
|
)
|
||||||
# Output a bytes of the desired length
|
# Output a bytes of the desired length
|
||||||
@@ -48,15 +79,17 @@ def enc_hook(obj):
|
|||||||
return obj.as_posix()
|
return obj.as_posix()
|
||||||
raise TypeError
|
raise TypeError
|
||||||
|
|
||||||
|
|
||||||
def dec_hook(typ, obj):
|
def dec_hook(typ, obj):
|
||||||
if typ is Path:
|
if typ is Path:
|
||||||
return Path(obj)
|
return Path(obj)
|
||||||
raise TypeError
|
raise TypeError
|
||||||
|
|
||||||
def config_update(modify):
|
|
||||||
|
def config_update(
|
||||||
|
modify: RawModifyFunc,
|
||||||
|
) -> ResultStr | Literal["collision"]:
|
||||||
global config
|
global config
|
||||||
if not conffile.exists():
|
|
||||||
conffile.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmpname = conffile.with_suffix(".tmp")
|
tmpname = conffile.with_suffix(".tmp")
|
||||||
try:
|
try:
|
||||||
f = tmpname.open("xb")
|
f = tmpname.open("xb")
|
||||||
@@ -70,12 +103,8 @@ def config_update(modify):
|
|||||||
old = conffile.read_bytes()
|
old = conffile.read_bytes()
|
||||||
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
|
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
# No existing config file, make sure we have a folder...
|
|
||||||
confdir = conffile.parent
|
|
||||||
confdir.mkdir(parents=True, exist_ok=True)
|
|
||||||
confdir.chmod(0o700)
|
|
||||||
old = b""
|
old = b""
|
||||||
c = None
|
c = Config(path=Path(), listen="", secret=secrets.token_hex(12))
|
||||||
c = modify(c)
|
c = modify(c)
|
||||||
new = msgspec.toml.encode(c, enc_hook=enc_hook)
|
new = msgspec.toml.encode(c, enc_hook=enc_hook)
|
||||||
if old == new:
|
if old == new:
|
||||||
@@ -85,6 +114,10 @@ def config_update(modify):
|
|||||||
return "read"
|
return "read"
|
||||||
f.write(new)
|
f.write(new)
|
||||||
f.close()
|
f.close()
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# Windows doesn't support atomic replace
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
|
conffile.unlink()
|
||||||
tmpname.rename(conffile) # Atomic replace
|
tmpname.rename(conffile) # Atomic replace
|
||||||
except:
|
except:
|
||||||
f.close()
|
f.close()
|
||||||
@@ -93,39 +126,60 @@ def config_update(modify):
|
|||||||
config = c
|
config = c
|
||||||
return "modified" if old else "created"
|
return "modified" if old else "created"
|
||||||
|
|
||||||
def modifies_config(modify):
|
|
||||||
"""Decorator for functions that modify the config file"""
|
def modifies_config(
|
||||||
|
modify: Callable[Concatenate[Config, P], Config],
|
||||||
|
) -> Callable[P, ResultStr]:
|
||||||
|
"""Decorator for functions that modify the config file
|
||||||
|
|
||||||
|
The decorated function takes as first arg Config and returns it modified.
|
||||||
|
The wrapper handles atomic modification and returns a string indicating the result.
|
||||||
|
"""
|
||||||
|
|
||||||
@wraps(modify)
|
@wraps(modify)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> ResultStr:
|
||||||
m = lambda c: modify(c, *args, **kwargs)
|
def m(c: Config) -> Config:
|
||||||
|
return modify(c, *args, **kwargs)
|
||||||
|
|
||||||
# Retry modification in case of write collision
|
# Retry modification in case of write collision
|
||||||
while (c := config_update(m)) == "collision":
|
while (c := config_update(m)) == "collision":
|
||||||
time.sleep(0.01)
|
sleep(0.01)
|
||||||
return c
|
return c
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
global config
|
global config
|
||||||
|
init_confdir()
|
||||||
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
|
|
||||||
@modifies_config
|
@modifies_config
|
||||||
def update_config(conf: Config, changes: dict) -> Config:
|
def update_config(conf: Config, changes: dict) -> Config:
|
||||||
"""Create/update the config with new values, respecting changes done by others."""
|
"""Create/update the config with new values, respecting changes done by others."""
|
||||||
# Encode into dict, update values with new, convert to Config
|
# Encode into dict, update values with new, convert to Config
|
||||||
settings = {} if conf is None else msgspec.to_builtins(conf, enc_hook=enc_hook)
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
settings.update(changes)
|
settings.update(changes)
|
||||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
|
|
||||||
@modifies_config
|
@modifies_config
|
||||||
def update_user(conf: Config, name: str, changes: dict) -> Config:
|
def update_user(conf: Config, name: str, changes: dict) -> Config:
|
||||||
"""Create/update a user with new values, respecting changes done by others."""
|
"""Create/update a user with new values, respecting changes done by others."""
|
||||||
# Encode into dict, update values with new, convert to Config
|
# Encode into dict, update values with new, convert to Config
|
||||||
try:
|
try:
|
||||||
u = conf.users[name].__copy__()
|
# Copy user by converting to dict and back
|
||||||
|
u = msgspec.convert(
|
||||||
|
msgspec.to_builtins(conf.users[name], enc_hook=enc_hook),
|
||||||
|
User,
|
||||||
|
dec_hook=dec_hook,
|
||||||
|
)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
u = User()
|
u = User()
|
||||||
if "password" in changes:
|
if "password" in changes:
|
||||||
from . import auth
|
from . import auth
|
||||||
|
|
||||||
auth.set_password(u, changes["password"])
|
auth.set_password(u, changes["password"])
|
||||||
del changes["password"]
|
del changes["password"]
|
||||||
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
|
||||||
@@ -134,9 +188,11 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
|
|||||||
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
|
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
|
||||||
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|
||||||
|
|
||||||
@modifies_config
|
@modifies_config
|
||||||
def del_user(conf: Config, name: str) -> Config:
|
def del_user(conf: Config, name: str) -> Config:
|
||||||
"""Delete named user account."""
|
"""Delete named user account."""
|
||||||
ret = conf.__copy__()
|
# Create a copy by converting to dict and back
|
||||||
ret.users.pop(name)
|
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
|
||||||
return ret
|
settings["users"].pop(name)
|
||||||
|
return msgspec.convert(settings, Config, dec_hook=dec_hook)
|
||||||
|
|||||||
19
cista/droppy.py
Executable file → Normal file
@@ -11,6 +11,7 @@ def readconf() -> dict:
|
|||||||
cf["listen"] = _droppy_listeners(cf)
|
cf["listen"] = _droppy_listeners(cf)
|
||||||
return cf | db
|
return cf | db
|
||||||
|
|
||||||
|
|
||||||
def _droppy_listeners(cf):
|
def _droppy_listeners(cf):
|
||||||
"""Convert Droppy listeners to our format, for typical cases but not in full."""
|
"""Convert Droppy listeners to our format, for typical cases but not in full."""
|
||||||
for listener in cf["listeners"]:
|
for listener in cf["listeners"]:
|
||||||
@@ -20,15 +21,21 @@ def _droppy_listeners(cf):
|
|||||||
continue
|
continue
|
||||||
socket = listener.get("socket")
|
socket = listener.get("socket")
|
||||||
if socket:
|
if socket:
|
||||||
if isinstance(socket, list): socket = socket[0]
|
if isinstance(socket, list):
|
||||||
|
socket = socket[0]
|
||||||
return f"{socket}"
|
return f"{socket}"
|
||||||
port = listener["port"]
|
port = listener["port"]
|
||||||
if isinstance(port, list): port = port[0]
|
if isinstance(port, list):
|
||||||
|
port = port[0]
|
||||||
host = listener["host"]
|
host = listener["host"]
|
||||||
if isinstance(host, list): host = host[0]
|
if isinstance(host, list):
|
||||||
if host in ("127.0.0.1", "::", "localhost"): return f":{port}"
|
host = host[0]
|
||||||
return f"{host}:{port}"
|
|
||||||
except (KeyError, IndexError):
|
except (KeyError, IndexError):
|
||||||
continue
|
continue
|
||||||
|
else:
|
||||||
|
if host in ("127.0.0.1", "::", "localhost"):
|
||||||
|
return f":{port}"
|
||||||
|
return f"{host}:{port}"
|
||||||
|
|
||||||
# If none matched, fallback to Droppy default
|
# If none matched, fallback to Droppy default
|
||||||
return f"0.0.0.0:8989"
|
return "0.0.0.0:8989"
|
||||||
|
|||||||
29
cista/fileio.py
Executable file → Normal file
@@ -1,7 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import unicodedata
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
from cista import config
|
from cista import config
|
||||||
from cista.util import filename
|
from cista.util import filename
|
||||||
@@ -13,6 +11,7 @@ def fuid(stat) -> str:
|
|||||||
"""Unique file ID. Stays the same on renames and modification."""
|
"""Unique file ID. Stays the same on renames and modification."""
|
||||||
return config.derived_secret("filekey-inode", stat.st_dev, stat.st_ino).hex()
|
return config.derived_secret("filekey-inode", stat.st_dev, stat.st_ino).hex()
|
||||||
|
|
||||||
|
|
||||||
class File:
|
class File:
|
||||||
def __init__(self, filename):
|
def __init__(self, filename):
|
||||||
self.path = config.config.path / filename
|
self.path = config.config.path / filename
|
||||||
@@ -30,23 +29,26 @@ class File:
|
|||||||
self.writable = True
|
self.writable = True
|
||||||
|
|
||||||
def write(self, pos, buffer, *, file_size=None):
|
def write(self, pos, buffer, *, file_size=None):
|
||||||
assert self.fd is not None
|
|
||||||
if not self.writable:
|
if not self.writable:
|
||||||
# Create/open file
|
# Create/open file
|
||||||
self.open_rw()
|
self.open_rw()
|
||||||
|
assert self.fd is not None
|
||||||
if file_size is not None:
|
if file_size is not None:
|
||||||
|
assert pos + len(buffer) <= file_size
|
||||||
os.ftruncate(self.fd, file_size)
|
os.ftruncate(self.fd, file_size)
|
||||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
if buffer:
|
||||||
os.write(self.fd, buffer)
|
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||||
|
os.write(self.fd, buffer)
|
||||||
|
|
||||||
def __getitem__(self, slice):
|
def __getitem__(self, slice):
|
||||||
assert self.fd is not None
|
|
||||||
if self.fd is None:
|
if self.fd is None:
|
||||||
self.open_ro()
|
self.open_ro()
|
||||||
|
assert self.fd is not None
|
||||||
os.lseek(self.fd, slice.start, os.SEEK_SET)
|
os.lseek(self.fd, slice.start, os.SEEK_SET)
|
||||||
l = slice.stop - slice.start
|
size = slice.stop - slice.start
|
||||||
data = os.read(self.fd, l)
|
data = os.read(self.fd, size)
|
||||||
if len(data) < l: raise EOFError("Error reading requested range")
|
if len(data) < size:
|
||||||
|
raise EOFError("Error reading requested range")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
@@ -59,10 +61,13 @@ class File:
|
|||||||
|
|
||||||
|
|
||||||
class FileServer:
|
class FileServer:
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
self.alink = AsyncLink()
|
self.alink = AsyncLink()
|
||||||
self.worker = asyncio.get_event_loop().run_in_executor(None, self.worker_thread, self.alink.to_sync)
|
self.worker = asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
self.worker_thread,
|
||||||
|
self.alink.to_sync,
|
||||||
|
)
|
||||||
self.cache = LRUCache(File, capacity=10, maxage=5.0)
|
self.cache = LRUCache(File, capacity=10, maxage=5.0)
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
@@ -91,4 +96,4 @@ class FileServer:
|
|||||||
def download(self, name, start, end):
|
def download(self, name, start, end):
|
||||||
name = filename.sanitize(name)
|
name = filename.sanitize(name)
|
||||||
f = self.cache[name]
|
f = self.cache[name]
|
||||||
return f[start: end]
|
return f[start:end]
|
||||||
|
|||||||
237
cista/preview.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import asyncio
|
||||||
|
import gc
|
||||||
|
import io
|
||||||
|
import mimetypes
|
||||||
|
import urllib.parse
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from time import perf_counter
|
||||||
|
from urllib.parse import unquote
|
||||||
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
|
import av
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
import numpy as np
|
||||||
|
import pillow_heif
|
||||||
|
from PIL import Image
|
||||||
|
from sanic import Blueprint, empty, raw
|
||||||
|
from sanic.exceptions import NotFound
|
||||||
|
from sanic.log import logger
|
||||||
|
|
||||||
|
from cista import config
|
||||||
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
|
pillow_heif.register_heif_opener()
|
||||||
|
|
||||||
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
# Map EXIF Orientation value to a corresponding PIL transpose
|
||||||
|
EXIF_ORI = {
|
||||||
|
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
||||||
|
3: Image.Transpose.ROTATE_180,
|
||||||
|
4: Image.Transpose.FLIP_TOP_BOTTOM,
|
||||||
|
5: Image.Transpose.TRANSPOSE,
|
||||||
|
6: Image.Transpose.ROTATE_270,
|
||||||
|
7: Image.Transpose.TRANSVERSE,
|
||||||
|
8: Image.Transpose.ROTATE_90,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<path:path>")
|
||||||
|
async def preview(req, path):
|
||||||
|
"""Preview a file"""
|
||||||
|
maxsize = int(req.args.get("px", 1024))
|
||||||
|
maxzoom = float(req.args.get("zoom", 2.0))
|
||||||
|
quality = int(req.args.get("q", 60))
|
||||||
|
rel = PurePosixPath(sanitize(unquote(path)))
|
||||||
|
path = config.config.path / rel
|
||||||
|
stat = path.lstat()
|
||||||
|
etag = config.derived_secret(
|
||||||
|
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||||
|
).hex()
|
||||||
|
savename = PurePosixPath(path.name).with_suffix(".avif")
|
||||||
|
headers = {
|
||||||
|
"etag": etag,
|
||||||
|
"last-modified": format_date_time(stat.st_mtime),
|
||||||
|
"cache-control": "max-age=604800, immutable"
|
||||||
|
+ ("" if config.config.public else ", private"),
|
||||||
|
"content-type": "image/avif",
|
||||||
|
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
||||||
|
}
|
||||||
|
if req.headers.if_none_match == etag:
|
||||||
|
# The client has it cached, respond 304 Not Modified
|
||||||
|
return empty(304, headers=headers)
|
||||||
|
|
||||||
|
if not path.is_file():
|
||||||
|
raise NotFound("File not found")
|
||||||
|
|
||||||
|
img = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
|
||||||
|
)
|
||||||
|
return raw(img, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch(path, quality, maxsize, maxzoom):
|
||||||
|
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
||||||
|
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||||
|
type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if type and type.startswith("video/"):
|
||||||
|
return process_video(path, quality=quality, maxsize=maxsize)
|
||||||
|
return process_image(path, quality=quality, maxsize=maxsize)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image(path, *, maxsize, quality):
|
||||||
|
t_load = perf_counter()
|
||||||
|
with Image.open(path) as img:
|
||||||
|
# Force decode to include I/O in load timing
|
||||||
|
img.load()
|
||||||
|
t_proc = perf_counter()
|
||||||
|
# Resize
|
||||||
|
w, h = img.size
|
||||||
|
img.thumbnail((min(w, maxsize), min(h, maxsize)))
|
||||||
|
# Transpose pixels according to EXIF Orientation
|
||||||
|
orientation = img.getexif().get(274, 1)
|
||||||
|
if orientation in EXIF_ORI:
|
||||||
|
img = img.transpose(EXIF_ORI[orientation])
|
||||||
|
# Save as AVIF
|
||||||
|
imgdata = io.BytesIO()
|
||||||
|
t_save = perf_counter()
|
||||||
|
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
|
||||||
|
|
||||||
|
t_end = perf_counter()
|
||||||
|
ret = imgdata.getvalue()
|
||||||
|
|
||||||
|
load_ms = (t_proc - t_load) * 1000
|
||||||
|
proc_ms = (t_save - t_proc) * 1000
|
||||||
|
save_ms = (t_end - t_save) * 1000
|
||||||
|
logger.debug(
|
||||||
|
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms",
|
||||||
|
path.name,
|
||||||
|
load_ms,
|
||||||
|
proc_ms,
|
||||||
|
save_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||||
|
t_load_start = perf_counter()
|
||||||
|
pdf = fitz.open(path)
|
||||||
|
page = pdf.load_page(page_number)
|
||||||
|
w, h = page.rect[2:4]
|
||||||
|
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||||
|
mat = fitz.Matrix(zoom, zoom)
|
||||||
|
pix = page.get_pixmap(matrix=mat) # type: ignore[attr-defined]
|
||||||
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
|
t_save_start = perf_counter()
|
||||||
|
ret = pix.pil_tobytes(format="avif", quality=quality, speed=10, max_threads=1)
|
||||||
|
t_save_end = perf_counter()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Preview pdf %s: load+render=%.1fms save=%.1fms",
|
||||||
|
path.name,
|
||||||
|
(t_load_end - t_load_start) * 1000,
|
||||||
|
(t_save_end - t_save_start) * 1000,
|
||||||
|
)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def process_video(path, *, maxsize, quality):
|
||||||
|
frame = None
|
||||||
|
imgdata = io.BytesIO()
|
||||||
|
istream = ostream = icc = occ = frame = None
|
||||||
|
t_load_start = perf_counter()
|
||||||
|
# Initialize to avoid "possibly unbound" in static analysis when exceptions occur
|
||||||
|
t_load_end = t_load_start
|
||||||
|
t_save_start = t_load_start
|
||||||
|
t_save_end = t_load_start
|
||||||
|
with (
|
||||||
|
av.open(str(path)) as icontainer,
|
||||||
|
av.open(imgdata, "w", format="avif") as ocontainer,
|
||||||
|
):
|
||||||
|
istream = icontainer.streams.video[0]
|
||||||
|
istream.codec_context.skip_frame = "NONKEY"
|
||||||
|
icontainer.seek((icontainer.duration or 0) // 8)
|
||||||
|
for frame in icontainer.decode(istream):
|
||||||
|
if frame.dts is not None:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise RuntimeError("No frames found in video")
|
||||||
|
|
||||||
|
# Resize frame to thumbnail size
|
||||||
|
if frame.width > maxsize or frame.height > maxsize:
|
||||||
|
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
||||||
|
new_width = int(frame.width * scale_factor)
|
||||||
|
new_height = int(frame.height * scale_factor)
|
||||||
|
frame = frame.reformat(width=new_width, height=new_height)
|
||||||
|
|
||||||
|
# Simple rotation detection and logging
|
||||||
|
if frame.rotation:
|
||||||
|
try:
|
||||||
|
fplanes = frame.to_ndarray()
|
||||||
|
# Split into Y, U, V planes of proper dimensions
|
||||||
|
planes = [
|
||||||
|
fplanes[: frame.height],
|
||||||
|
fplanes[frame.height : frame.height + frame.height // 4].reshape(
|
||||||
|
frame.height // 2, frame.width // 2
|
||||||
|
),
|
||||||
|
fplanes[frame.height + frame.height // 4 :].reshape(
|
||||||
|
frame.height // 2, frame.width // 2
|
||||||
|
),
|
||||||
|
]
|
||||||
|
# Rotate
|
||||||
|
planes = [np.rot90(p, frame.rotation // 90) for p in planes]
|
||||||
|
# Restore PyAV format
|
||||||
|
planes = np.hstack([p.flat for p in planes]).reshape(
|
||||||
|
-1, planes[0].shape[1]
|
||||||
|
)
|
||||||
|
frame = av.VideoFrame.from_ndarray(planes, format=frame.format.name)
|
||||||
|
del planes, fplanes
|
||||||
|
except Exception as e:
|
||||||
|
if "not yet supported" in str(e):
|
||||||
|
logger.warning(
|
||||||
|
f"Not rotating {path.name} preview image by {frame.rotation}°:\n PyAV: {e}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.exception(f"Error rotating video frame: {e}")
|
||||||
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
|
t_save_start = perf_counter()
|
||||||
|
crf = str(int(63 * (1 - quality / 100) ** 2)) # Closely matching PIL quality-%
|
||||||
|
ostream = ocontainer.add_stream(
|
||||||
|
"av1",
|
||||||
|
options={
|
||||||
|
"crf": crf,
|
||||||
|
"usage": "realtime",
|
||||||
|
"cpu-used": "8",
|
||||||
|
"threads": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert isinstance(ostream, av.VideoStream)
|
||||||
|
ostream.width = frame.width
|
||||||
|
ostream.height = frame.height
|
||||||
|
icc = istream.codec_context
|
||||||
|
occ = ostream.codec_context
|
||||||
|
|
||||||
|
# Copy HDR metadata from input video stream
|
||||||
|
occ.color_primaries = icc.color_primaries
|
||||||
|
occ.color_trc = icc.color_trc
|
||||||
|
occ.colorspace = icc.colorspace
|
||||||
|
occ.color_range = icc.color_range
|
||||||
|
|
||||||
|
ocontainer.mux(ostream.encode(frame))
|
||||||
|
ocontainer.mux(ostream.encode(None)) # Flush the stream
|
||||||
|
t_save_end = perf_counter()
|
||||||
|
|
||||||
|
# Capture frame dimensions before cleanup
|
||||||
|
ret = imgdata.getvalue()
|
||||||
|
logger.debug(
|
||||||
|
"Preview video %s: load+decode=%.1fms save=%.1fms",
|
||||||
|
path.name,
|
||||||
|
(t_load_end - t_load_start) * 1000,
|
||||||
|
(t_save_end - t_save_start) * 1000,
|
||||||
|
)
|
||||||
|
del imgdata, istream, ostream, icc, occ, frame
|
||||||
|
gc.collect()
|
||||||
|
return ret
|
||||||
114
cista/protocol.py
Executable file → Normal file
@@ -11,19 +11,24 @@ from cista.util import filename
|
|||||||
|
|
||||||
## Control commands
|
## Control commands
|
||||||
|
|
||||||
|
|
||||||
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
|
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
|
||||||
def __call__(self):
|
def __call__(self):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class MkDir(ControlBase):
|
class MkDir(ControlBase):
|
||||||
path: str
|
path: str
|
||||||
|
|
||||||
def __call__(self):
|
def __call__(self):
|
||||||
path = config.config.path / filename.sanitize(self.path)
|
path = config.config.path / filename.sanitize(self.path)
|
||||||
path.mkdir(parents=False, exist_ok=False)
|
path.mkdir(parents=True, exist_ok=False)
|
||||||
|
|
||||||
|
|
||||||
class Rename(ControlBase):
|
class Rename(ControlBase):
|
||||||
path: str
|
path: str
|
||||||
to: str
|
to: str
|
||||||
|
|
||||||
def __call__(self):
|
def __call__(self):
|
||||||
to = filename.sanitize(self.to)
|
to = filename.sanitize(self.to)
|
||||||
if "/" in to:
|
if "/" in to:
|
||||||
@@ -31,17 +36,24 @@ class Rename(ControlBase):
|
|||||||
path = config.config.path / filename.sanitize(self.path)
|
path = config.config.path / filename.sanitize(self.path)
|
||||||
path.rename(path.with_name(to))
|
path.rename(path.with_name(to))
|
||||||
|
|
||||||
|
|
||||||
class Rm(ControlBase):
|
class Rm(ControlBase):
|
||||||
sel: list[str]
|
sel: list[str]
|
||||||
|
|
||||||
def __call__(self):
|
def __call__(self):
|
||||||
root = config.config.path
|
root = config.config.path
|
||||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
sel = [root / filename.sanitize(p) for p in self.sel]
|
||||||
for p in sel:
|
for p in sel:
|
||||||
shutil.rmtree(p, ignore_errors=True)
|
if p.is_dir():
|
||||||
|
shutil.rmtree(p)
|
||||||
|
else:
|
||||||
|
p.unlink()
|
||||||
|
|
||||||
|
|
||||||
class Mv(ControlBase):
|
class Mv(ControlBase):
|
||||||
sel: list[str]
|
sel: list[str]
|
||||||
dst: str
|
dst: str
|
||||||
|
|
||||||
def __call__(self):
|
def __call__(self):
|
||||||
root = config.config.path
|
root = config.config.path
|
||||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
sel = [root / filename.sanitize(p) for p in self.sel]
|
||||||
@@ -51,9 +63,11 @@ class Mv(ControlBase):
|
|||||||
for p in sel:
|
for p in sel:
|
||||||
shutil.move(p, dst)
|
shutil.move(p, dst)
|
||||||
|
|
||||||
|
|
||||||
class Cp(ControlBase):
|
class Cp(ControlBase):
|
||||||
sel: list[str]
|
sel: list[str]
|
||||||
dst: str
|
dst: str
|
||||||
|
|
||||||
def __call__(self):
|
def __call__(self):
|
||||||
root = config.config.path
|
root = config.config.path
|
||||||
sel = [root / filename.sanitize(p) for p in self.sel]
|
sel = [root / filename.sanitize(p) for p in self.sel]
|
||||||
@@ -61,73 +75,79 @@ class Cp(ControlBase):
|
|||||||
if not dst.is_dir():
|
if not dst.is_dir():
|
||||||
raise BadRequest("The destination must be a directory")
|
raise BadRequest("The destination must be a directory")
|
||||||
for p in sel:
|
for p in sel:
|
||||||
# Note: copies as dst rather than in dst unless name is appended.
|
if p.is_dir():
|
||||||
shutil.copytree(p, dst / p.name, dirs_exist_ok=True, ignore_dangling_symlinks=True)
|
# Note: copies as dst rather than in dst unless name is appended.
|
||||||
|
shutil.copytree(
|
||||||
|
p,
|
||||||
|
dst / p.name,
|
||||||
|
dirs_exist_ok=True,
|
||||||
|
ignore_dangling_symlinks=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
shutil.copy2(p, dst)
|
||||||
|
|
||||||
|
|
||||||
|
ControlTypes = MkDir | Rename | Rm | Mv | Cp
|
||||||
|
|
||||||
|
|
||||||
## File uploads and downloads
|
## File uploads and downloads
|
||||||
|
|
||||||
|
|
||||||
class FileRange(msgspec.Struct):
|
class FileRange(msgspec.Struct):
|
||||||
name: str
|
name: str
|
||||||
size: int
|
size: int
|
||||||
start: int
|
start: int
|
||||||
end: int
|
end: int
|
||||||
|
|
||||||
|
|
||||||
class StatusMsg(msgspec.Struct):
|
class StatusMsg(msgspec.Struct):
|
||||||
status: str
|
status: str
|
||||||
req: FileRange
|
req: FileRange
|
||||||
|
|
||||||
|
|
||||||
class ErrorMsg(msgspec.Struct):
|
class ErrorMsg(msgspec.Struct):
|
||||||
error: dict[str, Any]
|
error: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
## Directory listings
|
## Directory listings
|
||||||
|
|
||||||
class FileEntry(msgspec.Struct):
|
|
||||||
size: int
|
class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
||||||
|
level: int
|
||||||
|
name: str
|
||||||
|
key: str
|
||||||
mtime: int
|
mtime: int
|
||||||
|
|
||||||
class DirEntry(msgspec.Struct):
|
|
||||||
size: int
|
size: int
|
||||||
mtime: int
|
isfile: int
|
||||||
dir: DirList
|
|
||||||
|
|
||||||
def __getitem__(self, name):
|
def __str__(self):
|
||||||
return self.dir[name]
|
return self.key or "FileEntry()"
|
||||||
|
|
||||||
def __setitem__(self, name, value):
|
def __repr__(self):
|
||||||
self.dir[name] = value
|
return f"{self.name} ({self.size}, {self.mtime})"
|
||||||
|
|
||||||
def __contains__(self, name):
|
|
||||||
return name in self.dir
|
|
||||||
|
|
||||||
def __delitem__(self, name):
|
|
||||||
del self.dir[name]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def props(self):
|
|
||||||
return {
|
|
||||||
k: v
|
|
||||||
for k, v in self.__struct_fields__
|
|
||||||
if k != "dir"
|
|
||||||
}
|
|
||||||
|
|
||||||
DirList = dict[str, FileEntry | DirEntry]
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateEntry(msgspec.Struct, omit_defaults=True):
|
class Update(msgspec.Struct, array_like=True): ...
|
||||||
"""Updates the named entry in the tree. Fields that are set replace old values. A list of entries recurses directories."""
|
|
||||||
name: str = ""
|
|
||||||
deleted: bool = False
|
|
||||||
size: int | None = None
|
|
||||||
mtime: int | None = None
|
|
||||||
dir: DirList | None = None
|
|
||||||
|
|
||||||
def make_dir_data(root):
|
|
||||||
if len(root) == 2:
|
class UpdKeep(Update, tag="k"):
|
||||||
return FileEntry(*root)
|
count: int
|
||||||
size, mtime, listing = root
|
|
||||||
converted = {}
|
|
||||||
for name, data in listing.items():
|
class UpdDel(Update, tag="d"):
|
||||||
converted[name] = make_dir_data(data)
|
count: int
|
||||||
sz = sum(x.size for x in converted.values())
|
|
||||||
mt = max(x.mtime for x in converted.values())
|
|
||||||
return DirEntry(sz, max(mt, mtime), converted)
|
class UpdIns(Update, tag="i"):
|
||||||
|
items: list[FileEntry]
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateMessage(msgspec.Struct):
|
||||||
|
update: list[UpdKeep | UpdDel | UpdIns]
|
||||||
|
|
||||||
|
|
||||||
|
class Space(msgspec.Struct):
|
||||||
|
disk: int
|
||||||
|
free: int
|
||||||
|
usage: int
|
||||||
|
storage: int
|
||||||
|
|||||||
46
cista/serve.py
Executable file → Normal file
@@ -1,47 +1,61 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path
|
||||||
|
|
||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
|
|
||||||
from cista import config, server80
|
from cista import config, server80
|
||||||
|
|
||||||
|
|
||||||
def run(dev=False):
|
def run(*, dev=False):
|
||||||
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
||||||
from .app import app
|
from .app import app
|
||||||
|
|
||||||
url, opts = parse_listen(config.config.listen)
|
url, opts = parse_listen(config.config.listen)
|
||||||
# Silence Sanic's warning about running in production rather than debug
|
# Silence Sanic's warning about running in production rather than debug
|
||||||
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "1"
|
||||||
confdir = config.conffile.parent
|
confdir = config.conffile.parent
|
||||||
wwwroot = PurePath(__file__).parent / "wwwroot"
|
|
||||||
if opts.get("ssl"):
|
if opts.get("ssl"):
|
||||||
# Run plain HTTP redirect/acme server on port 80
|
# Run plain HTTP redirect/acme server on port 80
|
||||||
server80.app.prepare(port=80, motd=False)
|
server80.app.prepare(port=80, motd=False)
|
||||||
domain = opts["host"]
|
domain = opts["host"]
|
||||||
check_cert(confdir / domain, domain)
|
check_cert(confdir / domain, domain)
|
||||||
opts["ssl"] = str(confdir / domain) # type: ignore
|
opts["ssl"] = str(confdir / domain) # type: ignore
|
||||||
app.prepare(**opts, motd=False, dev=dev, auto_reload=dev, reload_dir={confdir, wwwroot}, access_log=True) # type: ignore
|
app.prepare(
|
||||||
Sanic.serve()
|
**opts,
|
||||||
|
motd=False,
|
||||||
|
dev=dev,
|
||||||
|
auto_reload=dev,
|
||||||
|
access_log=True,
|
||||||
|
) # type: ignore
|
||||||
|
if dev:
|
||||||
|
Sanic.serve()
|
||||||
|
else:
|
||||||
|
Sanic.serve_single()
|
||||||
|
|
||||||
|
|
||||||
def check_cert(certdir, domain):
|
def check_cert(certdir, domain):
|
||||||
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
|
if (certdir / "privkey.pem").exist() and (certdir / "fullchain.pem").exists():
|
||||||
return
|
return
|
||||||
# TODO: Use certbot to fetch a cert
|
# TODO: Use certbot to fetch a cert
|
||||||
raise ValueError(f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}")
|
raise ValueError(
|
||||||
|
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_listen(listen):
|
def parse_listen(listen):
|
||||||
if listen.startswith("/"):
|
if listen.startswith("/"):
|
||||||
unix = Path(listen).resolve()
|
unix = Path(listen).resolve()
|
||||||
if not unix.parent.exists():
|
if not unix.parent.exists():
|
||||||
raise ValueError(f"Directory for unix socket does not exist: {unix.parent}/")
|
raise ValueError(
|
||||||
return "http://localhost", {"unix": unix}
|
f"Directory for unix socket does not exist: {unix.parent}/",
|
||||||
elif re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
|
)
|
||||||
|
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}
|
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
|
||||||
else:
|
try:
|
||||||
try:
|
addr, _port = listen.split(":", 1)
|
||||||
addr, _port = listen.split(":", 1)
|
port = int(_port)
|
||||||
port = int(_port)
|
except Exception:
|
||||||
except Exception:
|
raise ValueError(f"Invalid listen address: {listen}") from None
|
||||||
raise ValueError(f"Invalid listen address: {listen}")
|
return f"http://localhost:{port}", {"host": addr, "port": port}
|
||||||
return f"http://localhost:{port}", {"host": addr, "port": port}
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from sanic import Sanic, exceptions, response
|
|||||||
|
|
||||||
app = Sanic("server80")
|
app = Sanic("server80")
|
||||||
|
|
||||||
|
|
||||||
# Send all HTTP users to HTTPS
|
# Send all HTTP users to HTTPS
|
||||||
@app.exception(exceptions.NotFound, exceptions.MethodNotSupported)
|
@app.exception(exceptions.NotFound, exceptions.MethodNotSupported)
|
||||||
def redirect_everything_else(request, exception):
|
def redirect_everything_else(request, exception):
|
||||||
@@ -10,6 +11,7 @@ def redirect_everything_else(request, exception):
|
|||||||
return response.redirect(f"https://{server}{path}", status=308)
|
return response.redirect(f"https://{server}{path}", status=308)
|
||||||
return response.text("Bad Request. Please use HTTPS!", status=400)
|
return response.text("Bad Request. Please use HTTPS!", status=400)
|
||||||
|
|
||||||
|
|
||||||
# ACME challenge for LetsEncrypt
|
# ACME challenge for LetsEncrypt
|
||||||
@app.get("/.well-known/acme-challenge/<challenge>")
|
@app.get("/.well-known/acme-challenge/<challenge>")
|
||||||
async def letsencrypt(request, challenge):
|
async def letsencrypt(request, challenge):
|
||||||
@@ -18,4 +20,5 @@ async def letsencrypt(request, challenge):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return response.text(f"ACME challenge not found: {challenge}", status=404)
|
return response.text(f"ACME challenge not found: {challenge}", status=404)
|
||||||
|
|
||||||
|
|
||||||
acme_challenges = {}
|
acme_challenges = {}
|
||||||
|
|||||||
13
cista/session.py
Executable file → Normal file
@@ -4,16 +4,21 @@ import jwt
|
|||||||
|
|
||||||
from cista.config import derived_secret
|
from cista.config import derived_secret
|
||||||
|
|
||||||
session_secret = lambda: derived_secret("session")
|
|
||||||
|
def session_secret():
|
||||||
|
return derived_secret("session")
|
||||||
|
|
||||||
|
|
||||||
max_age = 365 * 86400 # Seconds since last login
|
max_age = 365 * 86400 # Seconds since last login
|
||||||
|
|
||||||
|
|
||||||
def get(request):
|
def get(request):
|
||||||
try:
|
try:
|
||||||
return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"])
|
return jwt.decode(request.cookies.s, session_secret(), algorithms=["HS256"])
|
||||||
except Exception as e:
|
except Exception:
|
||||||
s = None
|
|
||||||
return False if "s" in request.cookies else None
|
return False if "s" in request.cookies else None
|
||||||
|
|
||||||
|
|
||||||
def create(res, username, **kwargs):
|
def create(res, username, **kwargs):
|
||||||
data = {
|
data = {
|
||||||
"exp": int(time()) + max_age,
|
"exp": int(time()) + max_age,
|
||||||
@@ -23,12 +28,14 @@ def create(res, username, **kwargs):
|
|||||||
s = jwt.encode(data, session_secret())
|
s = jwt.encode(data, session_secret())
|
||||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
||||||
|
|
||||||
|
|
||||||
def update(res, s, **kwargs):
|
def update(res, s, **kwargs):
|
||||||
s.update(kwargs)
|
s.update(kwargs)
|
||||||
s = jwt.encode(s, session_secret())
|
s = jwt.encode(s, session_secret())
|
||||||
max_age = max(1, s["exp"] - int(time())) # type: ignore
|
max_age = max(1, s["exp"] - int(time())) # type: ignore
|
||||||
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
res.cookies.add_cookie("s", s, httponly=True, max_age=max_age)
|
||||||
|
|
||||||
|
|
||||||
def delete(res):
|
def delete(res):
|
||||||
res.cookies.delete_cookie("s")
|
res.cookies.delete_cookie("s")
|
||||||
|
|
||||||
|
|||||||
0
cista/util/__init__.py
Normal file
@@ -14,12 +14,13 @@ def asend(ws, msg):
|
|||||||
"""Send JSON message or bytes to a websocket"""
|
"""Send JSON message or bytes to a websocket"""
|
||||||
return ws.send(msg if isinstance(msg, bytes) else msgspec.json.encode(msg).decode())
|
return ws.send(msg if isinstance(msg, bytes) else msgspec.json.encode(msg).decode())
|
||||||
|
|
||||||
|
|
||||||
def jres(data, **kwargs):
|
def jres(data, **kwargs):
|
||||||
"""JSON Sanic response, using msgspec encoding"""
|
"""JSON Sanic response, using msgspec encoding"""
|
||||||
return raw(msgspec.json.encode(data), content_type="application/json", **kwargs)
|
return raw(msgspec.json.encode(data), content_type="application/json", **kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def handle_sanic_exception(request, e):
|
async def handle_sanic_exception(request, e):
|
||||||
logger.exception(e)
|
|
||||||
context, code = {}, 500
|
context, code = {}, 500
|
||||||
message = str(e)
|
message = str(e)
|
||||||
if isinstance(e, SanicException):
|
if isinstance(e, SanicException):
|
||||||
@@ -28,31 +29,40 @@ async def handle_sanic_exception(request, e):
|
|||||||
if not message or not request.app.debug and code == 500:
|
if not message or not request.app.debug and code == 500:
|
||||||
message = "Internal Server Error"
|
message = "Internal Server Error"
|
||||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||||
|
if code == 500:
|
||||||
|
logger.exception(e)
|
||||||
# Non-browsers get JSON errors
|
# Non-browsers get JSON errors
|
||||||
if "text/html" not in request.headers.accept:
|
if "text/html" not in request.headers.accept:
|
||||||
return jres(ErrorMsg({"code": code, "message": message, **context}), status=code)
|
return jres(
|
||||||
|
ErrorMsg({"code": code, "message": message, **context}),
|
||||||
|
status=code,
|
||||||
|
)
|
||||||
# Redirections flash the error message via cookies
|
# Redirections flash the error message via cookies
|
||||||
if "redirect" in context:
|
if "redirect" in context:
|
||||||
res = redirect(context["redirect"])
|
res = redirect(context["redirect"])
|
||||||
res.cookies.add_cookie("message", message, max_age=5)
|
res.cookies.add_cookie("message", message, max_age=5)
|
||||||
return res
|
return res
|
||||||
# Otherwise use Sanic's default error page
|
# Otherwise use Sanic's default error page
|
||||||
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).full()
|
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).render()
|
||||||
|
|
||||||
|
|
||||||
def websocket_wrapper(handler):
|
def websocket_wrapper(handler):
|
||||||
"""Decorator for websocket handlers that catches exceptions and sends them back to the client"""
|
"""Decorator for websocket handlers that catches exceptions and sends them back to the client"""
|
||||||
|
|
||||||
@wraps(handler)
|
@wraps(handler)
|
||||||
async def wrapper(request, ws, *args, **kwargs):
|
async def wrapper(request, ws, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
auth.verify(request)
|
auth.verify(request)
|
||||||
await handler(request, ws, *args, **kwargs)
|
await handler(request, ws, *args, **kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
|
||||||
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
||||||
if isinstance(e, SanicException):
|
if isinstance(e, SanicException):
|
||||||
context = e.context or {}
|
context = e.context or {}
|
||||||
code = e.status_code
|
code = e.status_code
|
||||||
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
|
||||||
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
|
||||||
|
if not getattr(e, "quiet", False) or code == 500:
|
||||||
|
logger.exception(f"{code} {e!r}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
3
cista/util/asynclink.py
Executable file → Normal file
@@ -80,8 +80,9 @@ class SyncRequest:
|
|||||||
if exc:
|
if exc:
|
||||||
self.set_exception(exc)
|
self.set_exception(exc)
|
||||||
return True
|
return True
|
||||||
elif not self.done:
|
if not self.done:
|
||||||
self.set_result(None)
|
self.set_result(None)
|
||||||
|
return None
|
||||||
|
|
||||||
def set_result(self, value):
|
def set_result(self, value):
|
||||||
"""Set result value; mark as done."""
|
"""Set result value; mark as done."""
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ def sanitize(filename: str) -> str:
|
|||||||
filename = filename.replace("\\", "-")
|
filename = filename.replace("\\", "-")
|
||||||
filename = sanitize_filepath(filename)
|
filename = sanitize_filepath(filename)
|
||||||
filename = filename.strip("/")
|
filename = filename.strip("/")
|
||||||
return PurePosixPath(filename).as_posix()
|
p = PurePosixPath(filename)
|
||||||
|
if any(n.startswith(".") for n in p.parts):
|
||||||
|
raise ValueError("Filenames starting with dot are not allowed")
|
||||||
|
return p.as_posix()
|
||||||
|
|||||||
3
cista/util/lrucache.py
Executable file → Normal file
@@ -11,6 +11,7 @@ class LRUCache:
|
|||||||
maxage (float): Max age for items in cache in seconds.
|
maxage (float): Max age for items in cache in seconds.
|
||||||
cache (list): Internal list storing the cache items.
|
cache (list): Internal list storing the cache items.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, open: callable, *, capacity: int, maxage: float):
|
def __init__(self, open: callable, *, capacity: int, maxage: float):
|
||||||
"""
|
"""
|
||||||
Initialize LRUCache.
|
Initialize LRUCache.
|
||||||
@@ -40,7 +41,7 @@ class LRUCache:
|
|||||||
The corresponding item's handle.
|
The corresponding item's handle.
|
||||||
"""
|
"""
|
||||||
# Take from cache or open a new one
|
# Take from cache or open a new one
|
||||||
for i, (k, f, ts) in enumerate(self.cache):
|
for i, (k, f, _ts) in enumerate(self.cache): # noqa: B007
|
||||||
if k == key:
|
if k == key:
|
||||||
self.cache.pop(i)
|
self.cache.pop(i)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ def generate(n=4):
|
|||||||
wl = list(words)
|
wl = list(words)
|
||||||
return ".".join(wl.pop(secrets.randbelow(len(wl))) for i in range(n))
|
return ".".join(wl.pop(secrets.randbelow(len(wl))) for i in range(n))
|
||||||
|
|
||||||
|
|
||||||
# A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words, entropy 2.1b/letter 10b/word
|
# A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words, entropy 2.1b/letter 10b/word
|
||||||
words: list = """
|
words: list = """
|
||||||
able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead
|
able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead
|
||||||
|
|||||||
570
cista/watching.py
Executable file → Normal file
@@ -1,167 +1,459 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import shutil
|
import shutil
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
|
from os import stat_result
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
|
from stat import S_ISDIR, S_ISREG
|
||||||
|
|
||||||
import inotify.adapters
|
|
||||||
import msgspec
|
import msgspec
|
||||||
|
from natsort import humansorted, natsort_keygen, ns
|
||||||
|
from sanic.log import logger
|
||||||
|
|
||||||
from cista import config
|
from cista import config
|
||||||
from cista.protocol import DirEntry, FileEntry, UpdateEntry
|
from cista.fileio import fuid
|
||||||
|
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
||||||
|
|
||||||
pubsub = {}
|
pubsub = {}
|
||||||
tree = {"": None}
|
sortkey = natsort_keygen(alg=ns.LOCALE)
|
||||||
tree_lock = threading.Lock()
|
|
||||||
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
|
|
||||||
|
|
||||||
def watcher_thread(loop):
|
|
||||||
global disk_usage
|
|
||||||
|
|
||||||
while True:
|
class State:
|
||||||
rootpath = config.config.path
|
def __init__(self):
|
||||||
i = inotify.adapters.InotifyTree(rootpath.as_posix())
|
self.lock = threading.RLock()
|
||||||
old = format_tree() if tree[""] else None
|
self._space = Space(0, 0, 0, 0)
|
||||||
with tree_lock:
|
self.root: list[FileEntry] = []
|
||||||
# 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
|
@property
|
||||||
refreshdl = time.monotonic() + 60.0
|
def space(self):
|
||||||
|
with self.lock:
|
||||||
|
return self._space
|
||||||
|
|
||||||
for event in i.event_gen():
|
@space.setter
|
||||||
if quit: return
|
def space(self, space):
|
||||||
# Disk usage update
|
with self.lock:
|
||||||
du = shutil.disk_usage(rootpath)
|
self._space = space
|
||||||
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():
|
def treeiter(rootmod):
|
||||||
root = tree[""]
|
relpath = PurePosixPath()
|
||||||
return msgspec.json.encode({"update": [
|
for i, entry in enumerate(rootmod):
|
||||||
UpdateEntry(size=root.size, mtime=root.mtime, dir=root.dir)
|
if entry.level > 0:
|
||||||
]}).decode()
|
relpath = PurePosixPath(*relpath.parts[: entry.level - 1]) / entry.name
|
||||||
|
yield i, relpath, entry
|
||||||
|
|
||||||
def walk(path: Path) -> DirEntry | FileEntry | None:
|
|
||||||
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}
|
def treeget(rootmod: list[FileEntry], path: PurePosixPath):
|
||||||
if tree:
|
begin = None
|
||||||
size = sum(v.size for v in tree.values())
|
ret = []
|
||||||
mtime = max(mtime, max(v.mtime for v in tree.values()))
|
|
||||||
else:
|
|
||||||
size = 0
|
|
||||||
return DirEntry(size, mtime, tree)
|
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
except OSError as e:
|
|
||||||
print("OS error walking path", path, e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def update(relpath: PurePosixPath, loop):
|
for i, relpath, entry in treeiter(rootmod):
|
||||||
"""Called by inotify updates, check the filesystem and broadcast any changes."""
|
if begin is None:
|
||||||
new = walk(rootpath / relpath)
|
if relpath == path:
|
||||||
with tree_lock:
|
begin = i
|
||||||
update = update_internal(relpath, new)
|
ret.append(entry)
|
||||||
if not update: return # No changes
|
continue
|
||||||
msg = msgspec.json.encode({"update": update}).decode()
|
if entry.level <= len(path.parts):
|
||||||
asyncio.run_coroutine_threadsafe(broadcast(msg), loop)
|
break
|
||||||
|
ret.append(entry)
|
||||||
def update_internal(relpath: PurePosixPath, new: DirEntry | FileEntry | None) -> list[UpdateEntry]:
|
|
||||||
path = "", *relpath.parts
|
return begin, ret
|
||||||
old = tree
|
|
||||||
elems = []
|
|
||||||
for name in path:
|
def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
|
||||||
if name not in old:
|
# Find the first entry greater than the new one
|
||||||
# File or folder created
|
# precondition: the new entry doesn't exist
|
||||||
old = None
|
isfile = 0
|
||||||
elems.append((name, None))
|
level = 0
|
||||||
if len(elems) < len(path):
|
i = 0
|
||||||
# We got a notify for an item whose parent is not in tree
|
for i, rel, entry in treeiter(rootmod):
|
||||||
print("Tree out of sync DEBUG", relpath)
|
if entry.level > level:
|
||||||
print(elems)
|
# We haven't found item at level, skip subdirectories
|
||||||
print("Current tree:")
|
continue
|
||||||
print(tree[""])
|
if entry.level < level:
|
||||||
print("Walking all:")
|
# We have passed the level, so the new item is the first
|
||||||
print(walk(rootpath))
|
return i
|
||||||
raise ValueError("Tree out of sync")
|
if level == 0:
|
||||||
|
# root
|
||||||
|
level += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
ename = rel.parts[level - 1]
|
||||||
|
name = relpath.parts[level - 1]
|
||||||
|
|
||||||
|
esort = sortkey(ename)
|
||||||
|
nsort = sortkey(name)
|
||||||
|
# Non-leaf are always folders, only use relfile at leaf
|
||||||
|
isfile = relfile if len(relpath.parts) == level else 0
|
||||||
|
|
||||||
|
# First compare by isfile, then by sorting order and if that too matches then case sensitive
|
||||||
|
cmp = (
|
||||||
|
entry.isfile - isfile
|
||||||
|
or (esort > nsort) - (esort < nsort)
|
||||||
|
or (ename > name) - (ename < name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cmp > 0:
|
||||||
|
return i
|
||||||
|
if cmp < 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
level += 1
|
||||||
|
if level > len(relpath.parts):
|
||||||
|
logger.error(
|
||||||
|
f"insertpos level overflow: relpath={relpath}, i={i}, entry.name={entry.name}, entry.level={entry.level}, level={level}"
|
||||||
|
)
|
||||||
break
|
break
|
||||||
old = old[name]
|
|
||||||
elems.append((name, old))
|
|
||||||
if old == new:
|
|
||||||
return []
|
|
||||||
mt = new.mtime if new else 0
|
|
||||||
szdiff = (new.size if new else 0) - (old.size if old else 0)
|
|
||||||
# Update parents
|
|
||||||
update = []
|
|
||||||
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:
|
else:
|
||||||
del parent[name]
|
i += 1
|
||||||
u.deleted = True
|
|
||||||
update.append(u)
|
|
||||||
return update
|
|
||||||
|
|
||||||
async def broadcast(msg):
|
return i
|
||||||
for queue in pubsub.values():
|
|
||||||
await queue.put_nowait(msg)
|
|
||||||
|
|
||||||
async def start(app, loop):
|
|
||||||
|
state = State()
|
||||||
|
rootpath: Path = None # type: ignore
|
||||||
|
quit = threading.Event()
|
||||||
|
|
||||||
|
## Filesystem scanning
|
||||||
|
|
||||||
|
|
||||||
|
def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]:
|
||||||
|
path = rootpath / rel
|
||||||
|
ret = []
|
||||||
|
try:
|
||||||
|
st = stat or path.stat()
|
||||||
|
isfile = int(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: list[FileEntry] = [...] # type: ignore
|
||||||
|
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)
|
||||||
|
child = sub[0]
|
||||||
|
entry = FileEntry(
|
||||||
|
level=entry.level,
|
||||||
|
name=entry.name,
|
||||||
|
key=entry.key,
|
||||||
|
size=entry.size + child.size,
|
||||||
|
mtime=max(entry.mtime, child.mtime),
|
||||||
|
isfile=entry.isfile,
|
||||||
|
)
|
||||||
|
ret.extend(sub)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # Things may be rapidly in motion
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == 13: # Permission denied
|
||||||
|
pass
|
||||||
|
logger.error(f"Watching {path=}: {e!r}")
|
||||||
|
if ret:
|
||||||
|
ret[0] = entry
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def update_root(loop):
|
||||||
|
"""Full filesystem scan"""
|
||||||
|
old = state.root
|
||||||
|
new = walk(PurePosixPath())
|
||||||
|
if old != new:
|
||||||
|
update = format_update(old, new)
|
||||||
|
with state.lock:
|
||||||
|
broadcast(update, loop)
|
||||||
|
state.root = new
|
||||||
|
|
||||||
|
|
||||||
|
def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
||||||
|
"""Called on FS updates, check the filesystem and broadcast any changes."""
|
||||||
|
new = walk(relpath)
|
||||||
|
obegin, old = treeget(rootmod, relpath)
|
||||||
|
|
||||||
|
if old == new:
|
||||||
|
return
|
||||||
|
|
||||||
|
if obegin is not None:
|
||||||
|
del rootmod[obegin : obegin + len(old)]
|
||||||
|
|
||||||
|
if new:
|
||||||
|
i = treeinspos(rootmod, relpath, new[0].isfile)
|
||||||
|
rootmod[i:i] = new
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
oremain, nremain = set(old), set(new)
|
||||||
|
update = []
|
||||||
|
keep_count = 0
|
||||||
|
iteration_count = 0
|
||||||
|
# Precompute index maps to allow deterministic tie-breaking when both
|
||||||
|
# candidates exist in both sequences but are not equal (rename/move cases)
|
||||||
|
old_pos = {e: i for i, e in enumerate(old)}
|
||||||
|
new_pos = {e: i for i, e in enumerate(new)}
|
||||||
|
|
||||||
|
while oidx < len(old) and nidx < len(new):
|
||||||
|
iteration_count += 1
|
||||||
|
|
||||||
|
# Emergency brake for potential infinite loops
|
||||||
|
if iteration_count > 50000:
|
||||||
|
logger.error(
|
||||||
|
f"format_update potential infinite loop! iteration={iteration_count}, oidx={oidx}, nidx={nidx}"
|
||||||
|
)
|
||||||
|
raise Exception(
|
||||||
|
f"format_update infinite loop detected at iteration {iteration_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
modified = False
|
||||||
|
# Matching entries are kept
|
||||||
|
if old[oidx] == new[nidx]:
|
||||||
|
entry = old[oidx]
|
||||||
|
oremain.discard(entry)
|
||||||
|
nremain.discard(entry)
|
||||||
|
keep_count += 1
|
||||||
|
oidx += 1
|
||||||
|
nidx += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if keep_count > 0:
|
||||||
|
modified = True
|
||||||
|
update.append(UpdKeep(keep_count))
|
||||||
|
keep_count = 0
|
||||||
|
|
||||||
|
# Items only in old are deleted
|
||||||
|
del_count = 0
|
||||||
|
while oidx < len(old) and old[oidx] not in nremain:
|
||||||
|
oremain.remove(old[oidx])
|
||||||
|
del_count += 1
|
||||||
|
oidx += 1
|
||||||
|
if del_count:
|
||||||
|
update.append(UpdDel(del_count))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Items only in new are inserted
|
||||||
|
insert_items = []
|
||||||
|
while nidx < len(new) and new[nidx] not in oremain:
|
||||||
|
entry = new[nidx]
|
||||||
|
nremain.discard(entry)
|
||||||
|
insert_items.append(entry)
|
||||||
|
nidx += 1
|
||||||
|
if insert_items:
|
||||||
|
modified = True
|
||||||
|
update.append(UpdIns(insert_items))
|
||||||
|
|
||||||
|
if not modified:
|
||||||
|
# Tie-break: both items exist in both lists but don't match here.
|
||||||
|
# Decide whether to delete old[oidx] first or insert new[nidx] first
|
||||||
|
# based on which alignment is closer.
|
||||||
|
if oidx >= len(old) or nidx >= len(new):
|
||||||
|
break
|
||||||
|
cur_old = old[oidx]
|
||||||
|
cur_new = new[nidx]
|
||||||
|
|
||||||
|
pos_old_in_new = new_pos.get(cur_old)
|
||||||
|
pos_new_in_old = old_pos.get(cur_new)
|
||||||
|
|
||||||
|
# Default distances if not present (shouldn't happen if in remain sets)
|
||||||
|
dist_del = (pos_old_in_new - nidx) if pos_old_in_new is not None else 1
|
||||||
|
dist_ins = (pos_new_in_old - oidx) if pos_new_in_old is not None else 1
|
||||||
|
|
||||||
|
# Prefer the operation with smaller forward distance; tie => delete
|
||||||
|
if dist_del <= dist_ins:
|
||||||
|
# Delete current old item
|
||||||
|
oremain.discard(cur_old)
|
||||||
|
update.append(UpdDel(1))
|
||||||
|
oidx += 1
|
||||||
|
else:
|
||||||
|
# Insert current new item
|
||||||
|
nremain.discard(cur_new)
|
||||||
|
update.append(UpdIns([cur_new]))
|
||||||
|
nidx += 1
|
||||||
|
|
||||||
|
# Diff any remaining
|
||||||
|
if keep_count > 0:
|
||||||
|
update.append(UpdKeep(keep_count))
|
||||||
|
if oremain:
|
||||||
|
update.append(UpdDel(len(oremain)))
|
||||||
|
elif nremain:
|
||||||
|
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() + 300.0
|
||||||
|
tspace = time.monotonic() + 5.0
|
||||||
|
# Watch for changes (frequent wakeups needed for quiting)
|
||||||
|
while not quit.is_set():
|
||||||
|
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 events, update the tree
|
||||||
|
dirty = False
|
||||||
|
rootmod = state.root[:]
|
||||||
|
for event in i.event_gen(yield_nones=False, timeout_s=0.1):
|
||||||
|
assert event
|
||||||
|
if quit.is_set():
|
||||||
|
return
|
||||||
|
interesting = any(f in modified_flags for f in event[1])
|
||||||
|
if interesting:
|
||||||
|
# Update modified path
|
||||||
|
path = PurePosixPath(event[2]) / event[3]
|
||||||
|
try:
|
||||||
|
rel_path = path.relative_to(rootpath)
|
||||||
|
update_path(rootmod, rel_path, loop)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error processing inotify event for path {path}: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
if not dirty:
|
||||||
|
t = time.monotonic()
|
||||||
|
dirty = True
|
||||||
|
# Wait a maximum of 0.2s to push the updates
|
||||||
|
if dirty and time.monotonic() >= t + 0.2:
|
||||||
|
break
|
||||||
|
if dirty and state.root != rootmod:
|
||||||
|
try:
|
||||||
|
update = format_update(state.root, rootmod)
|
||||||
|
with state.lock:
|
||||||
|
broadcast(update, loop)
|
||||||
|
state.root = rootmod
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"format_update failed; falling back to full rescan"
|
||||||
|
)
|
||||||
|
# Fallback: full rescan and try diff again; last resort send full root
|
||||||
|
try:
|
||||||
|
fresh = walk(PurePosixPath())
|
||||||
|
try:
|
||||||
|
update = format_update(state.root, fresh)
|
||||||
|
with state.lock:
|
||||||
|
broadcast(update, loop)
|
||||||
|
state.root = fresh
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Fallback diff failed; sending full root snapshot"
|
||||||
|
)
|
||||||
|
with state.lock:
|
||||||
|
broadcast(format_root(fresh), loop)
|
||||||
|
state.root = fresh
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Full rescan failed; dropping this batch of updates"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def start(app, loop):
|
||||||
|
global rootpath
|
||||||
config.load_config()
|
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()
|
app.ctx.watcher.start()
|
||||||
|
|
||||||
async def stop(app, loop):
|
|
||||||
global quit
|
def stop(app):
|
||||||
quit = True
|
quit.set()
|
||||||
app.ctx.watcher.join()
|
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: 40 KiB |
5
cista-front/.gitignore → frontend/.gitignore
vendored
@@ -7,12 +7,17 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# No locking
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
coverage
|
coverage
|
||||||
*.local
|
*.local
|
||||||
|
components.d.ts
|
||||||
|
|
||||||
/cypress/videos/
|
/cypress/videos/
|
||||||
/cypress/screenshots/
|
/cypress/screenshots/
|
||||||
2
frontend/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
audit=false
|
||||||
|
fund=false
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# cista-front
|
# Cista Vue Frontend
|
||||||
|
|
||||||
This template should help get you started developing with Vue 3 in Vite.
|
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
|
## Recommended IDE Setup
|
||||||
|
|
||||||
@@ -17,24 +17,30 @@ If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has a
|
|||||||
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
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.
|
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||||
|
|
||||||
## Customize configuration
|
## Hot-Reload for Development
|
||||||
|
|
||||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
### Run the backend
|
||||||
|
|
||||||
## Project Setup
|
```fish
|
||||||
|
uv sync --dev
|
||||||
```sh
|
uv run cista --dev -l :8000
|
||||||
npm install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compile and Hot-Reload for Development
|
### And the Vite server (in another terminal)
|
||||||
|
|
||||||
```sh
|
```fish
|
||||||
npm run dev
|
cd frontend
|
||||||
|
bun install
|
||||||
|
bun 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
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
```sh
|
This is also called by `uv build` during Python packaging:
|
||||||
npm run build
|
|
||||||
|
```fish
|
||||||
|
bun run build
|
||||||
```
|
```
|
||||||
|
|
||||||
11
frontend/index.html
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<!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>
|
||||||
|
<body id="app">
|
||||||
61
frontend/package.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"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/"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"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-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: /
|
||||||
158
frontend/src/App.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<LoginModal />
|
||||||
|
<SettingsModal />
|
||||||
|
<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>
|
||||||
|
<footer>
|
||||||
|
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
||||||
|
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
||||||
|
</footer>
|
||||||
|
</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'
|
||||||
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
if (store.dialog) {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 (!input && 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 (!input && keyup && 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 (!input && !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 (!input && 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 (!input && 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 |
276
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
.headermain,
|
||||||
|
.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;
|
||||||
|
margin: 0 .5rem 0 1rem !important;
|
||||||
|
}
|
||||||
|
body#app {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
thead tr {
|
||||||
|
font-size: 1rem !important;
|
||||||
|
position: static !important;
|
||||||
|
background: none !important;
|
||||||
|
border-bottom: 1pt solid black !important;
|
||||||
|
}
|
||||||
|
audio::-webkit-media-controls-timeline,
|
||||||
|
video::-webkit-media-controls-timeline {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
audio::-webkit-media-controls,
|
||||||
|
video::-webkit-media-controls {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
tr, figure {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
body#app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding-bottom: 3em; /* convenience space on the bottom */
|
||||||
|
overflow-y: scroll;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
header nav.headermain {
|
||||||
|
/* Position so that tooltips can appear on top of other positioned elements */
|
||||||
|
position: relative;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.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 |