From 3b61a131c1753696636067c388db8c4e85e258c1 Mon Sep 17 00:00:00 2001 From: kfj001 Date: Mon, 22 Jun 2026 19:29:06 +0000 Subject: [PATCH] database driven image uploader and storage service model --- backend/app/__pycache__/main.cpython-312.pyc | Bin 7373 -> 6457 bytes .../app/__pycache__/models.cpython-312.pyc | Bin 6508 -> 7359 bytes .../station_config.cpython-312.pyc | Bin 5041 -> 2876 bytes .../api/__pycache__/storage.cpython-312.pyc | Bin 0 -> 3549 bytes backend/app/api/station_config.py | 47 +--- backend/app/api/storage.py | 90 +++++++ backend/app/api/upload.py | 59 ----- backend/app/main.py | 16 +- backend/app/models.py | 15 ++ src/app/about/about.component.html | 6 +- src/app/about/about.component.ts | 5 + src/app/admin/admin-show-form.component.html | 2 +- src/app/admin/admin-show-form.component.ts | 14 +- src/app/admin/admin-station-form.component.ts | 8 +- src/app/admin/admin-team-form.component.ts | 14 +- src/app/donate/donate.component.html | 4 +- src/app/donate/donate.component.ts | 5 + src/app/footer/footer.component.html | 2 +- src/app/footer/footer.component.ts | 5 + src/app/hero/hero.component.html | 6 +- src/app/hero/hero.component.ts | 5 + src/app/login/login.component.html | 2 +- src/app/login/login.component.ts | 5 + src/app/navbar/navbar.component.html | 2 +- src/app/navbar/navbar.component.ts | 5 + src/app/schedule/schedule.component.html | 2 +- src/app/schedule/schedule.component.ts | 10 +- src/app/services/app-config.service.ts | 13 + src/app/services/station-config.service.ts | 2 +- src/app/services/upload.service.ts | 2 +- we-added-a-picture-peppy-manatee.md | 236 ++++++++++++++++++ 31 files changed, 433 insertions(+), 149 deletions(-) create mode 100644 backend/app/api/__pycache__/storage.cpython-312.pyc create mode 100644 backend/app/api/storage.py delete mode 100644 backend/app/api/upload.py create mode 100644 we-added-a-picture-peppy-manatee.md diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 7620861e3fe53ef805f8ac6828c3be43a56cd745..d28250702df834fa4184360e3bf470ff1b57d9d6 100644 GIT binary patch delta 2303 zcma)6U1(cn82-NVo1EsSNq^EbOPl^A?P$-K*ZJ{`Y=CDrG2_9wLSd8~j(O8*NvcG-U^ZAT)n&O=Hyw+n$MULPt5I#Ve5%j#tA49VZL*rx zW*T>xEmo`A+)4*dGhnr;ZB|eXTJ7rj)nEb3(m9}Z8BlL9*wc*eI>#-Ot>Z`SW`M!! zZr!7MjqWRr%Uo6OF?!OI6<&)5Z%FAxty1B-PxsG2@?L3L<|`Tmc#fl>@QB-JPftA} z0=-#p!Rf8b_!`MmO|J}U&}gfh9njnAXa8q?BUm>*sJGWouUQ#WBUCp#r0;law*4LS z6zZKftam&%sb*CpTzAP%J@Q1^9VFZSoC!q#|7JVWQ})kH@Clp|>>Of)h1xl#^z${_ zu2Q}4^>F}D47Wl*u1r~#ANyV9y62Sjfq$QKVQ^tk9{PdIFWWLdW}E!i4l$R{B5lT4 zc}6(N2qKOlw|&_$*w8^}CxM8)<&cFc^*E35gIM+0r=0hghy2RR(obB64z6+^!6n?e z3t$|4$Sj_uI7%^UCmN&CeP`KMz&xbNAV#?OBgJbJmnmMv&Ylol{OoTpT>O+qY>Mxb zco;4&)x^^@!i|H)OZc6`j&9H5dlkb~b{@)3`-pEGhV8d}GX*(>$bV0nKV{6MGGzyv z%S`7MGD*}xREK5cHC@YVWhsdaEpJR~riq;74!Q`?+YxAfGCP;aBNyRJN-qn!q-JUe zMTw2x9JwRpAaT#ck)xAS)5l+Y_62+1-%{|ANH>9D0=o(HVkonkuA>-cvWSL=z$LSp z4C*6>ege220Fp^W(sV18nV!oTC`Wb`sXS`C1=LM1-|| zp75O{cWpmR^l)nj8kNE!SaiZfj4MiHq9UF&E)>ILqGH6WIN0JaR2ZQ+!Vx}3l8bSu z@Z35W!+d=VDjmZ5UgBThKM_^>*&AV~bO|>)CSrK7!9k^0*x)0CM@ja^2vlO+#%{_F z!va3fCWJV!$q9*oxGBMe+qKys67F{rJq+=_HzO2#s53@U77_==O?>iE*XD>w_&D*G z7>Ku8;)$o^5R{Nu@<7GsDtSf1TdAiD;tiF0q0)(e=yauiL3v6n?Sjf~S7}%ze4nTs zvO|G)VBEeKI1Zcb`++XMAAiv|rD1J0HJnf30zgf6tZlr|N@Qn6HZ%(b$k;>2$*g6~ zWm5SC)b>wq?o4(*hk`_KQCIb!0JUSOEX|^+I|=Pu~n0=u@riCf^r9T3?9+AW}c1GIaB z3+~+R08Gm^(+8!G#d`q%+-Lt1et8K3(Q{jh-EKU3+qLqrJqqnFLOy$G$0+wIUhPk9 C+~R)# delta 3121 zcma)7U2Gd!6~5P=%=q7l6DM(#*!g#zU#Cu+rZi2vi=ZVo3+gVg1Js%IOjwD^x+Vi8jW0)vh^2hvpQW%obFa<`&(I+fcl!GaH#cKKnyVMR1atkVuRKwHfl{`lNJ<%T1X6O z&0@3GBDQF)Vyo6Bwo$F?5)#`Li|kgo35)Ex#4W=f=_7VnkiyAcxl*oDI#ztkbhFIg zsdQ#u09IhO2@0>cGhZ=HlgQO_&3O#J75{QY$&C@6)o810P*{zVh=ll?oJLJx1N^jfua ztSIvh<5hcg-8apcNtANz+osDT<=ElO`?B#@>tPQrI?v{%d^+`ow5sSv3->#LpPo{4l6>Re+_;6eQ6=#h<3hJH1hg5T z#dt%A@H=!}c%h<)dMk~9?TO`2{0+DLFPtlXgu2+1ISMBg`dr!G9Y3QP*^QOiUQJ;? z&55$Q{B_HtY%ibScZEE?I;AIbvdnhJ?+7x#;wY=36TZj2ftN5wvuFq{Smy6D{29-N zw+zYC7PibUU$nlAW-#-;!*HBoAH!ybKF~H;=G&gq*0YF>BA)*Q;vvht%Z%gsEf$$& z=pRD!f2NvN&-^DP$$Qor9B~?6?+^|eU-X_Y6ot}}=@DGCDHqdvKAD@0v=ArVU$Np9 z<$O9@w2|p-QlHJHhzq4eRI-4hM;Fh#p zH($z_m3U%ud8|j4{E|tq>$w8Pc0&onV3nC6l*3#MJw62h%b9#VX zT|Ei8u!ZKGhjfG1rluzpeM-ubGt`8&>xmn`sQf8@{_mZr+P@TD3@`O8_FN^`X0FU! zD_kk8koRY<&%9r_Uij6;yZxu{hR*zE_^$7z?~VO|<1dfRjV(Cek?(Wek2(AHStIE? zSNPX<2-URxb7^#GbaC`Q`j2Qw`F^HwV1RN!_cB zcFXY}jisSX%t>MZ< zA%@rNINrdmIbfo-MjRjH*P39WwPtU8#JWbO9^%)c9PmL%UK_>nW869#qxfZ%1*Wk-I(c9HPdZSOL=w-8q86 z80OPd_dS}$MLXhqeB4R!}LR#u65%W z(~R(mN@3HA6TEAa6M#EGvssJjiZ%ln7Pi?4(#;^BhzgtSI1zSjb_l?Gghb4Exn>TZ zF&@^ug#E^Vzr|NgFTrb$erYP*pHC}9CpCubA1c&=+g@@jl1A#m_Aon@)3oVqIzLMS zySe&>+>A~RfWygDrGg|4)Khd!k(@l8%BOQ#G6)h||8Bk7=~Y!y`5c+0#mhk*&@ouS zZuQQ>7$;w+nUO(qk`6{y?FqShLelee-F*~k|AxxZ2~VR%hyJQ6si_N!HcOy{SuxW> znVRaOWi*jKPwXIbfdg8=D-tapR{E@AFrk){6;(HWQX6O|RiO5Q8mcT=3K6LEq5xjH z2;*UGq!KnuSTBI`BE5#UZnSU;=yL$i1AHFfG=(D18kJtG&{%(g?OjJ7Q$#z(dYry! zT2)SvN$P{YW<5;*j<>BCFN2q%XwLM0mE!4M#&V7W=JwnY}X#68I zejkmuKH*)scRPqI{%uP)cDyM(L3H@oT6X;HTQ^qg25{lX(#Yb-yG=JQ+!TL{eD~1l zZ60y1%g5)AFC729HT1w&Q*IA$n(g6TvpwAWbbELp*e}n_&0Kzc?)8PqdqV32oAbx^ iAKDkXu1x-X>|XHXuV;T?zi0dEQ?D>QfiRb$oBS`Dm7ksf diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index f9d3267d462c23afb0d28232cb89441df4561d4c..66c581547ff3d7ce95c867279c5e6c7a9b244cd0 100644 GIT binary patch delta 2629 zcmb7FUyKt)7~gHT?e>3r?Oj_qPmv4IJ3NsD1R{r?aCmSWCN#!0K;3p1u9w{|H@oHN zh1f&y#TRpA62bT&i7`1n9x%N4V#I{R7>vfGiLwtGA8RzxfI&?(e%}l&r5HUo+279m zzM0wIeDlrs{kZkxosk>ia6o{k_Q#HyExqR>>9spVLAW5Of-35QA>;)^%!?xSUHbW* z>*_`@A2dSwkP*&@jYvMiOm01D#PTsCo{x)yOV}@{(xRYxwAhLp7Z-$l4`aQ+`m~-l zn_#RAtiOXzGByBgu!HSoY&`^YxP$IvbOhLF2b*GS4A^)Fn`Ueeu!#;f!`LLSy&dct z#`Xc5>R{JS)Y92Z6StIgIkKwQnq4+DM@F1CD_S<>c%L=(s!?&|yTf}sx;*cAnY@0w;`t&qgcBpyv^iI3&(4tny5t>9 zJqorah8Lf|16o;;Y^FE7yXyCACnGqjt(ZKH-6+B+!X53IafE7-qF%lL=%Z$jC*l zqMaz)7TJ#6K$p--BoS~8OQA3OGPDt#dHEf{FJU3 zMO7J{Pj+l=q{U+BS#f{`;rgaeH-EDE^D|$b{p@UG>?kiAcyIqD?bLZ`;)&jm8hq%h zLRob}kQZ&OVi)Y%Db10r^1Mc59D!L?A-Fv!P$C+nShb+oUAoCU*crSI4eKQUNcYM0 z3x~e0JJbHk;?Tf62`8{lWRD2m?+8}CSA(RD{?!7bM6r?=CU^ld>F Uhi~`E;&E|l@K@*@7ul}=21=0;3IG5A delta 1843 zcmbW2O>7%Q6vt=Q>-9(0U$Nsjg{CDnp{5i;K?s*BQiKR;tws^;1xt}xXE*Ui`$6BX ziNeJy%7q*4NN`46_&6YxGpdldA#veAwLtrP{ob+X2EPNMG9EjzjppJv-6wx zW}ff0f4x-vu29G`{2hMt^4@dRJH@^uS9c5D z0%r<)otddaW~!F{MT$PcShpzMG`gBqoVX?FX3)(h+_H3Y=;jk{MY=QS>Iru=EA0Z> z#e`jzZVBCT!mUZSg6?d>os(`A-CDw}AI!DtV`6XRBkfh+8MlrXuo4Yks7&RdtjME z#|of^?UH0}HOWtvzF2L66;GW#Z+62EB8`9GKr)57N*>Acck}PXXshD3z92r%-yad$_!7!mONq3hXWtrHK!-v?jum<% z@QqgKB#<(cD>NyCf``EMhh7M;kwf{4vVF_5cI+?!S`-$hSl7uUSrHX|FNR$|x~<<; zHYF(yUxOxUD+L`IELq8rz>(UyescpTA#fHY%5|<2fnj^^>T8a_Z^79&4Ab`EwAe0n z_>%at^kee`(NBxd%b#*h+^96CnKx-MlB-7_RJM2wy(yEgSHG4KP9{H2`AMP~sRxz; zQ`wHxzHc7H(K|Nf141GhFtJ|Sijf8q>D$`R5=n+9<{E!U!xQJR{NGYNIf+rKZ~Ia1 z?SUWqy&*WqP-0;LdHG#hmn0DFI>eMNQvMbb(lz;ik&9y{Pu&C#F4Ai9)EyhQfQFzb zY$VP!`b)ALk-5>DkMhAS$8fNaxd;Ci6yZE}euQOe()FwlE)?iHvY1f$jf<457V<8x zlP6D&%#)0QT=~(<5M{5pJ{8Rs*vA?8U&n&?QGQQx&L1i>TzSZ(q)8j1{kWI^gJt;z HSziAF1`t=I diff --git a/backend/app/api/__pycache__/station_config.cpython-312.pyc b/backend/app/api/__pycache__/station_config.cpython-312.pyc index 7a1a2c6e8d42a2b20afd0aa294803abaeeb26c89..bd3b7d95c6f80b59302b7564b99b828cab65661a 100644 GIT binary patch delta 823 zcmaJ348R=42W@$FogQUpGJk7_#76nG=xQO{KxkfgR|s*5Tny-0eRSo`A#a z$_@7mGlMMHmg)w5t|DWZ_Z57v$mj)q1*2}F1C2<{f@|N>VS(Vc)lDs73H-Mj_6&^i z4!ZXnEo`HOU36iD8v6Qy7DuQ)Le&wv^3y54oZhhZEJW<*l_!-Ki{EQEHY(epS?Z+r LNf8&-yiWQD@ujZg literal 5041 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 diff --git a/backend/app/api/__pycache__/storage.cpython-312.pyc b/backend/app/api/__pycache__/storage.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7165171a0c936aa6e4a29b12ecdb5a7627da8bc1 GIT binary patch literal 3549 zcma)9Uu+b|8K1rVcenS)XZ!r$CJVR;C+s_jp@cMooUx$}wsEjY!YR>eeLKc`?A~2x z_hM|$l7XriffP`yI8s`PDv{cV^u3Qo=|iNx*mYYUDmgw04g;>x1YU`EqXSN0o? zW>dP!l>NDFHbtwD?al1sPamh6D%cD>aa z?}6vEfE^Bpp6N+mZ^oxJVNq+wF)fCpGlJIgQ~v2UJHhgis*UZn*6OW(*76Lawc&1R zYlhR>x7q2L_r*IFP7NCQY1y*#M7@Azk}ukryebz9MqbrqHK)l0+eF9jV|iN7sbpSO zEeqS0tmkBV7R#DytJA856aS{ZjK>8hkeV1d>zZ-IAzZ*Y&2qv&o|>8%zLdcQThHel z|5ji8pnIa)LXDg~^>N%xoVFDvTI$~kV0yDHb zS@oO70A21}b(UaZ17~c|gi_XgE;CtOaEt>Nw*tBektPed3tLiERNvQUJULsyPxW(n zx}YO8fk*=^B#pF+(y9rnIA_shyfHC;DlBzM77Ly4%imieXrXk^*z*Ny*i*<sQ0>`- zYioR=o1#^=^aU(qfHc-D`LH}TC>y$|+n@QIU=_zoCa(dgHEgT8u`uK=0tjF^m$zlB zSSaL)jkSb)2KkWQ8seeA~6=iNwuISYqXY(H#i)@s^+i;&N<<+)H}+_k{;!;=oL+t{=m31TF+HK*IN4p%IK zKRm4uIqWRHM1s^9t7@7fWb!8LN<2Vl7806(q=k-(HTRt`Y!Gl2)~bQoQ#Y}z zApjZVa5QWy03tn4w`?Vnb9r*nDySK3C4pHKwNOY-tC@>Hxe$F}`wk=u^G>izI|?;L z4uYv}_*wsj>R;%)B~)oeEgdT(cSe4GdL#0}(y2{RT0U~E^b7OP;@-_j*Jk^!jrM`H z_JMNq!6!W1AXN|>kSZZqv^2Ka{nAEv|5|td-@21){`O^ldA=NM+6eZn1$#cie+~AQ zyMM4e_8`<)5mC>9iiG?vt77-FR?pY1J_(_Q<`1$rvNw&5$gZ`>F7PJUT8_4rTlQ>* zqqo~`zFQ8*o&@-iurvfN@S*FcubjTk{e^2OON}2SZY0*F&JC_}mFxT})Oh>7yGQQE zSDX7*gZ-;qf8{u8kiV1iP58c8$HBk4Mg)Ct%li0o4ec5S*2Jz*(36goS~#-qS>_a+&55AkdrJ0!t+1iQ=>Y zj0cb?%(C!n-Gf5IOke)dBYJ1wu**Lk}zP=D~HkmIY1Zq>9fei^^c%CLMMC- zRTb9)igm3VzjJ)kAGvjT-QTt0-@WGF{ptRVJ%`u!9R7XV+Md_f{i(;SFDCp4`69y7 z$%=qNfZpDrez4rUcN0fY7k3d+BZ&MN5^s{XlpLwP?>0N|H{9cD=VLp>G&RqAC;a zB*Us6Sp!wjx0X`V(nkz1`%etj$joAM-aTHYwXR6uOLjt~FG@X6C_>;&sxRH9)adA$ zw}*$6v5~Q1W$OII@Fdv{6UbgViz~pz>Qb)5!by>%bcCMNXXr81sCJ02fgeQ!A0uyo zjsn{H5~>QzFw6rq_B9HAgC;l7}4^huUl>B?3>1NlG@Q;?!Rn%0D zzOWRo_|d+B^1egmmj>YPQGjpoEd?u4j{VLp1;h}|OXm0zKjyz_Ouf5`rBUkqp$y= fYyIfhBNSzXk4{u*^V_~}X+zg^KJtM?Xj=Xc4gpZb literal 0 HcmV?d00001 diff --git a/backend/app/api/station_config.py b/backend/app/api/station_config.py index 1771e65..1f0203a 100644 --- a/backend/app/api/station_config.py +++ b/backend/app/api/station_config.py @@ -1,9 +1,6 @@ """Station config router: read/update the singleton station branding config.""" -import os -import uuid - -from fastapi import APIRouter, Depends, HTTPException, UploadFile, status +from fastapi import APIRouter, Depends, HTTPException, status from app.auth import get_current_admin_user from app.database import get_session @@ -15,9 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession router = APIRouter() -UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads") -MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB - @router.get("", response_model=StationConfigResponse) async def get_station_config(session: AsyncSession = Depends(get_session)): @@ -54,42 +48,3 @@ async def update_station_config( await session.commit() await session.refresh(config) return config - - -@router.post("/upload") -async def upload_image( - file: UploadFile, - current_user: User = Depends(get_current_admin_user), -): - """Upload an image file for station branding. Admin only. - - Saves the file to the uploads/ directory with a UUID filename. - Returns the relative URL path. - """ - # Validate MIME type - if not file.content_type or not file.content_type.startswith("image/"): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Only image files are allowed", - ) - - # Read and validate size - content = await file.read() - if len(content) > MAX_FILE_SIZE: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="File size exceeds 5 MB limit", - ) - - # Ensure upload directory exists - os.makedirs(UPLOAD_DIR, exist_ok=True) - - # Generate unique filename preserving extension - ext = os.path.splitext(file.filename or "upload")[1] if file.filename else ".bin" - filename = f"{uuid.uuid4().hex}{ext}" - filepath = os.path.join(UPLOAD_DIR, filename) - - with open(filepath, "wb") as f: - f.write(content) - - return {"url": f"uploads/{filename}"} diff --git a/backend/app/api/storage.py b/backend/app/api/storage.py new file mode 100644 index 0000000..aaa2816 --- /dev/null +++ b/backend/app/api/storage.py @@ -0,0 +1,90 @@ +"""Blob storage router: upload and retrieve binary assets in the database.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status + +from app.auth import get_current_admin_user +from app.database import get_session +from app.models import StorageBlob +from app.user_models import User +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +router = APIRouter() + +ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/webp", "image/avif"} +MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB + +# Magic byte signatures → MIME type +MAGIC_BYTES = { + b'\x89PNG\r\n\x1a\n': 'image/png', + b'\xff\xd8\xff': 'image/jpeg', + b'\x00\x00\x00\x1cftypavif': 'image/avif', + b'\x00\x00\x00\x20ftypwebp': 'image/webp', +} + + +@router.post("/upload") +async def upload_blob( + file: UploadFile, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_admin_user), +): + """Upload an image blob. Admin only. + + Validates magic bytes, stores binary in PostgreSQL, returns a URL path. + """ + # Read and validate size + content = await file.read() + if len(content) > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File size exceeds 5 MB limit", + ) + + # Validate magic bytes + detected_type = None + for magic, mime_type in MAGIC_BYTES.items(): + if content.startswith(magic): + detected_type = mime_type + break + + if detected_type is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="File type not supported. Only PNG, JPEG, WebP, and AVIF are allowed.", + ) + + # Generate unique blob_id and insert + blob_id = uuid.uuid4().hex + blob = StorageBlob( + blob_id=blob_id, + content_type=detected_type, + size=len(content), + data=content, + ) + session.add(blob) + await session.commit() + + return {"url": f"/api/storage/{blob_id}"} + + +@router.get("/{blob_id}") +async def get_blob(blob_id: str, session: AsyncSession = Depends(get_session)): + """Retrieve a stored blob by blob_id. Public endpoint.""" + result = await session.execute( + select(StorageBlob).where(StorageBlob.blob_id == blob_id) + ) + blob = result.scalar_one_or_none() + if blob is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Blob not found", + ) + + return Response( + content=blob.data, + media_type=blob.content_type, + headers={"Content-Length": str(blob.size)}, + ) diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py deleted file mode 100644 index 8050a55..0000000 --- a/backend/app/api/upload.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Shared image upload router — admin-only, reused across all forms.""" - -import os -import uuid - -from fastapi import APIRouter, Depends, HTTPException, UploadFile, status - -from app.auth import get_current_admin_user -from app.user_models import User - -router = APIRouter() - -UPLOAD_DIR = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", -) -MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB - - -@router.post("/image") -async def upload_image( - file: UploadFile, - current_user: User = Depends(get_current_admin_user), -): - """Upload an image file. Admin only. - - Saves the file to the uploads/ directory with a UUID filename. - Returns the relative URL path. - """ - # Validate MIME type - if not file.content_type or not file.content_type.startswith("image/"): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Only image files are allowed", - ) - - # Read and validate size - content = await file.read() - if len(content) > MAX_FILE_SIZE: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="File size exceeds 5 MB limit", - ) - - # Ensure upload directory exists - os.makedirs(UPLOAD_DIR, exist_ok=True) - - # Generate unique filename preserving extension - ext = ( - os.path.splitext(file.filename or "upload")[1] - if file.filename - else ".bin" - ) - filename = f"{uuid.uuid4().hex}{ext}" - filepath = os.path.join(UPLOAD_DIR, filename) - - with open(filepath, "wb") as f: - f.write(content) - - return {"url": f"uploads/{filename}"} diff --git a/backend/app/main.py b/backend/app/main.py index fae6adc..b58463a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,16 +1,14 @@ -import os from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from sqlalchemy import func, select from app.config import settings from app.database import async_session, engine from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight from app.user_models import User -from app.api import events, tiers, auth, station_config, history, team, community, shows, upload +from app.api import events, tiers, auth, station_config, history, team, community, shows, storage async def _ensure_station_config(session): @@ -68,10 +66,6 @@ async def lifespan(app: FastAPI): await session.commit() print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}") - # Ensure uploads directory exists - uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads") - os.makedirs(uploads_dir, exist_ok=True) - yield @@ -99,13 +93,7 @@ app.include_router(history.router, prefix="/api/history", tags=["history"]) app.include_router(team.router, prefix="/api/team", tags=["team"]) app.include_router(community.router, prefix="/api/community", tags=["community"]) app.include_router(shows.router, prefix="/api/shows", tags=["shows"]) -app.include_router(upload.router, prefix="/api/upload", tags=["upload"]) - -# Serve uploaded files (dev only — Nginx handles this in production) -if settings.ENV != "production": - uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads") - if os.path.exists(uploads_dir): - app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads") +app.include_router(storage.router, prefix="/api/storage", tags=["storage"]) # Dev-only admin router (disabled in production) if settings.ENV != "production": diff --git a/backend/app/models.py b/backend/app/models.py index c3b839d..c683101 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,6 +1,10 @@ +from datetime import datetime, timezone + from sqlalchemy import ( Column, + DateTime, Integer, + LargeBinary, String, Text, Numeric, @@ -133,3 +137,14 @@ class CommunityHighlight(Base): description = Column(Text, nullable=False) display_order = Column(Integer, nullable=False) active = Column(Boolean, default=True) + + +class StorageBlob(Base): + __tablename__ = "storage_blobs" + + id = Column(Integer, primary_key=True, autoincrement=True) + blob_id = Column(String(32), unique=True, nullable=False, index=True) + content_type = Column(String(100), nullable=False) + size = Column(Integer, nullable=False) + data = Column(LargeBinary, nullable=False) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) diff --git a/src/app/about/about.component.html b/src/app/about/about.component.html index f9a35c1..0c1e79a 100644 --- a/src/app/about/about.component.html +++ b/src/app/about/about.component.html @@ -2,7 +2,7 @@
- +

