From 8d1ff79912ce884ff6e06f058610f49bc0b727fa Mon Sep 17 00:00:00 2001 From: kodi Date: Fri, 13 Mar 2026 13:44:41 +0100 Subject: [PATCH] upload: deel 01 --- project_docs/LOCAL_UPLOAD_V1_DESIGN.md | 233 ++++++++++++++++++ .../__pycache__/routes_files.cpython-313.pyc | Bin 5270 -> 5801 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 9230 -> 9450 bytes webui/backend/app/api/routes_files.py | 13 +- webui/backend/app/api/schemas.py | 6 + .../history_repository.cpython-313.pyc | Bin 7758 -> 7766 bytes webui/backend/app/db/history_repository.py | 2 +- .../filesystem_adapter.cpython-313.pyc | Bin 17353 -> 18026 bytes webui/backend/app/fs/filesystem_adapter.py | 12 + .../file_ops_service.cpython-313.pyc | Bin 23669 -> 25600 bytes .../backend/app/services/file_ops_service.py | 57 ++++- .../test_api_upload_golden.cpython-313.pyc | Bin 0 -> 10660 bytes .../tests/golden/test_api_upload_golden.py | 186 ++++++++++++++ 13 files changed, 505 insertions(+), 4 deletions(-) create mode 100644 project_docs/LOCAL_UPLOAD_V1_DESIGN.md create mode 100644 webui/backend/tests/golden/__pycache__/test_api_upload_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/test_api_upload_golden.py diff --git a/project_docs/LOCAL_UPLOAD_V1_DESIGN.md b/project_docs/LOCAL_UPLOAD_V1_DESIGN.md new file mode 100644 index 0000000..91a739c --- /dev/null +++ b/project_docs/LOCAL_UPLOAD_V1_DESIGN.md @@ -0,0 +1,233 @@ +# Local Upload v1 + +## 1. Doel +Local upload voegt nu direct waarde toe omdat de app al een bruikbare dual-pane bestandsworkflow heeft, maar nog geen ingang om bestanden vanaf de lokale machine de beheerde storage in te brengen. Dat gat is functioneel groot: browse, rename, move, copy en delete bestaan al, maar import ontbreekt. + +Binnen de dual-pane workflow is de meest natuurlijke semantiek: +- bron: lokale machine via de native browser file picker +- doel: `currentPath` van het actieve paneel + +Dat houdt het model eenvoudig en voorspelbaar. De gebruiker kiest eerst waar in de storage hij staat, en uploadt daarna naar die locatie. + +## 2. Scope +Aanbevolen scope voor v1: +- upload van lokale bestanden via browser naar storage +- target = `currentPath` van het actieve paneel +- native browser file picker gebruiken +- single-file upload +- multi-file upload +- geen folder upload in v1 +- geen drag & drop in v1 +- geen resumable upload +- geen chunked upload + +Motivatie: +- Multi-file upload via de native picker is klein en nuttig. +- Folder upload verhoogt de complexiteit direct sterk: recursie, conflictgedrag, voortgang, directory-creatie, mixed failures. +- Drag & drop is UX-matig aantrekkelijk, maar voegt event-complexiteit en extra foutpaden toe zonder dat het nodig is voor een eerste bruikbare versie. +- Chunking/resume is pas zinvol als gewone multipart upload aantoonbaar onvoldoende is. + +## 3. Startgedrag / UI +Voor v1: +- een `Upload` knop links van `F1 Settings` in de onderbalk/topactiezone waar die nu logisch past +- klik op `Upload` opent direct de native browser file picker +- de upload werkt altijd naar het actieve paneel +- de UI toont compact en expliciet: + - `Upload to: ` + +Aanbevolen flow: +1. gebruiker activeert een paneel +2. gebruiker klikt `Upload` +3. browser opent native file picker +4. gebruiker kiest 1 of meerdere bestanden +5. upload start naar `currentPath` van actief paneel +6. voortgang wordt zichtbaar +7. na afronding wordt het actieve paneel refreshed + +Belangrijk: +- de actieve-paneelcontext moet vooraf duidelijk zijn +- de knop hoeft niet disabled te zijn zolang een geldig `currentPath` bestaat +- als een modal open is, moet `Upload` niet tegelijk een nieuwe flow starten + +## 4. Voortgang +Aanbevolen v1-model: +- één compacte upload-progress UI per lopende uploadbatch +- globale voortgang over de batch +- daarnaast compacte status per huidig bestand indien nodig + +V1 hoeft niet meteen een volledige task-UI te hergebruiken. De eenvoudigste bruikbare richting is: +- één uploadstatusblok of kleine modal +- toont: + - totaal aantal bestanden + - huidig bestand + - globale voortgangsbalk of percentage + +Aanbevolen velden in de UI: +- `Uploading 3 files to /Volumes/...` +- `2/3 files` +- huidige bestandsnaam +- percentage of bytes-progress voor de actieve upload + +Dit is lichter dan de bestaande task-list volledig integreren in v1. + +## 5. Backend-impact +Er is zeer waarschijnlijk een nieuw upload-endpoint nodig, bijvoorbeeld: +- `POST /api/files/upload` + +Verwachte vorm: +- multipart/form-data +- target path als apart veld, bijvoorbeeld `target_path` +- één of meerdere file parts + +Veiligheidsmodel: +- `target_path` altijd via bestaande `path_guard` +- target moet binnen whitelist/toegestane roots vallen +- target moet bestaan +- target moet een directory zijn +- bestandsnamen niet vertrouwen vanuit clientpad-informatie +- alleen de basename van het gekozen lokale bestand gebruiken +- validatie van naam via bestaande naamregels (`validate_name` of equivalent) +- geen client-side padsegmenten overnemen + +Traversalpreventie: +- geen directorystructuur uit de browser aan serverzijde interpreteren in v1 +- geen relatieve paden uit multipart metadata vertrouwen +- ieder bestand wordt server-side gemapt naar: + - `target_path / validated_basename` + +## 6. Conflictgedrag +Ontwerp voor Engelstalige keuzes: +- `Overwrite` +- `Overwrite all` +- `Skip` +- `Cancel` + +Aanbevolen v1-gedrag: +- conflictcontrole gebeurt server-side per bestand +- bij conflict in een batch wordt de batch niet stil doorgezet +- de UI toont een compacte conflictmodal voor het huidige conflicterende bestand +- de gebruiker kiest één actie + +Semantiek: +- `Overwrite`: alleen huidig conflicterend bestand overschrijven +- `Overwrite all`: huidig en alle volgende conflicten automatisch overschrijven +- `Skip`: huidig conflicterend bestand overslaan en doorgaan +- `Cancel`: resterende batch stoppen + +Aanbevolen v1-realisatie: +- conflict afhandelen per bestand binnen de uploadbatch-flow +- geen complexe vooraf-scan van alle conflicten nodig +- geen rollback + +Belangrijk: +- ook directoryconflicten moeten duidelijk zijn +- als target al een directory met dezelfde naam bevat voor een file-upload, moet dat als conflict/typefout behandeld worden + +## 7. Grote bestanden / performance +Aanbevolen v1: +- gewone multipart upload +- geen chunking +- geen resumable upload + +Motivatie: +- technisch het eenvoudigst +- breed ondersteund door browser en backendstack +- voldoende voor een eerste bruikbare versie + +Risico: +- zeer grote bestanden kunnen lang duren of mislukken bij netwerkonderbreking +- dat risico moet in v1 geaccepteerd en netjes gecommuniceerd worden + +V1 hoeft daarom niet meer te doen dan: +- voortgang tonen +- foutmelding tonen bij mislukking +- geen herstart of resume bieden + +## 8. Relatie met tasks/history +Aanbevolen v1: +- upload opnemen in `history` +- upload niet meteen in het generieke `tasks` model stoppen + +Motivatie: +- upload heeft wel auditwaarde, dus history is logisch +- task-integratie maakt de slice groter: background execution, task persistence, progress mapping, polling-UI integratie +- voor een eerste bruikbare upload is een lichtere directe UI-flow met history-opslag pragmatischer + +History v1 voor upload zou moeten registreren: +- operation = `upload` +- status = `completed` / `failed` +- destination = doelpad +- path of source-naam waar nuttig +- error_code / error_message bij failure + +Als later blijkt dat uploads langlopend worden of meerdere gelijktijdige uploads normaal zijn, kan task-integratie in v2 logisch worden. + +## 9. Regressierisico +Belangrijkste risico's: +- security: onbetrouwbare bestandsnamen of target path misbruik +- grote bestanden: timeouts of langlopende requests +- foutafhandeling: deels geslaagde batch zonder duidelijke feedback +- UI-complexiteit: conflictflow kan snel onrustig worden +- actieve-paneelcontext: upload naar verkeerd paneel/pad als context niet duidelijk is +- conflictafhandeling: onduidelijke semantiek rond overwrite/skip + +Laag-regressierisico aanpak: +- target altijd expliciet koppelen aan actief paneel +- geen folder upload +- geen drag & drop +- geen chunking/resume +- compacte conflictmodal per bestand +- direct paneelrefresh na succesvolle upload(s) + +## 10. Teststrategie +Backend golden tests: +- upload single file success +- upload multi-file success +- target path not found +- target path is file -> type_conflict +- traversal blocked +- invalid root alias +- invalid filename blocked +- conflict -> already_exists of equivalent +- overwrite success +- skip/cancel flow indien servercontract dat nodig maakt + +UI smoke/regressietests: +- `Upload` knop aanwezig links van `F1 Settings` +- geen uploadstart als ongeldige UI-context aanwezig is +- targetpaneel-context zichtbaar in uploadflow +- progress UI verschijnt +- conflictkeuze-UI verschijnt met: + - `Overwrite` + - `Overwrite all` + - `Skip` + - `Cancel` + +Handmatige validatie: +- upload 1 klein bestand +- upload meerdere bestanden +- conflict op bestaand bestand +- overwrite all werkt over meerdere conflicten +- skip laat batch doorgaan +- cancel stopt batch +- actief paneel bepaalt doelpad correct +- history bevat upload-resultaten + +## 11. Aanbeveling +Aanbevolen v1-richting met laag regressierisico: +- native browser file picker +- single + multi-file upload +- target = `currentPath` van actief paneel +- geen folder upload +- geen drag & drop +- gewone multipart upload +- directe voortgangsweergave in lichte upload-UI +- conflictafhandeling per bestand met: + - `Overwrite` + - `Overwrite all` + - `Skip` + - `Cancel` +- wel history-integratie +- nog geen task-integratie + +Dit is de kleinste versie die echt bruikbaar is, zonder meteen te ontsporen in mediaserver- of synchronisatiecomplexiteit. diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index a3b0caa61ad3e43f2f472796dd33de81f81f1756..bd83a8ae2758199c7f8cdee5f2bb01b6320b79aa 100644 GIT binary patch delta 2195 zcmaKsO>7%Q6vt=1>-AUc*zs2yJI==W+@$%SC4@8~p-Pj`ji7B74i!rySKcIB96KGa zTY(TM&_i+owIiV(%L(Ni4hSkPAdX;(19YV#5CYVjOOQ%AF>lrmu@i8V=Qs2Jy*F>> zjpsvoXQ+SA@Ao455yVf7-vS8z0)yH|v>k5GE8OZ&cPJtj6&H3XZtRwjid25lqj<5G z`NHBYqy1SWjKfLNXSaSK|$ER}F4%tu0YI(MS@OQf2j6LrnL6Oj~12G(Mmv8t|Td zJb4t}-hlV+5cqKL3Y^bB|2AkjPSe1V8+R>?oMu$xIFi`Zt|7qw0H`nN#gVN zCnu?E@$`5wJY}+AC+h$}BImsE5VXW{UNf?K0iUM+47u+8F)0Hu0R|G_DQajT%zutt z^|cK_ww>zEB|x61rf%mJzmZ+R^K|+p@~Q7jZkD{&lq78ta)=9V=9Lm3-dx z0=GZ}|C=;s(=YY?M~uf@E3IG7XN(+vk;bi&ul*3$9JnpAhD?x;104gl3#*<3bLd{3 zhgmQsQVE>hWG6%4db^c4PCZM+x>>?`+)M|uh#3;BL+}L{h7RbyBlITF-S$o7izdb+ z0C^6Qo5)TTu~An15&&?!Ea7sYkYjg@6=tJRKt;Q+43&U~!fo9Ty8D36>UV=Kf%%uD z=REtrsh|Pu3@Uh;mhoqQHdhcHV9 z(HJ(4qvU=t#l^^9!NJXH2MQ(nGFZY!fu0+CX4oq-05tpY&VY9k4EtTcO=fqGT`6`y zSd(|q9#s%~h%EQidLdiN>C^ZUo#W_#(V)*lh37c#0ZRXl7QRQxg$HQlv4FU<{A0@0 z>?7ur-OwMG67M9oF5Z3nMxx9;^K(3LXO_HizxtKxDfgcGE;U(kIk`FRhft)#gY%O& nQgMP)5m6v%ixoE#BY%2ec8^Dn@rr{1!QGEq9&y5Jw6=c$D@U## delta 1941 zcmZ`)O>7%g5Z?8!*IsY^8{3JKI$1l3)v?WFnUW zBv4YwQ9XT$nZ4z)Qk=w@E|n9dBuT1VMaXixPxlvjGF}XdwgcRbfU(_H^wpVeb7#xye0h$o>a1IdeXdfXK{JtBFcm(EE}PdusOyq#OZpMU8Pp7lNf4F?gsPG>8nQi+K+haq@+Zf>+5c%}^Xbaw z$7?6b)`__FFPWyF`|e#Pz)~56XwvGqYM6Jnvvh+Rr)}l$g$8odM<*n z1Q2Sk0u9Y5=IPh68aWj~eH4lD550wYbI+t-`I7X7e6=tKcIb_WM$O}7XKel3xq@UU z7U>&-41bB5f$yM(E6Tcr2Sb*rrQ|o3LA8Z_qfLCEX`(|mg#pND`&-0lRf$ovuo$S+ zX_oDMzg{y;@+uft=r798@697*2?bJ;S3p2%Oux=_JJzMzZPoU6tu`^L2Q2BaaJB=o z3b6xCu>}YxuYvhCRf9jge$yd~_sHr8)qSI%j@&v&;blQ&9UMD!Hgtzyqm9r*{w94M zQWtRFLojmtUgzL$vs!PE4T#vKi($ll5dKV35wS$Wkr`*4*seFxcy6Y)(2Al#-;Hb^ zvc4*4!{(AP5Vk~&c84^{1z2bjVIlC^?h7Gza2e|J`-k{t^!r&WZ2{U#aL8=&u4y)U z*$#n*aYY?26&_?8#sB3ve*A!Rxg0;MN*WT0-Gi!CbPZ$b`WZgV?x8DH1PSKoZ&58_ zNB62$yV|NR62of2g*PnXqlH*H#P*9DBv=_O$7boH*c2b5U&byUcDv}j6~vYaduNH* zeP(xX1(6yGb}4XZ*+j94V8_eO=$JbN569jHw*S_FS?e^6HS!MF@WWv7a9RHrc%Fa8 lWuJ3}r(EF~S9lo}IsQY>ONi-7S{wW4$+z*pc=2t3{RaivRX_j$ diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index c3eb33d305828545f419bce36ce9c449564fee17..e9dce69aa04461558d5ad5e26e4b25f061585cb4 100644 GIT binary patch delta 1556 zcmZ9MZD?C%6vy+dS=T1HOOqyPa`SeRRc1xhe$cIxf=z2lYfVeDY1bEHx?a|-X>N1x z%|_!K)S{_aHp@{EUp5BC3H@M;f*%Gdi1w?xdfjBQ)cX9J+$AW}u7K{Q+9A zzrwodp1ok}!6490B}WJA5$?|YPlxU4>{)eKEtXWn+)hE&07QU(Qlxg<08Ai7W0JxK zc0>0HQrK}65Ez0CI1WPrL?uH)pFH>t8zqPHdDcs#&R+Q>{Jp>^Fb0eR6ZCb&Z%e}j zM(LJQV`(vm+LR0D;7D?8IgM%UT*jO(%}y4wxxA6mN9fn4H|r;%I3;Guzyxyiq3bBi zh*^Gfm5&tpSgLHF)U>>EUP);MRnI~@0@NmeH3g`EgQLw(3r?>rgf0v|C=@wQ!vhrQ zw)T05pGFemC z>7K8Zo@j|QEg<4G09m;Mdod3as8F$mcqi-rej4;kP4B}Y64x?OyFEhB1R8yC7!o2Z zRflu&xvI;vY4y-c0flZHXs5sY9?zA%*sTL*)|koUrt|{hZA)+fSExTAvnA2yg}|h1 zm5=c~hI=>NAAtd{FvV`5QT-xtNtC?|6IdlvKE{?s*>~j@wnnbtz6%50^U zCzy_|!_f+ef@XIS#rIR_J%zVQLNg?bk6{%PAJ&1N;ZBlNR<;A}Z76@W;uoFf0nX zf2Z$T7HCAh|7|9AT-N~yN1KD6V_E+9bUdgQyG}QvjrGW93{%$QrN1M+?6O!R)Vvt= z27chqvP*QF=Upa16~ws zeLmL6ZqkPG2wUa(kBVn767xS?<&v^b_NZ)Fa(JA2rr4a+Tz-Z%PH=K{yH2>z%sBxiFlk{ jqnG1h+ZrEW{A;{LmH0c3I_7@+=HyV6ciyMXej|7f*%m{K~balXiE&@n+T#0eaJ)UTkro&RZ<+dKhB)|A@=a3X=nN4KIu!BE#1Xpe{?XK zE)L}Neb9CS<^r&?KsR-{A{vJs9}MG5IYfXyy5(vXIr*hgVbEFkMw!(g?lq#w<@7=Z z7#ORl(I2H&k6ZLo*3%)5&~?w3v4hY6N935t=AT?NX9>?%gw1_C$W<)&{!>2$C7(_K z)_wq$>HWX~iL}0rntcs2he6*u=YaG)Q;bqpFXeNYY)LQDBVV26N2m`2WqRd{o2e>? z-~fi`D?f=t{M9=#^oNtQfGWl*=5x_vo2DFt6d0#WASjM4kUj{wedi%SqR*;IS)(sg z$Z?(|Y{B{yhyf58RtZ_IYCqmOXRR6DVwQY@S4?T$jtLo0o5y4pNw>9D51V{AC|W-S zoaPP~feB2|H^FscWI<~8gFbPEo(DgzAB7CKBFEb#6!my&&SD$hY$h?vbAy!;vR5gj zhgQZXF{=*X{eOQ?&ccV?Muo~4-hfGZ5vmho@~k&dGVB#olnMJQ&C03 zxP|@-`)xPidG{r#qvzlP5HqSIR&vCYe+TXsOhVTPjvS1sb;Vb?#tfK(( zmmSg;sng!|)BiQx)wH^HshFm#kuGt8YfDExp&2>5?C38TrQJ!1qJB74${*OPzpXZg zlhgqy4djP556xDD%mOC3fIp+z_+u!!fG{jhvjf&A0DcU-&6n^7Jf^|eN^$AGqi$UF zMEc5JPGws@tM`20kvD$mFTKx4Z^qP361>o5ya7*WYdly5E4?B?Mttx36hi!i;~x|M tUvlvA#pavK_l9o-cQ!X5I!hlTH4&%J5&`9R`6}cvj?&J=IQ^ZN{};9jOAG)2 diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 5738ed0..812b687 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -1,9 +1,9 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, File, Form, Request, UploadFile from fastapi.responses import StreamingResponse -from backend.app.api.schemas import DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, ViewResponse +from backend.app.api.schemas import DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, UploadResponse, ViewResponse from backend.app.dependencies import get_file_ops_service from backend.app.services.file_ops_service import FileOpsService @@ -34,6 +34,15 @@ async def delete( return service.delete(path=request.path) +@router.post("/upload", response_model=UploadResponse) +async def upload( + target_path: str = Form(...), + file: UploadFile = File(...), + service: FileOpsService = Depends(get_file_ops_service), +) -> UploadResponse: + return service.upload(target_path=target_path, upload_file=file) + + @router.get("/view", response_model=ViewResponse) async def view( path: str, diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index 074d014..52e1ad2 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -58,6 +58,12 @@ class DeleteResponse(BaseModel): path: str +class UploadResponse(BaseModel): + path: str + size: int + modified: str + + class ViewResponse(BaseModel): path: str name: str diff --git a/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc index 31a6984eebd5ebe9f7cdca5dcafb6ffccd8fe0ab..007e0fa0b6f5d2cdb9d47f43dad53c32750a631d 100644 GIT binary patch delta 1134 zcmZuw&1(};5YKLs%}28;N}HHAb#0rrYd@tHtcD&01uqf2iIsKDZq4dux6Z!!QG^@> z@8$gy^;ja_>dCWu^6>rv(M!<^f+BV1ZA}zh$S?Ee<2N&J=Dn?bsOkH6L7|Sp+i+lm)5;%LISL<53)0_X00! zx{meG_S-I;M`e^nFb+Q`clYy6wceu?*>#sSnzrx568f2%%plUf*YcRiwd}{16eX2N zLHHlYm5t-7>XAybX(rGCtz_=;q2a*R?MMbnRb#K3rJ)Wg7qQ>Xx+!01VG z;8G{IMoQy7>qJkY73s)ep@GZV5P*#;vk)(jOxRETKh{213j;-pN-~vC4zZ-*3h9y_ zPKd0VdEblJO}eLBXsn|O(~TgK6#@jjfWDl972X*xm1XPzg&p?%$7`1F3}5UFQ}6|X zRf4Mo*9d6FkRqV15CwW_UZdr*X6VR0mJde^T2^eaY{tO5(`IgDS#Xa6`?ESuM*ljQ5YBTPCrx5RP!cDN6HJ0yOlVrQNPvMA6oSOBzuAy zO8SYe>2`W;+or1!r%O~}V>z#4rZ3Xrjzh1ah!l!|#DRQMP!=&lunf8eM$palgq4OK zoi*)8ADb=^*ts7w2QJ+W`icgUpm*=I61$N@zx1?CF{F-~GD6ttRDu?eLfTL!Kff;l^qt=iRv8{ z(j=u~8qaO8xp`xZ-;je1>q5{E_N(WEQSjdRcDRK@`JHrJs=d%QhXa#VfS^wh&W4s2 zT6~$@TMfBdXyN9^JlU71>5k}tZUd#KgFEqGX$@RKrJ3qX%LcD6VoN&tVW5ggZy^`8 zU<2sgOWKy>KEN401m|s_pxh=PdTAd31~9WlKF7T?&NJI`&9%ne;m*mSxm*gBrp!rV zoxLkHTl1)Q4PhN&0|Dy5S`kD9ya;~Uaoeud>6>nEXz&|L8*D`_Yb8E;!{ z5{a`J_PVS{_mRHDK9rYq{*PVYVF>@``-xV6XpC(0I^AX2N?qCb7AZ`mErA&1D8gU7 F*k9TsW&{j@T61Z5@x1(~_b8 z)PJ1L%)mVHk?bF%(a6lE%QgEC1~W6k%}t_FQWF#Z@HLHw&25Q^k8@57u9>IFC*R-q z_sBWtp02(C*H3`+ro*8MbVAh_yo!4@sAPBDtBLW5_U=so?A`O{pgu@b+kROR$ z$*2xVTq$T9lDRUY08+TJpn7EH%1V`mD-|^$D_1t!p>k!X%EpxgHKHJLP@R(w19qbA zs0le)ibiELYDQ(n$c0*vj$Fm4oJOsvycku`D1<7Ck$a>PwIMf)BJ7NGVGn9YmCV6a zG~13mC0XxCWsaIvC8m!TdP}@&?)gf*8tzq>c(vTCDe*K^i~R@fdN^PHP%t-?=ri+-j4hIw58v<=)CbOpMUa z#@3V^lUoW>NK)@{cT5_V$vM$Y{?e?$fbdwGARzvWmi9u(mW@u$NMWHMB z0j76Qe!)~squIw{cI|)97;?jCsLTusth}HfG^CxVBUQ+htt_q zd}ey$NFtd^#@Uqo_Is`|eDvP3IW{m$@tm;kD4*MReBXTbMEp%5S{46Q9Jd083$jNvSWZrv0@#RhHi9I_5_%~+VAd9*jFGyH9k?j8{?leL~!$3HYn9|?OO^vsrM2riP&XfwEtkthJz z$kV-plyCLwlvjEKyKVG+V7N<|E(WGbu&yJQa7 z0IFn82!ca07c!@0ZrBJe$+ED*Em=0QEXh2u3Ch8Pe9|_=cn(xRCFGEWQ7%SRkh>J+ zK{eDs-cpp0kp}roQ9xq+bQyq+@ z`bOfyQF)l1F?uRbF!d&Fa7)ZrMco&W>S2V1bLCzmQhkQ8%f_1;zm;7xzJ)q0Thr*7 z>Ue4_);p|Oc|C^wmedC0vA#Ihtr!G@K~7i0?~BQX67N`Qpt~=TjHmR(XiA?(L+?^V zUehk)^_K6};xuJGC%A^7xt5xW_uB{91JP1n%^n(q^`9&S&(OYZUpljVNv3{6FQ3*y?7$tBQ!m@v0#g}Z=w=JG8>!s=x7b82> z#ROqSHZM|#@I)()NYz1Y8K1(AnsZI{bvlA>gWp(T@&%Hntv%=esw(b8y}00$XgLdr zg(%OuLVa!%+!e#Ee&2nf4+!Y3@fm_yf;n-iwT#)tT&thW3$wLdRJT21Y4N2k dict: + with path.open("xb") as handle: + while True: + chunk = file_stream.read(chunk_size) + if not chunk: + break + handle.write(chunk) + return { + "size": int(path.stat().st_size), + "modified": self.modified_iso(path), + } + async def stream_file_range(self, path: Path, start: int, end: int, chunk_size: int = 1024 * 1024): with path.open("rb") as handle: handle.seek(start) diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index c9668dace75d8a70825fa2679884f64da27febb9..a590fe0b61f33a08c47423ce4b08a98ed5fb96ec 100644 GIT binary patch delta 6119 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 bJ)!A$Lh4;1#fB%ID2&-j)B6G<-!A?Qe>CKI diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index 03bf372..de185cb 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path from backend.app.api.errors import AppError -from backend.app.api.schemas import DeleteResponse, FileInfoResponse, MkdirResponse, RenameResponse, SaveResponse, ViewResponse +from backend.app.api.schemas import DeleteResponse, FileInfoResponse, MkdirResponse, RenameResponse, SaveResponse, UploadResponse, ViewResponse from backend.app.db.history_repository import HistoryRepository from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.security.path_guard import PathGuard @@ -204,6 +204,61 @@ class FileOpsService: self._record_history_error(operation="delete", path=path, error=error) raise error + def upload(self, target_path: str, upload_file) -> UploadResponse: + destination_relative = None + history_path = target_path + try: + resolved_target = self._path_guard.resolve_directory_path(target_path) + filename = Path(upload_file.filename or "").name + safe_name = self._path_guard.validate_name(filename, field="name") + destination_relative = self._join_relative(resolved_target.relative, safe_name) + history_path = destination_relative + resolved_destination = self._path_guard.resolve_path(destination_relative) + + if resolved_destination.absolute.exists(): + raise AppError( + code="already_exists", + message="Target path already exists", + status_code=409, + details={"path": resolved_destination.relative}, + ) + + saved = self._filesystem.write_uploaded_file(resolved_destination.absolute, upload_file.file) + self._record_history( + operation="upload", + status="completed", + destination=resolved_destination.relative, + path=resolved_destination.relative, + finished_at=self._now_iso(), + ) + return UploadResponse( + path=resolved_destination.relative, + size=saved["size"], + modified=saved["modified"], + ) + except AppError as exc: + self._record_history_error( + operation="upload", + destination=destination_relative, + path=history_path, + error=exc, + ) + raise + except OSError as exc: + error = AppError( + code="io_error", + message="Filesystem operation failed", + status_code=500, + details={"reason": str(exc)}, + ) + self._record_history_error( + operation="upload", + destination=destination_relative, + path=history_path, + error=error, + ) + raise error + def view(self, path: str, for_edit: bool = False) -> ViewResponse: resolved_target = self._path_guard.resolve_existing_path(path) diff --git a/webui/backend/tests/golden/__pycache__/test_api_upload_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_upload_golden.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4bd7a1893ee0f261462f6ea77a52c6891e3cb48d GIT binary patch literal 10660 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 Gko-3VNk4x8 literal 0 HcmV?d00001 diff --git a/webui/backend/tests/golden/test_api_upload_golden.py b/webui/backend/tests/golden/test_api_upload_golden.py new file mode 100644 index 0000000..eaf817e --- /dev/null +++ b/webui/backend/tests/golden/test_api_upload_golden.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_file_ops_service, get_history_service +from backend.app.db.history_repository import HistoryRepository +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.file_ops_service import FileOpsService +from backend.app.services.history_service import HistoryService + + +class UploadApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + self.uploads_dir = self.root / "uploads" + self.uploads_dir.mkdir(parents=True, exist_ok=True) + self.db_path = str(Path(self.temp_dir.name) / "history.db") + + history_repository = HistoryRepository(self.db_path) + file_ops_service = FileOpsService( + path_guard=PathGuard({"storage1": str(self.root)}), + filesystem=FilesystemAdapter(), + history_repository=history_repository, + ) + history_service = HistoryService(repository=history_repository) + + async def _override_file_ops_service() -> FileOpsService: + return file_ops_service + + async def _override_history_service() -> HistoryService: + return history_service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + app.dependency_overrides[get_history_service] = _override_history_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _upload(self, *, target_path: str, filename: str, content: bytes) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.post( + "/api/files/upload", + data={"target_path": target_path}, + files={"file": (filename, content, "application/octet-stream")}, + ) + + return asyncio.run(_run()) + + def _get_history(self) -> list[dict]: + async def _run() -> list[dict]: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + response = await client.get("/api/history") + return response.json()["items"] + + return asyncio.run(_run()) + + def test_upload_single_file_success(self) -> None: + response = self._upload(target_path="storage1/uploads", filename="hello.txt", content=b"hello") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["path"], "storage1/uploads/hello.txt") + self.assertEqual(body["size"], 5) + self.assertTrue((self.uploads_dir / "hello.txt").exists()) + + history = self._get_history() + self.assertEqual(history[0]["operation"], "upload") + self.assertEqual(history[0]["status"], "completed") + self.assertEqual(history[0]["destination"], "storage1/uploads/hello.txt") + + def test_upload_target_path_not_found(self) -> None: + response = self._upload(target_path="storage1/missing", filename="hello.txt", content=b"hello") + + self.assertEqual(response.status_code, 404) + self.assertEqual( + response.json(), + { + "error": { + "code": "path_not_found", + "message": "Requested path was not found", + "details": {"path": "storage1/missing"}, + } + }, + ) + + def test_upload_target_path_is_file(self) -> None: + target_file = self.root / "not_a_directory.txt" + target_file.write_text("x", encoding="utf-8") + + response = self._upload(target_path="storage1/not_a_directory.txt", filename="hello.txt", content=b"hello") + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + { + "error": { + "code": "path_type_conflict", + "message": "Requested path is not a directory", + "details": {"path": "storage1/not_a_directory.txt"}, + } + }, + ) + + def test_upload_traversal_blocked(self) -> None: + response = self._upload(target_path="storage1/../etc", filename="hello.txt", content=b"hello") + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json(), + { + "error": { + "code": "path_traversal_detected", + "message": "Path traversal is not allowed", + "details": {"path": "storage1/../etc"}, + } + }, + ) + + def test_upload_invalid_root_alias(self) -> None: + response = self._upload(target_path="unknown/uploads", filename="hello.txt", content=b"hello") + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json(), + { + "error": { + "code": "invalid_root_alias", + "message": "Unknown root alias", + "details": {"path": "unknown/uploads"}, + } + }, + ) + + def test_upload_invalid_filename_blocked(self) -> None: + response = self._upload(target_path="storage1/uploads", filename="..", content=b"hello") + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), + { + "error": { + "code": "invalid_request", + "message": "Invalid name", + "details": {"name": ".."}, + } + }, + ) + + def test_upload_conflict_on_existing_file(self) -> None: + existing = self.uploads_dir / "hello.txt" + existing.write_text("existing", encoding="utf-8") + + response = self._upload(target_path="storage1/uploads", filename="hello.txt", content=b"hello") + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json(), + { + "error": { + "code": "already_exists", + "message": "Target path already exists", + "details": {"path": "storage1/uploads/hello.txt"}, + } + }, + ) + + history = self._get_history() + self.assertEqual(history[0]["operation"], "upload") + self.assertEqual(history[0]["status"], "failed") + self.assertEqual(history[0]["error_code"], "already_exists")