From a32026d0133b868840d97b6b573310143c897269 Mon Sep 17 00:00:00 2001 From: Elizabeth Date: Tue, 9 Apr 2024 16:29:52 -0600 Subject: [PATCH] add error and success messages, finish profile validation --- .gitignore | 1 + api/auth/auth.go | 1 + api/dns/dns.go | 86 ++++++++------- api/guestbook/guestbook.go | 104 +++++++++--------- api/hcaptcha/hcaptcha.go | 4 +- api/keys/keys.go | 20 +++- api/profiles/profiles.go | 93 ++++++++++------ api/serve.go | 6 +- api/types/types.go | 4 +- database/migrate.go | 2 + database/users.go | 37 ++++--- static/css/club.css | 48 ++++++++ static/css/colors.css | 11 +- static/css/form.css | 3 + static/css/guestbook.css | 1 + static/css/styles.css | 36 +++++- static/js/components/infoBanners.js | 6 + static/js/script.js | 1 + templates/api_keys.html | 7 -- templates/base.html | 11 ++ templates/dns.html | 14 +-- templates/guestbook.html | 9 -- templates/home.html | 19 +++- templates/profile.html | 21 ++-- .../f54386d9-f310-4c5a-a3f9-e950320479f7 | Bin 29171 -> 0 bytes 25 files changed, 338 insertions(+), 207 deletions(-) create mode 100644 static/css/club.css create mode 100644 static/js/components/infoBanners.js delete mode 100644 uploads/avatars/f54386d9-f310-4c5a-a3f9-e950320479f7 diff --git a/.gitignore b/.gitignore index c7bbdba..12a6077 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env hatecomputers.club *.db +uploads diff --git a/api/auth/auth.go b/api/auth/auth.go index c54aad6..04d6c12 100644 --- a/api/auth/auth.go +++ b/api/auth/auth.go @@ -170,6 +170,7 @@ func VerifySessionContinuation(context *types.RequestContext, req *http.Request, func GoLoginContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + log.Println("GoLoginContinuation") http.SetCookie(resp, &http.Cookie{ Name: "redirect", Value: req.URL.Path, diff --git a/api/dns/dns.go b/api/dns/dns.go index bf91994..6357dfc 100644 --- a/api/dns/dns.go +++ b/api/dns/dns.go @@ -18,34 +18,6 @@ const MaxUserRecords = 100 var UserOwnedInternalFmtDomains = []string{"%s", "%s.endpoints"} -func userCanFuckWithDNSRecord(dbConn *sql.DB, user *database.User, record *database.DNSRecord, ownedInternalDomainFormats []string) bool { - ownedByUser := (user.ID == record.UserID) - if !ownedByUser { - return false - } - - if !record.Internal { - for _, format := range ownedInternalDomainFormats { - domain := fmt.Sprintf(format, user.Username) - - isInSubDomain := strings.HasSuffix(record.Name, "."+domain) - if domain == record.Name || isInSubDomain { - return true - } - } - return false - } - - owner, err := database.FindFirstDomainOwnerId(dbConn, record.Name) - if err != nil { - log.Println(err) - return false - } - - userIsOwnerOfDomain := owner == user.ID - return ownedByUser && userIsOwnerOfDomain -} - func ListDNSRecordsContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { dnsRecords, err := database.GetUserDNSRecords(context.DBConn, context.User.ID) @@ -63,8 +35,8 @@ func ListDNSRecordsContinuation(context *types.RequestContext, req *http.Request func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, maxUserRecords int, allowedUserDomainFormats []string) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { - formErrors := types.FormError{ - Errors: []string{}, + formErrors := types.BannerMessages{ + Messages: []string{}, } internal := req.FormValue("internal") == "on" || req.FormValue("internal") == "true" @@ -81,7 +53,7 @@ func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, max ttlNum, err := strconv.Atoi(ttl) if err != nil { resp.WriteHeader(http.StatusBadRequest) - formErrors.Errors = append(formErrors.Errors, "invalid ttl") + formErrors.Messages = append(formErrors.Messages, "invalid ttl") } dnsRecordCount, err := database.CountUserDNSRecords(context.DBConn, context.User.ID) @@ -92,7 +64,7 @@ func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, max } if dnsRecordCount >= maxUserRecords { resp.WriteHeader(http.StatusTooManyRequests) - formErrors.Errors = append(formErrors.Errors, "max records reached") + formErrors.Messages = append(formErrors.Messages, "max records reached") } dnsRecord := &database.DNSRecord{ @@ -106,10 +78,10 @@ func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, max if !userCanFuckWithDNSRecord(context.DBConn, context.User, dnsRecord, allowedUserDomainFormats) { resp.WriteHeader(http.StatusUnauthorized) - formErrors.Errors = append(formErrors.Errors, "'name' must end with "+context.User.Username+" or you must be a domain owner for internal domains") + formErrors.Messages = append(formErrors.Messages, "'name' must end with "+context.User.Username+" or you must be a domain owner for internal domains") } - if len(formErrors.Errors) == 0 { + if len(formErrors.Messages) == 0 { if dnsRecord.Internal { dnsRecord.ID = utils.RandomId() } else { @@ -117,24 +89,28 @@ func CreateDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter, max if err != nil { log.Println(err) resp.WriteHeader(http.StatusInternalServerError) - formErrors.Errors = append(formErrors.Errors, err.Error()) + formErrors.Messages = append(formErrors.Messages, err.Error()) } } } - if len(formErrors.Errors) == 0 { + if len(formErrors.Messages) == 0 { _, err := database.SaveDNSRecord(context.DBConn, dnsRecord) if err != nil { log.Println(err) - formErrors.Errors = append(formErrors.Errors, "error saving record") + formErrors.Messages = append(formErrors.Messages, "error saving record") } } - if len(formErrors.Errors) == 0 { + if len(formErrors.Messages) == 0 { + formSuccess := types.BannerMessages{ + Messages: []string{"record added."}, + } + (*context.TemplateData)["Success"] = formSuccess return success(context, req, resp) } - (*context.TemplateData)["FormError"] = &formErrors + (*context.TemplateData)[""] = &formErrors (*context.TemplateData)["RecordForm"] = dnsRecord return failure(context, req, resp) } @@ -172,7 +148,39 @@ func DeleteDNSRecordContinuation(dnsAdapter external_dns.ExternalDNSAdapter) fun return failure(context, req, resp) } + formSuccess := types.BannerMessages{ + Messages: []string{"record deleted."}, + } + (*context.TemplateData)["Success"] = formSuccess return success(context, req, resp) } } } + +func userCanFuckWithDNSRecord(dbConn *sql.DB, user *database.User, record *database.DNSRecord, ownedInternalDomainFormats []string) bool { + ownedByUser := (user.ID == record.UserID) + if !ownedByUser { + return false + } + + if !record.Internal { + for _, format := range ownedInternalDomainFormats { + domain := fmt.Sprintf(format, user.Username) + + isInSubDomain := strings.HasSuffix(record.Name, "."+domain) + if domain == record.Name || isInSubDomain { + return true + } + } + return false + } + + owner, err := database.FindFirstDomainOwnerId(dbConn, record.Name) + if err != nil { + log.Println(err) + return false + } + + userIsOwnerOfDomain := owner == user.ID + return ownedByUser && userIsOwnerOfDomain +} diff --git a/api/guestbook/guestbook.go b/api/guestbook/guestbook.go index 60a7b4b..c0c7892 100644 --- a/api/guestbook/guestbook.go +++ b/api/guestbook/guestbook.go @@ -10,6 +10,60 @@ import ( "git.hatecomputers.club/hatecomputers/hatecomputers.club/utils" ) +func SignGuestbookContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + name := req.FormValue("name") + message := req.FormValue("message") + + formErrors := types.BannerMessages{ + Messages: []string{}, + } + + entry := &database.GuestbookEntry{ + ID: utils.RandomId(), + Name: name, + Message: message, + } + formErrors.Messages = append(formErrors.Messages, validateGuestbookEntry(entry)...) + + if len(formErrors.Messages) == 0 { + _, err := database.SaveGuestbookEntry(context.DBConn, entry) + if err != nil { + log.Println(err) + formErrors.Messages = append(formErrors.Messages, "failed to save entry") + } + } + + if len(formErrors.Messages) > 0 { + (*context.TemplateData)["Error"] = formErrors + (*context.TemplateData)["EntryForm"] = entry + resp.WriteHeader(http.StatusBadRequest) + + return failure(context, req, resp) + } + + formSuccess := types.BannerMessages{ + Messages: []string{"entry added."}, + } + (*context.TemplateData)["Success"] = formSuccess + return success(context, req, resp) + } +} + +func ListGuestbookContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + entries, err := database.GetGuestbookEntries(context.DBConn) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + (*context.TemplateData)["GuestbookEntries"] = entries + return success(context, req, resp) + } +} + func validateGuestbookEntry(entry *database.GuestbookEntry) []string { errors := []string{} @@ -33,53 +87,3 @@ func validateGuestbookEntry(entry *database.GuestbookEntry) []string { return errors } - -func SignGuestbookContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { - return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { - name := req.FormValue("name") - message := req.FormValue("message") - - formErrors := types.FormError{ - Errors: []string{}, - } - - entry := &database.GuestbookEntry{ - ID: utils.RandomId(), - Name: name, - Message: message, - } - formErrors.Errors = append(formErrors.Errors, validateGuestbookEntry(entry)...) - - if len(formErrors.Errors) == 0 { - _, err := database.SaveGuestbookEntry(context.DBConn, entry) - if err != nil { - log.Println(err) - formErrors.Errors = append(formErrors.Errors, "failed to save entry") - } - } - - if len(formErrors.Errors) > 0 { - (*context.TemplateData)["FormError"] = formErrors - (*context.TemplateData)["EntryForm"] = entry - resp.WriteHeader(http.StatusBadRequest) - - return failure(context, req, resp) - } - - return success(context, req, resp) - } -} - -func ListGuestbookContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { - return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { - entries, err := database.GetGuestbookEntries(context.DBConn) - if err != nil { - log.Println(err) - resp.WriteHeader(http.StatusInternalServerError) - return failure(context, req, resp) - } - - (*context.TemplateData)["GuestbookEntries"] = entries - return success(context, req, resp) - } -} diff --git a/api/hcaptcha/hcaptcha.go b/api/hcaptcha/hcaptcha.go index 007190d..e8ea238 100644 --- a/api/hcaptcha/hcaptcha.go +++ b/api/hcaptcha/hcaptcha.go @@ -62,8 +62,8 @@ func CaptchaVerificationContinuation(context *types.RequestContext, req *http.Re err := verifyCaptcha(secretKey, hCaptchaResponse) if err != nil { - (*context.TemplateData)["FormError"] = types.FormError{ - Errors: []string{"hCaptcha verification failed"}, + (*context.TemplateData)["Error"] = types.BannerMessages{ + Messages: []string{"hCaptcha verification failed"}, } resp.WriteHeader(http.StatusBadRequest) diff --git a/api/keys/keys.go b/api/keys/keys.go index cef3f3c..7702f3d 100644 --- a/api/keys/keys.go +++ b/api/keys/keys.go @@ -27,8 +27,8 @@ func ListAPIKeysContinuation(context *types.RequestContext, req *http.Request, r func CreateAPIKeyContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { - formErrors := types.FormError{ - Errors: []string{}, + formErrors := types.BannerMessages{ + Messages: []string{}, } numKeys, err := database.CountUserAPIKeys(context.DBConn, context.User.ID) @@ -39,11 +39,11 @@ func CreateAPIKeyContinuation(context *types.RequestContext, req *http.Request, } if numKeys >= MAX_USER_API_KEYS { - formErrors.Errors = append(formErrors.Errors, "max types keys reached") + formErrors.Messages = append(formErrors.Messages, "max types keys reached") } - if len(formErrors.Errors) > 0 { - (*context.TemplateData)["FormError"] = formErrors + if len(formErrors.Messages) > 0 { + (*context.TemplateData)["Error"] = formErrors return failure(context, req, resp) } @@ -56,6 +56,11 @@ func CreateAPIKeyContinuation(context *types.RequestContext, req *http.Request, resp.WriteHeader(http.StatusInternalServerError) return failure(context, req, resp) } + + formSuccess := types.BannerMessages{ + Messages: []string{"key created."}, + } + (*context.TemplateData)["Success"] = formSuccess return success(context, req, resp) } } @@ -82,6 +87,11 @@ func DeleteAPIKeyContinuation(context *types.RequestContext, req *http.Request, return failure(context, req, resp) } + formSuccess := types.BannerMessages{ + Messages: []string{"key deleted."}, + } + (*context.TemplateData)["Success"] = formSuccess + return success(context, req, resp) } } diff --git a/api/profiles/profiles.go b/api/profiles/profiles.go index c46d2a2..8e10e5f 100644 --- a/api/profiles/profiles.go +++ b/api/profiles/profiles.go @@ -3,6 +3,7 @@ package profiles import ( "log" "net/http" + "strings" "git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/files" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/types" @@ -27,71 +28,91 @@ func GetProfileContinuation(context *types.RequestContext, req *http.Request, re func UpdateProfileContinuation(fileAdapter files.FilesAdapter, maxAvatarSize int, avatarPath string, avatarPrefix string) func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { - formErrors := types.FormError{ - Errors: []string{}, + formErrors := types.BannerMessages{ + Messages: []string{}, } err := req.ParseMultipartForm(int64(maxAvatarSize)) if err != nil { - log.Println(err) - - formErrors.Errors = append(formErrors.Errors, "avatar file too large") + formErrors.Messages = append(formErrors.Messages, "avatar file too large") } - if len(formErrors.Errors) == 0 { + if len(formErrors.Messages) == 0 { file, _, err := req.FormFile("avatar") - if file == nil { - formErrors.Errors = append(formErrors.Errors, "avatar required") - } - if err != nil { - formErrors.Errors = append(formErrors.Errors, "error uploading avatar") - } else { + if file != nil && err != nil { + formErrors.Messages = append(formErrors.Messages, "error uploading avatar") + } else if file != nil { defer file.Close() + reader := http.MaxBytesReader(resp, file, int64(maxAvatarSize)) + defer reader.Close() - _, err = fileAdapter.CreateFile(avatarPath+context.User.ID, file) + _, err = fileAdapter.CreateFile(avatarPath+context.User.ID, reader) if err != nil { log.Println(err) - formErrors.Errors = append(formErrors.Errors, "error saving avatar") + formErrors.Messages = append(formErrors.Messages, "error saving avatar (is it too big?)") } } } - bio := req.FormValue("bio") - location := req.FormValue("location") - website := req.FormValue("website") - if len(bio) > 128 { - formErrors.Errors = append(formErrors.Errors, "bio too long, keep it to 128") - } - if len(location) > 32 { - formErrors.Errors = append(formErrors.Errors, "location too long, keep it to 32") - } - if len(website) > 64 { - formErrors.Errors = append(formErrors.Errors, "website too long, keep it to 64") - } - - if len(formErrors.Errors) == 0 { - context.User.Bio = bio - context.User.Location = location - context.User.Website = website - context.User.Avatar = avatarPrefix + context.User.ID + context.User.Bio = strings.Trim(req.FormValue("bio"), "\n") + context.User.Pronouns = req.FormValue("pronouns") + context.User.Location = req.FormValue("location") + context.User.Website = req.FormValue("website") + context.User.Avatar = avatarPrefix + context.User.ID + formErrors.Messages = append(formErrors.Messages, validateProfileUpdate(context.User)...) + if len(formErrors.Messages) == 0 { _, err = database.SaveUser(context.DBConn, context.User) if err != nil { - formErrors.Errors = append(formErrors.Errors, "error saving profile") + formErrors.Messages = append(formErrors.Messages, "error saving profile") } } (*context.TemplateData)["Profile"] = context.User - (*context.TemplateData)["FormError"] = formErrors + (*context.TemplateData)["Error"] = formErrors - if len(formErrors.Errors) > 0 { - log.Println(formErrors.Errors) + if len(formErrors.Messages) > 0 { + log.Println(formErrors.Messages) resp.WriteHeader(http.StatusBadRequest) return failure(context, req, resp) } + formSuccess := types.BannerMessages{ + Messages: []string{"profile updated"}, + } + (*context.TemplateData)["Success"] = formSuccess return success(context, req, resp) } } } + +func validateProfileUpdate(user *database.User) []string { + errors := []string{} + + if (!strings.HasPrefix(user.Website, "https://") && !strings.HasPrefix(user.Website, "http://")) || len(user.Website) < 8 { + errors = append(errors, "website must be a valid URL") + } + if len(user.Website) > 64 { + errors = append(errors, "website cannot be longer than 64 characters") + } + + if len(user.Pronouns) > 64 { + errors = append(errors, "pronouns cannot be longer than 64 characters") + } + + if len(user.Bio) > 128 { + errors = append(errors, "bio cannot be longer than 128 characters") + } + + newLines := strings.Count(user.Bio, "\n") + if newLines > 8 { + errors = append(errors, "message cannot contain more than 8 new lines") + } + + if len(user.Location) > 32 { + errors = append(errors, "location cannot be longer than 64 characters") + } + + return errors +} diff --git a/api/serve.go b/api/serve.go index c129098..a688445 100644 --- a/api/serve.go +++ b/api/serve.go @@ -92,7 +92,7 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { staticFileServer := http.FileServer(http.Dir(argv.StaticPath)) uploadFileServer := http.FileServer(http.Dir(argv.UploadPath)) mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(staticFileServer, 3600))) - mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", CacheControlMiddleware(uploadFileServer, 3600))) + mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", CacheControlMiddleware(uploadFileServer, 60))) mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { requestContext := makeRequestContext() @@ -126,7 +126,9 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { mux.HandleFunc("POST /profile", func(w http.ResponseWriter, r *http.Request) { requestContext := makeRequestContext() - LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(profiles.UpdateProfileContinuation(uploadAdapter, profiles.MaxAvatarSize, profiles.AvatarPath, profiles.AvatarPrefix), auth.GoLoginContinuation)(profiles.GetProfileContinuation, FailurePassingContinuation)(template.TemplateContinuation("profile.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + updateProfileContinuation := profiles.UpdateProfileContinuation(uploadAdapter, profiles.MaxAvatarSize, profiles.AvatarPath, profiles.AvatarPrefix) + + LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(updateProfileContinuation, auth.GoLoginContinuation)(profiles.GetProfileContinuation, FailurePassingContinuation)(template.TemplateContinuation("profile.html", true), template.TemplateContinuation("profile.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) mux.HandleFunc("GET /dns", func(w http.ResponseWriter, r *http.Request) { diff --git a/api/types/types.go b/api/types/types.go index bbc25ea..84ed93c 100644 --- a/api/types/types.go +++ b/api/types/types.go @@ -20,8 +20,8 @@ type RequestContext struct { User *database.User } -type FormError struct { - Errors []string +type BannerMessages struct { + Messages []string } type Continuation func(*RequestContext, *http.Request, http.ResponseWriter) ContinuationChain diff --git a/database/migrate.go b/database/migrate.go index fbba6ff..e9e21b7 100644 --- a/database/migrate.go +++ b/database/migrate.go @@ -4,6 +4,7 @@ import ( "log" "database/sql" + _ "github.com/mattn/go-sqlite3" ) @@ -144,6 +145,7 @@ func MigrateProfiles(dbConn *sql.DB) (*sql.DB, error) { } columns := map[string]string{} + columns["pronouns"] = "unspecified" columns["bio"] = "a computer hater" columns["location"] = "earth" columns["website"] = "https://hatecomputers.club" diff --git a/database/users.go b/database/users.go index cb1b3d2..804b723 100644 --- a/database/users.go +++ b/database/users.go @@ -27,6 +27,7 @@ type User struct { Bio string `json:"bio"` Location string `json:"location"` Website string `json:"website"` + Pronouns string `json:"pronouns"` // liberals!! :O Avatar string `json:"avatar"` CreatedAt time.Time `json:"created_at"` } @@ -37,25 +38,10 @@ type UserSession struct { ExpireAt time.Time `json:"expire_at"` } -func GetUser(dbConn *sql.DB, id string) (*User, error) { - log.Println("getting user", id) - - row := dbConn.QueryRow(`SELECT id, mail, username, display_name, bio, location, website, avatar, created_at FROM users WHERE id = ?;`, id) - - var user User - err := row.Scan(&user.ID, &user.Mail, &user.Username, &user.DisplayName, &user.Bio, &user.Location, &user.Website, &user.Avatar, &user.CreatedAt) - if err != nil { - log.Println(err) - return nil, err - } - - return &user, nil -} - func ListUsers(dbConn *sql.DB) ([]*User, error) { log.Println("listing users") - rows, err := dbConn.Query(`SELECT id, mail, username, display_name, bio, location, website, avatar, created_at FROM users;`) + rows, err := dbConn.Query(`SELECT id, mail, username, display_name, bio, location, website, avatar, pronouns, created_at FROM users;`) if err != nil { log.Println(err) return nil, err @@ -65,7 +51,7 @@ func ListUsers(dbConn *sql.DB) ([]*User, error) { var users []*User for rows.Next() { var user User - err := rows.Scan(&user.ID, &user.Mail, &user.Username, &user.DisplayName, &user.Bio, &user.Location, &user.Website, &user.Avatar, &user.CreatedAt) + err := rows.Scan(&user.ID, &user.Mail, &user.Username, &user.DisplayName, &user.Bio, &user.Location, &user.Website, &user.Avatar, &user.Pronouns, &user.CreatedAt) if err != nil { log.Println(err) return nil, err @@ -77,6 +63,21 @@ func ListUsers(dbConn *sql.DB) ([]*User, error) { return users, nil } +func GetUser(dbConn *sql.DB, id string) (*User, error) { + log.Println("getting user", id) + + row := dbConn.QueryRow(`SELECT id, mail, username, display_name, bio, location, website, avatar, pronouns, created_at FROM users WHERE id = ?;`, id) + + var user User + err := row.Scan(&user.ID, &user.Mail, &user.Username, &user.DisplayName, &user.Bio, &user.Location, &user.Website, &user.Avatar, &user.Pronouns, &user.CreatedAt) + if err != nil { + log.Println(err) + return nil, err + } + + return &user, nil +} + func FindOrSaveBaseUser(dbConn *sql.DB, user *User) (*User, error) { log.Println("finding or saving user", user.ID) @@ -91,7 +92,7 @@ func FindOrSaveBaseUser(dbConn *sql.DB, user *User) (*User, error) { func SaveUser(dbConn *sql.DB, user *User) (*User, error) { log.Println("saving user", user.ID) - _, err := dbConn.Exec(`INSERT OR REPLACE INTO users (id, mail, username, display_name, bio, location, website, avatar) VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, user.ID, user.Mail, user.Username, user.DisplayName, user.Bio, user.Location, user.Website, user.Avatar) + _, err := dbConn.Exec(`INSERT INTO users (id, mail, username, display_name, bio, location, website, pronouns, avatar) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET mail = excluded.mail, username = excluded.username, display_name = excluded.display_name, bio = excluded.bio, location = excluded.location, website = excluded.website, pronouns = excluded.pronouns, avatar = excluded.avatar;`, user.ID, user.Mail, user.Username, user.DisplayName, user.Bio, user.Location, user.Website, user.Pronouns, user.Avatar) if err != nil { return nil, err } diff --git a/static/css/club.css b/static/css/club.css new file mode 100644 index 0000000..747f2d0 --- /dev/null +++ b/static/css/club.css @@ -0,0 +1,48 @@ +.club-members { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: left; + gap: 20px; + padding: 20px; +} + +.club-member { + flex: 1; + background-color: var(--background-color-2); + border: 1px solid var(--border-color); + padding: 10px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-around; + gap: 10px; + max-width: 600px; + min-width: 400px; + line-break: anywhere; +} + +.club-bio { + white-space: pre-wrap; + border-top: 1px solid var(--border-color); +} + +.avatar { + flex: 1; + display: flex; + justify-content: center; + align-items: center; +} + +.avatar div { + background-position: center center; + background-repeat: no-repeat; + background-size: cover; + width: 120px; + height: 120px; + border-radius: 25%; +} + +.about { + flex: 2; +} diff --git a/static/css/colors.css b/static/css/colors.css index c68bf8e..46357d9 100644 --- a/static/css/colors.css +++ b/static/css/colors.css @@ -1,7 +1,8 @@ :root { --background-color-light: #f4e8e9; - --background-color-light-2: #f7f7f7; + --background-color-light-2: #f5e6f3; --text-color-light: #333; + --confirm-color-light: #91d9bb; --link-color-light: #d291bc; --container-bg-light: #fff7f87a; --border-color-light: #692fcc; @@ -10,6 +11,7 @@ --background-color-dark: #333; --background-color-dark-2: #2c2c2c; --text-color-dark: #f4e8e9; + --confirm-color-dark: #4d8f73; --link-color-dark: #b86b77; --container-bg-dark: #424242ea; --border-color-dark: #956ade; @@ -24,6 +26,7 @@ --container-bg: var(--container-bg-dark); --border-color: var(--border-color-dark); --error-color: var(--error-color-dark); + --confirm-color: var(--confirm-color-dark); } [data-theme="LIGHT"] { @@ -34,9 +37,15 @@ --container-bg: var(--container-bg-light); --border-color: var(--border-color-light); --error-color: var(--error-color-light); + --confirm-color: var(--confirm-color-light); } .error { background-color: var(--error-color); padding: 1rem; } + +.success { + background-color: var(--confirm-color); + padding: 1rem; +} diff --git a/static/css/form.css b/static/css/form.css index a5dc358..7ccd8db 100644 --- a/static/css/form.css +++ b/static/css/form.css @@ -36,4 +36,7 @@ textarea { margin: 0 0 1em; border: 1px solid var(--border-color); background: var(--container-bg); + + resize: vertical; + min-height: 100px; } diff --git a/static/css/guestbook.css b/static/css/guestbook.css index 0fb7a16..6241717 100644 --- a/static/css/guestbook.css +++ b/static/css/guestbook.css @@ -3,6 +3,7 @@ border: 1px solid var(--border-color); padding: 10px; + max-width: 700px; } .entry-name { diff --git a/static/css/styles.css b/static/css/styles.css index ba58018..886052e 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -3,6 +3,7 @@ @import "/static/css/table.css"; @import "/static/css/form.css"; @import "/static/css/guestbook.css"; +@import "/static/css/club.css"; @font-face { font-family: "ComicSans"; @@ -22,15 +23,27 @@ } @-webkit-keyframes cursor { - 0% {cursor: url("/static/img/cursor-2.png"), auto;} - 50% {cursor: url("/static/img/cursor-1.png"), auto;} - 100% {cursor: url("/static/img/cursor-2.png"), auto;} + 0% { + cursor: url("/static/img/cursor-2.png"), auto; + } + 50% { + cursor: url("/static/img/cursor-1.png"), auto; + } + 100% { + cursor: url("/static/img/cursor-2.png"), auto; + } } @keyframes cursor { - 0% {cursor: url("/static/img/cursor-2.png"), auto;} - 50% {cursor: url("/static/img/cursor-1.png"), auto;} - 100% {cursor: url("/static/img/cursor-2.png"), auto;} + 0% { + cursor: url("/static/img/cursor-2.png"), auto; + } + 50% { + cursor: url("/static/img/cursor-1.png"), auto; + } + 100% { + cursor: url("/static/img/cursor-2.png"), auto; + } } body { @@ -70,3 +83,14 @@ hr { max-width: 900px; gap: 10px 10px; } + +.info { + margin-bottom: 1rem; + max-width: 600px; + + transition: opacity 0.3s; +} + +.info:hover { + opacity: 0.8; +} diff --git a/static/js/components/infoBanners.js b/static/js/components/infoBanners.js new file mode 100644 index 0000000..6a19864 --- /dev/null +++ b/static/js/components/infoBanners.js @@ -0,0 +1,6 @@ +const infoBanners = document.querySelectorAll(".info"); +Array.from(infoBanners).forEach((infoBanner) => { + infoBanner.addEventListener("click", () => { + infoBanner.remove(); + }); +}); diff --git a/static/js/script.js b/static/js/script.js index 56233e3..b5e6249 100644 --- a/static/js/script.js +++ b/static/js/script.js @@ -1,5 +1,6 @@ const scripts = [ "/static/js/components/themeSwitcher.js", "/static/js/components/formatDate.js", + "/static/js/components/infoBanners.js", ]; requirejs(scripts); diff --git a/templates/api_keys.html b/templates/api_keys.html index cd4d274..018fda3 100644 --- a/templates/api_keys.html +++ b/templates/api_keys.html @@ -28,12 +28,5 @@

generate 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 285b0dc..89d6dd2 100644 --- a/templates/base.html +++ b/templates/base.html @@ -48,6 +48,17 @@
+ {{ if and .Success (gt (len .Success.Messages) 0) }} + {{ range $message := .Success.Messages }} +
{{ $message }}
+ {{ end }} + {{ end }} + {{ if and .Error (gt (len .Error.Messages) 0) }} + {{ range $error := .Error.Messages }} +
{{ $error }}
+ {{ end }} + {{ end }} + {{ template "content" . }}
diff --git a/templates/dns.html b/templates/dns.html index d16ed89..2f3f0a7 100644 --- a/templates/dns.html +++ b/templates/dns.html @@ -76,18 +76,6 @@ {{ end }} /> - - - - - {{ if .FormError }} - {{ if (len .FormError.Errors) }} - {{ range $error := .FormError.Errors }} -
{{ $error }}
- {{ end }} - {{ end }} - {{ end }} + - - {{ end }} diff --git a/templates/guestbook.html b/templates/guestbook.html index 85727c7..d1f4417 100644 --- a/templates/guestbook.html +++ b/templates/guestbook.html @@ -21,18 +21,9 @@

-
- {{ if .FormError }} - {{ if (len .FormError.Errors) }} - {{ range $error := .FormError.Errors }} -
{{ $error }}
- {{ end }} - {{ end }} - {{ end }}
diff --git a/templates/home.html b/templates/home.html index 09edc20..76bbc6a 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,8 +1,19 @@ {{ define "content" }} -

hello there!

-

current peeps:

+

hello there!

+

current peeps in the club :D

+
{{ range $user := .Users }} -

{{ $user.Username }}

- {{ $user.Username }} +
+
+
+
+
+
name: {{ $user.Username }}
+
pronouns: {{ $user.Pronouns }}
+
{{ $user.Website }}
+
{{ $user.Bio }}
+
+
{{ end }} +
{{ end }} diff --git a/templates/profile.html b/templates/profile.html index 52f7127..a6e1b68 100644 --- a/templates/profile.html +++ b/templates/profile.html @@ -3,27 +3,22 @@

hey {{ .Profile.DisplayName }}


- - + + - + - + - + + + + - - {{ if .FormError }} - {{ if (len .FormError.Errors) }} - {{ range $error := .FormError.Errors }} -
{{ $error }}
- {{ end }} - {{ end }} - {{ end }}
{{ end }} diff --git a/uploads/avatars/f54386d9-f310-4c5a-a3f9-e950320479f7 b/uploads/avatars/f54386d9-f310-4c5a-a3f9-e950320479f7 deleted file mode 100644 index 60b7a106183eaaf7e94fdc27ddaa6df251babe36..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 29171 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