27 Commits

Author SHA1 Message Date
Leo Vasanko
acdd776b92 Formatting and fix Internal Server Error on upload 2023-10-28 20:20:34 +00:00
b3fd9637eb Login, Download and visuals update 2023-10-28 04:13:01 -05:00
2b72508206 Search Bar UI update 2023-10-27 15:32:21 +00:00
Leo Vasanko
8cc3ed1a04 Human-readable sizes 2023-10-27 08:28:03 +03:00
Leo Vasanko
0d186726b5 Prettier file listing, using browser instead of viewer for file display (for now), sorting improved, modified timestamps improved. 2023-10-27 07:53:30 +03:00
Leo Vasanko
63bbe84859 Provide file/dir id from server. The value will stay the same if the file is renamed/moved, but will change if a file is replaced by another with the same name. 2023-10-27 07:51:51 +03:00
Leo Vasanko
202f28ff15 Add esbuild dependency 2023-10-27 04:34:27 +03:00
41772e6c18 Fix get files #build_commit 2023-10-26 10:01:54 -05:00
e52379d515 Fix get files #build_commit 2023-10-26 09:48:12 -05:00
74987898c9 Fix get files #build_commit 2023-10-26 09:34:24 -05:00
859d312913 Fix get files 2023-10-26 09:14:24 -05:00
4bc9cf4534 Update endpoints and URL bases configuration 2023-10-26 09:06:07 -05:00
754d779069 Test frontend #only_compile 2023-10-26 00:10:52 -05:00
367e4ba0ea Test frontend #only_compile 2023-10-26 00:08:06 -05:00
c2e9a4af05 Test frontend #only_compile 2023-10-26 00:06:45 -05:00
6cdc37a172 Test frontend #only_compile 2023-10-26 00:05:11 -05:00
19699564c2 Test frontend #only_compile 2023-10-26 00:02:45 -05:00
7baf8b3f9b Test frontend #only_compile 2023-10-25 23:55:50 -05:00
47329ac04e Test frontend #only_compile 2023-10-25 23:46:21 -05:00
f4013d1196 Test frontend #only_compile 2023-10-25 23:34:20 -05:00
3672156b5e Test frontend #only_compile 2023-10-25 23:31:13 -05:00
f2b37852da Test frontend #only_compile 2023-10-25 21:56:45 -05:00
708e54d080 Test frontend #only_compile 2023-10-25 21:52:08 -05:00
d051265f40 Test frontend #only_compile 2023-10-25 21:47:45 -05:00
5cf133465e Test frontend #only_compile 2023-10-25 21:40:44 -05:00
1c91bf2e87 Test frontend #only_compile 2023-10-25 21:26:37 -05:00
9cd6f83bec Initialize VUE project with WS connection, main pages, and various small features 2023-10-25 18:43:44 -05:00
147 changed files with 8583 additions and 3643 deletions

1
.gitignore vendored
View File

@@ -3,5 +3,4 @@
__pycache__/ __pycache__/
*.egg-info/ *.egg-info/
/cista/_version.py /cista/_version.py
/cista/wwwroot/*
/dist /dist

119
README.md
View File

@@ -1,116 +1,25 @@
# Cista Web Storage # Web File Storage
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.jpg" align=right width=250> Run directly from repository with Hatch (or use pip install as usual):
```sh
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. hatch run cista -l :3000 /path/to/files
This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**.
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter.
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something...
## Getting Started
### Installation
To install the cista application, use:
```fish
pip install cista
``` ```
Note: Some Linux distributions might need `--break-system-packages` to install Python packages, which are safely installed in the user's home folder. As an alternative to avoid installation, run it with command `pipx run cista` 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).
### Running the Server Create your user account:
```sh
Create an account: (or run a public server without authentication) hatch run cista --user admin --privileged
```fish
cista --user yourname --privileged
``` ```
Serve your files at http://localhost:8000: ## Build frontend
```fish
cista -l :8000 /path/to/files
```
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments. Prebuilt frontend is provided in repository but for any changes it will need to be manually rebuilt:
### Internet Access ```sh
cd cista-front
To use your own TLS certificates, place them in the config folder and run:
```fish
cista -l cista.example.com
```
Most admins instead find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address. Caddy configuration **/etc/caddy/Caddyfile** is dead simple:
```Caddyfile
cista.example.com {
reverse_proxy :8000
}
```
## Development setup
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
```fish
cd frontend
npm install npm install
npm run dev npm run build
``` ```
Concurrently, start the backend on another terminal: 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`.
```fish
hatch shell
pip install -e '.[dev]'
cista --dev -l :8000 /path/to/files
```
We use `hatch shell` for installing on a virtual environment, to not disturb the rest of the system with our hacking.
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. Running `hatch build` builds the frontend and creates a NodeJS-independent Python package.
## System Deployment
This setup allows easy addition of storages, each with its own domain, configuration, and files.
Assuming a restricted user account **storage** for serving files and that cista is installed system-wide or on this account (check with `sudo -u storage -s`). Alternatively, use `pipx run cista` or `hatch run cista` as the ExecStart command.
Create **/etc/systemd/system/cista@.service**:
```ini
[Unit]
Description=Cista storage %i
[Service]
User=storage
ExecStart=cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/
Restart=always
[Install]
WantedBy=multi-user.target
```
This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience.
```fish
systemctl daemon-reload
systemctl enable --now cista@foo.example.com
systemctl enable --now cista@bar.example.com
```
Public exposure is easiest using the Caddy web server, but Nginx or others also work. Run the server with -l domain.example.com if you have TLS certificates in the config folder.
**/etc/caddy/Caddyfile**:
```Caddyfile
foo.example.com, bar.example.com {
reverse_proxy unix//srv/cista/{host}/socket
}
```

View File

@@ -7,17 +7,12 @@ 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/

37
cista-front/components.d.ts vendored Normal file
View File

@@ -0,0 +1,37 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']
ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem']
AButton: typeof import('ant-design-vue/es')['Button']
ACol: typeof import('ant-design-vue/es')['Col']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AInput: typeof import('ant-design-vue/es')['Input']
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
AModal: typeof import('ant-design-vue/es')['Modal']
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
APopover: typeof import('ant-design-vue/es')['Popover']
AppNavigation: typeof import('./src/components/AppNavigation.vue')['default']
AProgress: typeof import('ant-design-vue/es')['Progress']
ARow: typeof import('ant-design-vue/es')['Row']
ATable: typeof import('ant-design-vue/es')['Table']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
FileCarousel: typeof import('./src/components/FileCarousel.vue')['default']
FileExplorer: typeof import('./src/components/FileExplorer.vue')['default']
FileViewer: typeof import('./src/components/FileViewer.vue')['default']
HeaderMain: typeof import('./src/components/HeaderMain.vue')['default']
LoginModal: typeof import('./src/components/LoginModal.vue')['default']
NotificationLoading: typeof import('./src/components/NotificationLoading.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
UploadButton: typeof import('./src/components/UploadButton.vue')['default']
}
}