About {{ config().name_primary }} {{ config().name_secondary }}

For nearly four decades, {{ config().name_primary }} {{ config().name_secondary }} has been the voice of our @@ -56,7 +56,7 @@

@if (member.photo_url) { - + } @else { {{ member.name.charAt(0) }} } @@ -76,7 +76,7 @@

Become a Member

diff --git a/src/app/about/about.component.ts b/src/app/about/about.component.ts index 4dff96c..6a0ab90 100644 --- a/src/app/about/about.component.ts +++ b/src/app/about/about.component.ts @@ -10,6 +10,7 @@ import { CommunityHighlightService } from '../services/community-highlight.servi import { HistoryEntry } from '../interfaces/history-entry'; import { TeamMember } from '../interfaces/team-member'; import { CommunityHighlight } from '../interfaces/community-highlight'; +import { resolveImageUrl } from '../services/app-config.service'; @Component({ selector: 'app-about', @@ -29,6 +30,10 @@ export class AboutComponent { readonly teamMembers$ = new BehaviorSubject([]); readonly communityHighlights$ = new BehaviorSubject([]); + resolveImageUrl(url: string): string { + return resolveImageUrl(url); + } + constructor() { this.load(); } diff --git a/src/app/admin/admin-show-form.component.html b/src/app/admin/admin-show-form.component.html index 87656bf..b6655b5 100644 --- a/src/app/admin/admin-show-form.component.html +++ b/src/app/admin/admin-show-form.component.html @@ -91,7 +91,7 @@

