Compare commits
	
		
			6 Commits
		
	
	
		
			v0.4.0
			...
			ffafbc87d0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | ffafbc87d0 | ||
|   | f0fc4a7d30 | ||
|   | e4a62e1197 | ||
|   | 19a5c4ad8a | ||
|   | 3d3b078e60 | ||
|   | d4e91ea9a6 | 
							
								
								
									
										67
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,23 +1,19 @@ | ||||
| # Web File Storage | ||||
|  | ||||
| The Python package installs a `cista` executable. Use `hatch shell` to initiate and install in a virtual environment, or `pip install` it on your system. Alternatively `hatch run cista` may be used to skip the shell step but stay virtual. `pip install hatch` first if needed. | ||||
| Run directly from repository with Hatch (or use pip install as usual): | ||||
|  | ||||
| ```sh | ||||
| hatch run cista -l :3000 /path/to/files | ||||
| ``` | ||||
|  | ||||
| Settings incl. these arguments are stored to config file on the first startup and later `hatch run cista` is sufficient. If the `cista` script is missing, consider `pip install -e .` (within `hatch shell`) or some other trickery (known issue with installs made prior to adding the startup script). | ||||
|  | ||||
| Create your user account: | ||||
|  | ||||
| ```sh | ||||
| cista --user admin --privileged | ||||
| hatch run cista --user admin --privileged | ||||
| ``` | ||||
|  | ||||
| ## Running the server | ||||
|  | ||||
| Serve your files on localhost:8000: | ||||
|  | ||||
| ```sh | ||||
| cista -l :8000 /path/to/files | ||||
| ``` | ||||
|  | ||||
| The Git repository does not contain a frontend build, so you should first do that... | ||||
|  | ||||
| ## Build frontend | ||||
|  | ||||
| Frontend needs to be built before using and after any frontend changes: | ||||
| @@ -29,50 +25,3 @@ npm run build | ||||
| ``` | ||||
|  | ||||
| This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`. | ||||
|  | ||||
| ## Development setup | ||||
|  | ||||
| For rapid turnaround during development, you should run `npm run dev` Vite development server on the Vue frontend. While that is running, start the backend on another terminal `hatch run cista --dev -l :8000` and connect to the frontend. | ||||
|  | ||||
| The backend and the frontend will each reload automatically at any code or config changes. | ||||
|  | ||||
| ## System deployment | ||||
|  | ||||
| Clone the repository to `/srv/cista/cista-storage` or other suitable location accessible to the storage user account you plan to use. `sudo -u storage -s` and build the frontend if you hadn't already. | ||||
|  | ||||
| Create **/etc/systemd/system/cista@.service**: | ||||
|  | ||||
| ```ini | ||||
| [Unit] | ||||
| Description=Cista storage %i | ||||
|  | ||||
| [Service] | ||||
| User=storage | ||||
| WorkingDirectory=/srv/cista/cista-storage | ||||
| ExecStart=hatch run cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/ | ||||
| TimeoutStopSec=2 | ||||
| Restart=always | ||||
|  | ||||
| [Install] | ||||
| WantedBy=multi-user.target | ||||
| ``` | ||||
|  | ||||
| This assumes you may want to run multiple separate storages, each having their files under `/media/storage/<domain>` and configuration under `/srv/cista/<domain>/`. Instead of numeric ports, we use UNIX sockets for convenience. | ||||
|  | ||||
| ```sh | ||||
| systemctl daemon-reload | ||||
| systemctl enable --now cista@foo.example.com | ||||
| systemctl enable --now cista@bar.example.com | ||||
| ``` | ||||
|  | ||||
| Exposing this publicly online is the most convenient using the [Caddy](https://caddyserver.com/) web server but you can of course use Nginx or others as well. Or even run the server with `-l domain.example.com` given TLS certificates in the config folder. | ||||
|  | ||||
| **/etc/caddy/Caddyfile**: | ||||
|  | ||||
| ```Caddyfile | ||||
| foo.example.com, bar.example.com { | ||||
|     reverse_proxy unix//srv/cista/{host}/socket | ||||
| } | ||||
| ``` | ||||
|  | ||||
| Using the `{host}` placeholder we can just put all the domains on the same block. That's the full server configuration you need. `systemctl enable --now caddy` or `systemctl restart caddy` for the config to take effect. | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
|     aria-label="Breadcrumb" | ||||
|     @keyup.left.stop="move(-1)" | ||||
|     @keyup.right.stop="move(1)" | ||||
|     @keyup.enter="move(0)" | ||||
|     @focus="move(0)" | ||||
|   > | ||||
|     <a href="#/" | ||||
|       :ref="el => setLinkRef(0, el)" | ||||
|   | ||||
| @@ -5,9 +5,9 @@ | ||||
|         <th class="selection"> | ||||
|           <input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate"> | ||||
|         </th> | ||||
|         <th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th> | ||||
|         <th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th> | ||||
|         <th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th> | ||||
|         <th class="sortcolumn" :class="{ sortactive: sort === 'name' }" @click="toggleSort('name')">Name</th> | ||||
|         <th class="sortcolumn modified right" :class="{ sortactive: sort === 'modified' }" @click="toggleSort('modified')">Modified</th> | ||||
|         <th class="sortcolumn size right" :class="{ sortactive: sort === 'size' }" @click="toggleSort('size')">Size</th> | ||||
|         <th class="menu"></th> | ||||
|       </tr> | ||||
|     </thead> | ||||
| @@ -21,7 +21,7 @@ | ||||
|         <FileSize :doc=editing /> | ||||
|         <td class="menu"></td> | ||||
|       </tr> | ||||
|       <template v-for="(doc, index) in documents" :key="doc.key"> | ||||
|       <template v-for="(doc, index) in sortedDocuments" :key="doc.key"> | ||||
|         <tr class="folder-change" v-if="showFolderBreadcrumb(index)"> | ||||
|           <th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th> | ||||
|         </tr> | ||||
| @@ -84,10 +84,8 @@ import { useMainStore } from '@/stores/main' | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import FileRenameInput from './FileRenameInput.vue' | ||||
| import { connect, controlUrl } from '@/repositories/WS' | ||||
| import { formatSize } from '@/utils' | ||||
| import { collator, formatSize } from '@/utils' | ||||
| import { useRouter } from 'vue-router' | ||||
| import ContextMenu from '@imengyu/vue3-context-menu' | ||||
| import type { SortOrder } from '@/utils/docsort' | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   path: Array<string> | ||||
| @@ -122,6 +120,12 @@ const rename = (doc: Doc, newName: string) => { | ||||
|   } | ||||
|   doc.name = newName // We should get an update from watch but this is quicker | ||||
| } | ||||
| const sortedDocuments = computed(() => sorted(props.documents)) | ||||
| const showFolderBreadcrumb = (i: number) => { | ||||
|   const docs = sortedDocuments.value | ||||
|   const docloc = docs[i].loc | ||||
|   return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc | ||||
| } | ||||
| defineExpose({ | ||||
|   newFolder() { | ||||
|     const now = Math.floor(Date.now() / 1000) | ||||
| @@ -139,8 +143,8 @@ defineExpose({ | ||||
|     allSelected.value = !allSelected.value | ||||
|   }, | ||||
|   toggleSortColumn(column: number) { | ||||
|     const order = ['', 'name', 'modified', 'size', ''][column] | ||||
|     if (order) store.toggleSort(order as SortOrder) | ||||
|     const columns = ['', 'name', 'modified', 'size', ''] | ||||
|     toggleSort(columns[column]) | ||||
|   }, | ||||
|   isCursor() { | ||||
|     return cursor.value !== null && editing.value === null | ||||
| @@ -160,25 +164,25 @@ defineExpose({ | ||||
|   }, | ||||
|   cursorMove(d: number, select = false) { | ||||
|     // Move cursor up or down (keyboard navigation) | ||||
|     const docs = props.documents | ||||
|     if (docs.length === 0) { | ||||
|     const documents = sortedDocuments.value | ||||
|     if (documents.length === 0) { | ||||
|       cursor.value = null | ||||
|       return | ||||
|     } | ||||
|     const N = docs.length | ||||
|     const N = documents.length | ||||
|     const mod = (a: number, b: number) => ((a % b) + b) % b | ||||
|     const increment = (i: number, d: number) => mod(i + d, N + 1) | ||||
|     const index = | ||||
|       cursor.value !== null ? docs.indexOf(cursor.value) : docs.length | ||||
|       cursor.value !== null ? documents.indexOf(cursor.value) : documents.length | ||||
|     const moveto = increment(index, d) | ||||
|     cursor.value = docs[moveto] ?? null | ||||
|     cursor.value = documents[moveto] ?? null | ||||
|     const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null | ||||
|     if (select) { | ||||
|       // Go forwards, possibly wrapping over the end; the last entry is not toggled | ||||
|       let [begin, end] = d > 0 ? [index, moveto] : [moveto, index] | ||||
|       for (let p = begin; p !== end; p = increment(p, 1)) { | ||||
|         if (p === N) continue | ||||
|         const key = docs[p].key | ||||
|         const key = documents[p].key | ||||
|         if (store.selected.has(key)) store.selected.delete(key) | ||||
|         else store.selected.add(key) | ||||
|       } | ||||
| @@ -249,10 +253,22 @@ const mkdir = (doc: Doc, name: string) => { | ||||
|   doc.name = name | ||||
|   doc.key = crypto.randomUUID() | ||||
| } | ||||
| const showFolderBreadcrumb = (i: number) => { | ||||
|   const docs = props.documents | ||||
|   const docloc = docs[i].loc | ||||
|   return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc | ||||
|  | ||||
| // Column sort | ||||
| const toggleSort = (name: string) => { | ||||
|   sort.value = sort.value === name ? '' : name | ||||
| } | ||||
| const sort = ref<string>('') | ||||
| const sortCompare = { | ||||
|   name: (a: Doc, b: Doc) => collator.compare(a.name, b.name), | ||||
|   modified: (a: Doc, b: Doc) => b.mtime - a.mtime, | ||||
|   size: (a: Doc, b: Doc) => b.size - a.size | ||||
| } | ||||
| const sorted = (documents: Doc[]) => { | ||||
|   const cmp = sortCompare[sort.value as keyof typeof sortCompare] | ||||
|   const sorted = [...documents] | ||||
|   if (cmp) sorted.sort(cmp) | ||||
|   return sorted | ||||
| } | ||||
| const selectionIndeterminate = computed({ | ||||
|   get: () => { | ||||
| @@ -286,13 +302,9 @@ const allSelected = computed({ | ||||
|  | ||||
| const loc = computed(() => props.path.join('/')) | ||||
|  | ||||
| const contextMenu = (ev: MouseEvent, doc: Doc) => { | ||||
| const contextMenu = (ev: Event, doc: Doc) => { | ||||
|   cursor.value = doc | ||||
|   ContextMenu.showContextMenu({ | ||||
|     x: ev.x, y: ev.y, items: [ | ||||
|       { label: 'Rename', onClick: () => { editing.value = doc } }, | ||||
|     ], | ||||
|   }) | ||||
|   console.log('Context menu', ev, doc) | ||||
| } | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -18,6 +18,7 @@ | ||||
|           ref="search" | ||||
|           type="search" | ||||
|           :value="query" | ||||
|           @blur="ev => { if (!query) closeSearch(ev) }" | ||||
|           @input="updateSearch" | ||||
|           placeholder="Search words" | ||||
|           class="margin-input" | ||||
|   | ||||
| @@ -1,129 +0,0 @@ | ||||
| import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document' | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import { defineStore } from 'pinia' | ||||
| import { collator } from '@/utils' | ||||
| import { logoutUser } from '@/repositories/User' | ||||
| import { watchConnect } from '@/repositories/WS' | ||||
| import { shallowRef } from 'vue' | ||||
| import { sorted, type SortOrder } from '@/utils/docsort' | ||||
|  | ||||
| type User = { | ||||
|   username: string | ||||
|   privileged: boolean | ||||
|   isOpenLoginModal: boolean | ||||
|   isLoggedIn: boolean | ||||
| } | ||||
|  | ||||
| export const useMainStore = defineStore({ | ||||
|   id: 'main', | ||||
|   state: () => ({ | ||||
|     document: shallowRef<Doc[]>([]), | ||||
|     selected: new Set<FUID>(), | ||||
|     query: '' as string, | ||||
|     fileExplorer: null as any, | ||||
|     error: '' as string, | ||||
|     connected: false, | ||||
|     server: {} as Record<string, any>, | ||||
|     prefs: { | ||||
|       sortListing: '' as SortOrder, | ||||
|       sortFiltered: '' as SortOrder, | ||||
|     }, | ||||
|     user: { | ||||
|       username: '', | ||||
|       privileged: false, | ||||
|       isLoggedIn: false, | ||||
|       isOpenLoginModal: false | ||||
|     } as User | ||||
|   }), | ||||
|   persist: { | ||||
|     paths: ['prefs'], | ||||
|   }, | ||||
|   actions: { | ||||
|     updateRoot(root: FileEntry[]) { | ||||
|       const docs = [] | ||||
|       let loc = [] as string[] | ||||
|       for (const [level, name, key, mtime, size, isfile] of root) { | ||||
|         loc = loc.slice(0, level - 1) | ||||
|         docs.push(new Doc({ | ||||
|           name, | ||||
|           loc: level ? loc.join('/') : '/', | ||||
|           key, | ||||
|           size, | ||||
|           mtime, | ||||
|           dir: !isfile, | ||||
|         })) | ||||
|         loc.push(name) | ||||
|       } | ||||
|       this.document = docs | ||||
|     }, | ||||
|     login(username: string, privileged: boolean) { | ||||
|       this.user.username = username | ||||
|       this.user.privileged = privileged | ||||
|       this.user.isLoggedIn = true | ||||
|       this.user.isOpenLoginModal = false | ||||
|       if (!this.connected) watchConnect() | ||||
|     }, | ||||
|     loginDialog() { | ||||
|       this.user.isOpenLoginModal = true | ||||
|     }, | ||||
|     async logout() { | ||||
|       console.log("Logout") | ||||
|       await logoutUser() | ||||
|       this.$reset() | ||||
|       localStorage.clear() | ||||
|       history.go() // Reload page | ||||
|     }, | ||||
|     toggleSort(name: SortOrder) { | ||||
|       if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name | ||||
|       else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name | ||||
|     }, | ||||
|   }, | ||||
|   getters: { | ||||
|     sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing }, | ||||
|     isUserLogged(): boolean { return this.user.isLoggedIn }, | ||||
|     recentDocuments(): Doc[] { return sorted(this.document, 'modified') }, | ||||
|     selectedFiles(): SelectedItems { | ||||
|       const selected = this.selected | ||||
|       const found = new Set<FUID>() | ||||
|       const ret: SelectedItems = { | ||||
|         missing: new Set(), | ||||
|         docs: {}, | ||||
|         keys: [], | ||||
|         recursive: [], | ||||
|       } | ||||
|       for (const doc of this.document) { | ||||
|         if (selected.has(doc.key)) { | ||||
|           found.add(doc.key) | ||||
|           ret.keys.push(doc.key) | ||||
|           ret.docs[doc.key] = doc | ||||
|         } | ||||
|       } | ||||
|       // What did we not select? | ||||
|       for (const key of selected) if (!found.has(key)) ret.missing.add(key) | ||||
|       // Build a flat list including contents recursively | ||||
|       const relnames = new Set<string>() | ||||
|       function add(rel: string, full: string, doc: Doc) { | ||||
|         if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`) | ||||
|         relnames.add(rel) | ||||
|         ret.recursive.push([rel, full, doc]) | ||||
|       } | ||||
|       for (const key of ret.keys) { | ||||
|         const base = ret.docs[key] | ||||
|         const basepath = base.loc ? `${base.loc}/${base.name}` : base.name | ||||
|         const nremove = base.loc.length | ||||
|         add(base.name, basepath, base) | ||||
|         for (const doc of this.document) { | ||||
|           if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') { | ||||
|             const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name | ||||
|             const rel = full.slice(nremove) | ||||
|             add(rel, full, doc) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|       // Sort by rel (name stored as on download) | ||||
|       ret.recursive.sort((a, b) => collator.compare(a[0], b[0])) | ||||
|  | ||||
|       return ret | ||||
|     } | ||||
|   } | ||||
| }) | ||||
| @@ -1,15 +0,0 @@ | ||||
| import { Doc } from '@/repositories/Document' | ||||
| import { collator } from '@/utils' | ||||
|  | ||||
| export const ordering = { | ||||
|   name: (a: Doc, b: Doc) => collator.compare(a.name, b.name), | ||||
|   modified: (a: Doc, b: Doc) => b.mtime - a.mtime, | ||||
|   size: (a: Doc, b: Doc) => b.size - a.size | ||||
| } | ||||
| export type SortOrder = keyof typeof ordering | '' | ||||
| export const sorted = (documents: Doc[], order: SortOrder) => { | ||||
|   if (!order) return documents | ||||
|   const sorted = [...documents] | ||||
|   sorted.sort(ordering[order]) | ||||
|   return sorted | ||||
| } | ||||
| @@ -13,7 +13,6 @@ import { watchEffect, ref, computed } from 'vue' | ||||
| import { useMainStore } from '@/stores/main' | ||||
| import Router from '@/router/index' | ||||
| import { needleFormat, localeIncludes, collator } from '@/utils'; | ||||
| import { sorted } from '@/utils/docsort'; | ||||
|  | ||||
| const store = useMainStore() | ||||
| const fileExplorer = ref() | ||||
| @@ -25,10 +24,7 @@ const documents = computed(() => { | ||||
|   const loc = props.path.join('/') | ||||
|   const query = props.query | ||||
|   // List the current location | ||||
|   if (!query) return sorted( | ||||
|     store.document.filter(doc => doc.loc === loc), | ||||
|     store.prefs.sortListing, | ||||
|   ) | ||||
|   if (!query) return store.document.filter(doc => doc.loc === loc) | ||||
|   // Find up to 100 newest documents that match the search | ||||
|   const needle = needleFormat(query) | ||||
|   let limit = 100 | ||||
| @@ -39,11 +35,8 @@ const documents = computed(() => { | ||||
|       if (--limit === 0) break | ||||
|     } | ||||
|   } | ||||
|   // Organize by folder, by relevance | ||||
|   const locsub = loc + '/' | ||||
|   // Custom sort override in effect? | ||||
|   const order = store.prefs.sortFiltered | ||||
|   if (order) return sorted(docs, order) | ||||
|   // Sort by relevance - current folder, then subfolders, then others | ||||
|   docs.sort((a, b) => ( | ||||
|     // @ts-ignore | ||||
|     (b.loc === loc) - (a.loc === loc) || | ||||
| @@ -61,6 +54,5 @@ const documents = computed(() => { | ||||
|  | ||||
| watchEffect(() => { | ||||
|   store.fileExplorer = fileExplorer.value | ||||
|   store.query = props.query | ||||
| }) | ||||
| </script> | ||||
|   | ||||
| @@ -29,7 +29,7 @@ dependencies = [ | ||||
| ] | ||||
|  | ||||
| [project.urls] | ||||
| Homepage = "https://git.zi.fi/Vasanko/cista-storage" | ||||
| Homepage = "" | ||||
|  | ||||
| [project.scripts] | ||||
| cista = "cista.__main__:main" | ||||
|   | ||||
		Reference in New Issue
	
	Block a user