13
cista-front/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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, shrink-to-fit=no">
<title>Vite Vasanko</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5732
cista-front/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{ {
"name": "cista-frontend", "name": "front",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -13,16 +13,16 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@imengyu/vue3-context-menu": "^1.3.3", "@ant-design/icons-vue": "^7.0.0",
"@vueuse/core": "^10.4.1", "@vueuse/core": "^10.4.1",
"ant-design-vue": "^4.0.3",
"axios": "^1.5.0",
"esbuild": "^0.19.5", "esbuild": "^0.19.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"pinia": "^2.1.6", "pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.2.0",
"unplugin-vue-components": "^0.25.2", "unplugin-vue-components": "^0.25.2",
"vite-plugin-rewrite-all": "^1.0.1", "vite-plugin-rewrite-all": "^1.0.1",
"vite-svg-loader": "^4.0.0",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "^4.2.4" "vue-router": "^4.2.4"
}, },
@@ -37,9 +37,8 @@
"@vue/eslint-config-typescript": "^12.0.0", "@vue/eslint-config-typescript": "^12.0.0",
"@vue/test-utils": "^2.4.1", "@vue/test-utils": "^2.4.1",
"@vue/tsconfig": "^0.4.0", "@vue/tsconfig": "^0.4.0",
"babel-eslint": "^10.1.0", "eslint": "^8.49.0",
"eslint": "^8.52.0", "eslint-plugin-vue": "^9.17.0",
"eslint-plugin-vue": "^9.18.1",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"npm-run-all2": "^6.0.6", "npm-run-all2": "^6.0.6",
"prettier": "^3.0.3", "prettier": "^3.0.3",
@@ -47,13 +46,5 @@
"vite": "^4.4.9", "vite": "^4.4.9",
"vitest": "^0.34.4", "vitest": "^0.34.4",
"vue-tsc": "^1.8.11" "vue-tsc": "^1.8.11"
},
"prettier": {
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid",
"endOfLine": "lf",
"printWidth": 88
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

241
cista-front/public/old-index.html Executable file
View File

@@ -0,0 +1,241 @@
<!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>

65
cista-front/src/App.vue Normal file
View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import type { ComputedRef } from 'vue'
import { watchEffect } from 'vue'
import createWebSocket from '@/repositories/WS'
import { url_document_watch_ws, url_document_upload_ws, DocumentHandler, DocumentUploadHandler } from '@/repositories/Document'
import { useDocumentStore } from '@/stores/documents'
import { computed } from 'vue'
import HeaderMain from './components/HeaderMain.vue'
import AppNavigation from './components/AppNavigation.vue'
import Router from './router/index';
interface Path {
path: string;
pathList: string[];
}
const documentStore = useDocumentStore()
const path: ComputedRef<Path> = computed( () => {
const pathList = Router.currentRoute.value.path
.split('/')
.filter( value => value !== '')
return {
path: Router.currentRoute.value.path,
pathList
}
})
// Update human-readable x seconds ago messages from mtimes
setInterval(documentStore.updateModified, 1000)
watchEffect(() => {
const documentHandler = new DocumentHandler()
const documentUploadHandler = new DocumentUploadHandler()
const wsWatch = createWebSocket(url_document_watch_ws, documentHandler.handleWebSocketMessage)
const wsUpload = createWebSocket(url_document_upload_ws, documentUploadHandler.handleWebSocketMessage)
documentStore.wsWatch = wsWatch;
documentStore.wsUpload = wsUpload;
})
export type { Path }
</script>
<template>
<header class="wrapper">
<HeaderMain WS="WS"></HeaderMain>
<AppNavigation :path="path.pathList"></AppNavigation>
</header>
<RouterView class="page-container" />
</template>
<style scoped>
.wrapper{
background-color: var(--primary-background);
padding: 0.2em 0.5em;
display: flex;
flex-direction: column;
gap: 10px;
}
.page-container{
flex-grow: 2;
padding: 0;
}
</style>

View File

@@ -0,0 +1,105 @@
:root {
--primary-background: #181818;
--secondary-background: #ffffff;
--font-color: #333333;
--table-background: #535353;
--primary-color: #ffffff;
--secondary-color: #ccc;
--blue-color: #66ffeb;
--red-color: #ff4d4f;
}
@media (prefers-color-scheme: dark) {
:root {
--primary-background: #181818;
--secondary-background: #333333;
--font-color: #ffffff;
--table-background: #535353;
--primary-color: #ffffff;
--secondary-color: #ccc;
--blue-color: #66ffeb;
--red-color: #ff4d4f;
}
}
body {
background-color: var(--table-background);
}
.ant-breadcrumb-separator, .ant-breadcrumb-link, .ant-breadcrumb .anticon {
color: var(--primary-color) !important;
font-size: 1.2em !important;
}
#app{
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--secondary-background);
}
.ant-image-preview-mask{
background-color: rgba(0, 0, 0, 0.6) !important;
backdrop-filter: blur(15px) !important;
}
.ant-table-cell:hover{
background-color: initial !important;
}
.ant-table-wrapper .ant-table-tbody >tr.ant-table-row-selected >td,
.ant-table-wrapper .ant-table-tbody >tr.ant-table-row-selected:hover>td {
background-color: transparent;
}
.ant-form-item .ant-form-item-label >label{
color: var(--font-color);
}
@media (prefers-color-scheme: dark) {
.ant-input-group >.ant-input-group-addon:last-child .ant-input-search-button:not(.ant-btn-primary):hover{
color : var(--blue-color) !important
}
.ant-input-search .ant-input:hover,
.ant-input-search .ant-input:focus {
border-color: var(--blue-color);
}
.ant-input{
background-color:var(--table-background);
border-color: var(--table-background);
color: var(--font-color)
}
.ant-table-wrapper .ant-table-thead>tr>th {
background-color: var(--table-background);
}
.ant-table-wrapper .ant-table-thead th.ant-table-column-has-sorters:hover {
background-color: var(--table-background);
}
.ant-table-content {
background-color: var(--secondary-background);
}
.ant-table-cell-row-hover,
.ant-table-wrapper .ant-table-tbody>tr.ant-table-row:hover>td {
background-color: var(--primary-background) !important;
}
.ant-table-column-title,
.ant-table-column-sorter,
.ant-table-column-sort,
.ant-table-cell,
a {
color: var(--primary-color) !important;
}
.ant-table-cell>button>.anticon {
color: var(--primary-color);
}
.ant-notification-close-x{
color: var(--secondary-background);
}
.ant-empty-description{
color: var(--primary-color);
}
.ant-modal .ant-modal-content{
background-color: var(--secondary-background);
}
.ant-modal .ant-modal-close-x{
color: var(--font-color);
}
}

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { HomeOutlined } from '@ant-design/icons-vue';
import { RouterLink } from 'vue-router'
const props = withDefaults(
defineProps<{
path: Array<string>
}>(),
{},
)
function generateUrl(pathIndex: number) {
return "/" + props.path.slice(0, pathIndex + 1).join('/')
}
</script>
<template>
<nav>
<a-breadcrumb>
<a-breadcrumb-item>
<RouterLink to="/">
<home-outlined />
</RouterLink>
</a-breadcrumb-item>
<a-breadcrumb-item v-for="(location, index) in path" :key="index">
<RouterLink :to ="generateUrl(index)">
<span :class="(index === path.length - 1) && 'last' ">{{ decodeURIComponent(location) }}</span>
</RouterLink>
</a-breadcrumb-item>
</a-breadcrumb>
</nav>
</template>
<style scoped>
nav, span{
color: var(--primary-color);
}
span:hover, .last{
color: var(--blue-color)
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<div class="carousel" ref="fileCarousel">
<a-page-header :style="{ visibility: idle ? 'hidden' : 'visible' }">
<template #extra>
<a-button type="text" class="action-button" :onclick="toggle" :icon="h(isFullscreen? FullscreenExitOutlined: FullscreenOutlined)" />
<a-button type="text" class="action-button" :onclick="redirectBack" :icon="h(CloseOutlined)" />
</template>
</a-page-header>
<a-row class="slider">
<a-col :span="2" class="centered-vertically">
<div class="custom-slick-arrow slick-arrow slick-prev centered" @click="next(-1)" style="left: 10px; z-index: 1">
<LeftOutlined v-show="!idle" />
</div>
</a-col>
<a-col :span="20" class="centered">
<FileViewer v-if="!documentStore.loading" :visibleImg="visible" @visibleImg="setVisible" :type="fileType"></FileViewer>
</a-col>
<a-col :span="2" class="centered-vertically right">
<div class="custom-slick-arrow slick-arrow slick-prev centered" @click="next(1)" style="right: 10px">
<RightOutlined v-show="!idle" />
</div>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { h, ref, watchEffect } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import { useIdle, useFullscreen } from '@vueuse/core'
import { LeftOutlined, RightOutlined, FullscreenOutlined, FullscreenExitOutlined, CloseOutlined } from '@ant-design/icons-vue';
import { getFileType } from '@/utils'
import Router from '@/router/index';
import FileViewer from './FileViewer.vue';
const fileCarousel = ref<HTMLElement | null>(null)
const { isFullscreen, toggle } = useFullscreen(fileCarousel)
const visible = ref<boolean>(false);
const documentStore = useDocumentStore()
const fileType = ref<string| undefined>(undefined);
watchEffect(() => {
if(documentStore.mainDocument[0] && documentStore.mainDocument[0].type === 'file'){
const fileExt = documentStore.mainDocument[0].ext
fileType.value = getFileType(fileExt)
}
})
function setVisible(value: boolean) {
visible.value = value;
}
const { idle } = useIdle(2000)
function redirectBack() {
const currentPath = Router.currentRoute.value.path;
const pathParts = currentPath.split('/').filter(part => part); // Remove empty parts
// Ensure there's always at least one part in the path
if (pathParts.length <= 1) {
// If it's empty, set it to the base URL
pathParts[0] = '/';
} else {
pathParts.pop();
}
const newPath = pathParts.join('/');
Router.push(newPath);
}
function next(direction: number){
const path = decodeURIComponent(new String(Router.currentRoute.value.path) as string)
const name = documentStore.getNextDocumentInRoute(direction, path)
let nextFileLocation : string[] | string = path.split('/')
nextFileLocation.pop()
nextFileLocation.push(name)
nextFileLocation = nextFileLocation.join('/')
Router.push(nextFileLocation)
}
</script>
<style scoped>
:deep(.slick-arrow.custom-slick-arrow) {
width: 60px;
height: 60px;
font-size: 60px;
color: var(--primary-color);
transition: ease-in all 0.3s;
opacity: 0.3;
z-index: 1;
}
:deep(.slick-arrow.custom-slick-arrow:before) {
display: none;
}
:deep(.slick-arrow.custom-slick-arrow:hover) {
color: var(--primary-color);
opacity: 1;
}
.slider {
height: 80vh;
background-color: inherit;
}
.centered {
display: flex;
justify-content: center;
align-content: center;
}
.centered-vertically {
display: flex;
align-items: center;
}
.right {
flex-direction: row-reverse;
}
.action-button {
padding: 0;
font-size: 1.5em;
opacity: 0.5;
color: var(--secondary-color);
&:hover {
color: var(--blue-color);
}
}
.ant-page-header {
padding: 0;
}
.carousel{
margin: 0;
height: inherit;
background-color: var(--table-background);
}
</style>

View File

@@ -0,0 +1,208 @@
<template>
<main>
<context-holder />
<!-- <h2 v-if="!documentStore.loading && documentStore.error"> {{ documentStore.error }} </h2> -->
<div class="carousel-container" v-if="!documentStore.loading && documentStore.mainDocument[0] && documentStore.mainDocument[0].type === 'file'">
<FileCarousel></FileCarousel>
</div>
<a-table
v-else-if="!documentStore.loading && documentStore.mainDocument"
:pagination=false
:row-selection="{ selectedRowKeys: state.selectedRowKeys, onChange: onSelectChange }"
:columns="columns"
:data-source="documentStore.mainDocument"
>
<template #headerCell="{column}"></template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="editable-cell" :class="record.type === 'folder' ? 'folder' : 'file'">
<div v-if="editableData[record.key]" class="action-container editable-cell-input-wrapper">
<a-input class="name" v-model:value="editableData[record.key].name" @pressEnter="save(record.key)" />
<CheckOutlined class="edit-action editable-cell-icon-check" @click="save(record.key)" />
</div>
<div v-else class="action-container editable-cell-text-wrapper">
<a v-if="record.type === 'folder'" class="name" :href="`#${linkBasePath}/${record.name}`">{{record.name}}</a>
<a v-else class="name" :href="`${filesBasePath}/${record.name}`">{{record.name}}</a>
<edit-outlined class="edit-action editable-cell-icon" @click="edit(record.key)" />
</div>
</div>
</template>
<template v-if="column.key === 'action'">
<a-popover trigger="click">
<template #content>
<div class="more-action">
<div class="action-container">
<a :href="`${record.type === 'folder'? linkBasePath+'#' : filesBasePath}/${record.name}`">
<a-button type="text" class="action-button" :icon="h(ImportOutlined)"/>
</a>
Open
</div>
<div v-if="record.type === 'folder-file'" class="action-container">
<a :href="`${filesBasePath}/${record.name}`" download>
<a-button type="text" class="action-button" :icon="h(DownloadOutlined)"/>
</a>
Download
</div>
<div class="action-container">
<a-button type="text" class="action-button" @click="edit(record.key)" :icon="h(EditOutlined)"/> Rename
</div>
<div class="action-container">
<a-button
type="text"
class="action-button"
@click="share(`${record.type === 'folder'? linkBasePath+'/#' : filesBasePath}/${record.name}`)"
:icon="h(LinkOutlined)"
/> Share
</div>
<div class="action-container">
<a-button type="text" class="action-button" :icon="h(CopyOutlined)"/> Copy
</div>
<div class="action-container">
<a-button type="text" class="action-button" :icon="h(ScissorOutlined)"/> Cut
</div>
<div class="action-container">
<a-button type="text" class="action-button" :icon="h(DeleteOutlined)"/> Delete
</div>
</div>
</template>
<a-button type="text" class="action-button" :icon="h(EllipsisOutlined)" />
</a-popover>
</template>
</template>
</a-table>
</main>
</template>
<script setup lang="ts">
import { ref, h, computed, reactive, watchEffect } from 'vue'
import type { UnwrapRef } from 'vue'
import { cloneDeep } from 'lodash';
import { useDocumentStore } from '@/stores/documents'
import { EditOutlined, ImportOutlined, CheckOutlined,CopyOutlined, ScissorOutlined, LinkOutlined, DownloadOutlined, DeleteOutlined, EllipsisOutlined } from '@ant-design/icons-vue'
import type { TableColumnsType } from 'ant-design-vue';
import Router from '@/router/index';
import { message } from 'ant-design-vue';
import type { Document, FolderDocument } from '@/repositories/Document';
import FileCarousel from './FileCarousel.vue';
const [messageApi, contextHolder] = message.useMessage();
type Key = string | number;
const documentStore = useDocumentStore()
const editableData: UnwrapRef<Record<string, Document>> = reactive({});
const state = reactive<{
selectedRowKeys: Key[];
}>({
selectedRowKeys: [],
});
const linkBasePath = computed(()=>{
const path = Router.currentRoute.value.path
return path === '/' ? '' : path
})
const filesBasePath = computed(() => `/files${linkBasePath.value}`)
const columns = ref<TableColumnsType>([
{
title: 'Name',
dataIndex: 'name',
width: '70%',
key: 'name',
sortDirections: ['ascend', 'descend'],
sorter: (a: Document, b: Document) => a.name.localeCompare(b.name),
},
{
title: 'Modified',
dataIndex: 'modified',
className: 'column-date',
responsive: ['lg'],
sortDirections: ['ascend', 'descend'],
defaultSortOrder: 'descend',
sorter: (a: FolderDocument, b: FolderDocument) => a.mtime - b.mtime,
key: 'modified',
},
{
// TODO BETTER SORT FOR MULTPLE SIZE OR CUSTOM PIPE TO kB to MB / GB
title: 'Size',
dataIndex: 'sizedisp',
className: 'column-size',
responsive: ['lg'],
sortDirections: ['ascend', 'descend'],
sorter: (a: FolderDocument, b: FolderDocument) => a.size - b.size,
key: 'size',
},
{
width: '5%',
key: 'action',
},
])
const onSelectChange = (selectedRowKeys: Key[]) => {
const newSelectedRowKeys: Document[] = []
selectedRowKeys.forEach( key => {
if(documentStore.mainDocument){
const found = documentStore.mainDocument.find( e=> e.key === key )
if(found) newSelectedRowKeys.push(found)
}
})
documentStore.setSelectedDocuments(newSelectedRowKeys)
state.selectedRowKeys = selectedRowKeys;
};
const edit = (key: number) => {
editableData[key] = cloneDeep(documentStore.mainDocument.filter(item => key === item.key)[0]);
};
const save = (key: number) => {
Object.assign(documentStore.mainDocument.filter(item => key === item.key)[0], editableData[key]);
delete editableData[key];
};
const share = async (url : string) => {
await navigator.clipboard.writeText(location.origin + url)
messageApi.success("Link successfully copied to the clipboard");
}
</script>
<style>
.column-date, .column-size {
text-align: right;
}
main {
padding: 5px;
height: 100%;
}
.more-action{
display: flex;
flex-direction: column;
justify-content: start;
}
.action-container{
display: flex;
align-items: center;
}
.name{
max-width: 70%;
}
.edit-action{
min-width: 5%;
}
.carousel-container{
height: inherit;
}
.file .name::before {
content: '📄 ';
font-size: 1.5em;
}
.folder .name::before {
content: '📁 ';
font-size: 1.5em;
}
.editable-cell-text-wrapper .editable-cell-icon {
visibility: hidden; /* Oculta el ícono de manera predeterminada */
}
.editable-cell-text-wrapper:hover .editable-cell-icon {
visibility: visible; /* Muestra el ícono al hacer hover en el contenedor */
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<object
v-if="props.type === 'pdf'"
:data= "dataURL"
type="application/pdf" width="100%"
height="100%"
>
</object>
<a-image
v-else-if="props.type === 'image'"
width="50%"
:src="dataURL"
@click="() => setVisible(true)"
:previewMask=false
:preview="{
visibleImg,
onVisibleChange: setVisible,
}"
/>
<!-- Unknown case -->
<h1 v-else>
Unsupported file type
</h1>
</template>
<script setup lang="ts">
import { watchEffect, ref } from 'vue'
import Router from '@/router/index';
import { url_document_get } from '@/repositories/Document';
const dataURL = ref('')
watchEffect(()=>{
dataURL.value = new URL(
url_document_get + Router.currentRoute.value.path,
location.origin
).toString();
})
const emit = defineEmits({
visibleImg(value: boolean){
return value
}
})
function setVisible(value: boolean) {
emit('visibleImg', value)
}
const props = defineProps < {
type?: string
visibleImg: boolean
} > ()
</script>
<style></style>

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import LoginModal from '@/components/LoginModal.vue'
import UploadButton from '@/components/UploadButton.vue'
import { InfoCircleOutlined, SettingOutlined, PlusSquareOutlined, SearchOutlined, DeleteOutlined, DownloadOutlined, FileAddFilled, LinkOutlined, FolderAddFilled, FileFilled, FolderFilled } from '@ant-design/icons-vue'
import { h, ref } from 'vue';
const documentStore = useDocumentStore()
const searchQuery = ref<string>('')
const showSearchInput = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const toggleSearchInput = () => {
showSearchInput.value = !showSearchInput.value;
if (!showSearchInput.value) {
searchQuery.value = '';
}
};
const executeSearch = () => {
isLoading.value = true;
console.log(
documentStore.mainDocument
)
setTimeout(() => {
isLoading.value = false;
// Perform your search logic here
}, 2000);
};
function createFileHandler() {
console.log("Creating file")
}
function uploadFolderHandler() {
console.log("Uploading Folder")
}
function createFolderHandler() {
console.log("Uploading Folder")
}
function searchHandler() {
console.log("Searching ...")
}
function newViewHandler() {
console.log("Creating new view ...")
}
function preferencesHandler() {
console.log("Preferences ...")
}
function about() {
console.log("About ...")
}
function deleteHandler(){
if(documentStore.selectedDocuments){
documentStore.selectedDocuments.forEach(document => {
documentStore.deleteDocument(document)
})
}
}
function share(){
console.log("Share ...")
}
function download(){
console.log("Download ...")
}
</script>
<template>
<div class="actions-container">
<div class="actions-list">
<UploadButton />
<a-tooltip title="Upload folder from disk">
<a-button @click="uploadFolderHandler" type="text" class="action-button" :icon="h(FolderAddFilled)" />
</a-tooltip>
<a-tooltip title="Create file">
<a-button @click="createFileHandler" type="text" class="action-button" :icon="h(FileFilled)" />
</a-tooltip>
<a-tooltip title="Create folder">
<a-button @click="createFolderHandler" type="text" class="action-button" :icon="h(FolderFilled)" />
</a-tooltip>
<!-- TODO ADD CONDITIONAL RENDER -->
<template v-if="documentStore.selectedDocuments && documentStore.selectedDocuments.length > 0">
<a-tooltip title="Share">
<a-button type="text" @click="share" class="action-button" :icon="h(LinkOutlined)" />
</a-tooltip>
<a-tooltip title="Download Zip">
<a-button type="text" @click="download" class="action-button" :icon="h(DownloadOutlined)" />
</a-tooltip>
<a-tooltip title="Delete">
<a-button type="text" @click="deleteHandler" class="action-button" :icon="h(DeleteOutlined)" />
</a-tooltip>
</template>
</div>
<div class="actions-list">
<LoginModal></LoginModal>
<template v-if="showSearchInput">
<a-input-search
v-model="searchQuery"
class="margin-input"
v-on:keyup.enter="executeSearch"
@search="executeSearch"
:loading="isLoading"
/>
</template>
<a-tooltip title="Search">
<a-button @click="toggleSearchInput" type="text" class="action-button" :icon="h(SearchOutlined)" />
</a-tooltip>
<a-tooltip title="Create new view">
<a-button @click="newViewHandler" type="text" class="action-button" :icon="h(PlusSquareOutlined)" />
</a-tooltip>
<a-tooltip title="Preferences">
<a-button @click="preferencesHandler" type="text" class="action-button" :icon="h(SettingOutlined)" />
</a-tooltip>
<a-tooltip title="About">
<a-button @click="about" type="text" class="action-button" :icon="h(InfoCircleOutlined)" />
</a-tooltip>
</div>
</div>
</template>
<style>
.actions-container,
.actions-list {
display: flex;
flex-wrap: nowrap;
gap: 15px;
}
.actions-container {
justify-content: space-between;
}
.action-button {
padding: 0;
font-size: 1.5em;
color: var(--secondary-color);
&:hover {
color: var(--blue-color) !important;
}
}
@media only screen and (max-width: 600px) {
.actions-container,
.actions-list {
gap: 6px;
}
}
.margin-input{
margin-top: 5px;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<a-tooltip title="Login">
<template v-if="DocumentStore.isUserLogged">
<a-button @click="logout" type="text" class="action-button" :icon="h(UserDeleteOutlined)" />
</template>
<template v-else>
<a-button @click="showModal" type="text" class="action-button" :icon="h(UserOutlined)" />
</template>
</a-tooltip>
<a-modal v-model:open="DocumentStore.user.isOpenLoginModal" :confirm-loading="confirmLoading" okText="Login" @ok="login">
<div class="login-container">
<a-form :model="loginForm">
<a-form-item label="Username" prop="username" :rules="[{ required: true, message: 'Please input your username!' }]">
<a-input v-model:value="loginForm.username" />
</a-form-item>
<a-form-item label="Password" prop="password" :rules="[{ required: true, message: 'Please input your password!' }]">
<a-input type="password" v-model:value="loginForm.password" />
</a-form-item>
<h3 v-if="loginForm.error.length > 0" class="error-text">{{loginForm.error}}</h3>
</a-form>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, h } from 'vue';
import { UserOutlined, UserDeleteOutlined } from '@ant-design/icons-vue';
import { useDocumentStore } from '@/stores/documents';
import { loginUser, logoutUser } from '@/repositories/User';
import type { ISimpleError } from '@/repositories/Client';
const DocumentStore = useDocumentStore();
const confirmLoading = ref<boolean>(false);
const showModal = () => {
DocumentStore.user.isOpenLoginModal = true;
};
const logout = async () => {
try {
await logoutUser();
} catch (error) {} finally {
location.reload();
}
}
const loginForm = ref({
username: '',
password: '',
error: '',
});
const login = async () => {
try {
loginForm.value.error = '';
confirmLoading.value = true;
const user = await loginUser(loginForm.value.username, loginForm.value.password);
if(user){
location.reload();
}
} catch (error) {
const httpError = error as ISimpleError
if(httpError.name){
loginForm.value.error = httpError.message
}
}finally{
confirmLoading.value = false;
}
};
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 30vh;
}
.button-login {
background-color: var(--secondary-color);
color: var(--secondary-background);
}
.ant-btn-primary:not(:disabled):hover{
background-color: var(--blue-color);
}
.error-text {
color :var(--red-color)
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<template v-for="upload in documentStore.uploadingDocuments" :key="upload.key">
<span>{{ upload.name }}</span>
<div class="progress-container">
<a-progress :percent="upload.progress" />
<CloseCircleOutlined class="close-button" @click="dismissUpload(upload.key)" />
</div>
</template>
</template>
<script setup lang="ts">
import { CloseCircleOutlined } from '@ant-design/icons-vue';
import { useDocumentStore } from '@/stores/documents'
const documentStore = useDocumentStore();
function dismissUpload(key: number){
documentStore.deleteUploadingDocument(key)
}
</script>
<style scoped>
.progress-container{
display: flex;
align-items: center;
}
.close-button:hover{
color: #b81414;
}
</style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { useDocumentStore } from '@/stores/documents'
import NotificationLoading from '@/components/NotificationLoading.vue'
import { FileAddFilled } from '@ant-design/icons-vue'
import { notification } from 'ant-design-vue';
import type { NotificationPlacement } from 'ant-design-vue';
import { h, ref } from 'vue';
const [api, contextHolder] = notification.useNotification();
const fileUploadButton = ref()
const documentStore = useDocumentStore();
const open = (placement: NotificationPlacement) => openNotification(placement);
const isNotificationOpen = ref(false);
const openNotification = (placement: NotificationPlacement) => {
if(!isNotificationOpen.value){
api.open({
message: `Uploading documents`,
description: h(NotificationLoading),
placement,
duration: 0,
onClose: () => { isNotificationOpen.value = false }
});
isNotificationOpen.value = true;
}
};
function uploadFileHandler() {
fileUploadButton.value.click()
}
async function load(file: File, start: number, end: number): Promise<ArrayBuffer> {
const reader = new FileReader();
const load = new Promise<Event>((resolve) => (reader.onload = resolve));
reader.readAsArrayBuffer(file.slice(start, end));
const event = await load;
if (event.target && event.target instanceof FileReader) {
return event.target.result as ArrayBuffer;
} else {
throw new Error('Error loading file' );
}
}
async function sendChunk(file :File, start: number, end: number) {
const ws = documentStore.wsUpload;
if(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)
}
}
async function uploadFileChangeHandler(event: Event) {
const target = event.target as HTMLInputElement;
const chunkSize = 1 << 20
if (target && target.files && target.files.length > 0) {
const file = target.files[0];
const numChunks = Math.ceil(file.size / chunkSize)
const document = documentStore.pushUploadingDocuments(file.name)
open('bottomRight')
for (let i = 0; i < numChunks; i++) {
const start = i * chunkSize
const end = Math.min(file.size, start + chunkSize)
const res = await sendChunk(file, start, end)
console.log( 'progress: '+ ( ( 100 * (i + 1) ) / numChunks) )
console.log( 'Num Chunks: '+ numChunks )
documentStore.updateUploadingDocuments( document.key, ((100 * (i + 1) ) / numChunks))
}
}
}
</script>
<template>
<a-tooltip title="Upload files from disk">
<a-button @click="uploadFileHandler" type="text" class="action-button" :icon="h(FileAddFilled)" />
<input ref="fileUploadButton" @change="uploadFileChangeHandler" class="upload-input" type="file" onclick="this.value=null;" />
</a-tooltip>
<contextHolder />
</template>
<style scoped>
/* Extends styles from HeaderMain.vue too */
.upload-input{
display: none;
}
</style>

