A api/key.go => api/key.go +20 -0
@@ 0,0 1,20 @@
+/*
+ * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
+ *
+ * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for
+ * more information.
+ */
+package api
+
+import "math/rand"
+
+const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+func GenKey() string {
+ // NOTE: "Good enough"
+ b := make([]byte, 48)
+ for i := range b {
+ b[i] = chars[rand.Int63() % int64(len(chars))]
+ }
+ return string(b)
+}
M components/manage.templ => components/manage.templ +21 -3
@@ 1,10 1,28 @@
package components
+import "git.src.quest/~skye/felu-ddns/config"
+
+func getDomainPattern() string {
+ return config.FeluConfig.DNSPattern
+}
+
templ Manage() {
@ManageBase("Manage") {
- <div>
- something something manaag
- <br/>have the domains of a user listed here, with also the edit stuff and all that
+ <div class="bg-teal-200">
+ <form class="flex flex-col p-2 gap-2" hx-post="/manage/domains" hx-target="#add_domain_error">
+ <label for="domain">Domain</label>
+ <div>
+ <input class="border" type="text" placeholder="..." name="domain" id="domain"/>
+ <span>{ getDomainPattern() }</span>
+ </div>
+ <label for="a_record">A Record</label>
+ <input class="border" type="text" placeholder="..." name="a_record" id="a_record"/>
+ <div class="text-rose-600 text-center" id="add_domain_error"></div>
+ <button class="border p-1" type="submit">Add</button>
+ </form>
+ </div>
+
+ <div hx-get="/manage/partials/domains" hx-trigger="load, update-domain-list from:body" hx-target="this">
</div>
}
}
M components/manage_templ.go => components/manage_templ.go +37 -13
@@ 9,6 9,12 @@ import "context"
import "io"
import "bytes"
+import "git.src.quest/~skye/felu-ddns/config"
+
+func getDomainPattern() string {
+ return config.FeluConfig.DNSPattern
+}
+
func Manage() templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
templBuffer, templIsBuffer := w.(*bytes.Buffer)
@@ 28,25 34,43 @@ func Manage() templ.Component {
templBuffer = templ.GetBuffer()
defer templ.ReleaseBuffer(templBuffer)
}
- _, err = templBuffer.WriteString("<div>")
+ _, err = templBuffer.WriteString("<div class=\"bg-teal-200\"><form class=\"flex flex-col p-2 gap-2\" hx-post=\"/manage/domains\" hx-target=\"#add_domain_error\"><label for=\"domain\">")
if err != nil {
return err
}
- var_3 := `something something manaag`
+ var_3 := `Domain`
_, err = templBuffer.WriteString(var_3)
if err != nil {
return err
}
- _, err = templBuffer.WriteString(" <br>")
+ _, err = templBuffer.WriteString("</label><div><input class=\"border\" type=\"text\" placeholder=\"...\" name=\"domain\" id=\"domain\"><span>")
if err != nil {
return err
}
- var_4 := `have the domains of a user listed here, with also the edit stuff and all that`
- _, err = templBuffer.WriteString(var_4)
+ var var_4 string = getDomainPattern()
+ _, err = templBuffer.WriteString(templ.EscapeString(var_4))
if err != nil {
return err
}
- _, err = templBuffer.WriteString("</div>")
+ _, err = templBuffer.WriteString("</span></div><label for=\"a_record\">")
+ if err != nil {
+ return err
+ }
+ var_5 := `A Record`
+ _, err = templBuffer.WriteString(var_5)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</label><input class=\"border\" type=\"text\" placeholder=\"...\" name=\"a_record\" id=\"a_record\"><div class=\"text-rose-600 text-center\" id=\"add_domain_error\"></div><button class=\"border p-1\" type=\"submit\">")
+ if err != nil {
+ return err
+ }
+ var_6 := `Add`
+ _, err = templBuffer.WriteString(var_6)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</button></form></div> <div hx-get=\"/manage/partials/domains\" hx-trigger=\"load, update-domain-list from:body\" hx-target=\"this\"></div>")
if err != nil {
return err
}
@@ 74,12 98,12 @@ func ManageSettings() templ.Component {
defer templ.ReleaseBuffer(templBuffer)
}
ctx = templ.InitializeContext(ctx)
- var_5 := templ.GetChildren(ctx)
- if var_5 == nil {
- var_5 = templ.NopComponent
+ var_7 := templ.GetChildren(ctx)
+ if var_7 == nil {
+ var_7 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
- var_6 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ var_8 := templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
templBuffer, templIsBuffer := w.(*bytes.Buffer)
if !templIsBuffer {
templBuffer = templ.GetBuffer()
@@ 89,8 113,8 @@ func ManageSettings() templ.Component {
if err != nil {
return err
}
- var_7 := `user settings here, like updating email and password`
- _, err = templBuffer.WriteString(var_7)
+ var_9 := `user settings here, like updating email and password`
+ _, err = templBuffer.WriteString(var_9)
if err != nil {
return err
}
@@ 103,7 127,7 @@ func ManageSettings() templ.Component {
}
return err
})
- err = ManageBase("Settings").Render(templ.WithChildren(ctx, var_6), templBuffer)
+ err = ManageBase("Settings").Render(templ.WithChildren(ctx, var_8), templBuffer)
if err != nil {
return err
}
A components/managepartials.templ => components/managepartials.templ +74 -0
@@ 0,0 1,74 @@
+package components
+
+import "git.src.quest/~skye/felu-ddns/db"
+import "fmt"
+
+templ ManagePartialDomains(domains []db.Domain) {
+ if len(domains) > 0 {
+ <script>
+ function toggleApiKeyVisibility(inputId) {
+ var input = document.getElementById(inputId)
+ if (input.type === "password") {
+ input.type = "text";
+ } else {
+ input.type = "password";
+ }
+ }
+ </script>
+ <table class="table-auto">
+ <thead>
+ <tr>
+ <th class="text-start p-2">
+ Domain
+ </th>
+ <th class="text-start p-2">
+ A Record
+ </th>
+ <th class="text-start p-2">
+ Api Key
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ for _, domain := range domains {
+ <tr class="border">
+ <td>
+ <div class="p-2">
+ <span class="font-bold">{ domain.Domain }</span>
+ <span class="">{ getDomainPattern() }</span>
+ </div>
+ </td>
+ <td>
+ <div class="p-2">
+ <!-- TODO: Make this editable
+ And refresh the div with /manage/partials/domains
+ -->
+ <input class="border" value={ domain.A }/>
+ </div>
+ </td>
+ <td>
+ <div class="p-2">
+ <!-- TODO: Add refreshing -->
+ <input class="border" disabled type="password" value={ domain.ApiKey }
+ id={ fmt.Sprintf("domain_apikey_%s", domain.Id) }
+ />
+ <!-- TODO: Replace with one of those eye thingies -->
+ <input type="checkbox" onclick="toggleApiKeyVisibility(this.previousElementSibling.id)"/>
+ <span>show</span>
+ </div>
+ </td>
+ <td>
+ <div class="p-2">
+ <button class="border p-1" hx-confirm="Sure?"
+ hx-delete={ fmt.Sprintf("/manage/domains/%s", domain.Id) }
+ >
+ Delete
+ </button>
+ </div>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ }
+}
A components/managepartials_templ.go => components/managepartials_templ.go +191 -0
@@ 0,0 1,191 @@
+// Code generated by templ@(devel) DO NOT EDIT.
+
+package components
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+
+import "git.src.quest/~skye/felu-ddns/db"
+import "fmt"
+
+func ManagePartialDomains(domains []db.Domain) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_1 := templ.GetChildren(ctx)
+ if var_1 == nil {
+ var_1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ if len(domains) > 0 {
+ _, err = templBuffer.WriteString("<script>")
+ if err != nil {
+ return err
+ }
+ var_2 := `
+ function toggleApiKeyVisibility(inputId) {
+ var input = document.getElementById(inputId)
+ if (input.type === "password") {
+ input.type = "text";
+ } else {
+ input.type = "password";
+ }
+ }
+ `
+ _, err = templBuffer.WriteString(var_2)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</script> <table class=\"table-auto\"><thead><tr><th class=\"text-start p-2\">")
+ if err != nil {
+ return err
+ }
+ var_3 := `Domain`
+ _, err = templBuffer.WriteString(var_3)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</th><th class=\"text-start p-2\">")
+ if err != nil {
+ return err
+ }
+ var_4 := `A Record`
+ _, err = templBuffer.WriteString(var_4)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</th><th class=\"text-start p-2\">")
+ if err != nil {
+ return err
+ }
+ var_5 := `Api Key`
+ _, err = templBuffer.WriteString(var_5)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</th></tr></thead><tbody>")
+ if err != nil {
+ return err
+ }
+ for _, domain := range domains {
+ _, err = templBuffer.WriteString("<tr class=\"border\"><td><div class=\"p-2\"><span class=\"font-bold\">")
+ if err != nil {
+ return err
+ }
+ var var_6 string = domain.Domain
+ _, err = templBuffer.WriteString(templ.EscapeString(var_6))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</span><span class=\"\">")
+ if err != nil {
+ return err
+ }
+ var var_7 string = getDomainPattern()
+ _, err = templBuffer.WriteString(templ.EscapeString(var_7))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</span></div></td><td><div class=\"p-2\"><!--")
+ if err != nil {
+ return err
+ }
+ var_8 := ` TODO: Make this editable
+ And refresh the div with /manage/partials/domains
+ `
+ _, err = templBuffer.WriteString(var_8)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("--><input class=\"border\" value=\"")
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString(templ.EscapeString(domain.A))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("\"></div></td><td><div class=\"p-2\"><!--")
+ if err != nil {
+ return err
+ }
+ var_9 := ` TODO: Add refreshing `
+ _, err = templBuffer.WriteString(var_9)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("--><input class=\"border\" disabled type=\"password\" value=\"")
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString(templ.EscapeString(domain.ApiKey))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("\" id=\"")
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString(templ.EscapeString(fmt.Sprintf("domain_apikey_%s", domain.Id)))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("\"><!--")
+ if err != nil {
+ return err
+ }
+ var_10 := ` TODO: Replace with one of those eye thingies `
+ _, err = templBuffer.WriteString(var_10)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("--><input type=\"checkbox\" onclick=\"toggleApiKeyVisibility(this.previousElementSibling.id)\"><span>")
+ if err != nil {
+ return err
+ }
+ var_11 := `show`
+ _, err = templBuffer.WriteString(var_11)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</span></div></td><td><div class=\"p-2\"><button class=\"border p-1\" hx-confirm=\"Sure?\" hx-delete=\"")
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString(templ.EscapeString(fmt.Sprintf("/manage/domains/%s", domain.Id)))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("\">")
+ if err != nil {
+ return err
+ }
+ var_12 := `Delete`
+ _, err = templBuffer.WriteString(var_12)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("</button></div></td></tr>")
+ if err != nil {
+ return err
+ }
+ }
+ _, err = templBuffer.WriteString("</tbody></table>")
+ if err != nil {
+ return err
+ }
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
A db/domains.go => db/domains.go +64 -0
@@ 0,0 1,64 @@
+/*
+ * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
+ *
+ * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for
+ * more information.
+ */
+package db
+
+import (
+ "git.src.quest/~skye/felu-ddns/api"
+ "github.com/oklog/ulid/v2"
+)
+
+type Domain struct {
+ Id string
+ ApiKey string
+ Domain string
+ A string
+}
+
+func FetchDomainsForUser(userId string) ([]Domain, error) {
+ rows, err := DBConn.Query(`SELECT id, apikey, ddns_domain, a_record
+ FROM domains WHERE owner = $1`, userId)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var domains []Domain
+ for rows.Next() {
+ var domain Domain
+ err = rows.Scan(&domain.Id, &domain.ApiKey, &domain.Domain, &domain.A)
+ if err != nil {
+ return nil, err
+ }
+ domains = append(domains, domain)
+ }
+ err = rows.Err()
+ if err != nil {
+ return nil, err
+ }
+
+ return domains, nil
+}
+
+func CreateDomain(domain string, aRecord string, owner string) error {
+ ulid := ulid.Make().String()
+ apikey := api.GenKey()
+ _, err := DBConn.Exec(`INSERT INTO domains(id, apikey, ddns_domain, a_record, owner)
+ VALUES ($1, $2, $3, $4, $5)`, ulid, apikey, domain, aRecord, owner)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func DeleteDomain(id string) error {
+ _, err := DBConn.Exec(`DELETE FROM domains WHERE id = $1`, id)
+ if err != nil {
+ return err
+ }
+ return nil
+}
M felu.go => felu.go +6 -0
@@ 85,6 85,12 @@ func setupFrontendRouter() *gin.Engine {
manage.GET("/settings", func(c *gin.Context) {
c.HTML(http.StatusOK, "", components.ManageSettings())
})
+
+ manage.POST("/domains", handlers.PostDomain())
+ manage.PATCH("/domains/:id") // TODO:
+ manage.DELETE("/domains/:id", handlers.DeleteDomain())
+
+ manage.GET("/partials/domains", handlers.ManagePartialDomains())
}
manageAdmin := r.Group("/manage/admin").Use(
middlewares.SessionExists(sessionManager),
A handlers/managedomains.go => handlers/managedomains.go +76 -0
@@ 0,0 1,76 @@
+/*
+ * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
+ *
+ * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for
+ * more information.
+ */
+package handlers
+
+import (
+ "log"
+ "net"
+ "net/http"
+
+ "git.src.quest/~skye/felu-ddns/db"
+ "github.com/gin-gonic/gin"
+)
+
+type postDomainData struct {
+ Domain string `form:"domain"`
+ ARecord string `form:"a_record"`
+}
+
+func PostDomain() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ data := &postDomainData{}
+ if err := c.Bind(data); err != nil {
+ log.Printf("[felu] ERROR: Could not bind domain data: %v", err)
+ c.String(http.StatusBadRequest, "Could not bind domain data")
+ return
+ }
+
+ if data.Domain == "" {
+ c.String(http.StatusBadRequest, "Domain can't be empty")
+ c.Abort()
+ return
+ }
+ if net.ParseIP(data.ARecord).To4() == nil {
+ c.String(http.StatusBadRequest, "The A record is invalid")
+ c.Abort()
+ return
+ }
+
+ userId, exists := c.Get("user_id")
+ if !exists {
+ c.String(http.StatusInternalServerError, "This should not be possible, but don't quote me on that")
+ c.Abort()
+ return
+ }
+
+ err := db.CreateDomain(data.Domain, data.ARecord, userId.(string))
+ if err != nil {
+ // FIXME: Handle better
+ c.String(http.StatusInternalServerError, "Something went wrong while creating a new domain")
+ c.Abort()
+ return
+ }
+
+ c.Header("HX-Trigger", "update-domain-list")
+ }
+}
+
+func DeleteDomain() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ id := c.Param("id")
+
+ err := db.DeleteDomain(id)
+ if err != nil {
+ // FIXME: Handle better
+ c.String(http.StatusInternalServerError, "Something went wrong while creating deleting the domain")
+ c.Abort()
+ return
+ }
+
+ c.Header("HX-Trigger", "update-domain-list")
+ }
+}
A handlers/managepartials.go => handlers/managepartials.go +35 -0
@@ 0,0 1,35 @@
+/*
+ * Copyright (C) 2023 Jonni Liljamo <jonni@liljamo.com>
+ *
+ * This file is licensed under AGPL-3.0-or-later, see NOTICE and LICENSE for
+ * more information.
+ */
+package handlers
+
+import (
+ "net/http"
+
+ "git.src.quest/~skye/felu-ddns/components"
+ "git.src.quest/~skye/felu-ddns/db"
+ "github.com/gin-gonic/gin"
+)
+
+func ManagePartialDomains() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ user_id, exists := c.Get("user_id")
+ if !exists {
+ c.String(http.StatusInternalServerError, "This should not be possible, but don't quote me on that")
+ c.Abort()
+ return
+ }
+
+ domains, err := db.FetchDomainsForUser(user_id.(string))
+ if err != nil {
+ c.String(http.StatusInternalServerError, "Failed to fetch domains for user")
+ c.Abort()
+ return
+ }
+
+ c.HTML(http.StatusOK, "", components.ManagePartialDomains(domains))
+ }
+}
M static/styles.css => static/styles.css +9 -0
@@ 584,6 584,11 @@ video {
background-color: rgb(254 205 211 / var(--tw-bg-opacity));
}
+.bg-teal-200 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(153 246 228 / var(--tw-bg-opacity));
+}
+
.p-1 {
padding: 0.25rem;
}
@@ 609,6 614,10 @@ video {
line-height: 2.5rem;
}
+.font-bold {
+ font-weight: 700;
+}
+
.text-rose-600 {
--tw-text-opacity: 1;
color: rgb(225 29 72 / var(--tw-text-opacity));