fix: plugin download-counter could be inconsistent in case of files with % in the name
Massimo Melina committed
Jan 24, 2026 at 12:31 UTC
73bdb120fc9215abb1100b0d79e082b14c737bf8
3 files changed
+44
-29
plugins/download-counter/plugin.js
+10
-27
@@ -1,7 +1,7 @@
1
// other plugins can use ctx.state.download_counter_ignore to mark downloads that shouldn't be counted
2
3
exports.description = "Counts downloads for each file, and displays the total in the list or file menu"
4
-exports.version = 6.3 // fixed slow conversion of big legacy files
4
+exports.version = 6.4 // removed legacy code; fixed wrong key for files with % in the name
5
exports.apiRequired = 8.89 // openDb
6
7
exports.config = {
@@ -16,25 +16,6 @@ exports.configDialog = {
16
17
exports.init = async api => {
18
const db = await api.openDb('counters.kv', { defaultPutDelay: 5_000, maxPutDelay: 30_000 })
19
-
20
- if (!db.size()) try { // load legacy file
21
- const countersFile = 'counters.yaml'
22
- const yaml = api.require('yaml')
23
- const input = api.require('fs').createReadStream(countersFile)
24
- const readline = api.require('readline').createInterface({ input })
25
- for await (const line of readline) {
26
- const parsed = yaml.parse(line) // parsing a 3.7MB file as a whole takes 25x in my tests
27
- for (const [k,v] of Object.entries(parsed))
28
- db.put(uri2key(k), v)
29
- }
30
- api.require('fs').promises.rename(countersFile, countersFile + ' - old format, now converted, you can delete')
31
- api.log("data converted")
32
- }
33
- catch(err) {
34
- if (err.code !== 'ENOENT')
35
- api.log(err)
36
- }
37
-
19
return {
20
frontend_js: 'main.js',
21
frontend_css: 'style.css',
@@ -44,23 +25,25 @@ exports.init = async api => {
25
ctx.state.completed.then(() => {
26
const key = uri2key(ctx.path)
27
const entries = ctx.state.vfsNode ? [key]
47
- : ctx.state.originalStream?.getArchiveEntries?.().filter(x => x.at(-1) !== '/').map(x => key + uri2key(x))
28
+ : ctx.state.originalStream?.getArchiveEntries?.().filter(x => x.at(-1) !== '/').map(x => key + path2key(x))
29
if (!entries) return
30
for (const k of entries)
31
db.put(k, db.getSync(k) + 1 || 1)
32
})
33
},
34
onDirEntry({ entry, listUri }) {
54
- const k = uri2key(listUri + entry.n)
35
+ const k = uri2key(listUri) + path2key(entry.n)
36
const n = db.getSync(k)
37
if (n)
38
entry.hits = n
39
}
40
}
60
-}
41
62
-function uri2key(uri) { // normalize uri to avoid having different keys for same file
63
- try { uri = decodeURIComponent(uri) } // decodeURI doesn't support #=%23
64
- catch {}
65
- return uri.toLowerCase()
42
+ function uri2key(uri) { // normalize uri to avoid having different keys for the same file
43
+ return path2key(api.misc.pathDecode(uri))
44
+ }
45
+
46
+ function path2key(path) {
47
+ return path.toLowerCase()
48
+ }
49
}
tests/config.yaml
+1
-1
@@ -14,7 +14,7 @@ vfs:
14
can_list: false
15
children:
16
- source: ../alfa.txt
17
- name: x%#x
17
+ name: x%25#x
18
- name: protectFromAbove
19
children:
20
- name: child
tests/test.ts
+33
-1
@@ -27,6 +27,8 @@ const BASE_URL = 'http://[::1]:81'
27
const BASE_URL_127 = 'http://127.0.0.1:81'
28
const UPLOAD_ROOT = '/for-admins/upload/'
29
const VIRTUAL_UPLOAD_ROOT = '/renameChild/'
30
+const FUNNY_NAME = 'x%25#x'
31
+const FUNNY_NAME_ENCODED = '/x%2525%23x'
32
const UPLOAD_DIR = 'temp'
33
const CANT_OVERWRITE_NAME = 'cant-overwrite'
34
const CANT_OVERWRITE_URI = `/for-admins/${CANT_OVERWRITE_NAME}/`
@@ -78,7 +80,9 @@ describe('basics', () => {
80
test('traversal.overlong-utf8', req('/f1/page/%c0%ae%c0%ae/%c0%ae%c0%ae/README.md', 404))
81
test('bad url encoding', req('/f1/%E0%A4%A', 404))
82
test('custom mime from above', req('/tests/page/index.html', { status: 200, mime:'text/plain' }))
81
- test('name encoding', req('/x%25%23x', 200))
83
+ test('name encoding', req(FUNNY_NAME_ENCODED, 200))
84
+ test('name encoding list', reqList('/', { inList: [FUNNY_NAME] }))
85
+ test('name encoding search', reqList('/', { inList: [FUNNY_NAME] }, { search: FUNNY_NAME }))
86
87
test('missing perm', reqList('/for-admins/', 401))
88
test('missing perm.file', req('/for-admins/alfa.txt', 401))
@@ -486,6 +490,34 @@ describe('admin', () => {
490
throw "plugin didn't start"
491
}, { auth })()
492
})
493
+ test('plugins.download-counter percent name', async () => {
494
+ const id = 'download-counter'
495
+ await reqApi('start_plugin', { id }, 200, { auth })()
496
+ try {
497
+ const before = await getHits()
498
+ await req(FUNNY_NAME_ENCODED, 200)()
499
+ let after = 0
500
+ for (const _x of _.range(10)) {
501
+ await wait(100)
502
+ after = await getHits()
503
+ if (after > before)
504
+ break
505
+ }
506
+ if (after <= before)
507
+ throw `counter not incremented (before ${before}, after ${after})`
508
+ }
509
+ finally {
510
+ await reqApi('stop_plugin', { id }, 200, { auth })()
511
+ }
512
+
513
+ async function getHits() {
514
+ const listRes = await reqList('/', { status: 200 })()
515
+ const entry = _.find(listRes?.list, { n: FUNNY_NAME })
516
+ if (!entry)
517
+ throw "missing entry in list"
518
+ return entry.hits || 0
519
+ }
520
+ })
521
})
522
523
function login(usr: string, pwd=password) {