From 48c0bec24e882e6c078cae7269309390934e60b9 Mon Sep 17 00:00:00 2001 From: Fuzzy Book Date: Sun, 15 Feb 2026 17:09:19 +0100 Subject: [PATCH] code created by GPT-5.3-Codex --- .DS_Store | Bin 0 -> 6148 bytes data/subscribers.db | Bin 0 -> 49152 bytes ..._201443_bag_exchange___password_reset.html | 23 + ...2435_bag_exchange___verify_your_email.html | 23 + docs/todo-auth.md | 47 + docs/video.txt | 18 + go.mod | 30 + go.sum | 63 ++ main.go | 909 ++++++++++++++++++ static/css/main.css | 351 +++++++ static/js/i18n.js | 572 +++++++++++ static/js/simple_i18n.js | 98 ++ templates/forgot_password.html | 99 ++ templates/howtowork.html | 58 ++ templates/index.html | 102 ++ templates/login.html | 104 ++ templates/reset_password.html | 118 +++ templates/signup.html | 120 +++ .../transactionalMails/password_reset.html | 18 + .../transactionalMails/verify_email.html | 18 + 20 files changed, 2771 insertions(+) create mode 100644 .DS_Store create mode 100644 data/subscribers.db create mode 100644 devEmails/20260214_201443_bag_exchange___password_reset.html create mode 100644 devEmails/20260214_202435_bag_exchange___verify_your_email.html create mode 100644 docs/todo-auth.md create mode 100644 docs/video.txt create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 static/css/main.css create mode 100644 static/js/i18n.js create mode 100644 static/js/simple_i18n.js create mode 100644 templates/forgot_password.html create mode 100644 templates/howtowork.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/reset_password.html create mode 100644 templates/signup.html create mode 100644 templates/transactionalMails/password_reset.html create mode 100644 templates/transactionalMails/verify_email.html diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..b694854143e1151da8e869c409ef0fbf948425d8 GIT binary patch literal 6148 zcmeHK%Sr=55Uh?N54_~y!Q-Ai1O%_bCMJhK%)$IXo=6~Zg+$N$8Ge9wf53x(rqwe; z*qy|qBGL^#HIM3^*;?4%0U+I{#U;=J(4Z?;nrx1kuB#tdCCq4?XpX@;o6pwSjAKn` z_V*RgcUQ#%Q|vH}-rtaG?N4WCAM7{DTjCjgY{*|>jcpY1PS#Z+LNjNW$2_@+cs1OU z*Jj=fi*inRiMVwPF~AK^F~S)AG8-MTuL~!rhqFE~JNJm#BeGu$>?ZD*w}T#TiF%&s zGl?5ajN`;Pi;@JQt}VtG`TVgwVInF6MODewadux6_@PCZ&{3YY?> zz+M6UJ|uL-G+^%0eLC2kv6T~!N?vXPz@u5T?D&dMD;pQMdoa<@e z=N^4H#O=&HVP^?96bTm(NgN%LdbHLQFa^pAocLi&>wo|I`+qseMy7x%@UIk*YH!@@ z@{vMqJ$N{+wF!MfS5sc@aUa5tE5+n$DL$t=<9wnWVj3{_$Qhdb2sjz6F$I2AfiHKT Bh3)_V literal 0 HcmV?d00001 diff --git a/data/subscribers.db b/data/subscribers.db new file mode 100644 index 0000000000000000000000000000000000000000..0da6dc2545223779c26f12c0969e8810360735f5 GIT binary patch literal 49152 zcmeI*-*4N-0S9nWk}b)#g+PhNh_Eav zB2~u@g2Jw!hhPtTXn^gX*i-s2Z2K2B4BO*?p>M;0z3g#E+H$1CDv-Eo+Tcq|;^~fe zB0pyyWZoUUb-Uzvgtt4rUDM-b<|hoxGN14~!!VcVbA~<#%LENX25;z^9SV;NUScu_ z-C6E`%ycZq%>9=8<6LRxzjHsI{?*K%;(whk#A5NIaaj=$0SG_<0uX?}+ZH(5n~ElG z++dHNcxJst$gb(MYM+yyV>=Dgb2{ys*V!R$cWlCRVbjQ048D@TSu*&soAL8g@wj90 z#d5`1H8%NAH;e1}&24_o*yi)q%0{tF%UL(dl{DS1?~-0^Xyfumg)di2B|k0Lom$g$ zn|#H%N8?Y^_-eU$yK4BGkVjpoN8B3yVl()-!B(fGTSIAnRHH{sj|8!!g)SQ_`D&@c z&s#Lzb9Tx6wRUIkS}Nt|TG`k%imPS+80JrRImK@pE5@c#E*M+9ALq_HR*K&!^UFrb zpiG7QRw2J^OvO{TrzaES2Fo~Yi#&24w&>Tfn%Va{!TZ`+$eK7dY367r7ES0nd)yBy zvunEUUZ-c(C8a-q*3psb8N~j8YUEvGz44|D9r%KH0et zLmyZm009U<00Izz00bZa0SG|gy%zZLF+0Vit{v{}9Iwf?S$8_0bbF>{W*SXV5Hg~e z&FErA$*p$|oL0+RR5JoUziu|1w%2i+AMwSuM_N3M;5WATd%P&rM75@;_wwjf9gqQZ;0B}rIPv?=<# z&2s-{=mQG`AOHafKmY;|fB*y_009U<00QrYz)!=e_~<2`S?=%51os<;`w#bf?w{Nr zXb=koAOHafKmY;|fB*y_009U<00Q5Oz|SVaH{xUGcf~{D1RG4b2z?j{>+!RhCByT( z=zhlI{+ySYaQHGinEVo%W5ZYCLlXh~@&6%ig5kcPFIXS|0SG_<0uX=z1Rwwb2tWV= z@3X+8P>e|(PQ>Mx=09AO%&VesRmd1bw~N+IYiHXq_YHATQZoHt?A$rHe{cWp!Af?` zy0zlw?~%pkeW7}1zawtm%?ypTrvvRJWl2_FiBiQc*chW9PQ+x{BA;6hz2T1Qc>8A) zFiv-1qj0mZx?0LqGllIt+TBOe%{#gJMs=r{mom4vlr3Xf(HB?s4)#~{B|#dBqWdJObf5h3{~_)#4EGIv!2$sYKmY;|fB*y_009U<00Izzz>h}Y zgK&x+9pxYTkPZ_#_XUNe#0>H?7;2BIdHr zv#Za~`u|m)cK$0G9TISI|G(!9_x#-)AU+iW5P$##AOHafKmY;|fB*y_0D*54Fd~Pu z>_x8%L{9qupE2CCZ~H8yK>z{}fB*y_009U<00Izz00bcLmINB~-T-mh_aBi-L)M6p z6N#Y73hnk6RZCWEO|k{4PW$_1QPYU2Wi^shOk$~a&eF4zWJ*~})N`gRIZyxfXXokP zzB%jvS9pK^zp|vrC;k6lGu+p2=_pVv1Rwwb2tWV=5P$##AOHafKmY<45m=@J0OA+D zG;q@Y{}sc1b&*{`UI;({0uX=z1Rwwb2tWV=5P$##-b}!v-T(g7e>(TSZY#E;63r4w zgLd$Xa!ycls$>b8-4L=?&erO(BoL9ViPA9Xz5gs?5?hn$=m420S?AZ+zjU7dNayrGftgKi4l3 literal 0 HcmV?d00001 diff --git a/devEmails/20260214_201443_bag_exchange___password_reset.html b/devEmails/20260214_201443_bag_exchange___password_reset.html new file mode 100644 index 0000000..7969794 --- /dev/null +++ b/devEmails/20260214_201443_bag_exchange___password_reset.html @@ -0,0 +1,23 @@ + + + + + + + + +
+

Reset your Bag Exchange password

+

We received a request to reset your password. Use the button below to set a new one.

+

+ Reset password +

+

If the button does not work, copy and paste this link into your browser:

+

http://localhost:6081/reset-password?token=U9SX3EIH5_vfUZ8vwo2L0vDHw1YRhriMQ5SuHSqfLrg

+
+ + diff --git a/devEmails/20260214_202435_bag_exchange___verify_your_email.html b/devEmails/20260214_202435_bag_exchange___verify_your_email.html new file mode 100644 index 0000000..026cc54 --- /dev/null +++ b/devEmails/20260214_202435_bag_exchange___verify_your_email.html @@ -0,0 +1,23 @@ + + + + + + + + +
+

Verify your Bag Exchange email

+

Thanks for joining Bag Exchange. Please verify your email to activate your account and start exchanging.

+

+ Verify email +

+

If the button does not work, copy and paste this link into your browser:

+

https://www.prada.ch/auth/verify-email?token=2xWEhEQH1Y26x05hE0eGKXBoKElbSZg6hZVUVFCU5OA

+
+ + diff --git a/docs/todo-auth.md b/docs/todo-auth.md new file mode 100644 index 0000000..f700bd6 --- /dev/null +++ b/docs/todo-auth.md @@ -0,0 +1,47 @@ +# TODO - Prossimi Passi Auth + +## 1. Iscrizione al servizio +- [ ] Definire modello `users` in SQLite (`id`, `email`, `password_hash`, `email_verified`, `created_at`, `updated_at`). +- [ ] Creare endpoint `POST /api/auth/register` con validazione input (email, password forte). +- [ ] Hash password con Argon2id o bcrypt (mai salvare password in chiaro). +- [ ] Bloccare email duplicate con vincolo `UNIQUE` e gestione errore chiara. +- [ ] Aggiungere form UI registrazione (email, password, conferma password, accettazione termini). +- [ ] Mostrare messaggi UX chiari (successo/errore/utente gia registrato). +- [ ] Preparare invio email verifica account (token con scadenza). + +## 2. Login +- [ ] Creare endpoint `POST /api/auth/login`. +- [ ] Verificare credenziali in modo sicuro (confronto hash password). +- [ ] Introdurre sessione con cookie `HttpOnly` + `SameSite=Lax` (+ `Secure` in HTTPS). +- [ ] Aggiungere endpoint `POST /api/auth/logout` per invalidare sessione. +- [ ] Aggiungere endpoint `GET /api/auth/me` per stato utente autenticato. +- [ ] Costruire pagina/modal login con gestione errori (credenziali errate, account non verificato). +- [ ] Rate limit su login per mitigare brute force. + +## 3. Recupero password +- [ ] Endpoint `POST /api/auth/forgot-password` (sempre risposta neutra per privacy). +- [ ] Generare token monouso con scadenza breve (es. 30-60 min). +- [ ] Salvare token hashato nel DB (`password_resets` o campi dedicati in `users`). +- [ ] Inviare email con link di reset. +- [ ] Endpoint `POST /api/auth/reset-password` con validazione token + nuova password. +- [ ] Invalidare token dopo utilizzo. +- [ ] Forzare logout da sessioni vecchie dopo reset password. + +## 4. Sicurezza e compliance (trasversale) +- [ ] CSRF protection per endpoint sensibili con cookie/sessione. +- [ ] Logging minimale senza dati sensibili (no password/token in log). +- [ ] Policy password minima (lunghezza, complessita, blacklist password comuni). +- [ ] Audit su cookie e header di sicurezza (`Content-Security-Policy`, `X-Frame-Options`, `Referrer-Policy`). +- [ ] Aggiornare privacy policy per raccolta dati (IP, user-agent, email). + +## 5. Testing +- [ ] Test unitari validazione auth. +- [ ] Test integrazione endpoint register/login/forgot/reset. +- [ ] Test casi limite: token scaduto, email duplicata, password debole. +- [ ] Test E2E flusso completo: registrazione -> login -> logout -> recupero password. + +## 6. Delivery +- [ ] Definire variabili env (`APP_BASE_URL`, `SMTP_*`, `SESSION_SECRET`). +- [ ] Preparare migrazione DB iniziale per tabelle auth. +- [ ] Setup provider email (SMTP o API) con ambiente dev/staging/prod. +- [ ] Checklist rilascio con smoke test post-deploy. diff --git a/docs/video.txt b/docs/video.txt new file mode 100644 index 0000000..87a22f3 --- /dev/null +++ b/docs/video.txt @@ -0,0 +1,18 @@ +[0-5s] +Hai borse che non usi più? + +[5-15s] +Con Bag Exchange puoi scambiarle in modo semplice, sicuro e sostenibile. + +[15-30s] +Pubblica la tua borsa con foto e descrizione. +Ricevi proposte da persone con gusti simili. +Conferma lo scambio in pochi passaggi. + +[30-45s] +Meno sprechi, più stile. +Un modo intelligente per rinnovare il guardaroba senza comprare sempre nuovo. + +[45-60s] +Stiamo sviluppando Bag Exchange. +Lascia la tua email e resta aggiornata sul lancio. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4a1e8bd --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module prada.ch + +go 1.25.4 + +require github.com/gofiber/fiber/v3 v3.0.0 + +require ( + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gofiber/schema v1.6.0 // indirect + github.com/gofiber/utils/v2 v2.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/tinylib/msgp v1.6.3 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.69.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/sqlite v1.34.5 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7124363 --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk= +github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY= +github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= +github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= +github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM= +github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/shamaton/msgpack/v3 v3.0.0 h1:xl40uxWkSpwBCSTvS5wyXvJRsC6AcVcYeox9PspKiZg= +github.com/shamaton/msgpack/v3 v3.0.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= diff --git a/main.go b/main.go new file mode 100644 index 0000000..75fbbe6 --- /dev/null +++ b/main.go @@ -0,0 +1,909 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html/template" + "log" + "net/mail" + "net/smtp" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "unicode" + + "github.com/gofiber/fiber/v3" + "golang.org/x/crypto/bcrypt" + _ "modernc.org/sqlite" +) + +type landingData struct { + Brand string + InitialTitle string + FooterText string + AssetVersion string +} + +type resetPasswordPageData struct { + Token string +} + +type subscribeRequest struct { + Email string `json:"email"` + BrowserData map[string]any `json:"browserData"` +} + +type registerRequest struct { + Email string `json:"email"` + Password string `json:"password"` + ConfirmPassword string `json:"confirmPassword"` +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type forgotPasswordRequest struct { + Email string `json:"email"` +} + +type resetPasswordRequest struct { + Token string `json:"token"` + Password string `json:"password"` + ConfirmPassword string `json:"confirmPassword"` +} + +type resendVerificationRequest struct { + Email string `json:"email"` +} + +type smtpConfig struct { + Host string + Port int + User string + Password string +} + +const subscriptionCookieName = "bag_exchange_subscribed" +const sessionCookieName = "bag_exchange_session" +const sessionDurationDays = 7 +const passwordResetDurationMinutes = 60 +const emailVerificationDurationHours = 24 + +func main() { + loadDotEnv(".env") + + db, err := initDB("data/subscribers.db") + if err != nil { + log.Fatal(err) + } + defer db.Close() + + mailConfig := getSMTPConfigFromEnv() + + landingTemplate := template.Must(template.ParseFiles("templates/index.html")) + howToWorkTemplate := template.Must(template.ParseFiles("templates/howtowork.html")) + loginTemplate := template.Must(template.ParseFiles("templates/login.html")) + signupTemplate := template.Must(template.ParseFiles("templates/signup.html")) + forgotPasswordTemplate := template.Must(template.ParseFiles("templates/forgot_password.html")) + resetPasswordTemplate := template.Must(template.ParseFiles("templates/reset_password.html")) + assetVersion := buildAssetVersion("static/css/main.css", "static/js/i18n.js") + + app := fiber.New() + app.Get("/static/*", func(c fiber.Ctx) error { + relativePath := filepath.Clean(c.Params("*")) + if relativePath == "." || strings.HasPrefix(relativePath, "..") { + return c.SendStatus(fiber.StatusNotFound) + } + return c.SendFile(filepath.Join("static", relativePath)) + }) + app.Post("/api/subscribe", func(c fiber.Ctx) error { + var req subscribeRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid payload"}) + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := mail.ParseAddress(email); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid email"}) + } + + emailHash := hashEmail(email) + if c.Cookies(subscriptionCookieName) == emailHash { + return c.Status(fiber.StatusConflict).JSON(map[string]string{"status": "already_submitted"}) + } + + browserData := "{}" + if len(req.BrowserData) > 0 { + payload, err := json.Marshal(req.BrowserData) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid browser data"}) + } + browserData = string(payload) + } + + _, err = db.Exec(` + INSERT INTO subscribers (email, ip_address, user_agent, accept_language, browser_data) + VALUES (?, ?, ?, ?, ?) + `, email, c.IP(), c.Get("User-Agent"), c.Get("Accept-Language"), browserData) + if err != nil { + if isUniqueConstraint(err) { + setSubscriptionCookie(c, emailHash) + return c.Status(fiber.StatusConflict).JSON(map[string]string{"status": "already_submitted"}) + } + log.Printf("unable to insert subscriber: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable to save subscription"}) + } + + setSubscriptionCookie(c, emailHash) + return c.Status(fiber.StatusCreated).JSON(map[string]string{"status": "subscribed"}) + }) + app.Post("/api/auth/register", func(c fiber.Ctx) error { + var req registerRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := mail.ParseAddress(email); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_email"}) + } + + if req.Password != req.ConfirmPassword { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_mismatch"}) + } + + if !isStrongPassword(req.Password) { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_too_weak"}) + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + log.Printf("unable to hash password: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) + } + + result, err := db.Exec(` + INSERT INTO users (email, password_hash, email_verified) + VALUES (?, ?, ?) + `, email, string(passwordHash), 0) + if err != nil { + if isUniqueConstraint(err) { + return c.Status(fiber.StatusConflict).JSON(map[string]string{"error": "email_exists"}) + } + log.Printf("unable to register user: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) + } + + userID, err := result.LastInsertId() + if err != nil { + log.Printf("unable to read new user id: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) + } + + verifyToken, err := createEmailVerificationToken(db, userID) + if err != nil { + log.Printf("unable to create verify token: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_register"}) + } + + if mailConfig.isConfigured() { + verifyURL := fmt.Sprintf("%s/auth/verify-email?token=%s", getAppBaseURL(), verifyToken) + if err := sendEmailVerificationEmail(mailConfig, email, verifyURL); err != nil { + log.Printf("unable to send verification email: %v", err) + } + } + + return c.Status(fiber.StatusCreated).JSON(map[string]string{"status": "registered_pending_verification"}) + }) + app.Post("/api/auth/login", func(c fiber.Ctx) error { + var req loginRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := mail.ParseAddress(email); err != nil || req.Password == "" { + return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"}) + } + + var userID int64 + var passwordHash string + var emailVerified int + err := db.QueryRow(` + SELECT id, password_hash, email_verified + FROM users + WHERE email = ? + `, email).Scan(&userID, &passwordHash, &emailVerified) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"}) + } + log.Printf("unable to fetch user for login: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"}) + } + + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "invalid_credentials"}) + } + if emailVerified == 0 { + return c.Status(fiber.StatusForbidden).JSON(map[string]string{"error": "email_not_verified"}) + } + + sessionToken, err := generateSessionToken() + if err != nil { + log.Printf("unable to generate session token: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"}) + } + + expiresAt := time.Now().AddDate(0, 0, sessionDurationDays).Unix() + _, err = db.Exec(` + INSERT INTO sessions (user_id, token_hash, expires_at) + VALUES (?, ?, ?) + `, userID, hashToken(sessionToken), expiresAt) + if err != nil { + log.Printf("unable to create session: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_login"}) + } + + setSessionCookie(c, sessionToken, expiresAt) + return c.Status(fiber.StatusOK).JSON(map[string]any{ + "status": "authenticated", + "email": email, + }) + }) + app.Post("/api/auth/logout", func(c fiber.Ctx) error { + sessionToken := c.Cookies(sessionCookieName) + if sessionToken != "" { + if _, err := db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, hashToken(sessionToken)); err != nil { + log.Printf("unable to delete session: %v", err) + } + } + clearSessionCookie(c) + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "logged_out"}) + }) + app.Get("/api/auth/me", func(c fiber.Ctx) error { + sessionToken := c.Cookies(sessionCookieName) + if sessionToken == "" { + return c.Status(fiber.StatusUnauthorized).JSON(map[string]any{"authenticated": false}) + } + + var userID int64 + var email string + err := db.QueryRow(` + SELECT users.id, users.email + FROM sessions + JOIN users ON users.id = sessions.user_id + WHERE sessions.token_hash = ? AND sessions.expires_at > ? + `, hashToken(sessionToken), time.Now().Unix()).Scan(&userID, &email) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + clearSessionCookie(c) + return c.Status(fiber.StatusUnauthorized).JSON(map[string]any{"authenticated": false}) + } + log.Printf("unable to resolve session: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_fetch_session"}) + } + + return c.Status(fiber.StatusOK).JSON(map[string]any{ + "authenticated": true, + "userId": userID, + "email": email, + }) + }) + app.Post("/api/auth/forgot-password", func(c fiber.Ctx) error { + var req forgotPasswordRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := mail.ParseAddress(email); err != nil { + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + } + + var userID int64 + err := db.QueryRow(`SELECT id FROM users WHERE email = ?`, email).Scan(&userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + } + log.Printf("unable to fetch user for forgot-password: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) + } + + resetToken, err := generateSessionToken() + if err != nil { + log.Printf("unable to generate reset token: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) + } + expiresAt := time.Now().Add(time.Minute * passwordResetDurationMinutes).Unix() + + _, err = db.Exec(` + INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) + VALUES (?, ?, ?) + `, userID, hashToken(resetToken), expiresAt) + if err != nil { + log.Printf("unable to persist reset token: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) + } + + if mailConfig.isConfigured() { + resetURL := fmt.Sprintf("%s/reset-password?token=%s", getAppBaseURL(), resetToken) + if err := sendPasswordResetEmail(mailConfig, email, resetURL); err != nil { + log.Printf("unable to send reset email: %v", err) + } + } else { + log.Printf("smtp not configured: skip password reset email") + } + + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + }) + app.Post("/api/auth/resend-verification", func(c fiber.Ctx) error { + var req resendVerificationRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if _, err := mail.ParseAddress(email); err != nil { + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + } + + var userID int64 + var verified int + err := db.QueryRow(`SELECT id, email_verified FROM users WHERE email = ?`, email).Scan(&userID, &verified) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + } + log.Printf("unable to fetch user for resend verification: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) + } + if verified != 0 { + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + } + + verifyToken, err := createEmailVerificationToken(db, userID) + if err != nil { + log.Printf("unable to create verify token on resend: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_process"}) + } + if mailConfig.isConfigured() { + verifyURL := fmt.Sprintf("%s/auth/verify-email?token=%s", getAppBaseURL(), verifyToken) + if err := sendEmailVerificationEmail(mailConfig, email, verifyURL); err != nil { + log.Printf("unable to resend verification email: %v", err) + } + } + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "ok"}) + }) + app.Post("/api/auth/reset-password", func(c fiber.Ctx) error { + var req resetPasswordRequest + if err := json.Unmarshal(c.Body(), &req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_payload"}) + } + + token := strings.TrimSpace(req.Token) + if token == "" { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_token"}) + } + + if req.Password != req.ConfirmPassword { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_mismatch"}) + } + + if !isStrongPassword(req.Password) { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "password_too_weak"}) + } + + var resetID int64 + var userID int64 + err := db.QueryRow(` + SELECT id, user_id + FROM password_reset_tokens + WHERE token_hash = ? + AND used_at IS NULL + AND expires_at > ? + ORDER BY id DESC + LIMIT 1 + `, hashToken(token), time.Now().Unix()).Scan(&resetID, &userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "invalid_or_expired_token"}) + } + log.Printf("unable to fetch reset token: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + log.Printf("unable to hash password in reset: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + + tx, err := db.Begin() + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + + if _, err := tx.Exec(`UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?`, string(passwordHash), userID); err != nil { + tx.Rollback() + log.Printf("unable to update password: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + if _, err := tx.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE id = ?`, time.Now().Unix(), resetID); err != nil { + tx.Rollback() + log.Printf("unable to mark reset token used: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + if _, err := tx.Exec(`DELETE FROM sessions WHERE user_id = ?`, userID); err != nil { + tx.Rollback() + log.Printf("unable to delete sessions after reset: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + if err := tx.Commit(); err != nil { + log.Printf("unable to commit reset tx: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(map[string]string{"error": "unable_to_reset_password"}) + } + + clearSessionCookie(c) + return c.Status(fiber.StatusOK).JSON(map[string]string{"status": "password_reset"}) + }) + app.Get("/auth/verify-email", func(c fiber.Ctx) error { + token := strings.TrimSpace(c.Query("token")) + if token == "" { + return c.Status(fiber.StatusBadRequest).SendString("Invalid verification token.") + } + + var tokenID int64 + var userID int64 + err := db.QueryRow(` + SELECT id, user_id + FROM email_verification_tokens + WHERE token_hash = ? + AND used_at IS NULL + AND expires_at > ? + ORDER BY id DESC + LIMIT 1 + `, hashToken(token), time.Now().Unix()).Scan(&tokenID, &userID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return c.Status(fiber.StatusBadRequest).SendString("Verification link is invalid or expired.") + } + log.Printf("unable to validate verification token: %v", err) + return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") + } + + tx, err := db.Begin() + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") + } + if _, err := tx.Exec(`UPDATE users SET email_verified = 1, updated_at = datetime('now') WHERE id = ?`, userID); err != nil { + tx.Rollback() + log.Printf("unable to mark email verified: %v", err) + return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") + } + if _, err := tx.Exec(`UPDATE email_verification_tokens SET used_at = ? WHERE id = ?`, time.Now().Unix(), tokenID); err != nil { + tx.Rollback() + log.Printf("unable to mark verify token used: %v", err) + return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") + } + if err := tx.Commit(); err != nil { + log.Printf("unable to commit verify email tx: %v", err) + return c.Status(fiber.StatusInternalServerError).SendString("Unable to verify email right now.") + } + + return c.SendString("Email verified successfully. You can now log in.") + }) + + app.Get("/", func(c fiber.Ctx) error { + data := landingData{ + Brand: "Bag Exchange", + InitialTitle: "Bag Exchange | Swap bags and handbags", + FooterText: "© 2026 Bag Exchange · Bag and handbag exchange between individuals", + AssetVersion: assetVersion, + } + + var out bytes.Buffer + if err := landingTemplate.Execute(&out, data); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render page") + } + + c.Type("html", "utf-8") + return c.SendString(out.String()) + }) + app.Get("/reset-password", func(c fiber.Ctx) error { + token := strings.TrimSpace(c.Query("token")) + var out bytes.Buffer + if err := resetPasswordTemplate.Execute(&out, resetPasswordPageData{Token: token}); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render reset password page") + } + c.Type("html", "utf-8") + return c.SendString(out.String()) + }) + app.Get("/howtowork", func(c fiber.Ctx) error { + var out bytes.Buffer + if err := howToWorkTemplate.Execute(&out, nil); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render howtowork page") + } + c.Type("html", "utf-8") + return c.SendString(out.String()) + }) + app.Get("/login", func(c fiber.Ctx) error { + var out bytes.Buffer + if err := loginTemplate.Execute(&out, nil); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render login page") + } + c.Type("html", "utf-8") + return c.SendString(out.String()) + }) + app.Get("/signup", func(c fiber.Ctx) error { + var out bytes.Buffer + if err := signupTemplate.Execute(&out, nil); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render signup page") + } + c.Type("html", "utf-8") + return c.SendString(out.String()) + }) + app.Get("/forgot-password", func(c fiber.Ctx) error { + var out bytes.Buffer + if err := forgotPasswordTemplate.Execute(&out, nil); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("unable to render forgot password page") + } + c.Type("html", "utf-8") + return c.SendString(out.String()) + }) + + log.Fatal(app.Listen(":6081")) +} + +func buildAssetVersion(paths ...string) string { + hasher := sha256.New() + for _, path := range paths { + content, err := os.ReadFile(path) + if err != nil { + log.Printf("asset version fallback, unable to read %s: %v", path, err) + return "dev" + } + hasher.Write(content) + } + return hex.EncodeToString(hasher.Sum(nil))[:12] +} + +func hashEmail(email string) string { + sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(email)))) + return hex.EncodeToString(sum[:]) +} + +func initDB(path string) (*sql.DB, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + + schema := ` + CREATE TABLE IF NOT EXISTS subscribers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + ip_address TEXT NOT NULL, + user_agent TEXT NOT NULL, + accept_language TEXT, + browser_data TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + );` + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, err + } + + userSchema := ` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + email_verified INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + );` + if _, err := db.Exec(userSchema); err != nil { + db.Close() + return nil, err + } + + sessionSchema := ` + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + );` + if _, err := db.Exec(sessionSchema); err != nil { + db.Close() + return nil, err + } + + resetSchema := ` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + used_at INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + );` + if _, err := db.Exec(resetSchema); err != nil { + db.Close() + return nil, err + } + + verifySchema := ` + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + used_at INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + );` + if _, err := db.Exec(verifySchema); err != nil { + db.Close() + return nil, err + } + + return db, nil +} + +func isUniqueConstraint(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "unique") +} + +func setSubscriptionCookie(c fiber.Ctx, value string) { + c.Cookie(&fiber.Cookie{ + Name: subscriptionCookieName, + Value: value, + Path: "/", + HTTPOnly: false, + Secure: false, + Expires: time.Now().AddDate(1, 0, 0), + MaxAge: 60 * 60 * 24 * 365, + }) +} + +func setSessionCookie(c fiber.Ctx, value string, expiresAtUnix int64) { + c.Cookie(&fiber.Cookie{ + Name: sessionCookieName, + Value: value, + Path: "/", + HTTPOnly: true, + Secure: false, + SameSite: "Lax", + Expires: time.Unix(expiresAtUnix, 0), + MaxAge: 60 * 60 * 24 * sessionDurationDays, + }) +} + +func clearSessionCookie(c fiber.Ctx) { + c.Cookie(&fiber.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + HTTPOnly: true, + Secure: false, + SameSite: "Lax", + Expires: time.Unix(0, 0), + MaxAge: -1, + }) +} + +func hashToken(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func generateSessionToken() (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func isStrongPassword(value string) bool { + if len(value) < 8 { + return false + } + + hasLetter := false + hasDigit := false + for _, r := range value { + if unicode.IsLetter(r) { + hasLetter = true + } + if unicode.IsDigit(r) { + hasDigit = true + } + } + return hasLetter && hasDigit +} + +func loadDotEnv(path string) { + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if key == "" { + continue + } + if _, exists := os.LookupEnv(key); !exists { + os.Setenv(key, value) + } + } +} + +func getSMTPConfigFromEnv() smtpConfig { + port := 587 + if rawPort := strings.TrimSpace(os.Getenv("STARTTLS_PORT")); rawPort != "" { + if p, err := strconv.Atoi(rawPort); err == nil { + port = p + } + } + return smtpConfig{ + Host: strings.TrimSpace(os.Getenv("SMTP")), + Port: port, + User: strings.TrimSpace(os.Getenv("SMTP_USER")), + Password: strings.TrimSpace(os.Getenv("SMTP_PASSWORD")), + } +} + +func (s smtpConfig) isConfigured() bool { + return s.Host != "" && s.User != "" && s.Password != "" && s.Port > 0 +} + +func getAppBaseURL() string { + base := strings.TrimSpace(os.Getenv("SITE_URL")) + if base == "" { + base = strings.TrimSpace(os.Getenv("APP_BASE_URL")) + } + if base == "" { + return "http://localhost:6081" + } + return strings.TrimRight(base, "/") +} + +func sendPasswordResetEmail(config smtpConfig, recipientEmail, resetURL string) error { + body, err := renderTransactionalTemplate("password_reset.html", map[string]string{ + "ActionURL": resetURL, + }) + if err != nil { + return err + } + return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Password reset", body) +} + +func sendEmailVerificationEmail(config smtpConfig, recipientEmail, verifyURL string) error { + body, err := renderTransactionalTemplate("verify_email.html", map[string]string{ + "ActionURL": verifyURL, + }) + if err != nil { + return err + } + return sendHTMLEmail(config, recipientEmail, "Bag Exchange - Verify your email", body) +} + +func sendHTMLEmail(config smtpConfig, recipientEmail, subject, htmlBody string) error { + if isDevEmailMode() { + return saveDevEmail(recipientEmail, subject, htmlBody) + } + + auth := smtp.PlainAuth("", config.User, config.Password, config.Host) + message := "From: " + config.User + "\r\n" + + "To: " + recipientEmail + "\r\n" + + "Subject: " + subject + "\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: text/html; charset=UTF-8\r\n\r\n" + + htmlBody + return smtp.SendMail(fmt.Sprintf("%s:%d", config.Host, config.Port), auth, config.User, []string{recipientEmail}, []byte(message)) +} + +func renderTransactionalTemplate(templateName string, data any) (string, error) { + tmpl, err := template.ParseFiles(filepath.Join("templates", "transactionalMails", templateName)) + if err != nil { + return "", err + } + var out bytes.Buffer + if err := tmpl.Execute(&out, data); err != nil { + return "", err + } + return out.String(), nil +} + +func createEmailVerificationToken(db *sql.DB, userID int64) (string, error) { + token, err := generateSessionToken() + if err != nil { + return "", err + } + expiresAt := time.Now().Add(time.Hour * emailVerificationDurationHours).Unix() + _, err = db.Exec(` + INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) + VALUES (?, ?, ?) + `, userID, hashToken(token), expiresAt) + if err != nil { + return "", err + } + return token, nil +} + +func isDevEmailMode() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv("EMAIL_MODE")), "dev") +} + +func saveDevEmail(recipientEmail, subject, htmlBody string) error { + const dir = "devEmails" + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + filename := fmt.Sprintf("%s_%s.html", time.Now().UTC().Format("20060102_150405"), sanitizeFilename(subject)) + path := filepath.Join(dir, filename) + + payload := "\n" + htmlBody + + return os.WriteFile(path, []byte(payload), 0o644) +} + +func sanitizeFilename(value string) string { + var b strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(value)) { + switch { + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): + b.WriteRune(r) + case r == ' ' || r == '-' || r == '_': + b.WriteRune('_') + } + } + name := strings.Trim(b.String(), "_") + if name == "" { + return "email" + } + return name +} diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..e89b7a5 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,351 @@ +:root { + --cream: #f5ede1; + --sand: #dfcfb7; + --ink: #202126; + --olive: #4f5e3d; + --coral: #e0795a; + --card: #fff9f0; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: "Avenir Next", "Segoe UI", sans-serif; + color: var(--ink); + background: + radial-gradient(circle at 10% 10%, #fff7eb 0%, transparent 42%), + radial-gradient(circle at 90% 85%, #f1e4d2 0%, transparent 38%), + linear-gradient(125deg, #f8f2e7 0%, #ebdfcd 100%); + min-height: 100vh; +} + +.container { + width: min(1100px, 92vw); + margin: 0 auto; +} + +header { + padding: 22px 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.logo { + font-size: 1.2rem; + letter-spacing: .06em; + font-weight: 700; + text-transform: uppercase; +} + +.header-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.badge { + background: var(--olive); + color: #fff; + border-radius: 999px; + padding: 8px 14px; + font-size: .82rem; + letter-spacing: .04em; + text-transform: uppercase; +} + +.lang-wrap { + display: flex; + align-items: center; + gap: 8px; + background: rgba(32, 33, 38, .07); + padding: 6px 10px; + border-radius: 10px; + font-size: .85rem; +} + +.lang-flags { + display: flex; + align-items: center; + gap: 4px; + line-height: 1; +} + +.lang-flags span { + font-size: .95rem; +} + +.lang-wrap select { + border: 1px solid rgba(32, 33, 38, .2); + background: #fff; + color: var(--ink); + border-radius: 8px; + padding: 6px 8px; + font-weight: 600; + font-size: .85rem; +} + +.hero { + padding: 40px 0 20px; + display: grid; + gap: 24px; + grid-template-columns: 1.1fr .9fr; + align-items: center; +} + +h1 { + margin: 0 0 14px; + font-size: clamp(2rem, 5vw, 4rem); + line-height: 1.06; +} + +.hero p { + font-size: 1.1rem; + line-height: 1.6; + max-width: 56ch; + margin: 0 0 26px; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.auth-status { + margin: 14px 0 0; + font-size: .92rem; + opacity: .8; +} + +.auth-row { + display: flex; + align-items: center; + gap: 10px; +} + +.auth-logout { + margin-top: 10px; + padding: 8px 12px; + font-size: .82rem; +} + +.btn { + border: none; + text-decoration: none; + border-radius: 14px; + padding: 13px 18px; + font-weight: 700; + cursor: pointer; + transition: transform .2s ease, opacity .2s ease; +} + +.btn:hover { transform: translateY(-2px); } + +.btn-primary { + background: var(--coral); + color: #fff; +} + +.btn-secondary { + background: rgba(32,33,38,.07); + color: var(--ink); +} + +.showcase { + background: linear-gradient(160deg, #fff9ef 0%, #f2e5d1 100%); + border: 1px solid rgba(32,33,38,.08); + border-radius: 28px; + padding: 24px; + box-shadow: 0 18px 40px rgba(69, 45, 20, .08); +} + +.showcase h2 { + margin: 0 0 12px; + font-size: 1.25rem; +} + +.showcase ul { + margin: 0; + padding: 0; + list-style: none; + display: grid; + gap: 10px; +} + +.showcase li { + background: rgba(255,255,255,.65); + border-radius: 12px; + padding: 10px 12px; + font-size: .95rem; +} + +.grid { + padding: 34px 0 70px; + display: grid; + grid-template-columns: repeat(3, minmax(0,1fr)); + gap: 16px; +} + +.card { + background: var(--card); + border: 1px solid rgba(32,33,38,.08); + border-radius: 20px; + padding: 20px; + box-shadow: 0 10px 26px rgba(57, 39, 18, .06); +} + +.card h3 { + margin-top: 0; + margin-bottom: 8px; + font-size: 1.06rem; +} + +.card p { + margin: 0; + font-size: .95rem; + line-height: 1.5; +} + +.community-note { + margin: 0 0 26px; + padding: 18px 20px; + border-radius: 16px; + background: rgba(255, 249, 240, .85); + border: 1px solid rgba(32, 33, 38, .08); +} + +.community-note h3 { + margin: 0 0 8px; + font-size: 1.05rem; +} + +.community-note p { + margin: 0; + line-height: 1.55; +} + +footer { + padding: 20px 0 34px; + font-size: .88rem; + opacity: .76; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(20, 20, 24, .45); + display: none; + align-items: center; + justify-content: center; + padding: 16px; + z-index: 30; +} + +.modal-backdrop.is-open { + display: flex; +} + +.modal-panel { + width: min(560px, 100%); + background: linear-gradient(160deg, #fffaf1 0%, #f2e6d6 100%); + border: 1px solid rgba(32,33,38,.12); + border-radius: 18px; + box-shadow: 0 20px 40px rgba(28, 23, 20, .22); + padding: 22px; + position: relative; +} + +.modal-close { + position: absolute; + right: 10px; + top: 10px; + border: none; + background: rgba(32,33,38,.09); + color: var(--ink); + width: 34px; + height: 34px; + border-radius: 999px; + font-size: 1.2rem; + line-height: 1; + cursor: pointer; +} + +.modal-panel h3 { + margin: 0 0 12px; + font-size: 1.2rem; +} + +.modal-list { + margin: 0; + padding-left: 20px; + display: grid; + gap: 10px; +} + +.modal-list li { + line-height: 1.5; +} + +.modal-desc { + margin: 0 0 14px; + line-height: 1.5; +} + +.modal-link-wrap { + margin: 6px 0 0; +} + +.modal-link { + color: #3b4a2b; + font-size: .9rem; + text-decoration: underline; +} + +.subscribe-form { + display: grid; + gap: 10px; +} + +.form-input { + width: 100%; + border: 1px solid rgba(32,33,38,.25); + background: #fff; + border-radius: 10px; + padding: 12px 14px; + font-size: .96rem; + color: var(--ink); +} + +.form-input:focus { + outline: 2px solid rgba(224, 121, 90, .35); + border-color: var(--coral); +} + +.subscribe-feedback { + margin: 8px 0 0; + padding: 12px 14px; + border-radius: 10px; + background: rgba(79, 94, 61, .12); + color: #2f3a23; + font-weight: 600; +} + +.subscribe-error { + margin: 8px 0 0; + color: #a13225; + font-size: .92rem; +} + +.is-hidden { + display: none !important; +} + +@media (max-width: 920px) { + .hero { grid-template-columns: 1fr; } + .grid { grid-template-columns: 1fr; } +} diff --git a/static/js/i18n.js b/static/js/i18n.js new file mode 100644 index 0000000..e6a1c0f --- /dev/null +++ b/static/js/i18n.js @@ -0,0 +1,572 @@ +(function () { + var supported = ["en", "it", "fr", "de", "es"]; + var fallback = "en"; + var aliases = { + "en-us": "en", + "de-ch": "de", + "fr-ch": "fr" + }; + + var translations = { + en: { + pageTitle: "Bag Exchange | Swap bags and handbags", + languageLabel: "Language", + badge: "community beta", + heroTitle: "Give your bags a new life.", + heroDesc: "Swap bags and handbags in a simple, safe, and sustainable way. Upload your item, find real matches, and refresh your style without buying new every time.", + ctaPrimary: "Start swapping", + ctaLogin: "Login", + ctaLogout: "Logout", + ctaSecondary: "See how it works", + ctaRegister: "Create account", + howTitle: "How it works in 3 steps", + step1: "1. List your bag with photos and condition", + step2: "2. Receive offers from people with similar taste", + step3: "3. Confirm the swap through chat and tracked shipping", + card1Title: "Verified profiles only", + card1Desc: "We reduce risk with account verification, feedback, and transparent swap history.", + card2Title: "Save and add value", + card2Desc: "A smart way to renew your wardrobe while avoiding waste and unnecessary spending.", + card3Title: "Circular style", + card3Desc: "From daily bags to elegant clutches: every piece can find a new owner.", + communityTitle: "Videochat and grow your profile", + communityDesc: "You can videochat with people interested in your items or simply exchange opinions and advice. You can aspire to become a fashion bag influencer.", + footer: "© 2026 Bag Exchange · Bag and handbag exchange between individuals", + modalTitle: "How to prepare your exchange", + modalStep1: "Create a photo gallery of your bag.", + modalStep2: "Describe the product.", + modalStep3: "Add your exchange conditions.", + subscribeTitle: "We are building Bag Exchange.", + subscribeDesc: "Enter your email to stay updated.", + subscribePlaceholder: "Your email", + subscribeCta: "Keep me updated", + subscribeThanks: "Thank you. We will keep you updated.", + subscribeInvalidEmail: "Please enter a valid email address.", + subscribeError: "Something went wrong. Please try again.", + registerTitle: "Create your account", + registerDesc: "Register to start exchanging bags and join the community.", + registerEmailPlaceholder: "Your email", + registerPasswordPlaceholder: "Password", + registerConfirmPlaceholder: "Confirm password", + registerCta: "Create account", + registerThanks: "Registration completed. You can now log in when the auth area is live.", + registerInvalidEmail: "Please enter a valid email address.", + registerPasswordMismatch: "Passwords do not match.", + registerWeakPassword: "Use at least 8 characters with letters and numbers.", + registerEmailExists: "This email is already registered.", + registerError: "Unable to complete registration. Please try again.", + loginTitle: "Access your account", + loginDesc: "Log in to continue your exchange journey.", + loginEmailPlaceholder: "Your email", + loginPasswordPlaceholder: "Password", + loginCta: "Log in", + loginForgotLink: "Forgot your password?", + loginSuccess: "Login successful.", + loginInvalid: "Invalid email or password.", + loginNotVerified: "Email not verified. Check your inbox for the verification link.", + loginError: "Unable to log in. Please try again.", + authStatusGuest: "Not logged in.", + authStatusUserPrefix: "Logged in as:" + }, + it: { + pageTitle: "Bag Exchange | Scambia borse e borsette", + languageLabel: "Lingua", + badge: "community beta", + heroTitle: "Dai nuova vita alle tue borse.", + heroDesc: "Scambia borse e borsette in modo semplice, sicuro e sostenibile. Carica il tuo articolo, trova match reali e rinnova il tuo stile senza comprare ogni volta da zero.", + ctaPrimary: "Inizia a scambiare", + ctaLogin: "Login", + ctaLogout: "Logout", + ctaSecondary: "Guarda come funziona", + ctaRegister: "Crea account", + howTitle: "Come funziona in 3 step", + step1: "1. Pubblica la tua borsa con foto e condizioni", + step2: "2. Ricevi proposte da persone con gusti simili", + step3: "3. Conferma lo scambio con chat e spedizione tracciata", + card1Title: "Solo profili verificati", + card1Desc: "Riduciamo i rischi grazie a verifica account, feedback e storico scambi trasparente.", + card2Title: "Risparmia e valorizza", + card2Desc: "Un modo intelligente per rinnovare il guardaroba evitando sprechi e spese inutili.", + card3Title: "Stile circolare", + card3Desc: "Dalle borse da giorno alle pochette eleganti: ogni pezzo trova una nuova proprietaria.", + communityTitle: "Videochat e crescita del tuo profilo", + communityDesc: "Hai la possibilita di videochattare con gli interessati ai tuoi prodotti o semplicemente per scambiarsi opinioni e consigli. Puoi aspirare a diventare una fashion bag influencer.", + footer: "© 2026 Bag Exchange · Scambio borse e borsette tra privati", + modalTitle: "Come preparare il tuo scambio", + modalStep1: "Crea una galleria di foto della tua borsa.", + modalStep2: "Descrivi il prodotto.", + modalStep3: "Inserisci le condizioni di scambio.", + subscribeTitle: "Stiamo sviluppando Bag Exchange.", + subscribeDesc: "Inserisci la tua email per rimanere aggiornato.", + subscribePlaceholder: "La tua email", + subscribeCta: "Tienimi aggiornato", + subscribeThanks: "Grazie. Ti terremo aggiornato.", + subscribeInvalidEmail: "Inserisci un indirizzo email valido.", + subscribeError: "Si e verificato un errore. Riprova.", + registerTitle: "Crea il tuo account", + registerDesc: "Registrati per iniziare a scambiare borse ed entrare nella community.", + registerEmailPlaceholder: "La tua email", + registerPasswordPlaceholder: "Password", + registerConfirmPlaceholder: "Conferma password", + registerCta: "Crea account", + registerThanks: "Registrazione completata. Potrai fare login quando l'area auth sara disponibile.", + registerInvalidEmail: "Inserisci un indirizzo email valido.", + registerPasswordMismatch: "Le password non coincidono.", + registerWeakPassword: "Usa almeno 8 caratteri con lettere e numeri.", + registerEmailExists: "Questa email e gia registrata.", + registerError: "Impossibile completare la registrazione. Riprova.", + loginTitle: "Accedi al tuo account", + loginDesc: "Effettua il login per continuare il tuo percorso di scambio.", + loginEmailPlaceholder: "La tua email", + loginPasswordPlaceholder: "Password", + loginCta: "Accedi", + loginForgotLink: "Hai dimenticato la password?", + loginSuccess: "Login effettuato con successo.", + loginInvalid: "Email o password non validi.", + loginNotVerified: "Email non verificata. Controlla la tua casella per il link di verifica.", + loginError: "Impossibile effettuare il login. Riprova.", + authStatusGuest: "Non autenticato.", + authStatusUserPrefix: "Connesso come:" + }, + fr: { + pageTitle: "Bag Exchange | Echange de sacs et pochettes", + languageLabel: "Langue", + badge: "communaute beta", + heroTitle: "Donnez une nouvelle vie a vos sacs.", + heroDesc: "Echangez sacs et pochettes de facon simple, sure et durable. Publiez votre article, trouvez de vrais matchs et renouvelez votre style sans acheter neuf a chaque fois.", + ctaPrimary: "Commencer l'echange", + ctaLogin: "Connexion", + ctaLogout: "Deconnexion", + ctaSecondary: "Voir comment ca marche", + ctaRegister: "Creer un compte", + howTitle: "Comment ca marche en 3 etapes", + step1: "1. Publiez votre sac avec photos et etat", + step2: "2. Recevez des propositions de personnes au style proche", + step3: "3. Confirmez l'echange via chat et livraison suivie", + card1Title: "Profils verifies uniquement", + card1Desc: "Nous reduisons les risques avec verification des comptes, avis et historique d'echange transparent.", + card2Title: "Economisez et valorisez", + card2Desc: "Une facon intelligente de renouveler votre dressing en evitant le gaspillage et les depenses inutiles.", + card3Title: "Style circulaire", + card3Desc: "Du sac quotidien a la pochette elegante: chaque piece peut trouver une nouvelle proprietaire.", + communityTitle: "Videochat et visibilite mode", + communityDesc: "Vous pouvez faire une videochat avec les personnes interessees par vos articles ou simplement echanger des avis et des conseils. Vous pouvez aspirer a devenir une influenceuse mode des sacs.", + footer: "© 2026 Bag Exchange · Echange de sacs et pochettes entre particuliers", + modalTitle: "Comment preparer votre echange", + modalStep1: "Creez une galerie photo de votre sac.", + modalStep2: "Decrivez le produit.", + modalStep3: "Ajoutez vos conditions d'echange.", + subscribeTitle: "Nous developpons Bag Exchange.", + subscribeDesc: "Entrez votre email pour rester informe.", + subscribePlaceholder: "Votre email", + subscribeCta: "Me tenir informe", + subscribeThanks: "Merci. Nous vous tiendrons informe.", + subscribeInvalidEmail: "Veuillez entrer une adresse email valide.", + subscribeError: "Une erreur est survenue. Veuillez reessayer.", + registerTitle: "Creez votre compte", + registerDesc: "Inscrivez-vous pour commencer les echanges et rejoindre la communaute.", + registerEmailPlaceholder: "Votre email", + registerPasswordPlaceholder: "Mot de passe", + registerConfirmPlaceholder: "Confirmer le mot de passe", + registerCta: "Creer un compte", + registerThanks: "Inscription terminee. Vous pourrez vous connecter quand l'espace auth sera disponible.", + registerInvalidEmail: "Veuillez entrer une adresse email valide.", + registerPasswordMismatch: "Les mots de passe ne correspondent pas.", + registerWeakPassword: "Utilisez au moins 8 caracteres avec lettres et chiffres.", + registerEmailExists: "Cet email est deja enregistre.", + registerError: "Impossible de finaliser l'inscription. Veuillez reessayer.", + loginTitle: "Accedez a votre compte", + loginDesc: "Connectez-vous pour poursuivre votre parcours d'echange.", + loginEmailPlaceholder: "Votre email", + loginPasswordPlaceholder: "Mot de passe", + loginCta: "Connexion", + loginForgotLink: "Mot de passe oublie ?", + loginSuccess: "Connexion reussie.", + loginInvalid: "Email ou mot de passe invalide.", + loginNotVerified: "Email non verifiee. Verifiez votre boite de reception pour le lien de verification.", + loginError: "Impossible de se connecter. Veuillez reessayer.", + authStatusGuest: "Non connecte.", + authStatusUserPrefix: "Connecte en tant que :" + }, + de: { + pageTitle: "Bag Exchange | Tausche Taschen und Handtaschen", + languageLabel: "Sprache", + badge: "community beta", + heroTitle: "Gib deinen Taschen ein neues Leben.", + heroDesc: "Tausche Taschen und Handtaschen einfach, sicher und nachhaltig. Lade dein Teil hoch, finde echte Matches und erneuere deinen Stil ohne standig neu zu kaufen.", + ctaPrimary: "Jetzt tauschen", + ctaLogin: "Login", + ctaLogout: "Logout", + ctaSecondary: "So funktioniert es", + ctaRegister: "Konto erstellen", + howTitle: "So funktioniert es in 3 Schritten", + step1: "1. Stelle deine Tasche mit Fotos und Zustand ein", + step2: "2. Erhalte Angebote von Menschen mit ahnlichem Geschmack", + step3: "3. Bestatige den Tausch per Chat und Sendungsverfolgung", + card1Title: "Nur verifizierte Profile", + card1Desc: "Wir reduzieren Risiken durch Kontoverifizierung, Bewertungen und transparente Tauschhistorie.", + card2Title: "Spare und schaffe Wert", + card2Desc: "Eine smarte Art, deine Garderobe zu erneuern und gleichzeitig Verschwendung und unnotige Ausgaben zu vermeiden.", + card3Title: "Kreislauf-Stil", + card3Desc: "Von Alltagstaschen bis zur eleganten Clutch: Jedes Stuck kann eine neue Besitzerin finden.", + communityTitle: "Videochat und Profilaufbau", + communityDesc: "Du kannst per Videochat mit Interessierten an deinen Produkten sprechen oder einfach Meinungen und Tipps austauschen. Du kannst darauf hinarbeiten, Fashion-Bag-Influencerin zu werden.", + footer: "© 2026 Bag Exchange · Taschen- und Handtaschentausch zwischen Privatpersonen", + modalTitle: "So bereitest du deinen Tausch vor", + modalStep1: "Erstelle eine Fotogalerie deiner Tasche.", + modalStep2: "Beschreibe das Produkt.", + modalStep3: "Hinterlege die Tauschbedingungen.", + subscribeTitle: "Wir entwickeln Bag Exchange.", + subscribeDesc: "Gib deine E-Mail ein, um auf dem Laufenden zu bleiben.", + subscribePlaceholder: "Deine E-Mail", + subscribeCta: "Auf dem Laufenden halten", + subscribeThanks: "Danke. Wir halten dich auf dem Laufenden.", + subscribeInvalidEmail: "Bitte gib eine gueltige E-Mail-Adresse ein.", + subscribeError: "Etwas ist schiefgelaufen. Bitte versuche es erneut.", + registerTitle: "Erstelle dein Konto", + registerDesc: "Registriere dich, um mit dem Tauschen zu starten und der Community beizutreten.", + registerEmailPlaceholder: "Deine E-Mail", + registerPasswordPlaceholder: "Passwort", + registerConfirmPlaceholder: "Passwort bestaetigen", + registerCta: "Konto erstellen", + registerThanks: "Registrierung abgeschlossen. Du kannst dich einloggen, sobald der Auth-Bereich live ist.", + registerInvalidEmail: "Bitte gib eine gueltige E-Mail-Adresse ein.", + registerPasswordMismatch: "Die Passwoerter stimmen nicht ueberein.", + registerWeakPassword: "Mindestens 8 Zeichen mit Buchstaben und Zahlen verwenden.", + registerEmailExists: "Diese E-Mail ist bereits registriert.", + registerError: "Registrierung konnte nicht abgeschlossen werden. Bitte erneut versuchen.", + loginTitle: "Zugang zu deinem Konto", + loginDesc: "Melde dich an, um deine Austauschreise fortzusetzen.", + loginEmailPlaceholder: "Deine E-Mail", + loginPasswordPlaceholder: "Passwort", + loginCta: "Anmelden", + loginForgotLink: "Passwort vergessen?", + loginSuccess: "Login erfolgreich.", + loginInvalid: "Ungueltige E-Mail oder Passwort.", + loginNotVerified: "E-Mail nicht verifiziert. Bitte pruefe dein Postfach auf den Verifizierungslink.", + loginError: "Anmeldung nicht moeglich. Bitte erneut versuchen.", + authStatusGuest: "Nicht angemeldet.", + authStatusUserPrefix: "Angemeldet als:" + }, + es: { + pageTitle: "Bag Exchange | Intercambio de bolsos y carteras", + languageLabel: "Idioma", + badge: "comunidad beta", + heroTitle: "Dale nueva vida a tus bolsos.", + heroDesc: "Intercambia bolsos y carteras de forma simple, segura y sostenible. Sube tu articulo, encuentra matches reales y renueva tu estilo sin comprar nuevo cada vez.", + ctaPrimary: "Empezar a intercambiar", + ctaLogin: "Iniciar sesion", + ctaLogout: "Cerrar sesion", + ctaSecondary: "Ver como funciona", + ctaRegister: "Crear cuenta", + howTitle: "Como funciona en 3 pasos", + step1: "1. Publica tu bolso con fotos y estado", + step2: "2. Recibe propuestas de personas con gustos similares", + step3: "3. Confirma el intercambio con chat y envio con seguimiento", + card1Title: "Solo perfiles verificados", + card1Desc: "Reducimos riesgos con verificacion de cuenta, valoraciones e historial de intercambios transparente.", + card2Title: "Ahorra y revaloriza", + card2Desc: "Una forma inteligente de renovar tu armario evitando desperdicios y gastos innecesarios.", + card3Title: "Estilo circular", + card3Desc: "Desde bolsos de diario hasta clutch elegante: cada pieza puede encontrar nueva duena.", + communityTitle: "Videochat y crecimiento de perfil", + communityDesc: "Puedes hacer videochat con personas interesadas en tus productos o simplemente intercambiar opiniones y consejos. Puedes aspirar a convertirte en una influencer de bolsos de moda.", + footer: "© 2026 Bag Exchange · Intercambio de bolsos y carteras entre particulares", + modalTitle: "Como preparar tu intercambio", + modalStep1: "Crea una galeria de fotos de tu bolso.", + modalStep2: "Describe el producto.", + modalStep3: "Indica las condiciones de intercambio.", + subscribeTitle: "Estamos desarrollando Bag Exchange.", + subscribeDesc: "Ingresa tu email para mantenerte al dia.", + subscribePlaceholder: "Tu email", + subscribeCta: "Mantenerme informado", + subscribeThanks: "Gracias. Te mantendremos informado.", + subscribeInvalidEmail: "Introduce un correo electronico valido.", + subscribeError: "Algo salio mal. Intentalo de nuevo.", + registerTitle: "Crea tu cuenta", + registerDesc: "Registrate para empezar a intercambiar y unirte a la comunidad.", + registerEmailPlaceholder: "Tu email", + registerPasswordPlaceholder: "Contrasena", + registerConfirmPlaceholder: "Confirmar contrasena", + registerCta: "Crear cuenta", + registerThanks: "Registro completado. Podras iniciar sesion cuando el area auth este disponible.", + registerInvalidEmail: "Introduce un correo electronico valido.", + registerPasswordMismatch: "Las contrasenas no coinciden.", + registerWeakPassword: "Usa al menos 8 caracteres con letras y numeros.", + registerEmailExists: "Este email ya esta registrado.", + registerError: "No se pudo completar el registro. Intentalo de nuevo.", + loginTitle: "Accede a tu cuenta", + loginDesc: "Inicia sesion para continuar tu recorrido de intercambio.", + loginEmailPlaceholder: "Tu email", + loginPasswordPlaceholder: "Contrasena", + loginCta: "Iniciar sesion", + loginForgotLink: "Has olvidado tu contrasena?", + loginSuccess: "Sesion iniciada correctamente.", + loginInvalid: "Email o contrasena no validos.", + loginNotVerified: "Email no verificado. Revisa tu bandeja de entrada para el enlace de verificacion.", + loginError: "No se pudo iniciar sesion. Intentalo de nuevo.", + authStatusGuest: "No autenticado.", + authStatusUserPrefix: "Conectado como:" + } + }; + var currentLanguage = fallback; + var currentAuthEmail = ""; + + function normalizeLanguage(lang) { + if (!lang) return fallback; + var normalized = String(lang).toLowerCase().replace("_", "-"); + if (aliases[normalized]) return aliases[normalized]; + var base = normalized.split("-")[0]; + if (supported.indexOf(base) !== -1) return base; + return fallback; + } + + function getInitialLanguage() { + var params = new URLSearchParams(window.location.search); + var queryLang = params.get("lang"); + if (queryLang) return normalizeLanguage(queryLang); + + var saved = window.localStorage.getItem("preferredLang"); + if (saved) return normalizeLanguage(saved); + + var browserLang = navigator.language || (navigator.languages && navigator.languages[0]) || fallback; + return normalizeLanguage(browserLang); + } + + function applyLanguage(lang) { + var active = translations[lang] || translations[fallback]; + currentLanguage = lang; + + document.documentElement.lang = lang; + document.title = active.pageTitle; + + var nodes = document.querySelectorAll("[data-i18n]"); + for (var i = 0; i < nodes.length; i++) { + var key = nodes[i].getAttribute("data-i18n"); + if (active[key]) nodes[i].textContent = active[key]; + } + + var placeholders = document.querySelectorAll("[data-i18n-placeholder]"); + for (var j = 0; j < placeholders.length; j++) { + var placeholderKey = placeholders[j].getAttribute("data-i18n-placeholder"); + if (active[placeholderKey]) placeholders[j].setAttribute("placeholder", active[placeholderKey]); + } + + var selector = document.getElementById("language-select"); + if (selector) selector.value = lang; + + var authStatusNode = document.getElementById("auth-status"); + if (authStatusNode) { + if (currentAuthEmail) { + authStatusNode.textContent = currentAuthEmail; + authStatusNode.classList.remove("is-hidden"); + } else { + authStatusNode.textContent = ""; + authStatusNode.classList.add("is-hidden"); + } + } + + var url = new URL(window.location.href); + url.searchParams.set("lang", lang); + window.history.replaceState({}, "", url.toString()); + } + + var initialLanguage = getInitialLanguage(); + applyLanguage(initialLanguage); + + var select = document.getElementById("language-select"); + if (select) { + select.addEventListener("change", function (event) { + var chosen = normalizeLanguage(event.target.value); + window.localStorage.setItem("preferredLang", chosen); + applyLanguage(chosen); + }); + } + + function closeModal(modal) { + if (!modal) return; + modal.classList.remove("is-open"); + modal.setAttribute("aria-hidden", "true"); + } + + function openModal(modal) { + if (!modal) return; + modal.classList.add("is-open"); + modal.setAttribute("aria-hidden", "false"); + } + + function bindModal(openButtonId, modalId, closeButtonId) { + var modal = document.getElementById(modalId); + var openButton = document.getElementById(openButtonId); + var closeButton = document.getElementById(closeButtonId); + if (!modal || !openButton || !closeButton) return null; + + openButton.addEventListener("click", function () { + openModal(modal); + }); + + closeButton.addEventListener("click", function () { + closeModal(modal); + }); + + modal.addEventListener("click", function (event) { + if (event.target === modal) { + closeModal(modal); + } + }); + + return modal; + } + + var modals = []; + var subscribeModal = bindModal("start-swapping-btn", "subscribe-modal", "subscribe-close-btn"); + if (subscribeModal) modals.push(subscribeModal); + + var subscribeForm = document.getElementById("subscribe-form"); + var subscribeEmailInput = document.getElementById("subscribe-email"); + var subscribeSubmitButton = document.getElementById("subscribe-submit-btn"); + var subscribeFeedback = document.getElementById("subscribe-feedback"); + var subscribeError = document.getElementById("subscribe-error"); + var loginButton = document.getElementById("login-btn"); + var registerButton = document.getElementById("register-btn"); + var logoutButton = document.getElementById("logout-btn"); + + function getActiveTranslation(key) { + var active = translations[currentLanguage] || translations[fallback]; + return active[key] || ""; + } + + function setSubscribeState(subscribed) { + if (!subscribeForm || !subscribeFeedback) return; + subscribeForm.classList.toggle("is-hidden", subscribed); + subscribeFeedback.classList.toggle("is-hidden", !subscribed); + if (subscribed && subscribeError) { + subscribeError.classList.add("is-hidden"); + subscribeError.textContent = ""; + } + } + + function setSubscribeError(message) { + if (!subscribeError) return; + subscribeError.textContent = message; + subscribeError.classList.remove("is-hidden"); + } + + function setAuthStatus(email) { + currentAuthEmail = email || ""; + var authStatusNode = document.getElementById("auth-status"); + if (!authStatusNode) return; + if (currentAuthEmail) { + authStatusNode.textContent = currentAuthEmail; + authStatusNode.classList.remove("is-hidden"); + if (loginButton) loginButton.classList.add("is-hidden"); + if (registerButton) registerButton.classList.add("is-hidden"); + if (logoutButton) logoutButton.classList.remove("is-hidden"); + } else { + authStatusNode.textContent = ""; + authStatusNode.classList.add("is-hidden"); + if (loginButton) loginButton.classList.remove("is-hidden"); + if (registerButton) registerButton.classList.remove("is-hidden"); + if (logoutButton) logoutButton.classList.add("is-hidden"); + } + } + + async function refreshAuthStatus() { + try { + var response = await fetch("/api/auth/me", { method: "GET" }); + if (!response.ok) { + setAuthStatus(""); + return; + } + var payload = await response.json(); + if (payload && payload.authenticated && payload.email) { + setAuthStatus(payload.email); + } else { + setAuthStatus(""); + } + } catch (error) { + setAuthStatus(""); + } + } + + function collectBrowserData() { + return { + userAgent: navigator.userAgent || "", + language: navigator.language || "", + languages: navigator.languages || [], + platform: navigator.platform || "", + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "", + screen: { + width: window.screen && window.screen.width ? window.screen.width : 0, + height: window.screen && window.screen.height ? window.screen.height : 0 + } + }; + } + + if (subscribeForm) { + subscribeForm.addEventListener("submit", async function (event) { + event.preventDefault(); + if (!subscribeEmailInput) return; + + var email = subscribeEmailInput.value.trim(); + if (!email || !subscribeEmailInput.checkValidity()) { + setSubscribeError(getActiveTranslation("subscribeInvalidEmail")); + return; + } + + if (subscribeError) { + subscribeError.classList.add("is-hidden"); + subscribeError.textContent = ""; + } + + if (subscribeSubmitButton) subscribeSubmitButton.disabled = true; + + try { + var response = await fetch("/api/subscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: email, + browserData: collectBrowserData() + }) + }); + + if (response.ok || response.status === 409) { + setSubscribeState(true); + subscribeForm.reset(); + if (subscribeFeedback) { + subscribeFeedback.textContent = getActiveTranslation("subscribeThanks"); + } + } else if (response.status === 400) { + setSubscribeError(getActiveTranslation("subscribeInvalidEmail")); + } else { + setSubscribeError(getActiveTranslation("subscribeError")); + } + } catch (error) { + setSubscribeError(getActiveTranslation("subscribeError")); + } finally { + if (subscribeSubmitButton) subscribeSubmitButton.disabled = false; + } + }); + } + + if (logoutButton) { + logoutButton.addEventListener("click", async function () { + try { + await fetch("/api/auth/logout", { method: "POST" }); + } catch (error) { + } + setAuthStatus(""); + }); + } + + refreshAuthStatus(); + + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + for (var i = 0; i < modals.length; i++) { + closeModal(modals[i]); + } + } + }); +})(); diff --git a/static/js/simple_i18n.js b/static/js/simple_i18n.js new file mode 100644 index 0000000..de1d7a8 --- /dev/null +++ b/static/js/simple_i18n.js @@ -0,0 +1,98 @@ +(function () { + var supported = ["en", "it", "fr", "de", "es"]; + var fallback = "en"; + var aliases = { + "en-us": "en", + "de-ch": "de", + "fr-ch": "fr" + }; + + function normalizeLanguage(lang) { + if (!lang) return fallback; + var normalized = String(lang).toLowerCase().replace("_", "-"); + if (aliases[normalized]) return aliases[normalized]; + var base = normalized.split("-")[0]; + if (supported.indexOf(base) !== -1) return base; + return fallback; + } + + function getInitialLanguage() { + var params = new URLSearchParams(window.location.search); + var queryLang = params.get("lang"); + if (queryLang) return normalizeLanguage(queryLang); + + var saved = window.localStorage.getItem("preferredLang"); + if (saved) return normalizeLanguage(saved); + + var browserLang = navigator.language || (navigator.languages && navigator.languages[0]) || fallback; + return normalizeLanguage(browserLang); + } + + window.initPageI18n = function (translations) { + var currentLanguage = getInitialLanguage(); + + function t(key) { + var active = translations[currentLanguage] || translations[fallback] || {}; + if (Object.prototype.hasOwnProperty.call(active, key)) return active[key]; + return key; + } + + function applyLanguage(lang) { + currentLanguage = normalizeLanguage(lang); + var active = translations[currentLanguage] || translations[fallback] || {}; + + document.documentElement.lang = currentLanguage; + if (active.pageTitle) document.title = active.pageTitle; + + var nodes = document.querySelectorAll("[data-i18n]"); + for (var i = 0; i < nodes.length; i++) { + var key = nodes[i].getAttribute("data-i18n"); + if (Object.prototype.hasOwnProperty.call(active, key)) nodes[i].textContent = active[key]; + } + + var placeholders = document.querySelectorAll("[data-i18n-placeholder]"); + for (var j = 0; j < placeholders.length; j++) { + var pKey = placeholders[j].getAttribute("data-i18n-placeholder"); + if (Object.prototype.hasOwnProperty.call(active, pKey)) { + placeholders[j].setAttribute("placeholder", active[pKey]); + } + } + + var selector = document.getElementById("language-select"); + if (selector) selector.value = currentLanguage; + + var links = document.querySelectorAll("a[data-lang-link]"); + for (var k = 0; k < links.length; k++) { + var href = links[k].getAttribute("href"); + if (!href || href.indexOf("javascript:") === 0 || href.indexOf("#") === 0) continue; + try { + var linkUrl = new URL(href, window.location.origin); + linkUrl.searchParams.set("lang", currentLanguage); + links[k].setAttribute("href", linkUrl.pathname + linkUrl.search + linkUrl.hash); + } catch (e) {} + } + + var url = new URL(window.location.href); + url.searchParams.set("lang", currentLanguage); + window.history.replaceState({}, "", url.toString()); + } + + var selector = document.getElementById("language-select"); + if (selector) { + selector.addEventListener("change", function (event) { + var chosen = normalizeLanguage(event.target.value); + window.localStorage.setItem("preferredLang", chosen); + applyLanguage(chosen); + }); + } + + applyLanguage(currentLanguage); + + window.pageT = t; + return { + t: t, + getLanguage: function () { return currentLanguage; }, + setLanguage: applyLanguage + }; + }; +})(); diff --git a/templates/forgot_password.html b/templates/forgot_password.html new file mode 100644 index 0000000..ed209c6 --- /dev/null +++ b/templates/forgot_password.html @@ -0,0 +1,99 @@ + + + + + + Bag Exchange | Forgot Password + + + +
+
+
+ +
+ +

Recover your password

+

Enter your email and we will send you a password reset link if your account exists.

+ +
+ + +
+ + + Back to homepage +
+
+ + + + + diff --git a/templates/howtowork.html b/templates/howtowork.html new file mode 100644 index 0000000..57f6132 --- /dev/null +++ b/templates/howtowork.html @@ -0,0 +1,58 @@ + + + + + + Bag Exchange | How To Work + + + +
+
+ Back to homepage + +
+ +
+

How Bag Exchange works

+

Three simple steps to publish and exchange your bag.

+ +
    +
  1. +

    1. Create your bag gallery

    +

    Upload clear photos from multiple angles and include details about model, size, and materials.

    +
  2. +
  3. +

    2. Describe your product

    +

    Write a transparent description with defects, usage history, and what makes the item special.

    +
  4. +
  5. +

    3. Set exchange conditions

    +

    Define what you are looking for in exchange, shipping preferences, and availability for videochat.

    +
  6. +
+ +
+

Tip: complete profiles and accurate listings get better quality matches.

+
+
+
+ + + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..63f1944 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,102 @@ + + + + + + {{.InitialTitle}} + + + +
+
+
+ +
+ + Login + Create account + +
+
+
+
+ + +
+
community beta
+
+
+ +
+
+

Give your bags a new life.

+

+ Swap bags and handbags in a simple, safe, and sustainable way. + Upload your item, find real matches, and refresh your style without buying new every time. +

+
+ + See how it works +
+
+ +
+ +
+
+

Verified profiles only

+

We reduce risk with account verification, feedback, and transparent swap history.

+
+
+

Save and add value

+

A smart way to renew your wardrobe while avoiding waste and unnecessary spending.

+
+
+

Circular style

+

From daily bags to elegant clutches: every piece can find a new owner.

+
+
+
+

Videochat and grow your profile

+

You can videochat with people interested in your items or simply exchange opinions and advice. You can aspire to become a fashion bag influencer.

+
+ +
{{.FooterText}}
+
+ + + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..2e43b20 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,104 @@ + + + + + + Bag Exchange | Login + + + +
+
+
+ +
+

Login

+

Access your Bag Exchange account.

+ +
+ + + +
+ +

Forgot your password?

+

Create an account

+

Back to homepage

+ + +
+
+ + + + + diff --git a/templates/reset_password.html b/templates/reset_password.html new file mode 100644 index 0000000..2b2e5a5 --- /dev/null +++ b/templates/reset_password.html @@ -0,0 +1,118 @@ + + + + + + Bag Exchange | Reset Password + + + +
+
+
+ +
+ +

Set a new password

+

Create your new password to recover access to your Bag Exchange account.

+ +
+ + + +
+ + + Back to login +
+
+ + + + + diff --git a/templates/signup.html b/templates/signup.html new file mode 100644 index 0000000..afffe04 --- /dev/null +++ b/templates/signup.html @@ -0,0 +1,120 @@ + + + + + + Bag Exchange | Sign Up + + + +
+
+
+ +
+ +

Create account

+

Join Bag Exchange and verify your email to activate access.

+ +
+ + + + +
+ +

Already have an account? Log in

+

Back to homepage

+ + +
+
+ + + + + diff --git a/templates/transactionalMails/password_reset.html b/templates/transactionalMails/password_reset.html new file mode 100644 index 0000000..8eab1df --- /dev/null +++ b/templates/transactionalMails/password_reset.html @@ -0,0 +1,18 @@ + + + + + + + +
+

Reset your Bag Exchange password

+

We received a request to reset your password. Use the button below to set a new one.

+

+ Reset password +

+

If the button does not work, copy and paste this link into your browser:

+

{{.ActionURL}}

+
+ + diff --git a/templates/transactionalMails/verify_email.html b/templates/transactionalMails/verify_email.html new file mode 100644 index 0000000..d99a180 --- /dev/null +++ b/templates/transactionalMails/verify_email.html @@ -0,0 +1,18 @@ + + + + + + + +
+

Verify your Bag Exchange email

+

Thanks for joining Bag Exchange. Please verify your email to activate your account and start exchanging.

+

+ Verify email +

+

If the button does not work, copy and paste this link into your browser:

+

{{.ActionURL}}

+
+ +