Browse Source

autentifikacia mvp

marek 1 month ago
parent
commit
a7aec6e674

+ 29 - 0
.env .example

@@ -0,0 +1,29 @@
+# ratelimits
+
+AUTH_EMAIL_HOURLY_RATELIMIT=3
+AUTH_IP_HOURLY_RATELIMIT=10
+AUTH_IP_DAILY_RATELIMIT=20
+AUTH_CODE_HOURLY_RATELIMIT=5
+
+# ---
+
+# timeouts
+
+EMAIL_VERIFICATION_TIMEOUT=300 # 5 minutes
+SESSION_TIMEOUT=259200 # 3 days
+
+# ---
+
+# turnstile
+
+TURNSTILE_SITE_KEY=""
+TURNSTILE_SECRET_KEY=""
+
+# ---
+
+# auth email sending
+
+AUTH_EMAIL_SMTP_HOSTNAME=""
+AUTH_EMAIL_SMTP_PORT=""
+AUTH_EMAIL_SMTP_USERNAME=""
+AUTH_EMAIL_SMTP_PASSWORD=""

+ 9 - 0
.gitignore

@@ -0,0 +1,9 @@
+# bun packages
+node_modules
+bun.lock
+
+# secrets
+.env
+
+# sqlite databases
+data/

+ 82 - 0
assets/css/style.css

@@ -0,0 +1,82 @@
+* {
+    box-sizing: border-box;
+}
+
+body {
+    background-color: #222;
+    color: #eee;
+    font-family: sans-serif;
+    padding-inline: 10%;
+}
+
+nav {
+    display: flex;
+    gap: 1rem 3rem;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: space-between;
+    margin-inline: 5%;
+    padding: 0.5rem 3rem;
+}
+
+nav h2 {
+    margin: 0 auto;
+}
+
+nav ul {
+    list-style: none;
+    display: flex;
+    gap: 1rem;
+    flex-wrap: wrap;
+    align-items: center;
+    justify-content: center;
+    padding: 0;
+    margin: 0 auto;
+}
+
+nav li {
+    margin-inline: auto;
+}
+
+nav hr {
+    border: 0;
+    margin-left: -1rem;
+}
+
+nav a {
+    color: #eee;
+    text-decoration: none;
+    font-size: 1.2rem;
+}
+
+nav a.button {
+    margin: 0;
+    padding: 0.5rem 1rem;
+    border-radius: 0.5rem;
+    font-size: 1rem;
+}
+
+a.button {
+    color: #eee;
+    text-decoration: none;
+    background-color: #333;
+    border-radius: 1rem;
+    corner-shape: squircle;
+    padding: 0.75rem 2rem;
+    display: inline-block;
+    margin-block: 1rem;
+    font-size: 1.1rem;
+}
+
+header {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: space-between;
+    padding-block: 2rem;
+    gap: 2rem;
+}
+
+img {
+    object-fit: cover;
+    width: 100%;
+}

+ 15 - 0
index.js

@@ -0,0 +1,15 @@
+import { Elysia } from 'elysia'
+
+import routes from './routes'
+
+new Elysia()
+  .use(routes)
+  .get('/assets/*', async ({ params, status }) => {
+    const file = Bun.file(`./assets/${params["*"]}`)
+    if (!(await file.exists())) return status(404, "not found")
+
+    return file;
+  })
+  .listen({ hostname: "127.0.0.1", port: 3000 })
+
+console.log(`running on port 3000`)

+ 63 - 0
lang/en.js

