From 243bb8e35ba5395739cb3fb74ce9cf8aca1591d5 Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Thu, 28 Mar 2024 00:27:12 -0600 Subject: [PATCH 1/3] initial commit --- adapters/cloudflare/cloudflare.go | 73 +++++++++++++++++++ api/dns.go | 112 +++++++++++++++++++++++++++++- api/serve.go | 14 +++- database/dns.go | 48 +++++++++++-- static/css/colors.css | 9 +++ static/css/form.css | 2 +- static/css/table.css | 7 +- templates/dns.html | 52 ++++++++++++-- 8 files changed, 299 insertions(+), 18 deletions(-) create mode 100644 adapters/cloudflare/cloudflare.go diff --git a/adapters/cloudflare/cloudflare.go b/adapters/cloudflare/cloudflare.go new file mode 100644 index 0000000..bfcbea6 --- /dev/null +++ b/adapters/cloudflare/cloudflare.go @@ -0,0 +1,73 @@ +package cloudflare + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + + "git.hatecomputers.club/hatecomputers/hatecomputers.club/database" +) + +type CloudflareDNSResponse struct { + Result database.DNSRecord `json:"result"` +} + +func CreateDNSRecord(zoneId string, apiToken string, record *database.DNSRecord) (string, error) { + url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneId) + + reqBody := fmt.Sprintf(`{"type":"%s","name":"%s","content":"%s","ttl":%d,"proxied":false}`, record.Type, record.Name, record.Content, record.TTL) + log.Println(reqBody) + payload := strings.NewReader(reqBody) + + req, _ := http.NewRequest("POST", url, payload) + + req.Header.Add("Authorization", "Bearer "+apiToken) + req.Header.Add("Content-Type", "application/json") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + if res.StatusCode != 200 { + return "", fmt.Errorf("error creating dns record: %s", body) + } + + var response CloudflareDNSResponse + err = json.Unmarshal(body, &response) + if err != nil { + return "", err + } + + result := &response.Result + + return result.ID, nil +} + +func DeleteDNSRecord(zoneId string, apiToken string, id string) error { + url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneId, id) + + req, _ := http.NewRequest("DELETE", url, nil) + + req.Header.Add("Authorization", "Bearer "+apiToken) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + + defer res.Body.Close() + body, _ := io.ReadAll(res.Body) + + if res.StatusCode != 200 { + return fmt.Errorf("error deleting dns record: %s", body) + } + + return nil +} diff --git a/api/dns.go b/api/dns.go index 3105f91..0822fbc 100644 --- a/api/dns.go +++ b/api/dns.go @@ -3,10 +3,21 @@ package api import ( "log" "net/http" + "strconv" + "strings" + "git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/cloudflare" "git.hatecomputers.club/hatecomputers/hatecomputers.club/database" ) +type FormError struct { + Errors []string +} + +func userCanFuckWithDNSRecord(user *database.User, record *database.DNSRecord) bool { + return user.ID == record.UserID && (record.Name == user.Username || strings.HasSuffix(record.Name, "."+user.Username)) +} + func ListDNSRecordsContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { return func(success Continuation, failure Continuation) ContinuationChain { dnsRecords, err := database.GetUserDNSRecords(context.DBConn, context.User.ID) @@ -17,7 +28,106 @@ func ListDNSRecordsContinuation(context *RequestContext, req *http.Request, resp } (*context.TemplateData)["DNSRecords"] = dnsRecords - + return success(context, req, resp) + } +} + +func CreateDNSRecordContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { + return func(success Continuation, failure Continuation) ContinuationChain { + formErrors := FormError{ + Errors: []string{}, + } + + name := req.FormValue("name") + recordType := req.FormValue("type") + recordContent := req.FormValue("content") + ttl := req.FormValue("ttl") + ttlNum, err := strconv.Atoi(ttl) + + dnsRecord := &database.DNSRecord{ + UserID: context.User.ID, + Name: name, + Type: recordType, + Content: recordContent, + TTL: ttlNum, + } + + if err != nil { + formErrors.Errors = append(formErrors.Errors, "invalid ttl") + } + + if !userCanFuckWithDNSRecord(context.User, dnsRecord) { + formErrors.Errors = append(formErrors.Errors, "'name' must end with "+context.User.Username) + } + + if len(formErrors.Errors) == 0 { + cloudflareRecordId, err := cloudflare.CreateDNSRecord(context.Args.CloudflareZone, context.Args.CloudflareToken, dnsRecord) + if err != nil { + log.Println(err) + formErrors.Errors = append(formErrors.Errors, err.Error()) + } + + dnsRecord.ID = cloudflareRecordId + } + + if len(formErrors.Errors) == 0 { + _, err := database.SaveDNSRecord(context.DBConn, dnsRecord) + if err != nil { + log.Println(err) + formErrors.Errors = append(formErrors.Errors, "error saving record") + } + } + + if len(formErrors.Errors) == 0 { + http.Redirect(resp, req, "/dns", http.StatusFound) + return success(context, req, resp) + } + + dnsRecords, err := database.GetUserDNSRecords(context.DBConn, context.User.ID) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + (*context.TemplateData)["DNSRecords"] = dnsRecords + (*context.TemplateData)["FormError"] = &formErrors + (*context.TemplateData)["RecordForm"] = dnsRecord + + resp.WriteHeader(http.StatusBadRequest) + return failure(context, req, resp) + } +} + +func DeleteDNSRecordContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { + return func(success Continuation, failure Continuation) ContinuationChain { + recordId := req.FormValue("id") + record, err := database.GetDNSRecord(context.DBConn, recordId) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + if !userCanFuckWithDNSRecord(context.User, record) { + resp.WriteHeader(http.StatusUnauthorized) + return failure(context, req, resp) + } + + err = cloudflare.DeleteDNSRecord(context.Args.CloudflareZone, context.Args.CloudflareToken, recordId) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + err = database.DeleteDNSRecord(context.DBConn, recordId) + if err != nil { + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + http.Redirect(resp, req, "/dns", http.StatusFound) return success(context, req, resp) } } diff --git a/api/serve.go b/api/serve.go index 38b65b2..09e2072 100644 --- a/api/serve.go +++ b/api/serve.go @@ -70,7 +70,7 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { mux := http.NewServeMux() fileServer := http.FileServer(http.Dir(argv.StaticPath)) - mux.Handle("/static/", http.StripPrefix("/static/", fileServer)) + mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer)) makeRequestContext := func() *RequestContext { return &RequestContext{ @@ -81,7 +81,7 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { } } - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { requestContext := makeRequestContext() LogRequestContinuation(requestContext, r, w)(VerifySessionContinuation, FailurePassingContinuation)(IdContinuation, IdContinuation)(TemplateContinuation("home.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) @@ -116,6 +116,16 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { LogRequestContinuation(requestContext, r, w)(VerifySessionContinuation, FailurePassingContinuation)(ListDNSRecordsContinuation, GoLoginContinuation)(TemplateContinuation("dns.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) + mux.HandleFunc("POST /dns", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + LogRequestContinuation(requestContext, r, w)(VerifySessionContinuation, FailurePassingContinuation)(CreateDNSRecordContinuation, GoLoginContinuation)(IdContinuation, TemplateContinuation("dns.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("POST /dns/delete", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + LogRequestContinuation(requestContext, r, w)(VerifySessionContinuation, FailurePassingContinuation)(DeleteDNSRecordContinuation, GoLoginContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + mux.HandleFunc("GET /{name}", func(w http.ResponseWriter, r *http.Request) { requestContext := makeRequestContext() name := r.PathValue("name") diff --git a/database/dns.go b/database/dns.go index 17487b7..bb5c1ef 100644 --- a/database/dns.go +++ b/database/dns.go @@ -8,13 +8,13 @@ import ( ) type DNSRecord struct { - ID string - UserID string - Name string - Type string - Content string - TTL int - CreatedAt time.Time + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL int `json:"ttl"` + CreatedAt time.Time `json:"created_at"` } func GetUserDNSRecords(db *sql.DB, userID string) ([]DNSRecord, error) { @@ -38,3 +38,37 @@ func GetUserDNSRecords(db *sql.DB, userID string) ([]DNSRecord, error) { return records, nil } + +func SaveDNSRecord(db *sql.DB, record *DNSRecord) (*DNSRecord, error) { + log.Println("saving dns record", record) + + record.CreatedAt = time.Now() + _, err := db.Exec("INSERT OR REPLACE INTO dns_records (id, user_id, name, type, content, ttl, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", record.ID, record.UserID, record.Name, record.Type, record.Content, record.TTL, record.CreatedAt) + + if err != nil { + return nil, err + } + return record, nil +} + +func GetDNSRecord(db *sql.DB, recordID string) (*DNSRecord, error) { + log.Println("getting dns record", recordID) + + row := db.QueryRow("SELECT * FROM dns_records WHERE id = ?", recordID) + var record DNSRecord + err := row.Scan(&record.ID, &record.UserID, &record.Name, &record.Type, &record.Content, &record.TTL, &record.CreatedAt) + if err != nil { + return nil, err + } + return &record, nil +} + +func DeleteDNSRecord(db *sql.DB, recordID string) error { + log.Println("deleting dns record", recordID) + + _, err := db.Exec("DELETE FROM dns_records WHERE id = ?", recordID) + if err != nil { + return err + } + return nil +} diff --git a/static/css/colors.css b/static/css/colors.css index 69e3e4b..c68bf8e 100644 --- a/static/css/colors.css +++ b/static/css/colors.css @@ -5,6 +5,7 @@ --link-color-light: #d291bc; --container-bg-light: #fff7f87a; --border-color-light: #692fcc; + --error-color-light: #a83254; --background-color-dark: #333; --background-color-dark-2: #2c2c2c; @@ -12,6 +13,7 @@ --link-color-dark: #b86b77; --container-bg-dark: #424242ea; --border-color-dark: #956ade; + --error-color-dark: #851736; } [data-theme="DARK"] { @@ -21,6 +23,7 @@ --link-color: var(--link-color-dark); --container-bg: var(--container-bg-dark); --border-color: var(--border-color-dark); + --error-color: var(--error-color-dark); } [data-theme="LIGHT"] { @@ -30,4 +33,10 @@ --link-color: var(--link-color-light); --container-bg: var(--container-bg-light); --border-color: var(--border-color-light); + --error-color: var(--error-color-light); +} + +.error { + background-color: var(--error-color); + padding: 1rem; } diff --git a/static/css/form.css b/static/css/form.css index 4e14b68..1378d75 100644 --- a/static/css/form.css +++ b/static/css/form.css @@ -1,4 +1,4 @@ -form { +.form { max-width: 600px; padding: 1em; background: var(--background-color-2); diff --git a/static/css/table.css b/static/css/table.css index 640ad83..75a961d 100644 --- a/static/css/table.css +++ b/static/css/table.css @@ -11,8 +11,13 @@ td { border-bottom: 1px solid var(--border-color); } +th, +thead { + background-color: var(--background-color-2); +} + tbody tr:nth-child(odd) { - background-color: var(--link-color); + background-color: var(--background-color); color: var(--text-color); } diff --git a/templates/dns.html b/templates/dns.html index 0a40cab..e317d05 100644 --- a/templates/dns.html +++ b/templates/dns.html @@ -5,10 +5,11 @@ Name Content TTL + Delete {{ if (eq (len .DNSRecords) 0) }} - No DNS records found + No DNS records found {{ end }} {{ range $record := .DNSRecords }} @@ -17,21 +18,60 @@ {{ $record.Name }} {{ $record.Content }} {{ $record.TTL }} + +
+ + +
+ {{ end }}
-
+

Add DNS Records

+

note that the name must be a subdomain of {{ .User.Username }}


- + - + - + - +
+ + {{ if .FormError }} + {{ if (len .FormError.Errors) }} + {{ range $error := .FormError.Errors }} +
{{ $error }}
+ {{ end }} + {{ end }} + {{ end }} {{ end }} -- 2.40.1 From 48f124f2723617f0b4eb570ff987b1abd8d41bbe Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Thu, 28 Mar 2024 10:53:30 -0600 Subject: [PATCH 2/3] add api keys and route --- adapters/cloudflare/cloudflare.go | 2 - api/api_keys.go | 84 +++++++++++++++++++++++++++++++ api/auth.go | 74 ++++++++++++++++++++------- api/dns.go | 20 +++++--- api/serve.go | 15 ++++++ database/users.go | 63 +++++++++++++++++++++++ templates/api_keys.html | 40 +++++++++++++++ templates/base.html | 3 ++ utils/RandomId.go | 9 ++-- 9 files changed, 276 insertions(+), 34 deletions(-) create mode 100644 api/api_keys.go create mode 100644 templates/api_keys.html diff --git a/adapters/cloudflare/cloudflare.go b/adapters/cloudflare/cloudflare.go index bfcbea6..40b04a5 100644 --- a/adapters/cloudflare/cloudflare.go +++ b/adapters/cloudflare/cloudflare.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" "strings" @@ -19,7 +18,6 @@ func CreateDNSRecord(zoneId string, apiToken string, record *database.DNSRecord) url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneId) reqBody := fmt.Sprintf(`{"type":"%s","name":"%s","content":"%s","ttl":%d,"proxied":false}`, record.Type, record.Name, record.Content, record.TTL) - log.Println(reqBody) payload := strings.NewReader(reqBody) req, _ := http.NewRequest("POST", url, payload) diff --git a/api/api_keys.go b/api/api_keys.go new file mode 100644 index 0000000..17ed6c9 --- /dev/null +++ b/api/api_keys.go @@ -0,0 +1,84 @@ +package api + +import ( + "log" + "net/http" + + "git.hatecomputers.club/hatecomputers/hatecomputers.club/database" + "git.hatecomputers.club/hatecomputers/hatecomputers.club/utils" +) + +const MAX_USER_API_KEYS = 5 + +func ListAPIKeysContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { + return func(success Continuation, failure Continuation) ContinuationChain { + apiKeys, err := database.ListUserAPIKeys(context.DBConn, context.User.ID) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + (*context.TemplateData)["APIKeys"] = apiKeys + return success(context, req, resp) + } +} + +func CreateAPIKeyContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { + return func(success Continuation, failure Continuation) ContinuationChain { + formErrors := FormError{ + Errors: []string{}, + } + + apiKeys, err := database.ListUserAPIKeys(context.DBConn, context.User.ID) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + if len(apiKeys) >= MAX_USER_API_KEYS { + formErrors.Errors = append(formErrors.Errors, "max api keys reached") + } + + _, err = database.SaveAPIKey(context.DBConn, &database.UserApiKey{ + UserID: context.User.ID, + Key: utils.RandomId(), + }) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + http.Redirect(resp, req, "/keys", http.StatusFound) + return success(context, req, resp) + } +} + +func DeleteAPIKeyContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { + return func(success Continuation, failure Continuation) ContinuationChain { + key := req.FormValue("key") + + apiKey, err := database.GetAPIKey(context.DBConn, key) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + if (apiKey == nil) || (apiKey.UserID != context.User.ID) { + resp.WriteHeader(http.StatusUnauthorized) + return failure(context, req, resp) + } + + err = database.DeleteAPIKey(context.DBConn, key) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + http.Redirect(resp, req, "/keys", http.StatusFound) + return success(context, req, resp) + } +} diff --git a/api/auth.go b/api/auth.go index 4733971..dcddf5a 100644 --- a/api/auth.go +++ b/api/auth.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/base64" "encoding/json" + "fmt" "io" "log" "net/http" @@ -116,32 +117,69 @@ func InterceptCodeContinuation(context *RequestContext, req *http.Request, resp } } +func getUserFromAuthHeader(dbConn *sql.DB, bearerToken string) (*database.User, error) { + if bearerToken == "" { + return nil, nil + } + + parts := strings.Split(bearerToken, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, nil + } + + apiKey, err := database.GetAPIKey(dbConn, parts[1]) + if err != nil { + return nil, err + } + if apiKey == nil { + return nil, nil + } + + user, err := database.GetUser(dbConn, apiKey.UserID) + if err != nil { + return nil, err + } + + return user, nil +} + +func getUserFromSession(dbConn *sql.DB, sessionId string) (*database.User, error) { + session, err := database.GetSession(dbConn, sessionId) + if err != nil { + return nil, err + } + + if session.ExpireAt.Before(time.Now()) { + session = nil + database.DeleteSession(dbConn, sessionId) + return nil, fmt.Errorf("session expired") + } + + user, err := database.GetUser(dbConn, session.UserID) + if err != nil { + return nil, err + } + + return user, nil +} + func VerifySessionContinuation(context *RequestContext, req *http.Request, resp http.ResponseWriter) ContinuationChain { return func(success Continuation, failure Continuation) ContinuationChain { + authHeader := req.Header.Get("Authorization") + user, userErr := getUserFromAuthHeader(context.DBConn, authHeader) + sessionCookie, err := req.Cookie("session") - if err != nil { - resp.WriteHeader(http.StatusUnauthorized) - return failure(context, req, resp) + if err == nil { + user, userErr = getUserFromSession(context.DBConn, sessionCookie.Value) } - session, err := database.GetSession(context.DBConn, sessionCookie.Value) - if err == nil && session.ExpireAt.Before(time.Now()) { - session = nil - database.DeleteSession(context.DBConn, sessionCookie.Value) - } - if err != nil || session == nil { + if userErr != nil || user == nil { + log.Println(userErr, user) + http.SetCookie(resp, &http.Cookie{ Name: "session", - MaxAge: 0, + MaxAge: 0, // reset session cookie in case }) - - return failure(context, req, resp) - } - - user, err := database.GetUser(context.DBConn, session.UserID) - if err != nil { - log.Println(err) - resp.WriteHeader(http.StatusUnauthorized) return failure(context, req, resp) } diff --git a/api/dns.go b/api/dns.go index 0822fbc..5123acc 100644 --- a/api/dns.go +++ b/api/dns.go @@ -10,6 +10,8 @@ import ( "git.hatecomputers.club/hatecomputers/hatecomputers.club/database" ) +const MAX_USER_RECORDS = 20 + type FormError struct { Errors []string } @@ -43,6 +45,9 @@ func CreateDNSRecordContinuation(context *RequestContext, req *http.Request, res recordContent := req.FormValue("content") ttl := req.FormValue("ttl") ttlNum, err := strconv.Atoi(ttl) + if err != nil { + formErrors.Errors = append(formErrors.Errors, "invalid ttl") + } dnsRecord := &database.DNSRecord{ UserID: context.User.ID, @@ -52,8 +57,14 @@ func CreateDNSRecordContinuation(context *RequestContext, req *http.Request, res TTL: ttlNum, } + dnsRecords, err := database.GetUserDNSRecords(context.DBConn, context.User.ID) if err != nil { - formErrors.Errors = append(formErrors.Errors, "invalid ttl") + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + if len(dnsRecords) >= MAX_USER_RECORDS { + formErrors.Errors = append(formErrors.Errors, "max records reached") } if !userCanFuckWithDNSRecord(context.User, dnsRecord) { @@ -83,13 +94,6 @@ func CreateDNSRecordContinuation(context *RequestContext, req *http.Request, res return success(context, req, resp) } - dnsRecords, err := database.GetUserDNSRecords(context.DBConn, context.User.ID) - if err != nil { - log.Println(err) - resp.WriteHeader(http.StatusInternalServerError) - return failure(context, req, resp) - } - (*context.TemplateData)["DNSRecords"] = dnsRecords (*context.TemplateData)["FormError"] = &formErrors (*context.TemplateData)["RecordForm"] = dnsRecord diff --git a/api/serve.go b/api/serve.go index 09e2072..d16ea99 100644 --- a/api/serve.go +++ b/api/serve.go @@ -126,6 +126,21 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { LogRequestContinuation(requestContext, r, w)(VerifySessionContinuation, FailurePassingContinuation)(DeleteDNSRecordContinuation, GoLoginContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) + mux.HandleFunc("GET /keys", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + LogRequestContinuation(requestContext, r, w)(VerifySessionContinuation, FailurePassingContinuation)(ListAPIKeysContinuation, GoLoginContinuation)(TemplateContinuation("api_keys.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("POST /keys", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + LogRequestContinuation(requestContext, r, w)(VerifySessionContinuation, FailurePassingContinuation)(CreateAPIKeyContinuation, GoLoginContinuation)(IdContinuation, TemplateContinuation("api_keys.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("POST /keys/delete", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + LogRequestContinuation(requestContext, r, w)(VerifySessionContinuation, FailurePassingContinuation)(DeleteAPIKeyContinuation, GoLoginContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + mux.HandleFunc("GET /{name}", func(w http.ResponseWriter, r *http.Request) { requestContext := makeRequestContext() name := r.PathValue("name") diff --git a/database/users.go b/database/users.go index d2b4f20..f9e4436 100644 --- a/database/users.go +++ b/database/users.go @@ -12,6 +12,12 @@ const ( ExpiryDuration = time.Hour * 24 ) +type UserApiKey struct { + Key string `json:"key"` + UserID string `json:"user_id"` + CreatedAt time.Time `json:"created_at"` +} + type User struct { ID string `json:"sub"` Mail string `json:"email"` @@ -119,3 +125,60 @@ func DeleteExpiredSessions(dbConn *sql.DB) error { } return nil } + +func ListUserAPIKeys(dbConn *sql.DB, userId string) ([]*UserApiKey, error) { + rows, err := dbConn.Query(`SELECT key, user_id, created_at FROM api_keys WHERE user_id = ?;`, userId) + if err != nil { + log.Println(err) + return nil, err + } + defer rows.Close() + + var apiKeys []*UserApiKey + for rows.Next() { + var apiKey UserApiKey + err := rows.Scan(&apiKey.Key, &apiKey.UserID, &apiKey.CreatedAt) + if err != nil { + log.Println(err) + return nil, err + } + + apiKeys = append(apiKeys, &apiKey) + } + + return apiKeys, nil +} + +func SaveAPIKey(dbConn *sql.DB, apiKey *UserApiKey) (*UserApiKey, error) { + _, err := dbConn.Exec(`INSERT OR REPLACE INTO api_keys (key, user_id) VALUES (?, ?);`, apiKey.Key, apiKey.UserID) + if err != nil { + log.Println(err) + return nil, err + } + + apiKey.CreatedAt = time.Now() + return apiKey, nil +} + +func GetAPIKey(dbConn *sql.DB, key string) (*UserApiKey, error) { + row := dbConn.QueryRow(`SELECT key, user_id, created_at FROM api_keys WHERE key = ?;`, key) + + var apiKey UserApiKey + err := row.Scan(&apiKey.Key, &apiKey.UserID, &apiKey.CreatedAt) + if err != nil { + log.Println(err) + return nil, err + } + + return &apiKey, nil +} + +func DeleteAPIKey(dbConn *sql.DB, key string) error { + _, err := dbConn.Exec(`DELETE FROM api_keys WHERE key = ?;`, key) + if err != nil { + log.Println(err) + return err + } + + return nil +} diff --git a/templates/api_keys.html b/templates/api_keys.html new file mode 100644 index 0000000..93eebd5 --- /dev/null +++ b/templates/api_keys.html @@ -0,0 +1,40 @@ +{{ define "content" }} + + + + + + + {{ if (eq (len .APIKeys) 0) }} + + + + {{ end }} + {{ range $key := .APIKeys }} + + + + + + {{ end }} +
KeyCreated AtRevoke
No API Keys Found
{{ $key.Key }}{{ $key.CreatedAt }} +
+ + +
+
+
+
+

Add An API Key

+
+ +
+ + {{ if .FormError }} + {{ if (len .FormError.Errors) }} + {{ range $error := .FormError.Errors }} +
{{ $error }}
+ {{ end }} + {{ end }} + {{ end }} +{{ end }} diff --git a/templates/base.html b/templates/base.html index 79e0d12..d0f97c7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -35,7 +35,10 @@ {{ if .User }} dns. | + api keys. + | logout, {{ .User.DisplayName }}. + {{ else }} login. {{ end }} diff --git a/utils/RandomId.go b/utils/RandomId.go index 09f089d..1b03ec8 100644 --- a/utils/RandomId.go +++ b/utils/RandomId.go @@ -6,14 +6,11 @@ import ( ) func RandomId() string { - uuid := make([]byte, 16) - _, err := rand.Read(uuid) + id := make([]byte, 16) + _, err := rand.Read(id) if err != nil { panic(err) } - uuid[8] = uuid[8]&^0xc0 | 0x80 - uuid[6] = uuid[6]&^0xf0 | 0x40 - - return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]) + return fmt.Sprintf("%x", id) } -- 2.40.1 From 6fcf4ef8723bbe407d4a80dd9a3daf76bb6ba7dd Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Thu, 28 Mar 2024 10:56:13 -0600 Subject: [PATCH 3/3] one moar blinky --- static/img/blinkies/capitalism.gif | Bin 0 -> 20828 bytes templates/base.html | 1 + 2 files changed, 1 insertion(+) create mode 100644 static/img/blinkies/capitalism.gif diff --git a/static/img/blinkies/capitalism.gif b/static/img/blinkies/capitalism.gif new file mode 100644 index 0000000000000000000000000000000000000000..b851831b5376dc4519fb3d34419baa34a5385fdc GIT binary patch literal 20828 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%0D_q3u!UM=cIM?(&`F?%?ba1a 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 literal 0 HcmV?d00001 diff --git a/templates/base.html b/templates/base.html index d0f97c7..9f5a903 100644 --- a/templates/base.html +++ b/templates/base.html @@ -77,6 +77,7 @@ + -- 2.40.1