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