@@ -0,0 +1,63 @@
+export default {
+  "timeFormats": {
+    "days": {
+      "1": "day",
+      "2": "days"
+    },
+    "hours": {
+      "1": "hour",
+      "2": "hours"
+    },
+    "minutes": {
+      "1": "minute",
+      "2": "minutes"
+    },
+    "seconds": {
+      "1": "second",
+      "2": "seconds"
+    }
+  },
+
+  "functionWords": {
+    "and": "and"
+  },
+
+  "emails": {
+    "auth": {
+      "subject": "login link",
+      "text": (code, link) => `
+        hi!
+
+        you can login using the following code: ${code}
+        or the following link: ${link}
+
+        if you did not request this email, feel free to ignore it.
+
+        meteostanica
+      `
+    }
+  },
+
+  "auth": {
+    "errors": {
+      "invalidEmail": "you need to provide a valid email.",
+      "noVerificationToken": "no verification token provided. please try again.",
+      "verificationTokenUsedOrExpired": "verification token already used or expired. please try again.",
+      "invalidVerificationCode": "invalid verification code. please try again.",
+      "loginNeeded": "please log in first.",
+
+      "turnstile": {
+        "unavailable": "cannot connect to Turnstile. please try again.",
+        "noToken": "Turnstile token was not provided. please try again.",
+        "invalidResponse": "invalid Turnstile response. please try again.",
+        "keyUsedOrExpired": "Turnstile key already used or expired. please try again."
+      },
+
+      "ratelimits": {
+        "email": "too many requests for this email. try again later.",
+        "ip": "you made too many requests. try again later.",
+        "code": "you entered too many wrong codes. try again later."
+      }
+    }
+  }
+}

+ 67 - 0
lang/sk.js

@@ -0,0 +1,67 @@
+export default {
+  "timeFormats": {
+    "days": {
+      "1": "deň",
+      "2": "dni",
+      "5": "dní"
+    },
+    "hours": {
+      "1": "hodina",
+      "2": "hodiny",
+      "5": "hodín"
+    },
+    "minutes": {
+      "1": "minúta",
+      "2": "minúty",
+      "5": "minút"
+    },
+    "seconds": {
+      "1": "sekunda",
+      "2": "sekundy",
+      "5": "sekúnd"
+    }
+  },
+
+  "functionWords": {
+    "and": "a"
+  },
+
+  "emails": {
+    "auth": {
+      "subject": "prihlasovací link",
+      "text": (code, link) => `
+        dobrý deň,
+
+        môžete sa prihlásiť nasledujúcim kódom: ${code}
+        alebo nasledujúcim linkom: ${link}
+
+        ak ste tento email nevyžiadali, môžete ho kľudne ignorovať.
+
+        meteostanica
+      `
+    }
+  },
+
+  "auth": {
+    "errors": {
+      "invalidEmail": "musíte zadať platný email.",
+      "noVerificationToken": "nebol poskytnutý žiadny verifikačný token. prosím skúste to znova.",
+      "verificationTokenUsedOrExpired": "verifikačný token už bol použitý alebo vypršal. prosím skúste to znova.",
+      "invalidVerificationCode": "neplatný verifikačný kód. prosím skúste to znova.",
+      "loginNeeded": "najprv sa prihláste prosím.",
+
+      "turnstile": {
+        "unavailable": "nemožno kontaktovať Turnstile. prosím skúste to znova.",
+        "noToken": "Turnstile token nebol poskytnutý. prosím skúste to znova.",
+        "invalidResponse": "neplatná Turnstile odpoveď. prosím skúste to znova.",
+        "keyUsedOrExpired": "Turnstile kľúč už bol použitý alebo vypršal. prosím skúste to znova."
+      },
+
+      "ratelimits": {
+        "email": "príliš veľa žiadostí pre tento email. skúste to znova neskôr.",
+        "ip": "poslali ste príliš veľa žiadostí. skúste to znova neskôr.",
+        "code": "zadali ste príliš veľa zlých kódov. skúste to znova neskôr."
+      }
+    }
+  }
+}

+ 13 - 0
package.json

@@ -0,0 +1,13 @@
+{
+  "name": "meteostanica",
+  "module": "index.js",
+  "type": "module",
+  "devDependencies": {
+    "@types/bun": "^1.3.9"
+  },
+  "dependencies": {
+    "elysia": "^1.4.25",
+    "eta": "^4.5.1",
+    "nodemailer": "^8.0.1"
+  }
+}

+ 119 - 0
routes/include/auth.js