21
cista-front/src/main.ts Normal file
View File

@@ -0,0 +1,21 @@
import 'ant-design-vue/dist/reset.css';
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue';
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.config.errorHandler = (err) => {
/* handle error */
console.log(err)
}
app.use(createPinia())
app.use(Antd);
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,42 @@
import axios, {AxiosError} from 'axios'
/* Base domain for all request */
export const baseURL = import.meta.env.VITE_URL_DOCUMENT
/* Config Client*/
const Client = axios.create({
baseURL: baseURL,
headers: {
Accept: 'application/json',
},
})
Client.interceptors.response.use(
(response) => {
// Any status code that lie within the range of 2xx cause this function to trigger
// Do something with response data
return response
},
(error: AxiosError<any>) => {
const msg = error.response && error.response.data && error.response.data.error ?
error.response.data.error.message : 'Unexpected error'
const code = error.code ? Number(error.response?.status) : 500
const standardizedError = new SimpleError(code, msg)
return Promise.reject(standardizedError)
}
)
export interface ISimpleError extends Error {
code : number
}
class SimpleError extends Error implements ISimpleError {
code : number
constructor(code: number, message:string) {
super(message)
this.code = code
}
}
export default Client

View File

@@ -0,0 +1,114 @@
import type { FileStructure, DocumentStore } from '@/stores/documents'
import { useDocumentStore } from '@/stores/documents'
import { getFileExtension } from '@/utils'
import Client from '@/repositories/Client'
type BaseDocument = {
name: string;
key?: number | string;
};
export type FolderDocument = BaseDocument & {
type: 'folder' | 'folder-file';
size: number;
sizedisp: string;
mtime: number;
modified: string;
};
export type FileDocument = BaseDocument & {
type: 'file';
ext: string;
data: string;
};
export type errorEvent = {
error: {
code : number;
message: string;
redirect: string;
}
};
export type Document = FolderDocument | FileDocument;
export const url_document_watch_ws = '/api/watch'
export const url_document_upload_ws = '/api/upload'
export const url_document_get ='/files'
export class DocumentHandler {
constructor( private store: DocumentStore = useDocumentStore() ) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this);
}
handleWebSocketMessage(event: MessageEvent) {
const msg = JSON.parse(event.data);
switch (true) {
case !!msg.root:
this.handleRootMessage(msg);
break;
case !!msg.update:
this.handleUpdateMessage(msg);
break;
case !!msg.error:
this.handleError(msg);
break;
default:
}
}
private handleRootMessage({ root }: { root: FileStructure }) {
if (this.store && this.store.root) {
this.store.user.isLoggedIn = true;
this.store.root = root;
}
}
private handleUpdateMessage(updateData: { update: FileStructure[] }) {
const root = updateData.update[0]
if(root) {
this.store.user.isLoggedIn = true;
this.store.root = root
}
}
private handleError(msg: errorEvent){
if(msg.error.code === 401){
this.store.user.isOpenLoginModal = true;
this.store.user.isLoggedIn = false;
return
}
}
}
export class DocumentUploadHandler {
constructor( private store: DocumentStore = useDocumentStore() ) {
this.handleWebSocketMessage = this.handleWebSocketMessage.bind(this);
}
handleWebSocketMessage(event: MessageEvent) {
const msg = JSON.parse(event.data);
switch (true) {
case !!msg.written:
this.handleWrittenMessage(msg);
break;
default:
}
}
private handleWrittenMessage(msg : { written : number}) {
// if (this.store && this.store.root) this.store.root = root;
console.log('Written message', msg.written)
}
}
export async function fetchFile(path: string): Promise<FileDocument>{
const file = await Client.get(url_document_get + path)
const name = path.substring(1 , path.length)
return {
name,
data: file.data,
type: 'file',
ext: getFileExtension(name)
}
}