-
diff --git a/src/app/admin/admin-show-form.component.ts b/src/app/admin/admin-show-form.component.ts index 2ef376d..f886eeb 100644 --- a/src/app/admin/admin-show-form.component.ts +++ b/src/app/admin/admin-show-form.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { firstValueFrom } from 'rxjs'; @@ -6,7 +6,7 @@ import { firstValueFrom } from 'rxjs'; import { Show } from '../interfaces/show'; import { ShowService, ShowScheduleCreate, ShowCreatePayload, ShowUpdatePayload } from '../services/show.service'; import { UploadService } from '../services/upload.service'; -import { getAppConfig } from '../services/app-config.service'; +import { resolveImageUrl } from '../services/app-config.service'; @Component({ selector: 'app-admin-show-form', @@ -16,6 +16,7 @@ import { getAppConfig } from '../services/app-config.service'; styleUrl: './admin-show-form.component.scss', }) export class AdminShowFormComponent implements OnChanges { + private cdr = inject(ChangeDetectorRef); private showService = inject(ShowService); private uploadService = inject(UploadService); @@ -117,15 +118,19 @@ export class AdminShowFormComponent implements OnChanges { const file = input.files[0]; if (!file.type.startsWith('image/')) { this.error = 'Only image files are allowed.'; + this.cdr.detectChanges(); return; } if (file.size > 5 * 1024 * 1024) { this.error = 'File size exceeds 5 MB limit.'; + this.cdr.detectChanges(); return; } this.uploading = true; this.error = ''; + this.success = ''; + this.cdr.detectChanges(); try { const url = await this.uploadService.uploadImage(file); @@ -135,14 +140,13 @@ export class AdminShowFormComponent implements OnChanges { this.error = this.formatError(err); } finally { this.uploading = false; + this.cdr.detectChanges(); } } /** Resolve a relative uploads/… path to an absolute URL for preview. */ getPreviewUrl(url: string): string { - if (!url) return ''; - if (url.startsWith('http://') || url.startsWith('https://')) return url; - return `${getAppConfig().apiBaseUrl}/${url}`; + return resolveImageUrl(url); } async onSubmit(): Promise { diff --git a/src/app/admin/admin-station-form.component.ts b/src/app/admin/admin-station-form.component.ts index e678a2f..75e7801 100644 --- a/src/app/admin/admin-station-form.component.ts +++ b/src/app/admin/admin-station-form.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { firstValueFrom } from 'rxjs'; @@ -14,6 +14,7 @@ import { StationConfigService } from '../services/station-config.service'; styleUrl: './admin-station-form.component.scss', }) export class AdminStationFormComponent implements OnChanges { + private cdr = inject(ChangeDetectorRef); private stationConfigService = inject(StationConfigService); @Input() editItem: StationConfig | null = null; @@ -98,15 +99,19 @@ export class AdminStationFormComponent implements OnChanges { const file = input.files[0]; if (!file.type.startsWith('image/')) { this.error = 'Only image files are allowed.'; + this.cdr.detectChanges(); return; } if (file.size > 5 * 1024 * 1024) { this.error = 'File size exceeds 5 MB limit.'; + this.cdr.detectChanges(); return; } this.loading = true; this.error = ''; + this.success = ''; + this.cdr.detectChanges(); try { const url = await this.stationConfigService.uploadImage(file); @@ -116,6 +121,7 @@ export class AdminStationFormComponent implements OnChanges { this.error = this.formatError(err); } finally { this.loading = false; + this.cdr.detectChanges(); } } diff --git a/src/app/admin/admin-team-form.component.ts b/src/app/admin/admin-team-form.component.ts index f378766..407d4a9 100644 --- a/src/app/admin/admin-team-form.component.ts +++ b/src/app/admin/admin-team-form.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { firstValueFrom } from 'rxjs'; @@ -6,7 +6,7 @@ import { firstValueFrom } from 'rxjs'; import { TeamMember } from '../interfaces/team-member'; import { TeamMemberService } from '../services/team-member.service'; import { UploadService } from '../services/upload.service'; -import { getAppConfig } from '../services/app-config.service'; +import { resolveImageUrl } from '../services/app-config.service'; @Component({ selector: 'app-admin-team-form', @@ -16,6 +16,7 @@ import { getAppConfig } from '../services/app-config.service'; styleUrl: './admin-team-form.component.scss', }) export class AdminTeamFormComponent implements OnChanges { + private cdr = inject(ChangeDetectorRef); private teamMemberService = inject(TeamMemberService); private uploadService = inject(UploadService); @@ -86,15 +87,19 @@ export class AdminTeamFormComponent implements OnChanges { const file = input.files[0]; if (!file.type.startsWith('image/')) { this.error = 'Only image files are allowed.'; + this.cdr.detectChanges(); return; } if (file.size > 5 * 1024 * 1024) { this.error = 'File size exceeds 5 MB limit.'; + this.cdr.detectChanges(); return; } this.uploading = true; this.error = ''; + this.success = ''; + this.cdr.detectChanges(); try { const url = await this.uploadService.uploadImage(file); @@ -104,14 +109,13 @@ export class AdminTeamFormComponent implements OnChanges { this.error = this.formatError(err); } finally { this.uploading = false; + this.cdr.detectChanges(); } } /** Resolve a relative uploads/… path to an absolute URL for preview. */ getPreviewUrl(url: string): string { - if (!url) return ''; - if (url.startsWith('http://') || url.startsWith('https://')) return url; - return `${getAppConfig().apiBaseUrl}/${url}`; + return resolveImageUrl(url); } async onSubmit(): Promise { diff --git a/src/app/donate/donate.component.html b/src/app/donate/donate.component.html index 76f28a9..c1ab344 100644 --- a/src/app/donate/donate.component.html +++ b/src/app/donate/donate.component.html @@ -3,7 +3,7 @@