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) {