View File

@@ -0,0 +1,23 @@
import Client from '@/repositories/Client'
export const url_login = '/login'
export const url_logout = '/logout '
export async function loginUser(username : string, password: string){
try {
const user = await Client.post(url_login, {
username, password
})
return user;
} catch (error) {
throw error
}
}
export async function logoutUser(){
try {
const data = await Client.post(url_logout)
return data;
} catch (error) {
throw error
}
}

View File

@@ -0,0 +1,8 @@
function createWebSocket(url: string, eventHandler: (event: MessageEvent) => void) {
const urlObject = new URL(url, location.origin.replace( /^http/, 'ws'));
const webSocket = new WebSocket(urlObject);
webSocket.onmessage = eventHandler;
return webSocket;
}
export default createWebSocket

View File

@@ -1,5 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import ExplorerView from '@/views/ExplorerView.vue' import ExplorerView from '../views/ExplorerView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
@@ -7,8 +7,8 @@ const router = createRouter({
{ {
path: '/:pathMatch(.*)*', path: '/:pathMatch(.*)*',
name: 'explorer', name: 'explorer',
component: ExplorerView component: ExplorerView,
} },
] ]
}) })

View File

@@ -0,0 +1,165 @@
import type { Document, FolderDocument } from '@/repositories/Document';
import type { ISimpleError } from '@/repositories/Client';
import { fetchFile } from '@/repositories/Document'
import { formatSize, formatUnixDate } from '@/utils';
import { defineStore } from 'pinia';
type FileData = { id: string, mtime: number, size: number, dir: DirectoryData};
type DirectoryData = {
[filename: string]: FileData;
};
export type FileStructure = {id: string, mtime: number, size: number, dir: DirectoryData};
type User = {
isOpenLoginModal: boolean,
isLoggedIn : boolean,
}
export type DocumentStore = {
root: FileStructure,
document: Document[],
loading: boolean,
uploadingDocuments: Array<{key: number, name: string, progress: number}>,
uploadCount: number,
wsWatch: WebSocket | undefined,
wsUpload: WebSocket | undefined,
selectedDocuments: Document[],
user: User,
error: string,
}
export const useDocumentStore = defineStore({
id: 'documents',
state: (): DocumentStore => ({
root: {} as FileStructure,
document: [] as Document[],
loading: true as boolean,
uploadingDocuments: [],
uploadCount: 0 as number,
wsWatch: undefined,
wsUpload: undefined,
selectedDocuments: [] as Document[],
error: '' as string,
user: { isLoggedIn: false, isOpenLoginModal: false } as User
}),
actions: {
setActualDocument(location: string){
this.loading = true;
let data = this.root
const dataMapped = [];
const locations = location.split('/').slice(1)
// Get data target location
locations.forEach(location => {
location = decodeURIComponent(location)
if(data && data.dir){
for (const key in data.dir) {
if(key === location) data = data.dir[key]
}
}
})
// Transform data
for (const [name, attr] of Object.entries(data.dir)) {
const {id, size, mtime, dir} = attr
const element: Document = {
name,
key: id,
size,
sizedisp: formatSize(size),
mtime,
modified: formatUnixDate(mtime),
type: dir === undefined ? 'folder-file' : 'folder',
}
dataMapped.push(element)
}
// Pre sort directory entries folders first then files, names in natural ordering
dataMapped.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "folder" ? -1 : 1)
this.document = dataMapped
this.loading = false;
},
async setActualDocumentFile(path: string){
this.loading = true;
const file = await fetchFile(path)
this.document = [file];
this.loading = false;
},
setSelectedDocuments(document: Document[]){
this.selectedDocuments = document
},
deleteDocument(document: Document){
this.document = this.document.filter(e => document.key !== e.key)
this.selectedDocuments = this.selectedDocuments.filter(e => document.key !== e.key)
},
updateUploadingDocuments(key: number, progress: number){
for (const d of this.uploadingDocuments) {
if(d.key === key) d.progress = progress
}
},
pushUploadingDocuments(name: string){
this.uploadCount++;
const document = {
key: this.uploadCount,
name: name,
progress: 0
}
this.uploadingDocuments.push(document)
return document
},
deleteUploadingDocument(key: number){
this.uploadingDocuments = this.uploadingDocuments.filter((e)=> e.key !== key)
},
getNextDocumentInRoute(direction: number, path: string){
const locations = path.split('/').slice(1)
locations.pop()
let data = this.root
const actualDirArr = []
// Get data target location
locations.forEach(location => {
// location = decodeURIComponent(location)
if(data && data.dir){
for (const key in data.dir) {
if(key === location) data = data.dir[key]
}
}
})
//Store in a temporary array
for (const key in data.dir) {
actualDirArr.push({
name: key,
content: data.dir[key]
})
}
const actualFileName = decodeURIComponent(this.mainDocument[0].name).split('/').pop()
let index = actualDirArr.findIndex(e => e.name === actualFileName)
if(index < 1 && direction === -1 ){
index = actualDirArr.length -1
}else if(index >= actualDirArr.length - 1 && direction === 1){
index = 0
}else {
index = index + direction
}
return actualDirArr[index].name
},
updateModified() {
for (const d of this.document) {
if ("mtime" in d) d.modified = formatUnixDate(d.mtime)
}
},
},
getters: {
mainDocument(): Document[] {
return this.document;
},
rootSize(): number | undefined {
if(this.root) return this.root.size
},
rootMain(): DirectoryData | undefined {
if(this.root) return this.root.dir
},
isUserLogged(): boolean{
return this.user.isLoggedIn
}
},
});

