88 Commits

Author SHA1 Message Date
Leo Vasanko
091d57dba7 Fix typing and import in the config file module. 2025-08-17 10:31:54 -06:00
Leo Vasanko
69a897cfec Startup banner with version display, and --version, using stderr/stdout properly. 2025-08-17 10:31:18 -06:00
Leo Vasanko
33db2c01b4 Cleaner server shutdowns:
- Remove a workaround for Sanic server not always terminating cleanly
- Terminate worker threads before server stop
- Silent closing of watching WebSocket attempted to open while shutting down
2025-08-17 08:15:01 -06:00
Leo Vasanko
26addb2f7b Image previews improved, all EXIF Orientations handled. 2025-08-17 07:00:52 -06:00
Leo Vasanko
ce6d60e588 Cleanup for release 1.0.0. 2025-08-15 11:11:57 -06:00
073f1a8707 Maintenance update (#7)
- Use modern tooling uv and bun
- Various changes to work with latest PyAV and PIL that have changed their API
- Improved image, video and document previews (uses AVIF, renders AVIF/HEIC/videos in HDR, faster processing)
- Fix a server hang in some cases where a folder was moved or renamed
- Log exceptions instead of only returning 500 response to client
- Log timing of preview generation functions
- Default to quality 50 in previews (previously 40)
2025-08-15 18:03:04 +01:00
Leo Vasanko
f38bb4bab9 Better transparent Cista image 2023-11-25 17:25:35 +00:00
Leo Vasanko
26f9bef087 Correct hatch build hook 2023-11-21 20:11:37 +00:00
Leo Vasanko
634dabe52d Less messy breadcrumbs on search results in gallery 2023-11-21 17:51:38 +00:00
Leo Vasanko
a383358369 Fix direct uploads and downloads, transfer bar UI 2023-11-21 16:13:46 +00:00
Leo Vasanko
369dc3ecaf Fixed New Folder, added Rename to Gallery 2023-11-21 15:49:33 +00:00
Leo Vasanko
0cf9c254e5 Various build fixes, cleanup and details (#6)
- Major memory usage reduction in video previews
- Finally builds properly on Windows too

Reviewed-on: #6
2023-11-21 15:32:49 +00:00
Leo Vasanko
58b9dd3dd4 Cleanup 2023-11-20 16:35:34 -08:00
Leo Vasanko
0965a56204 Download progress bar 2023-11-20 16:27:51 -08:00
Leo Vasanko
52beedcef0 Preserve cursor and selection across reloads. 2023-11-20 14:49:15 -08:00
Leo Vasanko
8592d462f2 Keep current cursor when switching gallery/details. 2023-11-20 14:11:05 -08:00
Leo Vasanko
b2a24fca57 Fixed renames and breadcrumbs for gallery. 2023-11-20 13:47:41 -08:00
Leo Vasanko
7cc7e32c33 Prune deleted folders off of breadcrumbs' longest visited (keeps current folder even if missing). 2023-11-20 12:22:03 -08:00
Leo Vasanko
fa98cb9177 Refactor empty folder to a separate component, allow creating new folders in empty folders again. 2023-11-20 12:08:17 -08:00
Leo Vasanko
b3ab09a614 Don't let global shortcuts interfere with input fields. 2023-11-20 11:45:47 -08:00
Leo Vasanko
a49dd2f111 Further bugfixes. Increase full update interval to 5 minutes. 2023-11-20 19:22:49 +00:00
Leo Vasanko
dbb06e111c Rewritten inotify watching and faster full tree traversal (also for polling worker). Bugs in inotify watcher fixed. 2023-11-20 18:49:24 +00:00
Leo Vasanko
667e31aa08 Fix regression: inotify watching was broken. 2023-11-20 13:58:20 +00:00
Leo Vasanko
007f021d6f Small fixes 2023-11-20 12:58:39 +00:00
Leo Vasanko
b2188eee68 Avoid showing dialogs at page load. 2023-11-20 12:45:29 +00:00
Leo Vasanko
7311ffdff1 Implement settings dialog and password changes. 2023-11-20 04:29:42 -08:00
Leo Vasanko
102a970174 Gallery improvements, better layout and autoplay of next media file. 2023-11-20 01:51:34 -08:00
Leo Vasanko
a9d713dbd0 More robust and flexible layout. 2023-11-18 21:55:52 -08:00
Leo Vasanko
434e55f303 Video preview posters 2023-11-18 17:50:27 -08:00
Leo Vasanko
be9772c90e Minor changes 2023-11-18 16:03:23 -08:00
Leo Vasanko
bb2448dc24 Add pymupdf/fitz dependency 2023-11-18 15:21:11 -08:00
Leo Vasanko
115f3e20d0 Added PDF previews. Preview quality configurable. Preview browser caching and cache busting. 2023-11-18 15:16:24 -08:00
Leo Vasanko
a366a0bcc6 Improve Gallery keyboard navigation 2023-11-18 12:37:48 -08:00
Leo Vasanko
2ff0f78759 Remove new folder button until implemented in gallery 2023-11-18 12:26:38 -08:00
Leo Vasanko
9a2d6e8065 Fix theming of filenames in gallery light mode. 2023-11-18 12:25:08 -08:00
Leo Vasanko
62388eb555 Fix preview images that need rotation 2023-11-18 12:04:35 -08:00
Leo Vasanko
53778543bf Remove debug 2023-11-18 11:47:01 -08:00
Leo Vasanko
8dda230510 Add PIL dependency 2023-11-18 11:40:34 -08:00
Leo Vasanko
696e3ab568 Implement media preview thumbnails for Gallery 2023-11-18 11:38:25 -08:00
Leo Vasanko
85ac12ad33 Fix empty folder / folder missing for empty folders at root 2023-11-18 10:22:07 -08:00
Leo Vasanko
e56cc47105 Large number of keyboard navigation and other fixes. 2023-11-18 10:15:13 -08:00
Leo Vasanko
ebbd96bc94 Global shortcut keys tuning. 2023-11-18 07:56:53 -08:00
Leo Vasanko
a9b6d04361 Remove defineProps imports (it is a compiler macro now). 2023-11-18 07:47:55 -08:00
Leo Vasanko
5808fe17ad Fix cursor handling 2023-11-18 07:47:34 -08:00
Leo Vasanko
671359e327 Search placeholder 2023-11-18 07:39:08 -08:00
Leo Vasanko
ba9495eb65 File explorer CSS, avoid modified/size being cut 2023-11-18 07:33:50 -08:00
Leo Vasanko
de482afd60 Improved breadcrumb scaling 2023-11-18 07:00:55 -08:00
Leo Vasanko
a547052e29 Added header eye button for gallery toggle 2023-11-18 07:00:46 -08:00
Leo Vasanko
07c2ff4c15 Keyboard sort by 1-2-3 supplemented by the key left of them for default sort. 2023-11-18 06:32:25 -08:00
Leo Vasanko
e20b04189f Refactoring cursor to be stored in store as key only. A few issues remain. 2023-11-17 19:44:18 -08:00
Leo Vasanko
8da141744e Implemented Gallery view for media files. 2023-11-17 18:32:24 -08:00
Leo Vasanko
11887edde3 Skip any symlinks while scanning. Stats on how long a scan took. 2023-11-17 17:49:35 -08:00
Leo Vasanko
034c6fdea9 Fixed header and breadcrumb layout and issues. 2023-11-17 16:16:53 -08:00
Leo Vasanko
c5083f0f2b Correct error page rendering via Sanic. 2023-11-17 09:20:14 -08:00
Leo Vasanko
f8a9197474 Tidying up log, correct HTTP status for error pages. 2023-11-16 17:10:18 -08:00
Leo Vasanko
5285cb2fb5 Watching cleanup 2023-11-16 09:17:50 -08:00
Leo Vasanko
b6b387d09b Code cleanup and bugfixes:
- Resolve threading deadlock when multiple watch request arrived at the same moment.
- Implement more graceful server exit.
- Reduce excessive logging.
- Fix unix socket clearing; until Sanic starts accepting Path for unix socket name.
2023-11-16 07:09:57 -08:00
Leo Vasanko
669762dfe7 Update documentation README 2023-11-16 07:07:52 -08:00
Leo Vasanko
51fd07d4fa Update frontend/README.md 2023-11-14 23:50:17 +00:00
Leo Vasanko
c40c245ce6 CLI simplification, better diagnostic for missing wwwroot, docs. 2023-11-14 11:05:04 -08:00
Leo Vasanko
1fdd00b833 Updated installation and README 2023-11-14 08:00:21 -08:00
Leo Vasanko
520a9dff47 Keyboard nav: 1/2/3 globally toggle table ordering. Esc closes search, slash also toggles search like Ctrl/Cmd+F does. 2023-11-14 05:55:55 -08:00
Leo Vasanko
c5c65d136a BreadCrumb & keyboard navigation improvements. 2023-11-14 05:43:01 -08:00
Leo Vasanko
61f9026e23 Attempt to fix config handling on Windows 2023-11-13 16:31:35 -08:00
Leo Vasanko
3e50149d4d Add more quit points for watching thread. 2023-11-13 16:28:53 -08:00
Leo Vasanko
7077b21159 Add frontend build to Python packaging. Remove dead code, cleanup. 2023-11-13 16:19:33 -08:00
Leo Vasanko
938c5ca657 Add project URL 2023-11-13 14:59:22 -08:00
Leo Vasanko
e0aef07783 Update README 2023-11-13 14:49:08 -08:00
Leo Vasanko
36826a83c1 Remember sort order 2023-11-13 14:15:28 -08:00
Leo Vasanko
6880f82c19 Add file context menu (only rename for now). 2023-11-13 10:09:12 -08:00
Leo Vasanko
5dd1bd9bdc Add missing file 2023-11-13 09:55:32 -08:00
Leo Vasanko
41e8c78ecd Refactoring Document storage (#5)
- Major refactoring that makes Doc a class with properties
- Data made only shallow reactive, for a good speedup of initial load
- Minor bugfixes and UX improvements along the way
- Fixed handling of hash and question marks in URLs (was confusing Vue Router)
- Search made stricter to find good results (not ignore all punctuation)

Reviewed-on: #5
2023-11-13 17:52:57 +00:00
Leo Vasanko
dc4bb494f3 Use localStoragerather than sessionStorage for cache. Rename variable. 2023-11-13 13:04:39 +00:00
Leo Vasanko
9b58b887b4 Log messages on session loading 2023-11-13 12:17:47 +00:00
Leo Vasanko
07848907f3 Typing error 2023-11-13 12:11:02 +00:00
Leo Vasanko
7a08f7cbe2 Pasteing files and folders to upload. 2023-11-13 03:39:10 -08:00
Leo Vasanko
dd37238510 Update modified immediately when entering a folder 2023-11-13 02:19:13 -08:00
Leo Vasanko
c8d5f335b1 Fix upload of zero-sized files. 2023-11-13 02:13:11 -08:00
Leo Vasanko
bb80b3ee54 Clear file upload input to allow re-uploading the same item. 2023-11-13 01:38:22 -08:00
Leo Vasanko
06d860c601 Only update time-ago modified field on current folder (optimization, full update was slow for large storages). 2023-11-13 00:52:03 -08:00
Leo Vasanko
c321de13fd Don't reload backend on wwwroot changes. 2023-11-13 00:48:45 -08:00
Leo Vasanko
278e8303c4 Upload manager UI fix/tuning. 2023-11-13 00:37:56 -08:00
Leo Vasanko
9854dd01cc More efficient flat file list format and various UX improvements (#3)
This is a major upgrade with assorted things included.

- Navigation flows improved, search appears in URL history, cleared when navigating to another folder
- More efficient file list format for faster loads
- Efficient updates, never re-send full root another time (except at connection)
- Large number of watching and filelist updates (inotify issues remain)
- File size coloring
- Fixed ZIP generation random glitches (thread race condition)
- Code refactoring, cleanup, typing fixes
- More tests

Reviewed-on: #3
2023-11-12 23:20:40 +00:00
Leo Vasanko
fb03fa5430 Favicon, title, automatic & manual server naming (#2)
Server name may be set in config file. If unset, backend uses the folder name being served. This is shown in page title for site root, and subfolder names are also now shown. New icon of Droppy icon, changing only the color.

Reviewed-on: #2
2023-11-08 23:00:07 +00:00
Leo Vasanko
e26cb8f70a Linting, removed extra line from pyproject.toml. 2023-11-08 13:08:46 -08:00
Leo Vasanko
9bbbc829a1 Correct processing of custom config dir 2023-11-08 12:56:43 -08:00
Leo Vasanko
876d76bc1f Frontend created and rewritten a few times, with some backend fixes (#1)
The software is fully operational.

Reviewed-on: #1
2023-11-08 20:38:40 +00:00
Leo Vasanko
4a53d0b8e2 Formatting and fix Internal Server Error on upload 2023-10-26 18:59:19 +03:00
162 changed files with 5601 additions and 2995 deletions

2
.gitignore vendored
View File

@@ -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
View File

@@ -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.

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve 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>

View File

@@ -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>
Vues
<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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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')

View File

@@ -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

View File

@@ -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 }
})

View File

@@ -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>

View File

@@ -1,9 +0,0 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>

View File

@@ -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
View 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
View 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())

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}

View File

@@ -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
View 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
View File

View 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
View 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."""

View File

@@ -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
View 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

View File

@@ -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
View 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()

View File

@@ -1 +0,0 @@
@media (min-width: 1024px){.about{min-height:100vh;display:flex;align-items:center}}

View File

@@ -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};

File diff suppressed because one or more lines are too long

View File

@@ -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}}

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -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>

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -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
View File

@@ -0,0 +1,2 @@
audit=false
fund=false

View File

@@ -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
View 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
View 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
}
}

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

158
frontend/src/App.vue Normal file
View 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>

View 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

View 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;
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Some files were not shown because too many files have changed in this diff Show More