@samitouri / QOSami-HFS / commits / bdc9d6df

admin/options: new "comments storage" option

Massimo Melina committed Feb 25, 2025 at 16:10 UTC bdc9d6df01a96d8a9fc15d3532121a4a39bd0fe4
6 files changed +72 -39
admin/src/OptionsPage.ts
+25 -21
@@ -106,12 +106,12 @@ export default function OptionsPage() {
106 }, sm ? "Config file" : "File"),
107 ],
108 defaults() {
109 - return { sm: 6 }
109 + return { xs: 6 }
110 },
111 fields: [
112 h(Section, { title: "Networking" }),
113 - { k: 'port', comp: PortField, label:"HTTP port", status: status?.http||true, suggestedPort: 80 },
114 - { k: 'https_port', comp: PortField, label: "HTTPS port", status: status?.https||true, suggestedPort: 443,
113 + { k: 'port', comp: PortField, xs: 12, sm: 6, label:"HTTP port", status: status?.http||true, suggestedPort: 80 },
114 + { k: 'https_port', comp: PortField, xs: 12, sm: 6, label: "HTTPS port", status: status?.https||true, suggestedPort: 443,
115 onChange(v: number) {
116 if (v >= 0 && !httpsEnabled && !values.cert)
117 void suggestMakingCert()
@@ -150,9 +150,9 @@ export default function OptionsPage() {
150 { k: 'admin_net', comp: NetmaskField, label: "Admin-panel accessible from", placeholder: "any address",
151 helperText: h(Fragment, {}, "IP address of browser machine. ", h(WildcardsSupported))
152 },
153 - { k: 'localhost_admin', comp: BoolField, label: "Unprotected admin on localhost",
153 + { k: 'localhost_admin', comp: BoolField, label: "Unprotected Admin-panel on localhost",
154 getError: x => !x && admins?.length===0 && "First create at least one admin account",
155 - helperText: "Access Admin-panel without entering credentials"
155 + helperText: "Access without entering credentials"
156 },
157
158 { k: 'proxies', comp: NumberField, sm: 4, md: 4, max: 9, label: "Number of HTTP proxies", placeholder: "none",
@@ -164,7 +164,7 @@ export default function OptionsPage() {
164 { k: 'allowed_referer', comp: AllowedReferer, sm: 3, md: 4, placeholder: "any", label: "Links from other websites",
165 helperText: "In case another website is linking your files" },
166
167 - { k: 'block', label: false, comp: ArrayField, prepend: true, sm: true, autoRowHeight: true,
167 + { k: 'block', label: false, comp: ArrayField, xs: 12, prepend: true, sm: true, autoRowHeight: true,
168 fields: [
169 { k: 'ip', label: "Blocked IP", sm: 12, required: true, wrap: true, $width: 2,
170 $column: { mergeRender: { comment: {}, expire: {} } },
@@ -199,11 +199,11 @@ export default function OptionsPage() {
199 { k: 'theme', comp: SelectField, xs: 6, sm: 3, options: THEME_OPTIONS },
200 { k: 'sort_by', comp: SelectField, xs: 6, sm: 3, options: SORT_BY_OPTIONS },
201
202 - { k: 'invert_order', comp: BoolField, xs: 6, sm: 3 },
203 - { k: 'folders_first', comp: BoolField, xs: 6, sm: 3 },
204 - { k: 'sort_numerics', comp: BoolField, xs: 6, sm: 3, label: "Sort numeric names" },
205 - { k: 'title_with_path', comp: BoolField, xs: 6, sm: 3 },
206 - { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.ico|' + IMAGE_FILEMASK, sm: 12,
202 + { k: 'invert_order', comp: BoolField, xs: 6, md: 3 },
203 + { k: 'folders_first', comp: BoolField, xs: 6, md: 3 },
204 + { k: 'sort_numerics', comp: BoolField, xs: 6, md: 3, label: "Sort numeric names" },
205 + { k: 'title_with_path', comp: BoolField, xs: 6, md: 3 },
206 + { k: 'favicon', comp: FileField, placeholder: "None", fileMask: '*.ico|' + IMAGE_FILEMASK, xs: 12,
207 helperText: "The icon associated to your website" },
208
209 h(Section, { title: "Uploads" }),
@@ -218,20 +218,24 @@ export default function OptionsPage() {
218
219 h(Section, { title: "Others" }),
220 { k: 'keep_session_alive', comp: BoolField, sm: 6, md: 6, helperText: "Keeps you logged in while the page is left open and the computer is on" },
221 - { k: 'session_duration', comp: NumberField, sm: 6, md: 3, min: 5, unit: "seconds", required: true },
222 - { k: 'zip_calculate_size_for_seconds', comp: NumberField, sm: 6, md: 3, unit: "seconds", required: true,
223 - label: "Calculate ZIP size for", helperText: "If time is not enough, the browser will not show download percentage" },
221 + { k: 'session_duration', comp: NumberField, sm: 3, md: 3, min: 5, unit: "seconds", required: true },
222 + { k: CFG.size_1024, label: "KB size", comp: SelectField, sm: 3, options: { 1000: false, 1024: true } },
223
225 - { k: 'show_hidden_files', comp: BoolField, sm: 6, md: 3 },
226 - { k: 'descript_ion', comp: BoolField, sm: 6, md: 3, label: "Enable comments", helperText: "In file DESCRIPT.ION" },
227 - { k: 'descript_ion_encoding', sm: 6, md: 3, label: "Encoding of file DESCRIPT.ION", comp: SelectField, disabled: !values.descript_ion,
224 + { k: 'show_hidden_files', comp: BoolField, sm: 3 },
225 + { k: CFG.comments_storage, comp: SelectField, xs: 12, sm: 6, md: 5, options: {
226 + "in file DESCRIPT.ION": '',
227 + "in file attributes": 'attr',
228 + "in file attributes + load DESCRIPT.ION": 'attr+ion',
229 + } },
230 + { k: 'descript_ion_encoding', xs: 8, sm: 3, md: 4, label: "Encoding of file DESCRIPT.ION", comp: SelectField, disabled: !values.descript_ion,
231 options: ['utf8',720,775,819,850,852,862,869,874,808, ..._.range(1250,1257),10029,20866,21866] },
229 - { k: CFG.size_1024, label: "KB size", comp: SelectField, sm: 6, md: 3, options: { 1000: false, 1024: true } },
232
231 - { k: 'open_browser_at_start', comp: BoolField, label: "Open Admin-panel at start", sm: 4, md: 6,
233 + { k: 'open_browser_at_start', comp: BoolField, label: "Open Admin-panel at start", xs: 12, sm: 6, md: 3,
234 helperText: "Browser is automatically launched with HFS"
235 },
234 - { k: 'mime', comp: ArrayField, label: false, reorder: true, prepend: true, sm: 12, md: 6,
236 + { k: 'zip_calculate_size_for_seconds', comp: NumberField, xs: 12, sm: 6, md: 3, unit: "seconds", required: true,
237 + label: "Calculate ZIP size for", helperText: "If time is not enough, the browser will not show download percentage" },
238 + { k: 'mime', comp: ArrayField, label: false, reorder: true, prepend: true, xs: 12, sm: 12, md: 6,
239 fields: [
240 { k: 'v', label: "Mime type", placeholder: "auto", $width: 2, helperText: "Leave empty to get automatic value" },
241 { k: 'k', label: "File mask", helperText: h(WildcardsSupported), $width: 1, $column: {
@@ -247,7 +251,7 @@ export default function OptionsPage() {
251 fromField: x => Object.fromEntries(x.map((row: any) => [row.k, row.v || 'auto'])),
252 },
253
250 - { k: 'server_code', comp: TextEditorField, lang: 'js', sm: 12,
254 + { k: 'server_code', comp: TextEditorField, lang: 'js', xs: 12,
255 helperText: md(`This code works similarly to [a plugin](${REPO_URL}blob/main/dev-plugins.md) (with some limitations)`)
256 },
257
config.md
+3 -2
@@ -92,8 +92,9 @@ Configuration can be done in several ways
92 - `base_url` URL to be used for links generation. Default is automatic.
93 - `force_address` disconnect any request not made with one of the hosts specified in `roots` or `base_url`. Default is false.
94 - `ignore_proxies` stop warning about detected proxies. Default is false.
95 -- `descript_ion` enable reading and writing of comments in the old file format *DESCRIPT.ION*. Default is yes.
96 -- `descript_ion_encoding` text encoding to be used for file *DESCRIPT.ION*. [List of supported values](https://github.com/ashtuchkin/iconv-lite/wiki/Supported-Encodings). Default is `utf8`.
95 +- `comments_storage` choose where to read/write comments. Default is in file `descript.ion`.
96 + - You can set `attr` to use only file's attributes, or `attr+ion` to store in file's attributes but fall-back to `descript.ion` as read-only.`
97 +- `descript_ion_encoding` text encoding to be used for file `descript.ion`. [List of supported values](https://github.com/ashtuchkin/iconv-lite/wiki/Supported-Encodings). Default is `utf8`.
98 - `server_code` javascript code that works similarly to [a plugin](dev-plugins.md).
99 - `tiles_size` starting value for frontend's tiles size. Default is 0.
100 - `auto_play_seconds` starting value for frontend's auto_play_seconds (used in Show). Default is 5.
src/comments.ts
+33 -9
@@ -1,26 +1,50 @@
1 import { defineConfig } from './config'
2 import { dirname, join } from 'path'
3 -import { basename } from './cross'
3 +import { basename, CFG } from './cross'
4 import { parseFileContent, parseFileCache } from './util-files'
5 import { createWriteStream } from 'fs'
6 -import { singleWorkerFromBatchWorker } from './misc'
6 +import { loadFileAttr, singleWorkerFromBatchWorker, storeFileAttr } from './misc'
7 import _ from 'lodash'
8 import iconv from 'iconv-lite'
9 import { unlink } from 'node:fs/promises'
10
11 export const DESCRIPT_ION = 'descript.ion'
12 -export const descriptIon = defineConfig('descript_ion', true)
12 +const commentsStorage = defineConfig<'' | 'attr' | 'attr+ion'>(CFG.comments_storage, '')
13 +defineConfig('descript_ion', true, (v, more) => { // legacy: convert previous setting
14 + if (!v && more.version?.olderThan('0.57.0-alpha1'))
15 + commentsStorage.set('attr')
16 +})
17 const descriptIonEncoding = defineConfig('descript_ion_encoding', 'utf8')
18
19 +function readFromDescriptIon(path: string) {
20 + return usingDescriptIon() && readDescriptIon(dirname(path)).then(x => x.get(basename(path)), () => undefined)
21 +}
22 +
23 +export function usingDescriptIon() {
24 + return ['', 'attr+ion'].includes(commentsStorage.get())
25 +}
26 +
27 +const COMMENT_ATTR = 'comment'
28 +
29 export async function getCommentFor(path?: string) {
16 - return !path || !descriptIon.get() ? undefined
17 - : readDescription(dirname(path)).then(x => x.get(basename(path)), () => undefined)
30 + return !path ? undefined : Promise.all([
31 + commentsStorage.get() ? loadFileAttr(path, COMMENT_ATTR) : undefined,
32 + readFromDescriptIon(path)
33 + ]).then(([fromAttr, fromIon]) => fromAttr || fromIon)
34 +}
35 +
36 +export async function setCommentFor(path: string, comment: string) {
37 + if (commentsStorage.get()) {
38 + await storeFileAttr(path, COMMENT_ATTR, comment || undefined)
39 + return setCommentDescriptIon(path, '')
40 + }
41 + return setCommentDescriptIon(path, comment) // should we also remove from file-attr? not sure, but for the time we won't because #1 storeFileAttr is not really deleting, and we would store a lot of empty attributes, #2 more people will switch from descript.ion to attr (because introduced later) than the opposite
42 }
43
20 -export const setCommentFor = singleWorkerFromBatchWorker(async (jobs: [path: string, comment: string][]) => {
44 +const setCommentDescriptIon = singleWorkerFromBatchWorker(async (jobs: [path: string, comment: string][]) => {
45 const byFolder = _.groupBy(jobs, job => dirname(job[0]))
46 return Promise.allSettled(_.map(byFolder, async (jobs, folder) => {
23 - const comments = await readDescription(folder).catch(() => new Map())
47 + const comments = await readDescriptIon(folder).catch(() => new Map())
48 for (const [path, comment] of jobs) {
49 const file = path.slice(folder.length + 1)
50 if (!comment)
@@ -46,11 +70,11 @@ export const setCommentFor = singleWorkerFromBatchWorker(async (jobs: [path: str
70 })
71
72 export function areCommentsEnabled() {
49 - return descriptIon.get()
73 + return true // true since we introduced comments in file-attr
74 }
75
76 const MULTILINE_SUFFIX = Buffer.from([4, 0xC2])
53 -function readDescription(path: string) {
77 +function readDescriptIon(path: string) {
78 // decoding could also be done with native TextDecoder.decode, but we need iconv for the encoding anyway
79 return parseFileContent(join(path, DESCRIPT_ION), raw => {
80 // for simplicity we "remove" the sequence MULTILINE_SUFFIX before iconv.decode messes it up
src/cross.ts
+3 -1
@@ -27,10 +27,12 @@ export const FRONTEND_OPTIONS = {
27 }
28 export const SORT_BY_OPTIONS = ['name', 'extension', 'size', 'time', 'creation']
29 export const THEME_OPTIONS = { auto: '', light: 'light', dark: 'dark' }
30 +// had found an interesting way to infer a type from all the calls to defineConfig (by the literals passed), but would not be usable also by admin-panel
31 export const CFG = constMap(['geo_enable', 'geo_allow', 'geo_list', 'geo_allow_unknown', 'dynamic_dns_url',
32 'log', 'error_log', 'log_rotation', 'dont_log_net', 'log_gui', 'log_api', 'log_ua', 'log_spam', 'track_ips',
33 'max_downloads', 'max_downloads_per_ip', 'max_downloads_per_account', 'roots', 'force_address', 'split_uploads',
33 - 'allow_session_ip_change', 'force_lang', 'suspend_plugins', 'base_url', 'size_1024', 'disable_custom_html'])
34 + 'allow_session_ip_change', 'force_lang', 'suspend_plugins', 'base_url', 'size_1024', 'disable_custom_html',
35 + 'comments_storage'])
36 export const LIST = { add: '+', remove: '-', update: '=', props: 'props', ready: 'ready', error: 'e' }
37 export type Dict<T=any> = Record<string, T>
38 export type Falsy = false | null | undefined | '' | 0
src/fileAttr.ts
+6 -4
@@ -18,9 +18,11 @@ const FN = 'file-attr.kv'
18 if (existsSync(FN))
19 fileAttrDb.open(FN)
20 const FILE_ATTR_PREFIX = 'user.hfs.' // user. prefix to be linux compatible
21 +
22 +/* @param v must be JSON-able or undefined */
23 export async function storeFileAttr(path: string, k: string, v: any) {
22 - const s = await stat(path)
23 - if (await fsx?.set(path, FILE_ATTR_PREFIX + k, JSON.stringify(v)).then(() => 1, () => 0)) {
24 + const s = await stat(path) // since we don't have fsx.remove, we simulate it with an empty string
25 + if (await fsx?.set(path, FILE_ATTR_PREFIX + k, v === undefined ? '' : JSON.stringify(v)).then(() => 1, () => 0)) {
26 if (IS_WINDOWS) utimes(path, s.atime, s.mtime) // restore timestamps, necessary only on Windows
27 return true
28 }
@@ -36,8 +38,8 @@ export async function storeFileAttr(path: string, k: string, v: any) {
38
39 export async function loadFileAttr(path: string, k: string) {
40 return await fsx?.get(path, FILE_ATTR_PREFIX + k)
39 - .then((x: any) => x && tryJson(String(x)), () => {})
40 - .then((x: any) => x ?? (fileAttrDb.isOpen() ? fileAttrDb.get(`${path}|${k}`) : null))
41 + .then((x: any) => x === '' ? undefined : tryJson(String(x)),
42 + () => fileAttrDb.isOpen() ? fileAttrDb.get(`${path}|${k}`) : null)
43 ?? undefined // normalize, as we get null instead of undefined on windows
44 }
45
src/vfs.ts
+2 -2
@@ -15,7 +15,7 @@ import { ctxBelongsTo } from './perm'
15 import { getCurrentUsername } from './auth'
16 import { Stats } from 'node:fs'
17 import fswin from 'fswin'
18 -import { DESCRIPT_ION, descriptIon } from './comments'
18 +import { DESCRIPT_ION, usingDescriptIon } from './comments'
19 import { walkDir } from './walkDir'
20 import { Readable } from 'node:stream'
21
@@ -333,7 +333,7 @@ export async function* walkNode(parent: VfsNode, {
333 const {path} = entry
334 const isFolder = entry.isDirectory()
335 const name = prefixPath + (parent.rename?.[path] || path)
336 - if (descriptIon.get() && basename(name) === DESCRIPT_ION)
336 + if (usingDescriptIon() && basename(name) === DESCRIPT_ION)
337 return
338 if (taken?.has(normalizeFilename(name))) // taken by vfs node above
339 return false // false just in case it's a folder