@@ -0,0 +1,119 @@
+import { Elysia } from 'elysia'
+
+import { Eta } from "eta"
+const eta = new Eta({ views: "./templates" })
+
+import ratelimits from '../../utils/ratelimits'
+import normalizeEmail from '../../utils/normalizeEmail';
+import Auth from '../../utils/auth';
+import validateTurnstile from '../../utils/validateTurnstile';
+import getFormatBasedOnCount from '../../utils/getFormatBasedOnCount';
+import formatTimeToString from '../../utils/formatTimeToString';
+
+export default (langName, lang) => new Elysia({ prefix: "/auth" })
+  .get("/", async ({ cookie, redirect, query, set }) => {
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (session) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}panel`)
+    }
+
+    const error = query?.error
+
+    set.headers['content-type'] = 'text/html; charset=utf8'
+    return eta.render(`${langName}/auth/index`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, error })
+  })
+  .post("/", async ({ request, server, body, redirect }) => {
+    const clientIP = request.headers.get('x-forwarded-for') ?? server.requestIP(request).address
+
+    const email = body?.email
+    if (!normalizeEmail(email)) return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=invalidEmail`)
+
+    const hourlyEmailRatelimit = ratelimits("authEmailHourly", email, process.env.AUTH_EMAIL_HOURLY_RATELIMIT, 3600)
+    if (hourlyEmailRatelimit.status) return Response.redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=ratelimits.email`)
+
+    const hourlyIPRatelimit = ratelimits("authIPHourly", clientIP, process.env.AUTH_IP_HOURLY_RATELIMIT, 3600)
+    if (hourlyIPRatelimit.status) return Response.redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=ratelimits.ip`)
+
+    const dailyIPRatelimit = ratelimits("authIPDaily", clientIP, process.env.AUTH_IP_DAILY_RATELIMIT, 86400)
+    if (dailyIPRatelimit.status) return Response.redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=ratelimits.ip`)
+
+    const turnstileResponse = body?.["cf-turnstile-response"]
+    // const turnstileResponse = "1x00000000000000000000AA"
+    if (!turnstileResponse) return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=turnstile.noToken`)
+
+    const turnstileValid = await validateTurnstile(turnstileResponse, clientIP)
+
+    if (!turnstileValid.success) {
+      let errorMessage = `turnstile.unavailable`;
+
+      if (turnstileValid["error-codes"]?.includes("invalid-input-response"))
+        errorMessage = `turnstile.invalidResponse`
+
+      if (turnstileValid["error-codes"]?.includes("timeout-or-duplicate"))
+        errorMessage = `turnstile.keyUsedOrExpired`
+
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=${errorMessage}`)
+    }
+
+    const verification = Auth.addVerification(email)
+
+    const emailLink = `https://meteostanica.com/${langName === "sk" ? `` : `${langName}/`}auth/verify?token=${verification.token}&code=${verification.code}`
+
+    await Auth.sendVerification(email, lang.emails.auth.subject, lang.emails.auth.text(verification.code, emailLink), eta.render(`${langName}/email/auth`, { code: verification.code, link: emailLink }))
+
+    return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth/verify?token=${verification.token}`)
+  })
+  .get("/verify", async ({ query, redirect, set, cookie }) => {
+    const token = query?.token
+    const code = Number.parseInt(query?.code)
+
+    if (!token) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=noVerificationToken`)
+    }
+
+    const verification = Auth.getVerification(token)
+
+    if (!verification?.valid) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=verificationTokenUsedOrExpired`)
+    }
+
+    if (!code) {
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/auth/verify`, { token })
+    }
+
+    if (code !== verification.code) {
+      const codeRatelimit = ratelimits("authCodeHourly", token, process.env.AUTH_CODE_HOURLY_RATELIMIT, 3600)
+      if (codeRatelimit.status) return eta.render(`${langName}/auth/verify`, { token, lang, error: `ratelimits.code` })
+
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/auth/verify`, { token, lang, error: `invalidVerificationCode` })
+    }
+
+    Auth.removeVerification(token)
+
+    let user = Auth.getUser(verification.email)
+
+    if (!user) {
+      user = Auth.addUser(verification.email)
+    }
+
+    const session = await Auth.createSession(verification.email)
+
+    cookie.session.value = session.token
+
+    return redirect(`/${langName === "sk" ? `` : `${langName}/`}panel`)
+  })
+  .get("/logout", async ({ cookie, redirect }) => {
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (session) {
+      Auth.removeSession(session.id)
+      delete cookie.session
+    }
+
+    return redirect(`/${langName === "sk" ? `` : langName}`)
+  })

+ 10 - 0
routes/include/main.js

@@ -0,0 +1,10 @@
+import { Elysia } from 'elysia'
+
+import { Eta } from "eta"
+const eta = new Eta({ views: "./templates" })
+
+export default (langName) => new Elysia()
+  .get('/', ({ set }) => {
+    set.headers['content-type'] = 'text/html; charset=utf8'
+    return eta.render(`${langName}/index`)
+  })

+ 21 - 0
routes/include/panel.js

@@ -0,0 +1,21 @@
+import { Elysia } from 'elysia'
+
+import { Eta } from "eta"
+const eta = new Eta({ views: "./templates" })
+
+import Auth from '../../utils/auth';
+
+export default (langName, lang) => new Elysia({ prefix: "/panel" })
+  .get("/", async ({ cookie, redirect, set }) => {
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (!session) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=${lang.auth.errors.loginNeeded}`)
+    }
+
+    const user = Auth.getUser(session.email)
+
+    set.headers['content-type'] = 'text/html; charset=utf8'
+    return eta.render(`${langName}/panel/index`, { user })
+  })

