Ver código fonte

pridany websocket na data, pridaany panel

marek 1 mês atrás
pai
commit
519cdeef83

+ 8 - 0
.env.example

@@ -1,3 +1,11 @@
+# ---
+
+# general
+
+BASE_URL="http://127.0.0.1:3000"
+
+# ---
+
 # ratelimits
 
 AUTH_EMAIL_HOURLY_RATELIMIT=3

+ 36 - 33
assets/css/style.css

@@ -1,11 +1,4 @@
-* {
-    box-sizing: border-box;
-}
-
 body {
-    background-color: #222;
-    color: #eee;
-    font-family: sans-serif;
     padding-inline: 10%;
 }
 
@@ -38,35 +31,11 @@ nav li {
     margin-inline: auto;
 }
 
-nav hr {
-    border: 0;
-    margin-left: -1rem;
-}
-
-nav a {
+/* 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;
@@ -79,4 +48,38 @@ header {
 img {
     object-fit: cover;
     width: 100%;
+}
+
+.stats {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 2rem;
+}
+
+.stats > div {
+    padding: 0.5rem 2rem;
+    background-color: lightgray;
+}
+
+div:has(> table) {
+  overflow-x: auto;
+}
+
+table {
+  border-collapse: collapse;
+  width: 100%;
+}
+
+td, th {
+  border: 1px solid #dddddd;
+  text-align: left;
+  padding: 8px;
+}
+
+tr:nth-child(even) {
+  background-color: #dddddd;
+}
+
+span.error {
+    color: red;
 }

+ 28 - 0
lang/en.js

@@ -59,5 +59,33 @@ export default {
         "code": "you entered too many wrong codes. try again later."
       }
     }
+  },
+
+  settings: {
+    errors: {
+      invalidEmail: "you need to provide a valid email.",
+
+      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."
+      }
+    }
+  },
+
+  stations: {
+    errors: {
+      noName: "you need to provide a name.",
+      invalidOwner: "you need to provide a valid owner email.",
+      ownerUserNotFound: "no user with the provided email exists.",
+
+      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."
+      }
+    }
   }
 }

+ 28 - 0
lang/sk.js

@@ -63,5 +63,33 @@ export default {
         "code": "zadali ste príliš veľa zlých kódov. skúste to znova neskôr."
       }
     }
+  },
+
+  settings: {
+    errors: {
+      invalidEmail: "musíte zadať platný email.",
+
+      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."
+      }
+    }
+  },
+
+  stations: {
+    errors: {
+      noName: "musíte zadať meno.",
+      invalidOwner: "musíte zadať platný email vlastníka.",
+      ownerUserNotFound: "používateľ so zadaným emailom neexistuje.",
+
+      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."
+      }
+    }
   }
 }

+ 1 - 0
package.json

@@ -8,6 +8,7 @@
   "dependencies": {
     "elysia": "^1.4.25",
     "eta": "^4.5.1",
+    "nanoid": "^5.1.6",
     "nodemailer": "^8.0.1"
   }
 }

+ 16 - 4
routes/include/auth.js

@@ -7,8 +7,7 @@ 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';
+import Meteostanice from '../../utils/meteostanice';
 
 export default (langName, lang) => new Elysia({ prefix: "/auth" })
   .get("/", async ({ cookie, redirect, query, set }) => {
@@ -40,7 +39,6 @@ export default (langName, lang) => new Elysia({ prefix: "/auth" })
     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)
@@ -59,7 +57,7 @@ export default (langName, lang) => new Elysia({ prefix: "/auth" })
 
     const verification = Auth.addVerification(email)
 
-    const emailLink = `https://meteostanica.com/${langName === "sk" ? `` : `${langName}/`}auth/verify?token=${verification.token}&code=${verification.code}`
+    const emailLink = `${process.env.BASE_URL}/${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 }))
 
@@ -115,5 +113,19 @@ export default (langName, lang) => new Elysia({ prefix: "/auth" })
       delete cookie.session
     }
 
+    return redirect(`/${langName === "sk" ? `` : langName}`)
+  })
+  .get("/delete", async ({ cookie, redirect }) => {
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (!session?.valid) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=loginNeeded`)
+    }
+
+    delete cookie.session
+
+    Auth.removeUser(session.email)
+    
     return redirect(`/${langName === "sk" ? `` : langName}`)
   })

+ 68 - 2
routes/include/panel.js

@@ -4,18 +4,84 @@ import { Eta } from "eta"
 const eta = new Eta({ views: "./templates" })
 
 import Auth from '../../utils/auth';
+import Meteostanice from '../../utils/meteostanice';
+import stations from './panel/stations';
+import validateTurnstile from '../../utils/validateTurnstile';
+import normalizeEmail from '../../utils/normalizeEmail';
 
 export default (langName, lang) => new Elysia({ prefix: "/panel" })
+  .use(stations(langName, lang))
   .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}`)
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=loginNeeded`)
     }
 
     const user = Auth.getUser(session.email)
+    const meteostanice = Meteostanice.getOwned(session.email)
 
     set.headers['content-type'] = 'text/html; charset=utf8'
-    return eta.render(`${langName}/panel/index`, { user })
+    return eta.render(`${langName}/panel/index`, { user, meteostanice })
+  })
+  .get("/settings", async ({ cookie, redirect, set }) => {
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (!session) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=loginNeeded`)
+    }
+
+    const user = Auth.getUser(session.email)
+
+    set.headers['content-type'] = 'text/html; charset=utf8'
+    return eta.render(`${langName}/panel/settings`, { siteKey: process.env.TURNSTILE_SITE_KEY, user })
+  })
+  .post("/settings", async ({ request, server, cookie, redirect, body, set }) => {
+    const clientIP = request.headers.get('x-forwarded-for') ?? server.requestIP(request).address
+
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (!session) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=loginNeeded`)
+    }
+
+    const user = Auth.getUser(session.email)
+
+    const newName = body?.name
+    const newEmail = body?.email
+
+    if (!normalizeEmail(newEmail)) {
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/panel/settings`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user, error: "invalidEmail" })
+    }
+
+    const turnstileResponse = body?.["cf-turnstile-response"]
+        
+    if (!turnstileResponse) {
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/panel/settings`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user, 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`
+
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/panel/settings`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user, error: errorMessage })
+    }
+
+    Auth.editUser(session.email, newName, newEmail)
+
+    set.headers['content-type'] = 'text/html; charset=utf8'
+    return redirect(`/${langName === "sk" ? `` : `${langName}/`}panel`)
   })

+ 259 - 0
routes/include/panel/stations.js

@@ -0,0 +1,259 @@
+import { Elysia } from 'elysia'
+
+import { Eta } from "eta"
+const eta = new Eta({ views: "./templates" })
+
+import Auth from '../../../utils/auth';
+import Meteostanice from '../../../utils/meteostanice';
+import validateTurnstile from '../../../utils/validateTurnstile';
+import normalizeEmail from '../../../utils/normalizeEmail';
+
+export default (langName, lang) => new Elysia({ prefix: "/stations" })
+  .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=loginNeeded`)
+    }
+
+    const user = Auth.getUser(session.email)
+    const meteostanice = Meteostanice.getOwned(session.email)
+
+    set.headers['content-type'] = 'text/html; charset=utf8'
+    return eta.render(`${langName}/panel/stations/index`, { user, meteostanice })
+  })
+  .get("/add", async ({ cookie, redirect, set }) => {
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (!session) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=loginNeeded`)
+    }
+
+    const user = Auth.getUser(session.email)
+
+    set.headers['content-type'] = 'text/html; charset=utf8'
+    return eta.render(`${langName}/panel/stations/add`, { siteKey: process.env.TURNSTILE_SITE_KEY, user })
+  })
+  .post("/add", async ({ request, server, cookie, redirect, body, set }) => {
+    const clientIP = request.headers.get('x-forwarded-for') ?? server.requestIP(request).address
+
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (!session) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=loginNeeded`)
+    }
+
+    const user = Auth.getUser(session.email)
+
+    const turnstileResponse = body?.["cf-turnstile-response"]
+    
+    if (!turnstileResponse) {
+        set.headers['content-type'] = 'text/html; charset=utf8'
+        return eta.render(`${langName}/panel/stations/add`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user, 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`
+
+        set.headers['content-type'] = 'text/html; charset=utf8'
+        return eta.render(`${langName}/panel/stations/add`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user, error: errorMessage })
+    }
+
+    let name = body?.name
+    if (!name) name = "test"
+
+    const description = body?.description
+
+    Meteostanice.add(session.email, name, description)
+
+    return redirect(`/${langName === "sk" ? `` : `${langName}/`}panel/stations`)
+  })
+  .get("/:station",  async ({ cookie, redirect, set, params: { station } }) => {
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (!session) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=loginNeeded`)
+    }
+
+    const user = Auth.getUser(session.email)
+    
+    if (!station) {
+        set.headers['content-type'] = 'text/html; charset=utf8'
+        return eta.render(`${langName}/panel/stations/notFound`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user })
+    }
+    
+    const meteostanica = Meteostanice.get(session.email, station)
+    
+    if (!meteostanica) {
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/panel/stations/notFound`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user })
+    }
+
+    const data = Meteostanice.getData(meteostanica.id)
+
+    set.headers['content-type'] = 'text/html; charset=utf8'
+    return eta.render(`${langName}/panel/stations/station`, { user, meteostanica, data })
+  })
+
+  .get("/:station/edit",  async ({ cookie, redirect, set, params: { station } }) => {
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (!session) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=loginNeeded`)
+    }
+
+    const user = Auth.getUser(session.email)
+    
+    if (!station) {
+        set.headers['content-type'] = 'text/html; charset=utf8'
+        return eta.render(`${langName}/panel/stations/notFound`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user })
+    }
+    
+    const meteostanica = Meteostanice.get(session.email, station)
+    
+    if (!meteostanica) {
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/panel/stations/notFound`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user })
+    }
+
+    set.headers['content-type'] = 'text/html; charset=utf8'
+    return eta.render(`${langName}/panel/stations/edit`, { siteKey: process.env.TURNSTILE_SITE_KEY, user, meteostanica })
+  })
+  .post("/:station/edit", async ({ request, server, cookie, redirect, params: { station }, body, set }) => {
+    const clientIP = request.headers.get('x-forwarded-for') ?? server.requestIP(request).address
+
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (!session) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=loginNeeded`)
+    }
+
+    const user = Auth.getUser(session.email)
+
+    if (!station) {
+        set.headers['content-type'] = 'text/html; charset=utf8'
+        return eta.render(`${langName}/panel/stations/notFound`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user })
+    }
+    
+    const meteostanica = Meteostanice.get(session.email, station)
+    
+    if (!meteostanica) {
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/panel/stations/notFound`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user })
+    }
+
+    const turnstileResponse = body?.["cf-turnstile-response"]
+    
+    if (!turnstileResponse) {
+        set.headers['content-type'] = 'text/html; charset=utf8'
+        return eta.render(`${langName}/panel/stations/edit`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user, meteostanica, 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`
+
+        set.headers['content-type'] = 'text/html; charset=utf8'
+        return eta.render(`${langName}/panel/stations/edit`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user, meteostanica, error: errorMessage })
+    }
+
+    const newName = body?.name
+
+    if (!newName) {
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/panel/stations/edit`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user, meteostanica, error: "noName" })
+    }
+
+    const newOwnerEmail = body?.owner
+    
+    if (!normalizeEmail(newOwnerEmail)) {
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/panel/stations/edit`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user, meteostanica, error: "invalidOwner" })
+    }
+
+    const newOwner = Auth.getUser(newOwnerEmail)
+
+    if (!newOwner) {
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/panel/stations/edit`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user, meteostanica, error: "ownerUserNotFound" })
+    }
+
+    const newDescription = body?.description
+
+    Meteostanice.edit(meteostanica.id, newName, newDescription, newOwnerEmail)
+
+    return redirect(`/${langName === "sk" ? `` : `${langName}/`}panel/stations/${meteostanica.id}`)
+  })
+  .get("/:station/resetWebsocketKey",  async ({ cookie, redirect, set, params: { station } }) => {
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (!session) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=loginNeeded`)
+    }
+
+    const user = Auth.getUser(session.email)
+    
+    if (!station) {
+        set.headers['content-type'] = 'text/html; charset=utf8'
+        return eta.render(`${langName}/panel/stations/notFound`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user })
+    }
+    
+    const meteostanica = Meteostanice.get(session.email, station)
+    
+    if (!meteostanica) {
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/panel/stations/notFound`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user })
+    }
+
+    Meteostanice.resetWebsocketKey(meteostanica.id)
+
+    return redirect(`/${langName === "sk" ? `` : `${langName}/`}panel/stations/${meteostanica.id}`)
+  })
+  .get("/:station/remove",  async ({ cookie, redirect, set, params: { station } }) => {
+    const token = cookie.session.value
+    const session = await Auth.getSession(token)
+
+    if (!session) {
+      return redirect(`/${langName === "sk" ? `` : `${langName}/`}auth?error=loginNeeded`)
+    }
+
+    const user = Auth.getUser(session.email)
+    
+    if (!station) {
+        set.headers['content-type'] = 'text/html; charset=utf8'
+        return eta.render(`${langName}/panel/stations/notFound`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user })
+    }
+    
+    const meteostanica = Meteostanice.get(session.email, station)
+    
+    if (!meteostanica) {
+      set.headers['content-type'] = 'text/html; charset=utf8'
+      return eta.render(`${langName}/panel/stations/notFound`, { siteKey: process.env.TURNSTILE_SITE_KEY, lang, user })
+    }
+
+    Meteostanice.remove(meteostanica.id)
+
+    return redirect(`/${langName === "sk" ? `` : `${langName}/`}panel/stations`)
+  })

+ 60 - 0
routes/include/websocket.js

@@ -0,0 +1,60 @@
+import { Elysia } from 'elysia'
+import Meteostanice from '../../utils/meteostanice'
+
+export default new Elysia()
+  .ws("/sendData/:key", {
+    message({ send, data: { params: { key } } }, message) {
+        if (message === "meow :3") {
+            return send("meow :3")
+        }
+
+        message = Bun.JSON5.parse(message.toString())
+
+        if (
+            !message?.indoorTemp ||
+            !message?.indoorPressure ||
+            !message?.indoorHumidity ||
+            !message?.indoorAltitude ||
+            !message?.outdoorConnected ||
+            !message?.outdoorTemp ||
+            !message?.outdoorPressure ||
+            !message?.outdoorHumidity ||
+            !message?.outdoorAltitude
+        ) {
+            return send("missing required fields: indoorTemp, indoorPressure, indoorHumidity, indoorAltitude, outdoorConnected, outdoorTemp, outdoorPressure, outdoorHumidity, outdoorAltitude")
+        }
+
+        const meteostanica = Meteostanice.getWebsocket(key)
+
+        if (!meteostanica) {
+            return send("invalid station websocket key")
+        }
+
+        const {
+            indoorTemp,
+            indoorPressure,
+            indoorHumidity,
+            indoorAltitude,
+            outdoorConnected,
+            outdoorTemp,
+            outdoorPressure,
+            outdoorHumidity,
+            outdoorAltitude
+        } = message
+        
+        Meteostanice.postData(
+            meteostanica.id,
+            indoorTemp,
+            indoorPressure,
+            indoorHumidity,
+            indoorAltitude,
+            outdoorConnected,
+            outdoorTemp,
+            outdoorPressure,
+            outdoorHumidity,
+            outdoorAltitude
+        )
+
+        send(`posted data for ${meteostanica.name}`)
+    }
+  })

+ 3 - 1
routes/index.js

@@ -2,7 +2,9 @@ import { Elysia } from 'elysia'
 
 import enRoutes from './lang/en'
 import skRoutes from './lang/sk'
+import websocket from './include/websocket'
 
 export default new Elysia()
   .use(skRoutes)
-  .use(enRoutes)
+  .use(enRoutes)
+  .use(websocket)

+ 1 - 1
templates/en/auth/index.eta

@@ -1,6 +1,6 @@
 <% layout("/en/layout", { title: "auth" }) %>
 
-<h1>auth</h1>
+<h2>auth</h2>
 <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) %>

+ 1 - 1
templates/en/auth/verify.eta

@@ -1,6 +1,6 @@
 <% layout("/en/layout", { title: "verify" }) %>
 
-<h1>verification</h1>
+<h2>verification</h2>
 <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) %>

+ 0 - 1
templates/en/index.eta

@@ -2,7 +2,6 @@
 
 <%~ include("/en/partials/navbar") %>
 
-
 <header>
     <div>
         <h1>it looks like this >>>>></h1>

+ 21 - 2
templates/en/panel/index.eta

@@ -1,6 +1,25 @@
 <% layout("/en/layout", { title: "panel" }) %>
 
-<h1>panel</h1>
-<p><%= it.user.email %> !!</p>
+<%~ include("/en/panel/partials/navbar") %>
+
+<h2>welcome back <%= it.user.name.length ? it.user.name : it.user.email %> !!</h2>
+
+<div>
+  <h3>your stations</h3>
+
+    <% if (it.meteostanice?.length) { %>
+        <ul>
+            <% for (const meteostanica of it.meteostanice) { %>
+                <li><a href="/en/panel/stations/<%= meteostanica.id %>"><%= meteostanica.name %></a></li>
+            <% } %>
+        </ul>
+    <% } else { %>
+        <p>no stations. :(</p>
+    <% } %>
+
+  <a href="/en/panel/stations">see all</a>
+</div>
+
+<br>
 
 <a href="/en/auth/logout">logout</a>

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

@@ -0,0 +1,10 @@
+<nav>
+    <h2>meteostanica panel</h2>
+
+    <ul>
+        <li><a href="/en/panel">dashboard</a></li>
+        <li><a href="/en/panel/stations">stations</a></li>
+        <li><a href="/en/panel/settings">settings</a></li>
+        <li><a href="/panel">⛰️⛰️⛰️🇸🇰🇸🇰🇸🇰</a></li>
+    </ul>
+</nav>

+ 46 - 0
templates/en/panel/settings.eta

@@ -0,0 +1,46 @@
+<% layout("/en/layout", { title: "settings" }) %>
+
+<%~ include("/en/panel/partials/navbar") %>
+
+<div>
+  <h2>settings</h2>
+
+  <% const errorValue = it.error?.split('.').reduce((a, b) => a[b], it.lang.settings?.errors) %>
+
+  <% if (typeof errorValue === "string") { %>
+    <span class="error"><%= errorValue %></span>
+  <% } %>
+  
+  <form action="/en/panel/settings" method="post">
+    <div>
+      <label for="name">name</label>
+      <input type="text" id="name" name="name" placeholder="ferris" value="<%= it.user.name ?? "" %>">
+    </div>
+
+    <br>
+
+    <div>
+      <label for="email">email</label>
+      <input type="text" id="email" name="email" placeholder="your@email.com" value="<%= it.user.email %>">
+    </div>
+
+    <br>
+
+    <div class="cf-turnstile" data-sitekey="<%= it.siteKey %>"></div>
+
+    <br>
+
+    <button type="submit">save</button>
+  </form>
+
+  <br>
+
+  <a href="/en/auth/delete">delete account</a>
+</div>
+
+
+<script
+  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
+  async
+  defer
+></script>

+ 43 - 0
templates/en/panel/stations/add.eta

@@ -0,0 +1,43 @@
+<% layout("/en/layout", { title: "add station" }) %>
+
+<%~ include("/en/panel/partials/navbar") %>
+
+<div>
+  <h2>add a station</h2>
+
+  <% const errorValue = it.error?.split('.').reduce((a, b) => a[b], it.lang.stations?.errors) %>
+
+  <% if (typeof errorValue === "string") { %>
+    <span class="error"><%= errorValue %></span>
+  <% } %>
+  
+  <form action="/en/panel/stations/add" method="post">
+    <div>
+      <label for="name">name</label>
+      <input type="text" id="name" name="name" placeholder="cool station">
+    </div>
+
+    <br>
+
+    <div>
+      <label for="description">description</label>
+      <textarea id="description" name="description" placeholder="cool station"></textarea>
+    </div>
+
+    <br>
+
+    <div class="cf-turnstile" data-sitekey="<%= it.siteKey %>"></div>
+
+    <br>
+
+    <button type="submit">add</button>
+    <a href="/en/panel/stations">&lt;&lt; go back</a>
+  </form>
+</div>
+
+
+<script
+  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
+  async
+  defer
+></script>

+ 49 - 0
templates/en/panel/stations/edit.eta

@@ -0,0 +1,49 @@
+<% layout("/en/layout", { title: `edit station` }) %>
+
+<%~ include("/en/panel/partials/navbar") %>
+
+<div>
+  <h2><%= it.meteostanica.name %> - edit</h2>
+
+  <% const errorValue = it.error?.split('.').reduce((a, b) => a[b], it.lang.stations?.errors) %>
+
+  <% if (typeof errorValue === "string") { %>
+    <span class="error"><%= errorValue %></span>
+  <% } %>
+  
+  <form action="/en/panel/stations/<%= it.meteostanica.id %>/edit" method="post">
+    <div>
+      <label for="name">name</label>
+      <input type="text" id="name" name="name" placeholder="cool station" value="<%= it.meteostanica.name %>">
+    </div>
+
+    <br>
+
+    <div>
+      <label for="description">description</label>
+      <textarea id="description" name="description" placeholder="cool station"><%= it.meteostanica.description %></textarea>
+    </div>
+
+    <br>
+
+    <div>
+      <label for="owner">owner</label>
+      <input type="text" id="owner" name="owner" placeholder="your@email.com" value="<%= it.meteostanica.owner %>">
+    </div>
+
+    <br>
+
+    <div class="cf-turnstile" data-sitekey="<%= it.siteKey %>"></div>
+
+    <br>
+
+    <button type="submit">save</button>
+    <a href="/en/panel/stations/<%= it.meteostanica.id %>">&lt;&lt; go back</a>
+  </form>
+</div>
+
+<script
+  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
+  async
+  defer
+></script>

+ 19 - 0
templates/en/panel/stations/index.eta

@@ -0,0 +1,19 @@
+<% layout("/en/layout", { title: "stations" }) %>
+
+<%~ include("/en/panel/partials/navbar") %>
+
+<div>
+  <h2>your stations</h2>
+
+    <% if (it.meteostanice?.length) { %>
+        <ul>
+            <% for (const meteostanica of it.meteostanice) { %>
+                <li><a href="/en/panel/stations/<%= meteostanica.id %>"><%= meteostanica.name %></a></li>
+            <% } %>
+        </ul>
+    <% } else { %>
+        <p>no stations. :(</p>
+    <% } %>
+
+    <a href="/en/panel/stations/add">add</a>
+</div>

+ 7 - 0
templates/en/panel/stations/notFound.eta

@@ -0,0 +1,7 @@
+<% layout("/en/layout", { title: "station not found" }) %>
+
+<%~ include("/en/panel/partials/navbar") %>
+
+<a href="/en/panel/stations">&lt;&lt; go back</a>
+
+<h2>station not found</h2>

+ 63 - 0
templates/en/panel/stations/station.eta

@@ -0,0 +1,63 @@
+<% layout("/en/layout", { title: it.meteostanica.name }) %>
+
+<%~ include("/en/panel/partials/navbar") %>
+
+<a href="/en/panel/stations">&lt;&lt; go back</a>
+
+<h2><%= it.meteostanica.name %></h2>
+
+<% if (it.meteostanica.description) { %>
+    <p><%= it.meteostanica.description %></p>
+<% } %>
+
+<p>
+    <a href="/en/panel/stations/<%= it.meteostanica.id %>/edit">edit</a>
+    <a href="/en/panel/stations/<%= it.meteostanica.id %>/remove">remove</a>
+</p>
+
+<p><strong>created at:</strong> <%= it.meteostanica.timestamp %></p>
+
+<p><strong>websocket key:</strong> <%= it.meteostanica.websocketKey %> <a href="/en/panel/stations/<%= it.meteostanica.id %>/resetWebsocketKey">reset</a></p>
+
+<% if (it.data?.[it.data?.length - 1]) { %>
+<div class="stats">
+    <div class="indoor">
+        <h3>indoor</h3>
+        <p>temp: <%= it.data?.[it.data?.length - 1].indoorTemp / 100 %> °C</p>
+        <p>pressure: <%= it.data?.[it.data?.length - 1].indoorPressure / 100 %> hPa</p>
+        <p>humidity: <%= it.data?.[it.data?.length - 1].indoorHumidity / 100 %>%</p>
+        <p>altitude: <%= it.data?.[it.data?.length - 1].indoorAltitude / 100 %>m</p>
+    </div>
+    <div class="outdoor">
+        <h3>outdoor</h3>
+        <p>connected: <%= it.data?.[it.data?.length - 1].outdoorConnected %></p>
+        <p>temp: <%= it.data?.[it.data?.length - 1].outdoorTemp / 100 %> °C</p>
+        <p>pressure: <%= it.data?.[it.data?.length - 1].outdoorPressure / 100 %> hPa</p>
+        <p>humidity: <%= it.data?.[it.data?.length - 1].outdoorHumidity / 100 %>%</p>
+        <p>altitude: <%= it.data?.[it.data?.length - 1].outdoorAltitude / 100 %>m</p>
+    </div>
+</div>
+<% } %>
+
+<h3>data</h3>
+
+<% if (it.data?.length) { %>
+    <div>
+        <table>
+            <tr>
+                <th>time</th>
+                <th>indoor</th>
+                <th>outdoor</th>
+            </tr>
+            <% for (const item of it.data) { %>
+                <tr>
+                    <td><%= item.timestamp %></td>
+                    <td><strong>temp:</strong> <%= item.indoorTemp / 100 %> °C, <strong>pressure:</strong> <%= item.indoorPressure / 100 %> hPa, <strong>humidity:</strong> <%= item.indoorHumidity / 100 %>%, <strong>altitude:</strong> <%= item.indoorAltitude / 100 %>m</td>
+                    <td><strong>connected:</strong> <%= item.outdoorConnected %>, <strong>temp:</strong> <%= item.outdoorTemp / 100 %> °C, <strong>pressure:</strong> <%= item.outdoorPressure  / 100%>hPa, <strong>humidity:</strong> <%= item.outdoorHumidity / 100 %>%, <strong>altitude:</strong> <%= item.outdoorAltitude / 100 %>m</td>
+                </tr>
+            <% } %>
+        </table>
+    </div>
+<% } else { %>
+    <p>no data yet. :(</p>
+<% } %>

+ 22 - 3
templates/sk/panel/index.eta

@@ -1,6 +1,25 @@
 <% layout("/sk/layout", { title: "panel" }) %>
 
-<h1>panel</h1>
-<p><%= it.user.email %> !!</p>
+<%~ include("/sk/panel/partials/navbar") %>
 
-<a href="/auth/logout">odhlásenie</a>
+<h2>vitajte späť <%= it.user.name.length ? it.user.name : it.user.email %> !!</h2>
+
+<div>
+  <h2>vaše stanice</h2>
+
+    <% if (it.meteostanice?.length) { %>
+        <ul>
+            <% for (const meteostanica of it.meteostanice) { %>
+                <li><a href="/panel/stations/<%= meteostanica.id %>"><%= meteostanica.name %></a></li>
+            <% } %>
+        </ul>
+    <% } else { %>
+        <p>žiadne stanice. :(</p>
+    <% } %>
+
+    <a href="/panel/stations">zobraziť všetky</a>
+</div>
+
+<br>
+
+<a href="/auth/logout">odhlásiť sa</a>

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

@@ -0,0 +1,10 @@
+<nav>
+    <h2>meteostanica panel</h2>
+
+    <ul>
+        <li><a href="/panel">dashboard</a></li>
+        <li><a href="/panel/stations">stanice</a></li>
+        <li><a href="/panel/settings">nastavenia</a></li>
+        <li><a href="/en/panel">🦅🦅🦅🇺🇸🇺🇸🇺🇸</a></li>
+    </ul>
+</nav>

+ 46 - 0
templates/sk/panel/settings.eta

@@ -0,0 +1,46 @@
+<% layout("/sk/layout", { title: "nastavenia" }) %>
+
+<%~ include("/sk/panel/partials/navbar") %>
+
+<div>
+  <h2>nastavenia</h2>
+
+  <% const errorValue = it.error?.split('.').reduce((a, b) => a[b], it.lang.settings?.errors) %>
+
+  <% if (typeof errorValue === "string") { %>
+    <span class="error"><%= errorValue %></span>
+  <% } %>
+  
+  <form action="/panel/settings" method="post">
+    <div>
+      <label for="name">meno</label>
+      <input type="text" id="name" name="name" placeholder="ferris" value="<%= it.user.name ?? "" %>">
+    </div>
+
+    <br>
+
+    <div>
+      <label for="email">email</label>
+      <input type="text" id="email" name="email" placeholder="váš@email.sk" value="<%= it.user.email %>">
+    </div>
+
+    <br>
+
+    <div class="cf-turnstile" data-sitekey="<%= it.siteKey %>"></div>
+
+    <br>
+
+    <button type="submit">uložiť</button>
+  </form>
+
+  <br>
+
+  <a href="/auth/delete">zmazať účet</a>
+</div>
+
+
+<script
+  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
+  async
+  defer
+></script>

+ 43 - 0
templates/sk/panel/stations/add.eta

@@ -0,0 +1,43 @@
+<% layout("/sk/layout", { title: "pridať stanicu" }) %>
+
+<%~ include("/sk/panel/partials/navbar") %>
+
+<div>
+  <h2>pridať stanicu</h2>
+
+  <% const errorValue = it.error?.split('.').reduce((a, b) => a[b], it.lang.stations?.errors) %>
+
+  <% if (typeof errorValue === "string") { %>
+    <span class="error"><%= errorValue %></span>
+  <% } %>
+  
+  <form action="/panel/stations/add" method="post">
+    <div>
+      <label for="name">meno</label>
+      <input type="text" id="name" name="name" placeholder="cool stanica">
+    </div>
+
+    <br>
+
+    <div>
+      <label for="description">popis</label>
+      <textarea id="description" name="description" placeholder="tá najviac cool"></textarea>
+    </div>
+
+    <br>
+
+    <div class="cf-turnstile" data-sitekey="<%= it.siteKey %>"></div>
+
+    <br>
+
+    <button type="submit">pridať</button>
+    <a href="/panel/stations">&lt;&lt; späť</a>
+  </form>
+</div>
+
+
+<script
+  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
+  async
+  defer
+></script>

+ 49 - 0
templates/sk/panel/stations/edit.eta

@@ -0,0 +1,49 @@
+<% layout("/sk/layout", { title: `upraviť stanicu` }) %>
+
+<%~ include("/sk/panel/partials/navbar") %>
+
+<div>
+  <h2><%= it.meteostanica.name %> - upraviť</h2>
+
+  <% const errorValue = it.error?.split('.').reduce((a, b) => a[b], it.lang.stations?.errors) %>
+
+  <% if (typeof errorValue === "string") { %>
+    <span class="error"><%= errorValue %></span>
+  <% } %>
+  
+  <form action="/panel/stations/<%= it.meteostanica.id %>/edit" method="post">
+    <div>
+      <label for="name">meno</label>
+      <input type="text" id="name" name="name" placeholder="cool stanica" value="<%= it.meteostanica.name %>">
+    </div>
+
+    <br>
+
+    <div>
+      <label for="description">popis</label>
+      <textarea id="description" name="description" placeholder="tá najviac cool"><%= it.meteostanica.description %></textarea>
+    </div>
+
+    <br>
+
+    <div>
+      <label for="owner">vlastník</label>
+      <input type="text" id="owner" name="owner" placeholder="váš@email.sk" value="<%= it.meteostanica.owner %>">
+    </div>
+
+    <br>
+
+    <div class="cf-turnstile" data-sitekey="<%= it.siteKey %>"></div>
+
+    <br>
+
+    <button type="submit">uložiť</button>
+    <a href="/panel/stations/<%= it.meteostanica.id %>">&lt;&lt; späť</a>
+  </form>
+</div>
+
+<script
+  src="https://challenges.cloudflare.com/turnstile/v0/api.js"
+  async
+  defer
+></script>

+ 19 - 0
templates/sk/panel/stations/index.eta

@@ -0,0 +1,19 @@
+<% layout("/sk/layout", { title: "stanice" }) %>
+
+<%~ include("/sk/panel/partials/navbar") %>
+
+<div>
+  <h2>vaše stanice</h2>
+
+    <% if (it.meteostanice?.length) { %>
+        <ul>
+            <% for (const meteostanica of it.meteostanice) { %>
+                <li><a href="/panel/stations/<%= meteostanica.id %>"><%= meteostanica.name %></a></li>
+            <% } %>
+        </ul>
+    <% } else { %>
+        <p>žiadne stanice. :(</p>
+    <% } %>
+
+    <a href="/panel/stations/add">pridať</a>
+</div>

+ 7 - 0
templates/sk/panel/stations/notFound.eta

@@ -0,0 +1,7 @@
+<% layout("/sk/layout", { title: "stanica nenájdená" }) %>
+
+<%~ include("/sk/panel/partials/navbar") %>
+
+<a href="/panel/stations">&lt;&lt; späť</a>
+
+<h2>stanica nenájdená</h2>

+ 63 - 0
templates/sk/panel/stations/station.eta

@@ -0,0 +1,63 @@
+<% layout("/sk/layout", { title: it.meteostanica.name }) %>
+
+<%~ include("/sk/panel/partials/navbar") %>
+
+<a href="/panel/stations">&lt;&lt; späť</a>
+
+<h2><%= it.meteostanica.name %></h2>
+
+<% if (it.meteostanica.description) { %>
+    <p><%= it.meteostanica.description %></p>
+<% } %>
+
+<p>
+    <a href="/panel/stations/<%= it.meteostanica.id %>/edit">upraviť</a>
+    <a href="/panel/stations/<%= it.meteostanica.id %>/remove">odstrániť</a>
+</p>
+
+<p><strong>vytvorená:</strong> <%= it.meteostanica.timestamp %></p>
+
+<p><strong>websocket kľúč:</strong> <%= it.meteostanica.websocketKey %> <a href="/panel/stations/<%= it.meteostanica.id %>/resetWebsocketKey">reset</a></p>
+
+<% if (it.data?.[it.data?.length - 1]) { %>
+<div class="stats">
+    <div class="indoor">
+        <h3>vnútorné</h3>
+        <p>teplota: <%= it.data?.[it.data?.length - 1].indoorTemp / 100 %> °C</p>
+        <p>tlak: <%= it.data?.[it.data?.length - 1].indoorPressure / 100 %> hPa</p>
+        <p>vlhkosť: <%= it.data?.[it.data?.length - 1].indoorHumidity / 100 %>%</p>
+        <p>nadmorská výška: <%= it.data?.[it.data?.length - 1].indoorAltitude / 100 %>m</p>
+    </div>
+    <div class="outdoor">
+        <h3>vonkajšie</h3>
+        <p>pripojené: <%= it.data?.[it.data?.length - 1].outdoorConnected %></p>
+        <p>teplota: <%= it.data?.[it.data?.length - 1].outdoorTemp / 100 %> °C</p>
+        <p>tlak: <%= it.data?.[it.data?.length - 1].outdoorPressure / 100 %> hPa</p>
+        <p>vlhkosť: <%= it.data?.[it.data?.length - 1].outdoorHumidity / 100 %>%</p>
+        <p>nadmorská výška: <%= it.data?.[it.data?.length - 1].outdoorAltitude / 100 %>m</p>
+    </div>
+</div>
+<% } %>
+
+<h3>dáta</h3>
+
+<% if (it.data?.length) { %>
+    <div>
+        <table>
+            <tr>
+                <th>čas</th>
+                <th>vnútorné</th>
+                <th>vonkajšie</th>
+            </tr>
+            <% for (const item of it.data) { %>
+                <tr>
+                    <td><%= item.timestamp %></td>
+                    <td><strong>teplota:</strong> <%= item.indoorTemp / 100 %> °C, <strong>tlak:</strong> <%= item.indoorPressure / 100 %> hPa, <strong>vlhkosť:</strong> <%= item.indoorHumidity / 100 %>%, <strong>nadmorská výška:</strong> <%= item.indoorAltitude / 100 %>m</td>
+                    <td><strong>pripojené:</strong> <%= item.outdoorConnected %>, <strong>teplota:</strong> <%= item.outdoorTemp / 100 %> °C, <strong>tlak:</strong> <%= item.outdoorPressure  / 100%>hPa, <strong>vlhkosť:</strong> <%= item.outdoorHumidity / 100 %>%, <strong>nadmorská výška:</strong> <%= item.outdoorAltitude / 100 %>m</td>
+                </tr>
+            <% } %>
+        </table>
+    </div>
+<% } else { %>
+    <p>zatiaľ žiadne dáta. :(</p>
+<% } %>

+ 51 - 37
utils/auth.js

@@ -22,6 +22,10 @@ authDB.run(`create table if not exists sessions (
 );`)
 
 import nodemailer from "nodemailer"
+import generateSecureRandomString from "./generateSecureRandomString";
+import generateSecureCode from "./generateSecureCode";
+import hashSecret from "./hashSecret";
+import Meteostanice from "./meteostanice";
 
 const transporter = nodemailer.createTransport({
   host: process.env.AUTH_EMAIL_SMTP_HOSTNAME,
@@ -87,6 +91,14 @@ export default class Auth {
         `).run(token)
     }
 
+    static removeUserVerifications(email) {
+        authDB.prepare(`
+            DELETE
+            FROM verifications 
+            WHERE email = ?;
+        `).run(email)
+    }
+
     static addUser(email) {
         const statement = authDB.prepare("INSERT INTO users (email) VALUES (?);")
 
@@ -107,6 +119,38 @@ export default class Auth {
         return result;
     }
 
+    static editUser(email, newName, newEmail) {
+        const statement = authDB.prepare("select * from users where email = ?;")
+
+        const user = statement.get(
+            email
+        );
+
+        if (!user) return null
+
+        const result = authDB.prepare(`
+            update users
+            set name = ?,
+            email = ?
+            where email = ?;
+        `).run(newName, newEmail, email)
+
+        return result
+    }
+
+    static removeUser(email) {
+        Meteostanice.removeOwned(email)
+
+        this.removeUserSessions(email)
+        this.removeUserVerifications(email)
+
+        authDB.prepare(`
+            DELETE
+            FROM users 
+            WHERE email = ?;
+        `).run(email)
+    }
+
     static async createSession(email) {
         const id = generateSecureRandomString();
         const secret = generateSecureRandomString();
@@ -154,45 +198,15 @@ export default class Auth {
         authDB.prepare(`
             DELETE
             FROM sessions 
-            WHERE id = $id;
+            WHERE 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];
+    static removeUserSessions(email) {
+        authDB.prepare(`
+            DELETE
+            FROM sessions 
+            WHERE email = ?;
+        `).run(email)
     }
-
-    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);
 }

+ 12 - 0
utils/generateSecureCode.js

@@ -0,0 +1,12 @@
+export default () => {
+    // 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');
+}

+ 18 - 0
utils/generateSecureRandomString.js

@@ -0,0 +1,18 @@
+export default () => {
+    // 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;
+}

+ 5 - 0
utils/hashSecret.js

@@ -0,0 +1,5 @@
+export default async (secret) => {
+    const secretBytes = new TextEncoder().encode(secret);
+    const secretHashBuffer = await crypto.subtle.digest("SHA-256", secretBytes);
+    return new Uint8Array(secretHashBuffer);
+}

+ 185 - 0
utils/meteostanice.js

@@ -0,0 +1,185 @@
+import { Database } from "bun:sqlite";
+import { nanoid } from 'nanoid'
+import generateSecureRandomString from "./generateSecureRandomString";
+
+const meteostaniceDB = new Database("./data/meteostanice.sqlite");
+
+meteostaniceDB.run(`create table if not exists list (
+    id text not null primary key,
+    owner text not null,
+    name text not null,
+    description text,
+    websocketKey text not null,
+    timestamp datetime default current_timestamp
+);`)
+
+meteostaniceDB.run(`create table if not exists data (
+    id text not null primary key,
+    meteostanica text not null,
+    timestamp datetime default current_timestamp,
+
+    indoorTemp text not null,
+    indoorPressure text not null,
+    indoorHumidity text not null,
+    indoorAltitude text not null,
+
+    outdoorConnected integer not null,
+    outdoorTemp text not null,
+    outdoorPressure text not null,
+    outdoorHumidity text not null,
+    outdoorAltitude text not null
+);`)
+
+meteostaniceDB.run(`create table if not exists public (
+    id text not null primary key,
+    name text not null,
+    description text,
+    timestamp datetime default current_timestamp,
+
+    showOwner integer default 1,
+    
+    showIndoorTemp integer default 1,
+    showIndoorPressure integer default 1,
+    showIndoorHumidity integer default 1,
+    showIndoorAltitude integer default 1,
+
+    showOutdoorConnected integer default 1,
+    showOutdoorTemp integer default 1,
+    showOutdoorPressure integer default 1,
+    showOutdoorHumidity integer default 1,
+    showOutdoorAltitude integer default 1
+);`)
+
+export default class Meteostanice {
+    static add(owner, name, description) {
+        const id = nanoid()
+        const websocketKey = generateSecureRandomString()
+
+        meteostaniceDB.prepare(`
+            INSERT INTO list (id, owner, name, description, websocketKey) 
+            VALUES (?, ?, ?, ?, ?);
+        `).run(id, owner, name, description, websocketKey)
+    }
+
+    static get(owner, id) {
+        const statement = meteostaniceDB.prepare(`
+            SELECT *
+            FROM list 
+            WHERE owner = $owner AND id = $id;
+        `)
+
+        const result = statement.get({
+            $owner: owner,
+            $id: id
+        });
+
+        return result
+    }
+
+    static getWebsocket(key) {
+        const statement = meteostaniceDB.prepare(`
+            SELECT *
+            FROM list 
+            WHERE websocketKey = $key;
+        `)
+
+        const result = statement.get({
+            $key: key
+        });
+
+        return result
+    }
+
+    static getOwned(owner) {
+        const statement = meteostaniceDB.prepare(`
+            SELECT *
+            FROM list 
+            WHERE owner = $owner;
+        `)
+
+        const result = statement.all({
+            $owner: owner
+        });
+
+        return result
+    }
+
+    static edit(id, newName, newDescription, newOwner) {
+        meteostaniceDB.prepare(`
+            update list
+            set name = ?,
+            description = ?,
+            owner = ?
+            where id = ?;
+        `).run(newName, newDescription, newOwner, id)
+    }
+
+    static remove(id) {
+        meteostaniceDB.prepare(`
+            DELETE
+            FROM data 
+            WHERE meteostanica = $id;
+        `).run({
+            $owner: owner,
+            $id: id
+        });
+
+        meteostaniceDB.prepare(`
+            DELETE
+            FROM public 
+            WHERE id = $id;
+        `).run({
+            $id: id
+        });
+
+        meteostaniceDB.prepare(`
+            DELETE
+            FROM list 
+            WHERE owner = $owner AND id = $id;
+        `).run({
+            $owner: owner,
+            $id: id
+        });
+    }
+
+    static removeOwned(owner) {
+        const meteostanice = this.getOwned(owner)
+
+        for (const meteostanica of meteostanice) {
+            this.remove(meteostanica.owner, meteostanica.id)
+        }
+    }
+
+    static postData(meteostanica, indoorTemp, indoorPressure, indoorHumidity, indoorAltitude, outdoorConnected, outdoorTemp, outdoorPressure, outdoorHumidity, outdoorAltitude) {
+        const id = nanoid()
+
+        meteostaniceDB.prepare(`
+            INSERT INTO data (id, meteostanica, indoorTemp, indoorPressure, indoorHumidity, indoorAltitude, outdoorConnected, outdoorTemp, outdoorPressure, outdoorHumidity, outdoorAltitude) 
+            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
+        `).run(id, meteostanica, indoorTemp, indoorPressure, indoorHumidity, indoorAltitude, outdoorConnected, outdoorTemp, outdoorPressure, outdoorHumidity, outdoorAltitude)
+    }
+
+    static getData(meteostanica) {
+        const statement = meteostaniceDB.prepare(`
+            SELECT *
+            FROM data 
+            WHERE meteostanica = $meteostanica;
+        `)
+
+        const result = statement.all({
+            $meteostanica: meteostanica
+        });
+
+        return result
+    }
+
+    static resetWebsocketKey(id) {
+        const websocketKey = generateSecureRandomString()
+
+        meteostaniceDB.prepare(`
+            update list
+            set websocketKey = ?
+            where id = ?;
+        `).run(websocketKey, id)
+    }
+}