View File

@@ -0,0 +1,67 @@
export function determineFileType(inputString: string): "file" | "folder" {
if (inputString.includes('.') && !inputString.endsWith('.')) {
return 'file';
} else {
return 'folder';
}
}
export function formatSize(size: number) {
for (const unit of [null, 'kB', 'MB', 'GB', 'TB', 'PB', 'EB']) {
if (size < 1e5) return size.toLocaleString().replace(',', '\u202F') + (unit ? `\u202F${unit}` : '')
size = Math.round(size / 1000)
}
return "huge"
}
export function formatUnixDate(t: number) {
const date = new Date(t * 1000)
const now = new Date()
const diff = date.getTime() - now.getTime()
const formatter = new Intl.RelativeTimeFormat('en', { numeric:
'auto' })
if (Math.abs(diff) <= 5000) {
return 'now'
}
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(undefined, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })
}
export function getFileExtension(filename: string) {
const parts = filename.split(".");
if (parts.length > 1) {
return parts[parts.length - 1];
} else {
return ""; // No hay extensión
}
}
export function getFileType(extension: string): string {
const videoExtensions = ["mp4", "avi", "mkv", "mov"];
const imageExtensions = ["jpg", "jpeg", "png", "gif"];
const pdfExtensions = ["pdf"];
if (videoExtensions.includes(extension)) {
return "video";
} else if (imageExtensions.includes(extension)) {
return "image";
} else if (pdfExtensions.includes(extension)) {
return "pdf";
} else {
return "unknown";
}
}

View File

@@ -0,0 +1,36 @@
<template>
<FileExplorer></FileExplorer>
</template>
<script setup lang="ts">
import { watchEffect } from 'vue'
import { useDocumentStore } from '@/stores/documents'
import Router from '@/router/index';
import FileExplorer from '@/components/FileExplorer.vue';
const documentStore = useDocumentStore()
function isFile(path: string) {
if (path.includes('.') && !path.endsWith('.')) {
return true;
} else {
return false;
}
}
watchEffect(async () => {
const path = new String(Router.currentRoute.value.path) as string
const file = isFile(path)
if(!file){
documentStore.setActualDocument(path.toString())
}else {
documentStore.setActualDocumentFile(path)
}
setTimeout( () => {
documentStore.loading = false
}, 2000)
})
</script>
<style scoped></style>

View File