+ 8 - 0
routes/index.js

@@ -0,0 +1,8 @@
+import { Elysia } from 'elysia'
+
+import enRoutes from './lang/en'
+import skRoutes from './lang/sk'
+
+export default new Elysia()
+  .use(skRoutes)
+  .use(enRoutes)

+ 12 - 0
routes/lang/en.js

@@ -0,0 +1,12 @@
+import { Elysia } from 'elysia'
+
+import mainRoutes from '../include/main'
+import authRoutes from '../include/auth'
+import panelRoutes from '../include/panel'
+
+import lang from '../../lang/en'
+
+export default new Elysia({ prefix: "/en" })
+  .use(mainRoutes("en"))
+  .use(authRoutes("en", lang))
+  .use(panelRoutes("en", lang))

+ 12 - 0
routes/lang/sk.js

@@ -0,0 +1,12 @@
+import { Elysia } from 'elysia'
+
+import mainRoutes from '../include/main'
+import authRoutes from '../include/auth'
+import panelRoutes from '../include/panel'
+
+import lang from '../../lang/sk'
+
+export default new Elysia()
+  .use(mainRoutes("sk", lang))
+  .use(authRoutes("sk", lang))
+  .use(panelRoutes("sk", lang))

+ 23 - 0
templates/en/auth/index.eta

@@ -0,0 +1,23 @@
+<% layout("/en/layout", { title: "auth" }) %>
+
+<h1>auth</h1>
+<p>enter your email and we will send you a code.</p>
+
+<% const errorValue = it.error?.split('.').reduce((a, b) => a[b], it.lang.auth?.errors) %>
+
+<% if (typeof errorValue === "string") { %>
+  <p class="error" style="color: red;"><%= errorValue %></p>
+<% } %>
+
+<form action="/en/auth" method="post">
+    <input type="email" name="email" placeholder="your@email.com" required />
+
+    <div class="cf-turnstile" data-sitekey="<%= it.siteKey %>"></div>
+    <button type="submit">send</button>
+</form>
+
+<script
+  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
+  async
+  defer
+></script>

+ 17 - 0
templates/en/auth/verify.eta

@@ -0,0 +1,17 @@
+<% layout("/en/layout", { title: "verify" }) %>
+
+<h1>verification</h1>
+<p>enter the code from your email or click on the link</p>
+
+<% const errorValue = it.error?.split('.').reduce((a, b) => a[b], it.lang.auth?.errors) %>
+
+<% if (errorValue) { %>
+  <p class="error" style="color: red;"><%= errorValue %></p>
+<% } %>
+
+<form action="/en/auth/verify">
+    <input type="text" name="token" value="<%= it.token %>" style="display: none;" />
+    <input type="number" length="6" name="code" placeholder="676767" />
+
+    <button type="submit">send</button>
+</form>

