mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
fix: collision names must survive the sanitiser the content view applies
unique_filename produced 'manual (2).pdf'; HouseplanContentView sanitises the name in the REQUEST too, turning ' (2)' into '_2_', so the file was written and then 404'd. The same pattern was already in files/migrate, so a rebind that hit a name collision has been producing dead links. Both use the shared helper now, with '-2', which round-trips sanitize_filename — asserted.
This commit is contained in:
@@ -35,7 +35,11 @@ def unique_filename(directory: Path, name: str) -> str:
|
||||
stem, suffix = safe, ""
|
||||
i = 2
|
||||
while True:
|
||||
candidate = f"{stem} ({i}){'.' + suffix if suffix else ''}"
|
||||
# "-2", not " (2)": the content view sanitizes the name in the REQUEST
|
||||
# too, and a space or a bracket there turns into "_", so a file called
|
||||
# "manual (2).pdf" was written and then never served. Only characters
|
||||
# that survive sanitize_filename may be used to build a name.
|
||||
candidate = f"{stem}-{i}{'.' + suffix if suffix else ''}"
|
||||
if not (directory / candidate).exists():
|
||||
return candidate
|
||||
i += 1
|
||||
|
||||
@@ -20,7 +20,7 @@ from .const import (
|
||||
CONTENT_URL, FILES_DIR, MAX_SIGN_PATHS, PLANS_DIR, PLANS_URL,
|
||||
)
|
||||
from .auth import may_write
|
||||
from .plans import collect_attachments, collect_plans
|
||||
from .plans import collect_attachments, collect_plans, unique_filename
|
||||
from .store import HouseplanData, get_data, get_entry
|
||||
from .validation import (
|
||||
CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES,
|
||||
@@ -193,15 +193,10 @@ async def ws_files_migrate(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
for f in sorted(src.iterdir()):
|
||||
if not f.is_file():
|
||||
continue
|
||||
target = dst / f.name
|
||||
if target.exists():
|
||||
# a different file already owns this name — do NOT silently
|
||||
# point the url at it; give the copy a unique name instead
|
||||
stem, suffix = f.stem, f.suffix
|
||||
i = 2
|
||||
while (dst / f"{stem} ({i}){suffix}").exists():
|
||||
i += 1
|
||||
target = dst / f"{stem} ({i}){suffix}"
|
||||
# a different file may already own this name — do NOT silently point
|
||||
# the url at it; the shared helper picks a free one, using only
|
||||
# characters the content view will accept back in a request
|
||||
target = dst / unique_filename(dst, f.name)
|
||||
shutil.copy2(str(f), str(target))
|
||||
mapping[f.name] = target.name
|
||||
return mapping
|
||||
|
||||
+5
-1
@@ -25,7 +25,11 @@
|
||||
shared folder, so two of them attaching `manual.pdf` ended up pointing at the
|
||||
same physical file. Uploads now take a free name and never overwrite, a new
|
||||
icon gets its own staging folder whose files move to the real icon when the
|
||||
save is accepted, and an upload nobody saved is collected an hour later.
|
||||
save is accepted, and an upload nobody saved is collected an hour later. The
|
||||
name a collision falls back to changed from `manual (2).pdf` to `manual-2.pdf`
|
||||
— the old one was sanitised on the way back in, so a renamed attachment was
|
||||
written and then never served (found by the new test, and it applied to
|
||||
rebind collisions before this release too).
|
||||
- **Two quick edits can no longer lose the second one (HP-1454-03).** The
|
||||
debounce spaced out the starts of a save, not the saves themselves. If one
|
||||
took longer than half a second — a busy instance, a slow link — the next edit
|
||||
|
||||
@@ -33,7 +33,11 @@
|
||||
один физический файл. Теперь загрузка занимает свободное имя и никогда не
|
||||
перезаписывает, новая иконка получает собственную промежуточную папку, файлы
|
||||
из которой переезжают к настоящей иконке при принятом сохранении, а загрузка,
|
||||
которую никто не сохранил, убирается через час.
|
||||
которую никто не сохранил, убирается через час. Имя, на которое уходит
|
||||
коллизия, сменилось с `manual (2).pdf` на `manual-2.pdf`: старое санитайзилось
|
||||
на обратном пути, поэтому переименованное вложение записывалось и больше не
|
||||
отдавалось (нашёл новый тест; до этого релиза так же ломались коллизии при
|
||||
перепривязке).
|
||||
- **Две быстрые правки больше не теряют вторую (HP-1454-03).** Debounce
|
||||
разносил старты сохранения, а не сами сохранения. Если одно длилось дольше
|
||||
полусекунды — занятый сервер, медленная связь, — следующая правка уходила с
|
||||
|
||||
+4
-2
@@ -798,8 +798,10 @@ test('migratePdfUrls: only confirmed copies are rewritten (review CR-3)', () =>
|
||||
{ name: 'b.pdf', url: '/houseplan_files/files/v_old1/b.pdf?v=2' },
|
||||
];
|
||||
// сервер скопировал только a.pdf, причём переименовал из-за коллизии
|
||||
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99', { 'a.pdf': 'a (2).pdf' });
|
||||
assert.equal(out[0].url, '/houseplan_files/files/dev99/a%20(2).pdf?v=1');
|
||||
// collision names use only characters the content view accepts back in a
|
||||
// request: ' (2)' was sanitised to '_2_' server-side and 404'd (v1.46.0)
|
||||
const out = migratePdfUrls(pdfs, 'v_old1', 'dev99', { 'a.pdf': 'a-2.pdf' });
|
||||
assert.equal(out[0].url, '/houseplan_files/files/dev99/a-2.pdf?v=1');
|
||||
assert.equal(out[1].url, pdfs[1].url, 'нескопированный файл ссылается на старую папку');
|
||||
// пустой маппинг = ничего не переносим
|
||||
assert.deepEqual(migratePdfUrls(pdfs, 'v_old1', 'dev99', {}).map((p) => p.url),
|
||||
|
||||
@@ -690,7 +690,7 @@ async def test_upload_never_overwrites_an_existing_attachment(
|
||||
names = sorted(
|
||||
n for n in await hass.async_add_executor_job(os.listdir, folder) if n.startswith("manual")
|
||||
)
|
||||
assert names == ["manual (2).pdf", "manual.pdf"]
|
||||
assert names == ["manual-2.pdf", "manual.pdf"]
|
||||
|
||||
got = await http.get(first.replace(CONTENT_URL, CONTENT_URL))
|
||||
assert await got.read() == b"ONE", "the first file is untouched"
|
||||
|
||||
@@ -392,14 +392,20 @@ def test_unique_filename_never_returns_a_taken_name(tmp_path):
|
||||
d.mkdir()
|
||||
assert unique_filename(d, "manual.pdf") == "manual.pdf"
|
||||
(d / "manual.pdf").write_bytes(b"x")
|
||||
assert unique_filename(d, "manual.pdf") == "manual (2).pdf"
|
||||
(d / "manual (2).pdf").write_bytes(b"x")
|
||||
assert unique_filename(d, "manual.pdf") == "manual (3).pdf"
|
||||
assert unique_filename(d, "manual.pdf") == "manual-2.pdf"
|
||||
(d / "manual-2.pdf").write_bytes(b"x")
|
||||
assert unique_filename(d, "manual.pdf") == "manual-3.pdf"
|
||||
# no extension, and a name that needs sanitising
|
||||
(d / "readme").write_bytes(b"x")
|
||||
assert unique_filename(d, "readme") == "readme (2)"
|
||||
assert unique_filename(d, "readme") == "readme-2"
|
||||
assert unique_filename(d, "../../etc/passwd") == "passwd"
|
||||
|
||||
# A generated name has to survive the sanitiser the CONTENT VIEW applies to
|
||||
# the request, or the file is written and then never served: " (2)" became
|
||||
# "_2_" and every collision-renamed attachment 404'd.
|
||||
for candidate in ("manual-2.pdf", "manual-3.pdf", "readme-2"):
|
||||
assert v.sanitize_filename(candidate) == candidate
|
||||
|
||||
|
||||
def _acfg(*pairs):
|
||||
return {"markers": [{"id": f"m{i}", "pdfs": [{"url": f"/api/houseplan/content/files/{p}"}]}
|
||||
|
||||
Reference in New Issue
Block a user