@@ -3,8 +3,6 @@
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"], "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"], "exclude": ["src/**/__tests__/*"],
"compilerOptions": { "compilerOptions": {
"lib": ["es2021", "DOM"],
"target": "es2021",
"composite": true, "composite": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {

View File

@@ -3,6 +3,7 @@
"exclude": [], "exclude": [],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"lib": [],
"types": ["node", "jsdom"] "types": ["node", "jsdom"]
} }
} }

View File

@@ -0,0 +1,41 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import Components from "unplugin-vue-components/vite";
import { AntDesignVueResolver } from "unplugin-vue-components/resolvers";
// @ts-ignore
import pluginRewriteAll from 'vite-plugin-rewrite-all';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
pluginRewriteAll(),
// Ant Design configuration
Components({
resolvers: [
AntDesignVueResolver({ importStyle:"less" })
],
}),
],
css: {
preprocessorOptions: {
less: {
modifyVars: {},
javascriptEnabled: true,
},
},
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
outDir: "../cista/wwwroot",
emptyOutDir: true,
}
})

0
cista/__init__.py Normal file → Executable file
View File

46
cista/__main__.py Normal file → Executable file
View File

@@ -62,35 +62,25 @@ 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 necessary_opts = exists or import_droppy or path and listen
if not necessary_opts: if not necessary_opts:
# Maybe run without arguments # Maybe run without arguments
print(doc) print(doc)
print( print(
"No config file found! Get started with one of:\n" "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"
" cista --user yourname --privileged\n"
" cista --import-droppy\n"
" cista -l :8000 /path/to/files\n"
) )
return 1 return 1
settings = {} settings = {}
if import_droppy: if import_droppy:
if exists: if exists:
raise ValueError( raise ValueError(
f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}", f"Importing Droppy: First remove the existing configuration:\n rm {config.conffile}"
) )
settings = droppy.readconf() settings = droppy.readconf()
if path: if path:
settings["path"] = path settings["path"] = path
elif not exists:
settings["path"] = Path.home() / "Downloads"
if listen: if listen:
settings["listen"] = 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}") print(f"Config {operation}: {config.conffile}")
# Prepare to serve # Prepare to serve
@@ -105,7 +95,6 @@ def _main():
print(f"Serving {config.config.path} at {url}{extra}") print(f"Serving {config.config.path} at {url}{extra}")
# Run the server # Run the server
serve.run(dev=dev) serve.run(dev=dev)
return 0
def _confdir(args): def _confdir(args):
@@ -115,30 +104,18 @@ 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 db.toml, use parent # Accidentally pointed to the cista.toml, use parent
confdir = confdir.parent confdir = confdir.parent
config.conffile = confdir / config.conffile.name config.conffile = config.conffile.with_parent(confdir)
def _user(args): def _user(args):
_confdir(args) _confdir(args)
if config.conffile.exists(): config.load_config()
config.load_config()
operation = False
else:
# Defaults for new config when user is created
operation = config.update_config(
{
"listen": ":8000",
"path": Path.home() / "Downloads",
"public": False,
}
)
print(f"Config {operation}: {config.conffile}\n")
name = args["--user"] 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 = {}
@@ -150,17 +127,12 @@ 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}\n" info += f"\n Password: {pw}"
res = config.update_user(name, changes) res = config.update_user(args["--user"], changes)
print(info) print(info)
if res == "read": if res == "read":
print(" No changes") print(" No changes")
if operation == "created":
print(
"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,13 +1,12 @@
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 __version__, config, watching from cista import watching
from cista.fileio import FileServer from cista.fileio import FileServer
from cista.protocol import ControlTypes, FileRange, StatusMsg from cista.protocol import ControlBase, 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")
@@ -33,28 +32,21 @@ async def upload(req, ws):
text = await ws.recv() text = await ws.recv()
if not isinstance(text, str): if not isinstance(text, str):
raise ValueError( raise ValueError(
f"Expected JSON control, got binary len(data) = {len(text)}", 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 True: data = None
data = await ws.recv() while pos < req.end and (data := await ws.recv()) and isinstance(data, bytes):
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")
@@ -66,7 +58,7 @@ async def download(req, ws):
text = await ws.recv() text = await ws.recv()
if not isinstance(text, str): if not isinstance(text, str):
raise ValueError( raise ValueError(
f"Expected JSON control, got binary len(data) = {len(text)}", 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
@@ -78,46 +70,28 @@ async def download(req, ws):
# 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):
while True: cmd = msgspec.json.decode(await ws.recv(), type=ControlBase)
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes) await asyncio.to_thread(cmd)
await asyncio.to_thread(cmd) await asend(ws, StatusMsg(status="ack", req=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.state.lock: with watching.tree_lock:
q = watching.pubsub[uuid] = asyncio.Queue() q = watching.pubsub[ws] = asyncio.Queue()
# Init with disk usage and full tree # Init with disk usage and full tree
await ws.send(watching.format_space(watching.state.space)) await ws.send(watching.format_du())
await ws.send(watching.format_root(watching.state.root)) await ws.send(watching.format_tree())
# Send updates # Send updates
while True: while True:
await ws.send(await q.get()) await ws.send(await q.get())
finally: finally:
del watching.pubsub[uuid] del watching.pubsub[ws]

224
cista/app.py Normal file → Executable file
View File

@@ -1,27 +1,15 @@
import asyncio
import datetime
import mimetypes import mimetypes
from concurrent.futures import ThreadPoolExecutor from importlib.resources import files
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
import brotli from sanic import Blueprint, Sanic, raw
import sanic.helpers
from blake3 import blake3
from sanic import Blueprint, Sanic, empty, raw
from sanic.exceptions import Forbidden, NotFound from sanic.exceptions import Forbidden, NotFound
from sanic.log import logging
from stream_zip import ZIP_AUTO, stream_zip
from cista import auth, config, session, watching from cista import auth, config, session, watching
from cista.api import bp from cista.api import bp
from cista.util import filename
from cista.util.apphelpers import handle_sanic_exception from cista.util.apphelpers import handle_sanic_exception
# Workaround until Sanic PR #2824 is merged
sanic.helpers._ENTITY_HEADERS = frozenset()
app = Sanic("cista", strict_slashes=True) app = Sanic("cista", strict_slashes=True)
app.blueprint(auth.bp) app.blueprint(auth.bp)
app.blueprint(bp) app.blueprint(bp)
@@ -32,25 +20,19 @@ app.exception(Exception)(handle_sanic_exception)
async def main_start(app, loop): async def main_start(app, loop):
config.load_config() config.load_config()
await watching.start(app, loop) await watching.start(app, loop)
app.ctx.threadexec = ThreadPoolExecutor(
max_workers=8, thread_name_prefix="cista-ioworker"
)
@app.after_server_stop @app.after_server_stop
async def main_stop(app, loop): async def main_stop(app, loop):
await watching.stop(app, loop) await watching.stop(app, loop)
app.ctx.threadexec.shutdown()
@app.on_request @app.on_request
async def use_session(req): async def use_session(req):
req.ctx.session = session.get(req) req.ctx.session = session.get(req)
try: try:
req.ctx.username = req.ctx.session["username"] # type: ignore req.ctx.user = config.config.users[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":
@@ -76,197 +58,15 @@ def http_fileserver(app, _):
app.blueprint(bp) app.blueprint(bp)
www = {} @app.get("/<path:path>", static=True)
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:
logging.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())
async def load_wwwroot(app):
global www
www = await asyncio.get_event_loop().run_in_executor(
app.ctx.threadexec, _load_wwwroot, www
)
async def refresh_wwwroot():
while True:
await asyncio.sleep(0.5)
try:
wwwold = www
await load_wwwroot(app)
changes = ""
for name in sorted(www):
attr = www[name]
if wwwold.get(name) == attr:
continue
headers = attr[2]
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n"
if changes:
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
print("Error loading wwwroot", e)
if not app.debug:
return
@app.route("/<path:path>", methods=["GET", "HEAD"])
async def wwwroot(req, path=""): async def wwwroot(req, path=""):
"""Frontend files only""" """Frontend files only"""
name = unquote(path) name = filename.sanitize(unquote(path)) if path else "index.html"
if name not in www: try:
raise NotFound(f"File not found: /{path}", extra={"name": name}) index = files("cista").joinpath("wwwroot", name).read_bytes()
data, br, headers = www[name] except OSError as e:
if req.headers.if_none_match == headers["etag"]:
# The client has it cached, respond 304 Not Modified
return empty(304, headers=headers)
# Brotli compressed?
if br and "br" in req.headers.accept_encoding.split(", "):
headers = {**headers, "content-encoding": "br"}
data = br
return raw(data, headers=headers)
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
loc = PurePosixPath()
idx = 0
ret = []
level: int | None = None
parent: PurePosixPath | None = None
with watching.state.lock:
root = watching.state.root
while idx < len(root):
f = root[idx]
loc = PurePosixPath(*loc.parts[: f.level - 1]) / f.name
if parent is not None and f.level <= level:
level = parent = None
if f.key in wanted:
level, parent = f.level, loc.parent
if parent is not None:
wanted.discard(f.key)
ret.append((loc.relative_to(parent), watching.rootpath / loc))
idx += 1
return ret
@app.get("/zip/<keys>/<zipfile:ext=zip>")
async def zip_download(req, keys, zipfile, ext):
"""Download a zip archive of the given keys"""
wanted = set(keys.split("+"))
files = get_files(wanted)
if not files:
raise NotFound( raise NotFound(
"No files found", f"File not found: /{path}", extra={"name": name, "exception": repr(e)}
context={"keys": keys, "zipfile": f"{zipfile}.{ext}", "wanted": wanted},
) )
if wanted: mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
raise NotFound("Files not found", context={"missing": wanted}) return raw(index, content_type=mime)
def local_files(files):
for rel, p in files:
s = p.stat()
size = s.st_size
modified = datetime.datetime.fromtimestamp(s.st_mtime, datetime.UTC)
name = rel.as_posix()
if p.is_dir():
yield f"{name}/", modified, S_IFDIR | 0o755, ZIP_AUTO(size), iter(b"")
else:
yield name, modified, S_IFREG | 0o644, ZIP_AUTO(size), contents(p, size)
def contents(name, size):
with name.open("rb") as f:
while size > 0 and (chunk := f.read(min(size, 1 << 20))):
size -= len(chunk)
yield chunk
assert size == 0
def worker():
try:
for chunk in stream_zip(local_files(files)):
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
except Exception:
logging.exception("Error streaming ZIP")
raise
finally:
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
# Don't block the event loop: run in a thread
queue = asyncio.Queue(maxsize=1)
loop = asyncio.get_event_loop()
thread = loop.run_in_executor(app.ctx.threadexec, worker)
# Stream the response
res = await req.respond(
content_type="application/zip",
headers={"cache-control": "no-store"},
)
while chunk := await queue.get():
await res.send(chunk)
await thread # If it raises, the response will fail download

17
cista/auth.py Normal file → Executable file
View File

@@ -25,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") from None raise ValueError("Invalid username")
# Verify password # Verify password
need_rehash = False need_rehash = False
if not u.hash: if not u.hash:
@@ -41,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") from None raise ValueError("Invalid password")
if _argon.check_needs_rehash(u.hash): if _argon.check_needs_rehash(u.hash):
need_rehash = True need_rehash = True
# Login successful # Login successful
@@ -62,16 +62,16 @@ class LoginResponse(msgspec.Struct):
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: if request.ctx.user.privileged:
return return
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True) raise Forbidden("Access Forbidden: Only for privileged users")
elif config.config.public or request.ctx.user: elif config.config.public or request.ctx.user:
return return
raise Unauthorized("Login required", "cookie", quiet=True) raise Unauthorized("Login required", "cookie", context={"redirect": "/login"})
bp = Blueprint("auth") bp = Blueprint("auth")
@@ -130,14 +130,11 @@ 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( raise BadRequest("Missing username or password", context={"redirect": "/login"})
"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"}) from e raise Forbidden(str(e), context={"redirect": "/login"})
if "text/html" in request.headers.accept: if "text/html" in request.headers.accept:
res = redirect("/") res = redirect("/")

10
cista/config.py Normal file → Executable file
View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import secrets import secrets
import sys
from functools import wraps from functools import wraps
from hashlib import sha256 from hashlib import sha256
from pathlib import Path, PurePath from pathlib import Path, PurePath
@@ -15,7 +14,6 @@ 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] = {}
@@ -23,7 +21,7 @@ class Config(msgspec.Struct):
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 # noqa: N815 lastSeen: int = 0
class Link(msgspec.Struct, omit_defaults=True): class Link(msgspec.Struct, omit_defaults=True):
@@ -91,8 +89,6 @@ def config_update(modify):
return "read" return "read"
f.write(new) f.write(new)
f.close() f.close()
if sys.platform == "win32":
conffile.unlink() # Windows doesn't support atomic replace
tmpname.rename(conffile) # Atomic replace tmpname.rename(conffile) # Atomic replace
except: except:
f.close() f.close()
@@ -138,7 +134,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
# 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__() u = conf.users[name].__copy__()
except (KeyError, AttributeError): except KeyError:
u = User() u = User()
if "password" in changes: if "password" in changes:
from . import auth from . import auth
@@ -147,7 +143,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
del changes["password"] del changes["password"]
udict = msgspec.to_builtins(u, enc_hook=enc_hook) udict = msgspec.to_builtins(u, enc_hook=enc_hook)
udict.update(changes) udict.update(changes)
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}} settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
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)

6
cista/droppy.py Normal file → Executable file
View File

@@ -30,12 +30,10 @@ def _droppy_listeners(cf):
host = listener["host"] host = listener["host"]
if isinstance(host, list): if isinstance(host, list):
host = host[0] host = host[0]
except (KeyError, IndexError):
continue
else:
if host in ("127.0.0.1", "::", "localhost"): if host in ("127.0.0.1", "::", "localhost"):
return f":{port}" return f":{port}"
return f"{host}:{port}" return f"{host}:{port}"
except (KeyError, IndexError):
continue
# If none matched, fallback to Droppy default # If none matched, fallback to Droppy default
return "0.0.0.0:8989" return "0.0.0.0:8989"

10
cista/fileio.py Normal file → Executable file
View File

@@ -34,11 +34,9 @@ class File:
self.open_rw() self.open_rw()
assert self.fd is not None 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)
if buffer: os.lseek(self.fd, pos, os.SEEK_SET)
os.lseek(self.fd, pos, os.SEEK_SET) os.write(self.fd, buffer)
os.write(self.fd, buffer)
def __getitem__(self, slice): def __getitem__(self, slice):
if self.fd is None: if self.fd is None:
@@ -64,9 +62,7 @@ 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( self.worker = asyncio.get_event_loop().run_in_executor(
None, None, self.worker_thread, self.alink.to_sync
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)

96
cista/protocol.py Normal file → Executable file
View File

@@ -22,7 +22,7 @@ class MkDir(ControlBase):
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=True, exist_ok=False) path.mkdir(parents=False, exist_ok=False)
class Rename(ControlBase): class Rename(ControlBase):
@@ -44,10 +44,7 @@ class Rm(ControlBase):
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:
if p.is_dir(): shutil.rmtree(p, ignore_errors=True)
shutil.rmtree(p)
else:
p.unlink()
class Mv(ControlBase): class Mv(ControlBase):
@@ -75,19 +72,10 @@ 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:
if p.is_dir(): # Note: copies as dst rather than in dst unless name is appended.
# Note: copies as dst rather than in dst unless name is appended. shutil.copytree(
shutil.copytree( p, dst / p.name, dirs_exist_ok=True, ignore_dangling_symlinks=True
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
@@ -112,40 +100,56 @@ class ErrorMsg(msgspec.Struct):
## Directory listings ## Directory listings
class FileEntry(msgspec.Struct, array_like=True): class FileEntry(msgspec.Struct):
level: int id: str
name: str
key: str
mtime: int
size: int size: int
isfile: int mtime: int
def __repr__(self):
return self.key or "FileEntry()"
class Update(msgspec.Struct, array_like=True): class DirEntry(msgspec.Struct):
... id: str
size: int
mtime: int
dir: DirList
def __getitem__(self, name):
return self.dir[name]
def __setitem__(self, name, value):
self.dir[name] = value
def __contains__(self, name):
return name in self.dir
def __delitem__(self, name):
del self.dir[name]
@property
def props(self):
return {k: v for k, v in self.__struct_fields__ if k != "dir"}
class UpdKeep(Update, tag="k"): DirList = dict[str, FileEntry | DirEntry]
count: int
class UpdDel(Update, tag="d"): class UpdateEntry(msgspec.Struct, omit_defaults=True):
count: int """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
id: str | None = None
size: int | None = None
mtime: int | None = None
dir: DirList | None = None
class UpdIns(Update, tag="i"): def make_dir_data(root):
items: list[FileEntry] if len(root) == 3:
return FileEntry(*root)
id_, size, mtime, listing = root
class UpdateMessage(msgspec.Struct): converted = {}
update: list[UpdKeep | UpdDel | UpdIns] for name, data in listing.items():
converted[name] = make_dir_data(data)
sz = sum(x.size for x in converted.values())
class Space(msgspec.Struct): mt = max(x.mtime for x in converted.values())
disk: int return DirEntry(id_, sz, max(mt, mtime), converted)
free: int
usage: int
storage: int

31
cista/serve.py Normal file → Executable file
View File

@@ -1,13 +1,13 @@
import os import os
import re import re
from pathlib import Path from pathlib import Path, PurePath
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
@@ -15,6 +15,7 @@ def run(*, dev=False):
# 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)
@@ -26,13 +27,10 @@ def run(*, dev=False):
motd=False, motd=False,
dev=dev, dev=dev,
auto_reload=dev, auto_reload=dev,
reload_dir={confdir}, reload_dir={confdir, wwwroot},
access_log=True, access_log=True,
) # type: ignore ) # type: ignore
if dev: Sanic.serve()
Sanic.serve()
else:
Sanic.serve_single()
def check_cert(certdir, domain): def check_cert(certdir, domain):
@@ -40,7 +38,7 @@ def check_cert(certdir, domain):
return return
# TODO: Use certbot to fetch a cert # TODO: Use certbot to fetch a cert
raise ValueError( raise ValueError(
f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}", f"TLS certificate files privkey.pem and fullchain.pem needed in {certdir}"
) )
@@ -49,14 +47,15 @@ def parse_listen(listen):
unix = Path(listen).resolve() unix = Path(listen).resolve()
if not unix.parent.exists(): if not unix.parent.exists():
raise ValueError( raise ValueError(
f"Directory for unix socket does not exist: {unix.parent}/", f"Directory for unix socket does not exist: {unix.parent}/"
) )
return "http://localhost", {"unix": unix} return "http://localhost", {"unix": unix}
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE): elif 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}
try: else:
addr, _port = listen.split(":", 1) try:
port = int(_port) addr, _port = listen.split(":", 1)
except Exception: port = int(_port)
raise ValueError(f"Invalid listen address: {listen}") from None except Exception:
return f"http://localhost:{port}", {"host": addr, "port": port} raise ValueError(f"Invalid listen address: {listen}")
return f"http://localhost:{port}", {"host": addr, "port": port}

0
cista/session.py Normal file → Executable file
View File

View File

View File

@@ -33,8 +33,7 @@ async def handle_sanic_exception(request, 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( return jres(
ErrorMsg({"code": code, "message": message, **context}), ErrorMsg({"code": code, "message": message, **context}), status=code
status=code,
) )
# Redirections flash the error message via cookies # Redirections flash the error message via cookies
if "redirect" in context: if "redirect" in context:

3
cista/util/asynclink.py Normal file → Executable file
View File

@@ -80,9 +80,8 @@ class SyncRequest:
if exc: if exc:
self.set_exception(exc) self.set_exception(exc)
return True return True
if not self.done: elif 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,7 +10,4 @@ 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("/")
p = PurePosixPath(filename) return PurePosixPath(filename).as_posix()
if any(n.startswith(".") for n in p.parts):
raise ValueError("Filenames starting with dot are not allowed")
return p.as_posix()

2
cista/util/lrucache.py Normal file → Executable file
View File

@@ -41,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): # noqa: B007 for i, (k, f, ts) in enumerate(self.cache):
if k == key: if k == key:
self.cache.pop(i) self.cache.pop(i)
break break

383
cista/watching.py Normal file → Executable file
View File

@@ -1,117 +1,19 @@
import asyncio import asyncio
import shutil import shutil
import stat
import sys
import threading import threading
import time import time
from os import stat_result
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
import inotify.adapters
import msgspec import msgspec
from natsort import humansorted, natsort_keygen, ns
from sanic.log import logging
from cista import config from cista import config
from cista.fileio import fuid from cista.fileio import fuid
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep from cista.protocol import DirEntry, FileEntry, UpdateEntry
pubsub = {} pubsub = {}
sortkey = natsort_keygen(alg=ns.LOCALE) tree = {"": None}
tree_lock = threading.Lock()
class State:
def __init__(self):
self.lock = threading.RLock()
self._space = Space(0, 0, 0, 0)
self._listing: list[FileEntry] = []
@property
def space(self):
with self.lock:
return self._space
@space.setter
def space(self, space):
with self.lock:
self._space = space
@property
def root(self) -> list[FileEntry]:
with self.lock:
return self._listing[:]
@root.setter
def root(self, listing: list[FileEntry]):
with self.lock:
self._listing = listing
def _slice(self, idx: PurePosixPath | tuple[PurePosixPath, int]):
relpath, relfile = idx if isinstance(idx, tuple) else (idx, 0)
begin, end = 0, len(self._listing)
level = 0
isfile = 0
# Special case for root
if not relpath.parts:
return slice(begin, end)
begin += 1
for part in relpath.parts:
level += 1
found = False
while begin < end:
entry = self._listing[begin]
if entry.level < level:
break
if entry.level == level:
if entry.name == part:
found = True
if level == len(relpath.parts):
isfile = relfile
else:
begin += 1
break
cmp = entry.isfile - isfile or sortkey(entry.name) > sortkey(part)
if cmp > 0:
break
begin += 1
if not found:
return slice(begin, begin)
# Found the starting point, now find the end of the slice
for end in range(begin + 1, len(self._listing) + 1):
if end == len(self._listing) or self._listing[end].level <= level:
break
return slice(begin, end)
def __getitem__(self, index: PurePosixPath | tuple[PurePosixPath, int]):
with self.lock:
return self._listing[self._slice(index)]
def __setitem__(
self, index: tuple[PurePosixPath, int], value: list[FileEntry]
) -> None:
rel, isfile = index
with self.lock:
if rel.parts:
parent = self._slice(rel.parent)
if parent.start == parent.stop:
raise ValueError(
f"Parent folder {rel.as_posix()} is missing for {rel.name}"
)
self._listing[self._slice(index)] = value
def __delitem__(self, relpath: PurePosixPath):
with self.lock:
del self._listing[self._slice(relpath)]
state = State()
rootpath: Path = None # type: ignore rootpath: Path = None # type: ignore
quit = False quit = False
modified_flags = ( modified_flags = (
@@ -123,35 +25,34 @@ modified_flags = (
"IN_MOVED_FROM", "IN_MOVED_FROM",
"IN_MOVED_TO", "IN_MOVED_TO",
) )
disk_usage = None
def watcher_thread(loop): def watcher_thread(loop):
global rootpath global disk_usage
import inotify.adapters
while not quit: while True:
rootpath = config.config.path rootpath = config.config.path
i = inotify.adapters.InotifyTree(rootpath.as_posix()) i = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem old = format_tree() if tree[""] else None
new = walk() with tree_lock:
with state.lock: # Initialize the tree from filesystem
old = state.root tree[""] = walk(rootpath)
if old != new: msg = format_tree()
state.root = new if msg != old:
broadcast(format_update(old, new), loop) asyncio.run_coroutine_threadsafe(broadcast(msg), loop)
# The watching is not entirely reliable, so do a full refresh every 30 seconds # The watching is not entirely reliable, so do a full refresh every minute
refreshdl = time.monotonic() + 30.0 refreshdl = time.monotonic() + 60.0
for event in i.event_gen(): for event in i.event_gen():
if quit: if quit:
return return
# Disk usage update # Disk usage update
du = shutil.disk_usage(rootpath) du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size) if du != disk_usage:
if space != state.space: disk_usage = du
state.space = space asyncio.run_coroutine_threadsafe(broadcast(format_du()), loop)
broadcast(format_space(space), loop)
break break
# Do a full refresh? # Do a full refresh?
if time.monotonic() > refreshdl: if time.monotonic() > refreshdl:
@@ -166,166 +67,138 @@ def watcher_thread(loop):
try: try:
update(path.relative_to(rootpath), loop) update(path.relative_to(rootpath), loop)
except Exception as e: except Exception as e:
print("Watching error", e, path, rootpath) print("Watching error", e)
raise break
i = None # Free the inotify object i = None # Free the inotify object
def watcher_thread_poll(loop): def format_du():
global rootpath return msgspec.json.encode(
{
while not quit: "space": {
rootpath = config.config.path "disk": disk_usage.total,
new = walk() "used": disk_usage.used,
with state.lock: "free": disk_usage.free,
old = state.root "storage": tree[""].size,
if old != new: }
state.root = new }
broadcast(format_update(old, new), loop) ).decode()
# Disk usage update
du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size)
if space != state.space:
state.space = space
broadcast(format_space(space), loop)
time.sleep(2.0)
def walk(rel=PurePosixPath()) -> list[FileEntry]: # noqa: B008 def format_tree():
path = rootpath / rel root = tree[""]
return msgspec.json.encode(
{
"update": [
UpdateEntry(id=root.id, size=root.size, mtime=root.mtime, dir=root.dir)
]
}
).decode()
def walk(path: Path) -> DirEntry | FileEntry | None:
try: try:
st = path.stat() s = path.stat()
except OSError: id_ = fuid(s)
return [] mtime = int(s.st_mtime)
return _walk(rel, int(not stat.S_ISDIR(st.st_mode)), st) if path.is_file():
return FileEntry(id_, s.st_size, mtime)
tree = {
def _walk(rel: PurePosixPath, isfile: int, st: stat_result) -> list[FileEntry]: p.name: v
entry = FileEntry( for p in path.iterdir()
level=len(rel.parts), if not p.name.startswith(".")
name=rel.name, if (v := walk(p)) is not None
key=fuid(st), }
mtime=int(st.st_mtime), if tree:
size=st.st_size if isfile else 0, size = sum(v.size for v in tree.values())
isfile=isfile, mtime = max(mtime, max(v.mtime for v in tree.values()))
) else:
if isfile: size = 0
return [entry] return DirEntry(id_, size, mtime, tree)
ret = [entry]
path = rootpath / rel
try:
li = []
for f in path.iterdir():
if quit:
raise SystemExit("quit")
if f.name.startswith("."):
continue # No dotfiles
s = f.stat()
li.append((int(not stat.S_ISDIR(s.st_mode)), f.name, s))
for [isfile, name, s] in humansorted(li):
if quit:
raise SystemExit("quit")
subtree = _walk(rel / name, isfile, s)
child = subtree[0]
entry.mtime = max(entry.mtime, child.mtime)
entry.size += child.size
ret.extend(subtree)
except FileNotFoundError: except FileNotFoundError:
pass # Things may be rapidly in motion return None
except OSError as e: except OSError as e:
print("OS error walking path", path, e) print("OS error walking path", path, e)
return ret return None
def update(relpath: PurePosixPath, loop): def update(relpath: PurePosixPath, loop):
"""Called by inotify updates, check the filesystem and broadcast any changes.""" """Called by inotify updates, check the filesystem and broadcast any changes."""
if rootpath is None or relpath is None: new = walk(rootpath / relpath)
print("ERROR", rootpath, relpath) with tree_lock:
new = walk(relpath) update = update_internal(relpath, new)
with state.lock: if not update:
old = state[relpath] return # No changes
if old == new: msg = msgspec.json.encode({"update": update}).decode()
return asyncio.run_coroutine_threadsafe(broadcast(msg), loop)
old = state.root
if new:
state[relpath, new[0].isfile] = new
else:
del state[relpath]
broadcast(format_update(old, state.root), loop)
def format_update(old, new): def update_internal(
# Make keep/del/insert diff until one of the lists ends relpath: PurePosixPath, new: DirEntry | FileEntry | None
oidx, nidx = 0, 0 ) -> list[UpdateEntry]:
path = "", *relpath.parts
old = tree
elems = []
for name in path:
if name not in old:
# File or folder created
old = None
elems.append((name, None))
if len(elems) < len(path):
# We got a notify for an item whose parent is not in tree
print("Tree out of sync DEBUG", relpath)
print(elems)
print("Current tree:")
print(tree[""])
print("Walking all:")
print(walk(rootpath))
raise ValueError("Tree out of sync")
break
old = old[name]
elems.append((name, old))
if old == new:
return []
mt = new.mtime if new else 0
szdiff = (new.size if new else 0) - (old.size if old else 0)
# Update parents
update = [] update = []
keep_count = 0 for name, entry in elems[:-1]:
while oidx < len(old) and nidx < len(new): u = UpdateEntry(name)
if old[oidx] == new[nidx]: if szdiff:
keep_count += 1 entry.size += szdiff
oidx += 1 u.size = entry.size
nidx += 1 if mt > entry.mtime:
continue u.mtime = entry.mtime = mt
if keep_count > 0: update.append(u)
update.append(UpdKeep(keep_count)) # The last element is the one that changed
keep_count = 0 name, entry = elems[-1]
parent = elems[-2][1] if len(elems) > 1 else tree
del_count = 0 u = UpdateEntry(name)
rest = new[nidx:] if new:
while oidx < len(old) and old[oidx] not in rest: parent[name] = new
del_count += 1 if u.size != new.size:
oidx += 1 u.size = new.size
if del_count: if u.mtime != new.mtime:
update.append(UpdDel(del_count)) u.mtime = new.mtime
continue if isinstance(new, DirEntry):
if u.dir == new.dir:
insert_items = [] u.dir = new.dir
rest = old[oidx:] else:
while nidx < len(new) and new[nidx] not in rest: del parent[name]
insert_items.append(new[nidx]) u.deleted = True
nidx += 1 update.append(u)
update.append(UpdIns(insert_items)) return update
# Diff any remaining
if keep_count > 0:
update.append(UpdKeep(keep_count))
if oidx < len(old):
update.append(UpdDel(len(old) - oidx))
elif nidx < len(new):
update.append(UpdIns(new[nidx:]))
return msgspec.json.encode({"update": update}).decode()
def format_space(usage): async def broadcast(msg):
return msgspec.json.encode({"space": usage}).decode() for queue in pubsub.values():
await queue.put_nowait(msg)
def format_root(root):
return msgspec.json.encode({"root": root}).decode()
def broadcast(msg, loop):
return asyncio.run_coroutine_threadsafe(abroadcast(msg), loop).result()
async def abroadcast(msg):
try:
for queue in pubsub.values():
queue.put_nowait(msg)
except Exception:
# Log because asyncio would silently eat the error
logging.exception("Broadcast error")
async def start(app, loop): async def start(app, loop):
config.load_config() config.load_config()
use_inotify = sys.platform == "linux" app.ctx.watcher = threading.Thread(target=watcher_thread, args=[loop])
app.ctx.watcher = threading.Thread(
target=watcher_thread if use_inotify else watcher_thread_poll,
args=[loop],
)
app.ctx.watcher.start() app.ctx.watcher.start()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
cista/wwwroot/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

15
cista/wwwroot/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!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, shrink-to-fit=no">
<title>Vite Vasanko</title>
<script type="module" crossorigin src="/assets/index-25722397.js"></script>
<link rel="stylesheet" href="/assets/index-213aec14.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

241
cista/wwwroot/old-index.html Executable file
View File

@@ -0,0 +1,241 @@
<!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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=UTF-8>
<title>Cista Storage</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" href="/src/assets/logo.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
<script type="module" src="/src/main.ts"></script>
<div id="app"></div>

View File

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

View File

@@ -1,136 +0,0 @@
<template>
<LoginModal />
<header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
<HeaderSelected :path="path.pathList" />
</HeaderMain>
<BreadCrumb :path="path.pathList" primary />
</header>
<main>
<RouterView :path="path.pathList" :query="path.query" />
</main>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import type { ComputedRef } from 'vue'
import type HeaderMain from '@/components/HeaderMain.vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { useMainStore } from '@/stores/main'
import { computed } from 'vue'
import Router from '@/router/index'
interface Path {
path: string
pathList: string[]
query: string
}
const store = useMainStore()
const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
const pathList = p[0].split('/').filter(value => value !== '')
const query = p.slice(1).join('//')
return {
path: p[0],
pathList,
query
}
})
watchEffect(() => {
document.title = path.value.path.replace(/\/$/, '').split('/').pop() || store.server.name || 'Cista Storage'
})
onMounted(loadSession)
onMounted(watchConnect)
onUnmounted(watchDisconnect)
const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0
let timer: any = null
const globalShortcutHandler = (event: KeyboardEvent) => {
const fileExplorer = store.fileExplorer as any
if (!fileExplorer) return
const c = fileExplorer.isCursor()
const input = (event.target as HTMLElement).tagName === 'INPUT'
const keyup = event.type === 'keyup'
if (event.repeat) {
if (
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
(c && event.code === 'Space')
) {
event.preventDefault()
}
return
}
//console.log("key pressed", event)
// For up/down implement custom fast repeat
if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1
else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1
// Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
headerMain.value!.toggleSearchInput()
}
// Search also on / (UNIX style)
else if (keyup && !input && event.key === '/') {
headerMain.value!.toggleSearchInput()
}
// Globally close search on Escape
else if (keyup && event.key === 'Escape') {
headerMain.value!.closeSearch(event)
}
// Select all (toggle); keydown to prevent builtin
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
fileExplorer.toggleSelectAll()
}
// Keys 1-3 to sort columns
else if (
!input &&
keyup &&
(event.key === '1' || event.key === '2' || event.key === '3')
) {
fileExplorer.toggleSortColumn(+event.key)
}
// Rename
else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
fileExplorer.cursorRename()
}
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
else if (c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey)
fileExplorer.cursorSelect()
} else return
event.preventDefault()
if (!vert) {
if (timer) {
clearTimeout(timer) // Good for either timeout or interval
timer = null
}
return
}
if (!timer) {
// Initial move, then t0 delay until repeats at tr intervals
const select = event.shiftKey
fileExplorer.cursorMove(vert, select)
const t0 = 200,
tr = 30
timer = setTimeout(
() =>
(timer = setInterval(() => {
fileExplorer.cursorMove(vert, select)
}, tr)),
t0 - tr
)
}
}
onMounted(() => {
window.addEventListener('keydown', globalShortcutHandler)
window.addEventListener('keyup', globalShortcutHandler)
})
onUnmounted(() => {
window.removeEventListener('keydown', globalShortcutHandler)
window.removeEventListener('keyup', globalShortcutHandler)
})
export type { Path }
</script>
@/stores/main

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 258 B

View File

@@ -1,268 +0,0 @@
@charset "UTF-8";
:root {
--primary-color: #000;
--primary-background: #ddd;
--header-background: var(--soft-color);
--header-color: #ccc;
--input-background: #fff;
--input-color: #000;
--primary-color: #000;
--soft-color: #146;
--accent-color: #f80;
--transition-time: 0.2s;
/* The following are overridden by responsive layouts */
--root-font-size: 1rem;
--header-font-size: 1rem;
--header-height: calc(6.5 * var(--header-font-size));
}
@media (prefers-color-scheme: dark) {
:root {
--primary-color: #ddd;
--primary-background: var(--soft-color);
--header-background: #000;
--header-color: #ccc;
--input-background: var(--soft-color);
--input-color: #ddd;
}
}
@media screen and (max-width: 600px) {
.size,
.modified,
.summary {
display: none;
}
}
@media screen and (min-width: 1000px) {
:root {
--root-font-size: calc(8px + 8 * 100vw / 1000);
}
header .buttons:has(input[type='search']) > div {
display: none;
}
header .buttons > div:has(input[type='search']) {
display: inherit;
}
}
@media screen and (min-width: 2000px) {
:root {
--root-font-size: 1.5rem;
}
}
/* Low (landscape) screens: smaller header */
@media screen and (max-height: 600px) {
:root {
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
--root-font-size: 0.8rem;
}
header .breadcrumb > * {
padding-top: calc(8 + 8 * 100vh / 600) !important;
padding-bottom: calc(8 + 8 * 100vh / 600) !important;
}
}
@media screen and (max-height: 300px) {
:root {
--header-font-size: 15px; /* Don't go smaller than this, no benefit */
--header-height: calc(1.75 * 16px);
--root-font-size: 0.6rem;
}
header .breadcrumb > * {
padding-top: 14px !important;
padding-bottom: 14px !important;
}
}
@media screen and (orientation: landscape) and (min-width: 700px) {
/* Breadcrumbs and buttons side by side */
:root {
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
}
header {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: end;
}
header .breadcrumb {
flex-shrink: 1;
}
header .breadcrumb > * {
flex-shrink: 1;
padding-top: 1rem !important;
padding-bottom: 1rem !important;
}
}
@media print {
:root {
--primary-color: black;
--primary-background: none;
--header-background: none;
--header-color: black;
}
nav,
.menu,
.rename-button {
display: none;
}
.breadcrumb > a {
color: black !important;
background: none !important;
padding: 0 !important;
margin: 0 !important;
clip-path: none !important;
max-width: none !important;
}
.breadcrumb > a::after {
content: '/';
}
.breadcrumb svg {
fill: black !important;
}
main {
height: auto !important;
padding-bottom: 0 !important;
}
thead tr {
position: static !important;
background: none !important;
border-bottom: 1pt solid black !important;
}
.selection {
min-width: 0 !important;
padding: 0 !important;
}
.selection input {
display: none;
}
.selection input:checked {
display: inherit;
}
tbody .selection input:checked {
opacity: 1 !important;
transform: scale(0.5);
left: 0;
}
}
html {
font-size: var(--root-font-size);
overflow: hidden;
}
/* Hide scrollbar for all browsers */
main::-webkit-scrollbar {
display: none;
}
main {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
body {
background-color: var(--primary-background);
font-size: 1rem;
font-family: 'Roboto';
color: var(--primary-color);
margin: 0;
}
tbody .size,
tbody .modified {
font-family: 'Roboto Mono';
}
header {
background-color: var(--header-background);
color: var(--header-color);
font-size: var(--header-font-size);
}
main {
height: 100%;
}
::selection {
color: #000;
background: yellow !important;
}
button {
font: inherit;
color: inherit;
margin: 0;
border: 0;
padding: 0;
background: none;
cursor: pointer;
min-width: 1rem;
min-height: 1rem;
}
input {
margin: 0;
}
:focus {
outline: none;
}
a:link,
a:visited,
a:active,
a:hover {
color: var(--primary-color);
text-decoration: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
border: 0;
gap: 0;
}
#app {
height: 100%;
display: flex;
flex-direction: column;
}
header nav.headermain {
/* Position so that tooltips can appear on top of other positioned elements */
position: relative;
z-index: 100;
}
main {
height: calc(100svh - var(--header-height));
padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll;
}
.spacer { flex-grow: 1 }
.smallgap { flex-shrink: 1; width: 2em }
[data-tooltip]:hover:after {
z-index: 101;
content: attr(data-tooltip);
position: absolute;
font-size: 1rem;
text-align: center;
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color);
transform: translate(calc(1rem + -50%), 150%);
background-color: var(--accent-color);
color: var(--primary-color);
white-space: pre;
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
}
.modified [data-tooltip]:hover:after {
transform: translate(calc(1rem + 1ex + -100%), calc(-1.5rem + 100%));
}
@keyframes appearbriefly {
from {
opacity: 0;
}
30% {
opacity: 0;
}
40% {
opacity: 1;
}
90% {
opacity: 1;
}
to {
opacity: 0;
}
}
.error-message {
padding: .5em;
font-weight: bold;
background: var(--accent-color);
color: #000;
}

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 158 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 168 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 388 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 126 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 158 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 208 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 563 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 212 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 293 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 310 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 193 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 278 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 711 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 365 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 783 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 382 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 200 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 698 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 156 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 416 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 517 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 257 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 297 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 312 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="2 0 32 32"><path d="M24 4v24h-4V17L10 27V5l10 10V4z"/></svg>