+ 10 - 0
templates/en/email/auth.eta

@@ -0,0 +1,10 @@
+<p>hi!</p>
+
+<p>you can login using the following code: <strong><%= it.code %></strong></p>
+<p>or <a href="<%= it.link %>">click on this link</a></p>
+
+<p><%= it.link %></p>
+
+<p><small>if you did not request this email, feel free to ignore it.</small></p>
+
+<p>meteostanica</p>

+ 16 - 0
templates/en/index.eta

@@ -0,0 +1,16 @@
+<% layout("/en/layout") %>
+
+<%~ include("/en/partials/navbar") %>
+
+
+<header>
+    <div>
+        <h1>it looks like this >>>>></h1>
+        <p>the best weather station you have ever seen</p>
+
+        <a href="/en/auth" class="button">login</a>
+    </div>
+    <div>
+        <img alt="a picture of the best weather station" src="https://placehold.net/600x400.png">
+    </div>
+</header>

+ 12 - 0
templates/en/layout.eta

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><%= it.title ? `${it.title} — meteostanica` : "meteostanica" %></title>
+    <link rel="stylesheet" href="/assets/css/style.css">
+</head>
+<body>
+    <%~ it.body %>
+</body>
+</html>

+ 6 - 0
templates/en/panel/index.eta

@@ -0,0 +1,6 @@
+<% layout("/en/layout", { title: "panel" }) %>
+
+<h1>panel</h1>
+<p><%= it.user.email %> !!</p>
+
+<a href="/en/auth/logout">logout</a>

+ 10 - 0
templates/en/partials/navbar.eta

@@ -0,0 +1,10 @@
+<nav>
+    <h2>meteostanica</h2>
+
+    <ul>
+        <li><a href="#">home</a></li>
+        <li><a href="#">home</a></li>
+        <li><a href="/en/auth" class="button">panel</a></li>
+        <li><a href="/">⛰️⛰️⛰️🇸🇰🇸🇰🇸🇰</a></li>
+    </ul>
+</nav>

+ 23 - 0
templates/sk/auth/index.eta

@@ -0,0 +1,23 @@
+<% layout("/sk/layout", { title: "prihlásenie" }) %>
+
+<h1>prihlásenie</h1>
+<p>zadajte svoj email a kliknite na link, ktorý vám príde.</p>
+
+<% const errorValue = it.error?.split('.').reduce((a, b) => a[b], it.lang.auth?.errors) %>
+
+<% if (typeof errorValue === "string") { %>
+  <p class="error" style="color: red;"><%= errorValue %></p>
+<% } %>
+
+<form action="/auth" method="post">
+    <input type="email" name="email" placeholder="vas@email.sk" required />
+
+    <div class="cf-turnstile" data-sitekey="<%= it.siteKey %>"></div>
+    <button type="submit">odoslať</button>
+</form>
+
+<script
+  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
+  async
+  defer
+></script>

+ 17 - 0
templates/sk/auth/verify.eta

@@ -0,0 +1,17 @@
+<% layout("/sk/layout", { title: "overenie" }) %>
+
+<h1>overenie</h1>
+<p>zadajte kód z emailu alebo kliknite na link v emaili</p>
+
+<% const errorValue = it.error?.split('.').reduce((a, b) => a[b], it.lang.auth?.errors) %>
+
+<% if (typeof errorValue === "string") { %>
+  <p class="error" style="color: red;"><%= errorValue %></p>
+<% } %>
+
+<form action="/auth/verify">
+    <input type="text" name="token" value="<%= it.token %>" style="display: none;" />
+    <input type="number" length="6" name="code" placeholder="676767" />
+
+    <button type="submit">odoslať</button>
+</form>

+ 10 - 0
templates/sk/email/auth.eta

@@ -0,0 +1,10 @@
+<p>dobrý deň,</p>
+
+<p>môžete sa prihlásiť nasledujúcim kódom: <strong><%= it.code %></strong></p>
+<p>alebo <a href="<%= it.link %>">kliknite na tento link</a></p>
+
+<p><%= it.link %></p>
+
+<p><small>ak ste tento email nevyžiadali, môžete ho kľudne ignorovať.</small></p>
+
+<p>meteostanica</p>

+ 16 - 0
templates/sk/index.eta

@@ -0,0 +1,16 @@
+<% layout("/sk/layout") %>
+
+<%~ include("/sk/partials/navbar") %>
+
+
+<header>
+    <div>
+        <h1>takto vyzerá >>>>></h1>
+        <p>najlepšia meteostanica akú ste kedy videli</p>
+
+        <a href="/auth" class="button">prihlásenie</a>
+    </div>
+    <div>
+        <img alt="obrázok najlepšej meteostanice" src="https://placehold.net/600x400.png">
+    </div>
+</header>

+ 12 - 0
templates/sk/layout.eta

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="sk">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><%= it.title ? `${it.title} — meteostanica` : "meteostanica" %></title>
+    <link rel="stylesheet" href="/assets/css/style.css">
+</head>
+<body>
+    <%~ it.body %>
+</body>
+</html>

+ 6 - 0
templates/sk/panel/index.eta

@@ -0,0 +1,6 @@
+<% layout("/sk/layout", { title: "panel" }) %>
+
+<h1>panel</h1>
+<p><%= it.user.email %> !!</p>
+
+<a href="/auth/logout">odhlásenie</a>

+ 10 - 0
templates/sk/partials/navbar.eta

@@ -0,0 +1,10 @@
+<nav>
+    <h2>meteostanica</h2>
+
+    <ul>
+        <li><a href="#">domov</a></li>
+        <li><a href="#">domov</a></li>
+        <li><a href="/auth" class="button">panel</a></li>
+        <li><a href="/en">🦅🦅🦅🇺🇸🇺🇸🇺🇸</a></li>
+    </ul>
+</nav>

+ 198 - 0
utils/auth.js