Before

Width:  |  Height:  |  Size: 109 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 587 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 269 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M4 4h10v24H4zm14 0h10v24H18z"/></svg>

Before

Width:  |  Height:  |  Size: 106 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 393 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M6 4l20 12L6 28z"/></svg>

Before

Width:  |  Height:  |  Size: 94 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 229 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M8 28V4h4v11L22 5v22L12 17v11z"/></svg>

Before

Width:  |  Height:  |  Size: 108 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 407 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 887 B

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 908 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M32 8l-8-8v6c-4.1 0-7.2.97-9.55 2.98l-.48.43A29.5 29.5 0 0 1 16.1 13c1.5-1.8 3.63-3 7.9-3v12c-6.8 0-8.3-3-10.2-6.9-1.1-2.1-2.2-4.3-4.3-6.1C7.2 7 4.1 6 0 6v4c6.76 0 8.28 3.04 10.2 6.9 1.08 2.14 2.2 4.36 4.25 6.12C16.8 25.02 19.9 26 24 26v6l8-8-8-8 8-8zM0 22v4c4.1 0 7.2-.97 9.55-2.98l.48-.43C9.17 21.4 8.5 20.1 7.9 19c-1.5 1.8-3.67 3-7.9 3z"/></svg>

Before

Width:  |  Height:  |  Size: 417 B

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