@@ -0,0 +1,198 @@
+import { Database } from "bun:sqlite";
+const authDB = new Database("./data/auth.sqlite");
+
+authDB.run(`create table if not exists verifications (
+    email text not null primary key,
+    token text not null,
+    code integer not null,
+    timestamp datetime default current_timestamp
+);`)
+
+authDB.run(`create table if not exists users (
+    email text not null primary key,
+    name text,
+    timestamp datetime default current_timestamp
+);`)
+
+authDB.run(`create table if not exists sessions (
+    id text not null primary key,
+    secretHash text not null,
+    email text not null,
+    timestamp datetime default current_timestamp
+);`)
+
+import nodemailer from "nodemailer"
+
+const transporter = nodemailer.createTransport({
+  host: process.env.AUTH_EMAIL_SMTP_HOSTNAME,
+  port: process.env.AUTH_EMAIL_SMTP_PORT,
+  secure: true, // Use true for port 465, false for port 587
+  auth: {
+    user: process.env.AUTH_EMAIL_SMTP_USERNAME,
+    pass: process.env.AUTH_EMAIL_SMTP_PASSWORD,
+  },
+});
+
+export default class Auth {
+    static addVerification(email) {
+        const token = generateSecureRandomString()
+        const code = generateSecureCode()
+
+        authDB.prepare(`
+            INSERT INTO verifications (email, token, code) 
+            VALUES (?, ?, ?)
+            ON CONFLICT(email) DO UPDATE SET
+                token = excluded.token,
+                code = excluded.code;
+        `).run(email, token, code)
+
+        return { email, token, code }
+    }
+
+    static async sendVerification(email, subject, text, html) {
+        return await transporter.sendMail({
+            from: '"auth — meteostanica" <auth@meteostanica.com>',
+            to: email,
+            subject,
+            text, // Plain-text version of the message
+            html, // HTML version of the message
+        });
+    }
+
+    static getVerification(token) {
+        const statement = authDB.prepare(`
+            SELECT *, 
+            ( CASE
+                WHEN (strftime('%s', 'now') - strftime('%s', timestamp)) > $seconds THEN 0
+                ELSE 1
+                END
+            ) AS valid
+            FROM verifications 
+            WHERE token = $token;
+        `)
+
+        const result = statement.get({
+            $seconds: process.env.EMAIL_VERIFICATION_TIMEOUT,
+            $token: token
+        });
+
+        return result;
+    }
+
+    static removeVerification(token) {
+        authDB.prepare(`
+            DELETE
+            FROM verifications 
+            WHERE token = ?;
+        `).run(token)
+    }
+
+    static addUser(email) {
+        const statement = authDB.prepare("INSERT INTO users (email) VALUES (?);")
+
+        statement.run(
+            email
+        );
+
+        return { email };
+    }
+
+    static getUser(email) {
+        const statement = authDB.prepare("select * from users where email = ?;")
+
+        const result = statement.get(
+            email
+        );
+
+        return result;
+    }
+
+    static async createSession(email) {
+        const id = generateSecureRandomString();
+        const secret = generateSecureRandomString();
+        const secretHash = await hashSecret(secret);
+
+        const token = id + "." + secret;
+
+        const statement = authDB.prepare("INSERT INTO sessions (id, secretHash, email) VALUES (?, ?, ?);")
+
+        statement.run(
+            id,
+            secretHash,
+            email
+        );
+
+        return { id, secretHash, token, email };
+    }
+
+    static async getSession(token) {
+        if (!token) return null
+
+        const [id, secret] = token.split(".")
+
+        const statement = authDB.prepare(`
+            SELECT *, 
+            ( CASE
+                WHEN (strftime('%s', 'now') - strftime('%s', timestamp)) > $seconds THEN 0
+                ELSE 1
+                END
+            ) AS valid
+            FROM sessions 
+            WHERE id = $id AND secretHash = $secretHash;
+        `)
+
+        const result = statement.get({
+            $seconds: process.env.SESSION_TIMEOUT,
+            $id: id,
+            $secretHash: await hashSecret(secret)
+        });
+
+        return result
+    }
+
+    static removeSession(id) {
+        authDB.prepare(`
+            DELETE
+            FROM sessions 
+            WHERE id = $id;
+        `).run(id)
+    }
+}
+
+function generateSecureCode() {
+    // Generate a random 32-bit unsigned integer
+    const array = new Uint32Array(1);
+    crypto.getRandomValues(array);
+
+    // Use modulo to get a value within 1,000,000
+    // Then pad with leading zeros if you want 000000-999999
+    // Or adjust the math if you strictly want 100,000-999,999
+    const number = array[0] % 1000000;
+
+    return number.toString().padStart(6, '0');
+}
+
+function generateSecureRandomString() {
+    // Human readable alphabet (a-z, 0-9 without l, o, 0, 1 to avoid confusion)
+    const alphabet = "abcdefghijkmnpqrstuvwxyz23456789";
+
+    // Generate 24 bytes = 192 bits of entropy.
+    // We're only going to use 5 bits per byte so the total entropy will be 192 * 5 / 8 = 120 bits
+    const bytes = new Uint8Array(24);
+    crypto.getRandomValues(bytes);
+
+    let id = "";
+
+    for (let i = 0; i < bytes.length; i++) {
+        // >> 3 "removes" the right-most 3 bits of the byte
+        id += alphabet[bytes[i] >> 3];
+    }
+
+    return id;
+}
+
+async function hashSecret(secret) {
+    const secretBytes = new TextEncoder().encode(secret);
+    const secretHashBuffer = await crypto.subtle.digest("SHA-256", secretBytes);
+    return new Uint8Array(secretHashBuffer);
+}

+ 42 - 0
utils/formatTimeToString.js

@@ -0,0 +1,42 @@
+import getFormatBasedOnCount from "./getFormatBasedOnCount"
+
+export default (formats, andFunctionWord, ms) => {
+  if ( !Number.isInteger(ms) ) {
+    return null
+  }
+  
+  const allocate = msUnit => {
+    const units = Math.trunc(ms / msUnit)
+    ms -= units * msUnit
+    return units
+  }
+  
+  const units = {
+    days: allocate(86400000),
+    hours: allocate(3600000),
+    minutes: allocate(60000),
+    seconds: allocate(1000),
+    ms // remainder
+  }
+
+  let result = [];
+
+  if (units.days > 0) {
+    result.push(`${units.days} ${getFormatBasedOnCount(formats.days, units.days)}`)
+  }
+
+  if (units.hours > 0) {
+    result.push(`${units.hours} ${getFormatBasedOnCount(formats.hours, units.hours)}`)
+  }
+
+  if (units.minutes > 0) {
+    result.push(`${units.minutes} ${getFormatBasedOnCount(formats.minutes, units.minutes)}`)
+  }
+
+  if (units.seconds > 0) {
+    result.push(`${units.seconds} ${getFormatBasedOnCount(formats.seconds, units.seconds)}`)
+  }
+
+  const last = result.pop();
+  return result.join(', ') + ` ${andFunctionWord} ` + last
+}

+ 15 - 0
utils/getFormatBasedOnCount.js

@@ -0,0 +1,15 @@
+export default (formats, count) => {
+  const keys = Object.keys(formats)
+  
+  let formatNumber;
+  
+  for (const key of keys) {
+    if (key > count) continue;
+    
+    if (!formatNumber || formatNumber < key) {
+      formatNumber = key
+    }
+  }
+
+  return formats[formatNumber];
+}

+ 17 - 0
utils/normalizeEmail.js

@@ -0,0 +1,17 @@
+// copied from https://github.com/Quiet-Terminal-Interactive/QTIAuth/blob/main/src/lib.js
+
+export default (email) => {
+  if (!email || !email?.match(/^[\w.!#$%&'*+/=?^`{|}~-]+@[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i)) return null;
+
+  email = email.toLowerCase().trim();
+
+  // Gmail ignores dots and everything after "+" in the local part.
+  if (email.endsWith('@gmail.com')) {
+    const [local] = email.split('@');
+    const cleaned = local.replace(/\./g, '').split('+')[0];
+    return `${cleaned}@gmail.com`;
+  }
+
+  const [local, domain] = email.split('@');
+  return `${local.split('+')[0]}@${domain}`;
+}

+ 38 - 0
utils/ratelimits.js

@@ -0,0 +1,38 @@
+import { Database } from "bun:sqlite";
+const ratelimitsDB = new Database("./data/ratelimits.sqlite");
+
+export default (type, value, limit, seconds) => {
+    ratelimitsDB.run(`create table if not exists ${type} (
+        subject text not null primary key,
+        countLeft integer default ${limit},
+        timestamp datetime default current_timestamp
+    );`)
+
+    const statement = ratelimitsDB.prepare(`
+        INSERT INTO ${type} (subject)
+        VALUES ($value)
+        ON CONFLICT(subject) DO UPDATE SET
+            countLeft = CASE 
+                WHEN (strftime('%s', 'now') - strftime('%s', timestamp)) > $seconds THEN $limit
+                ELSE MAX(0, countLeft - 1)
+            END,
+            timestamp = CASE 
+                WHEN (strftime('%s', 'now') - strftime('%s', timestamp)) > $seconds THEN CURRENT_TIMESTAMP
+                ELSE timestamp
+            END
+        RETURNING countLeft, timestamp;
+    `);
+
+    const result = statement.get({
+        $value: value,
+        $seconds: seconds,
+        $limit: limit
+    });
+    
+    if (result.countLeft <= 0) {
+        const resetTime = new Date(new Date(result.timestamp).getTime() + seconds * 1000);
+        return { status: true, until: resetTime };
+    }
+
+    return { status: false, countLeft: result.countLeft };
+}

+ 24 - 0
utils/validateTurnstile.js

@@ -0,0 +1,24 @@
+export default async (token, remoteip) => {
+  try {
+    const response = await fetch(
+      "https://challenges.cloudflare.com/turnstile/v0/siteverify",
+      {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({
+          secret: process.env.TURNSTILE_SECRET_KEY,
+          response: token,
+          remoteip: remoteip,
+        }),
+      },
+    );
+
+    const result = await response.json();
+    return result;
+  } catch (error) {
+    console.error("Turnstile validation error:", error);
+    return { success: false, "error-codes": ["internal-error"] };
+  }
+}