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/Dockerfile b/Dockerfile index 790c580..a95f9b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,4 +11,4 @@ RUN go build -o /app/hatecomputers EXPOSE 8080 -CMD ["/app/hatecomputers", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/hatecomputers.db", "--static-path", "/app/static", "--scheduler", "--dns", "--dns-port", "8053", "--dns-resolvers", "1.1.1.1:53,1.0.0.1:53"] +CMD ["/app/hatecomputers", "--server", "--migrate", "--port", "8080", "--template-path", "/app/templates", "--database-path", "/app/db/hatecomputers.db", "--static-path", "/app/static", "--scheduler", "--dns", "--dns-port", "8053", "--dns-resolvers", "1.1.1.1:53,1.0.0.1:53", "--uploads", "/app/uploads"] diff --git a/adapters/cloudflare/cloudflare.go b/adapters/external_dns/cloudflare/cloudflare.go similarity index 100% rename from adapters/cloudflare/cloudflare.go rename to adapters/external_dns/cloudflare/cloudflare.go diff --git a/adapters/external_dns.go b/adapters/external_dns/external_dns.go similarity index 100% rename from adapters/external_dns.go rename to adapters/external_dns/external_dns.go diff --git a/adapters/files/files_adapter.go b/adapters/files/files_adapter.go new file mode 100644 index 0000000..bf3ea5f --- /dev/null +++ b/adapters/files/files_adapter.go @@ -0,0 +1,8 @@ +package files + +import "io" + +type FilesAdapter interface { + CreateFile(path string, content io.Reader) (string, error) + DeleteFile(path string) error +} diff --git a/adapters/files/filesystem/filesystem.go b/adapters/files/filesystem/filesystem.go new file mode 100644 index 0000000..726a588 --- /dev/null +++ b/adapters/files/filesystem/filesystem.go @@ -0,0 +1,37 @@ +package filesystem + +import ( + "io" + "os" + "path/filepath" +) + +type FilesystemAdapter struct { + BasePath string + Permissions os.FileMode +} + +func (f *FilesystemAdapter) CreateFile(path string, content io.Reader) (string, error) { + fullPath := f.BasePath + path + dir := filepath.Dir(fullPath) + if _, err := os.Stat(dir); os.IsNotExist(err) { + os.MkdirAll(dir, f.Permissions) + } + + file, err := os.Create(f.BasePath + path) + if err != nil { + return "", err + } + defer file.Close() + + _, err = io.Copy(file, content) + if err != nil { + return "", err + } + + return path, nil +} + +func (f *FilesystemAdapter) DeleteFile(path string) error { + return os.Remove(f.BasePath + path) +} diff --git a/api/auth/auth.go b/api/auth/auth.go index 0ffbf9c..04d6c12 100644 --- a/api/auth/auth.go +++ b/api/auth/auth.go @@ -18,6 +18,18 @@ import ( "golang.org/x/oauth2" ) +func ListUsersContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + users, err := database.ListUsers(context.DBConn) + if err != nil { + return failure(context, req, resp) + } + + (*context.TemplateData)["Users"] = users + return success(context, req, resp) + } +} + func StartSessionContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { verifier := utils.RandomId() + utils.RandomId() @@ -158,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, @@ -216,7 +229,7 @@ func getOauthUser(dbConn *sql.DB, client *http.Client, uri string) (*database.Us return nil, err } - user, err := database.FindOrSaveUser(dbConn, userStruct) + user, err := database.FindOrSaveBaseUser(dbConn, userStruct) if err != nil { return nil, err } diff --git a/api/dns/dns.go b/api/dns/dns.go index aa2f356..6357dfc 100644 --- a/api/dns/dns.go +++ b/api/dns/dns.go @@ -8,39 +8,15 @@ import ( "strconv" "strings" - "git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters" + "git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/external_dns" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/types" "git.hatecomputers.club/hatecomputers/hatecomputers.club/database" "git.hatecomputers.club/hatecomputers/hatecomputers.club/utils" ) -func userCanFuckWithDNSRecord(dbConn *sql.DB, user *database.User, record *database.DNSRecord, ownedInternalDomainFormats []string) bool { - ownedByUser := (user.ID == record.UserID) - if !ownedByUser { - return false - } +const MaxUserRecords = 100 - 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 -} +var UserOwnedInternalFmtDomains = []string{"%s", "%s.endpoints"} func ListDNSRecordsContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { @@ -59,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" @@ -77,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) @@ -88,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{ @@ -102,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 { @@ -113,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) } @@ -168,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/dns/dns_test.go b/api/dns/dns_test.go index 43dc680..30baedf 100644 --- a/api/dns/dns_test.go +++ b/api/dns/dns_test.go @@ -39,7 +39,7 @@ func setup() (*sql.DB, *types.RequestContext, func()) { Mail: "test@test.com", DisplayName: "test", } - database.FindOrSaveUser(testDb, user) + database.FindOrSaveBaseUser(testDb, user) context := &types.RequestContext{ DBConn: testDb, @@ -246,7 +246,7 @@ func TestThatUserMustOwnRecordToRemove(t *testing.T) { defer testServer.Close() nonOwnerUser := &database.User{ID: "n/a", Username: "testuser"} - _, err := database.FindOrSaveUser(db, nonOwnerUser) + _, err := database.FindOrSaveBaseUser(db, nonOwnerUser) if err != nil { t.Error(err) } 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 new file mode 100644 index 0000000..8e10e5f --- /dev/null +++ b/api/profiles/profiles.go @@ -0,0 +1,118 @@ +package profiles + +import ( + "log" + "net/http" + "strings" + + "git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/files" + "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/types" + "git.hatecomputers.club/hatecomputers/hatecomputers.club/database" +) + +const MaxAvatarSize = 1024 * 1024 * 2 // 2MB +const AvatarPath = "avatars/" +const AvatarPrefix = "/uploads/avatars/" + +func GetProfileContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + if context.User == nil { + return failure(context, req, resp) + } + + (*context.TemplateData)["Profile"] = context.User + return success(context, req, resp) + } +} + +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.BannerMessages{ + Messages: []string{}, + } + + err := req.ParseMultipartForm(int64(maxAvatarSize)) + if err != nil { + formErrors.Messages = append(formErrors.Messages, "avatar file too large") + } + + if len(formErrors.Messages) == 0 { + file, _, err := req.FormFile("avatar") + 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, reader) + if err != nil { + log.Println(err) + formErrors.Messages = append(formErrors.Messages, "error saving avatar (is it too big?)") + } + } + } + + 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.Messages = append(formErrors.Messages, "error saving profile") + } + } + + (*context.TemplateData)["Profile"] = context.User + (*context.TemplateData)["Error"] = formErrors + + 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 c8775d8..a688445 100644 --- a/api/serve.go +++ b/api/serve.go @@ -7,12 +7,14 @@ import ( "net/http" "time" - "git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/cloudflare" + "git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/external_dns/cloudflare" + "git.hatecomputers.club/hatecomputers/hatecomputers.club/adapters/files/filesystem" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/auth" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/dns" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/guestbook" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/hcaptcha" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/keys" + "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/profiles" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/template" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/types" "git.hatecomputers.club/hatecomputers/hatecomputers.club/args" @@ -32,7 +34,6 @@ func LogRequestContinuation(context *types.RequestContext, req *http.Request, re func LogExecutionTimeContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { return func(success types.Continuation, _failure types.Continuation) types.ContinuationChain { end := time.Now() - log.Println(context.Id, "took", end.Sub(context.Start)) return success(context, req, resp) @@ -70,13 +71,15 @@ func CacheControlMiddleware(next http.Handler, maxAge int) http.Handler { func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { mux := http.NewServeMux() - fileServer := http.FileServer(http.Dir(argv.StaticPath)) - mux.Handle("GET /static/", http.StripPrefix("/static/", CacheControlMiddleware(fileServer, 3600))) - + // "dependency injection" cloudflareAdapter := &cloudflare.CloudflareExternalDNSAdapter{ APIToken: argv.CloudflareToken, ZoneId: argv.CloudflareZone, } + uploadAdapter := &filesystem.FilesystemAdapter{ + BasePath: argv.UploadPath, + Permissions: 0777, + } makeRequestContext := func() *types.RequestContext { return &types.RequestContext{ @@ -86,9 +89,14 @@ 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, 60))) + mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { requestContext := makeRequestContext() - LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(IdContinuation, IdContinuation)(template.TemplateContinuation("home.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(IdContinuation, IdContinuation)(auth.ListUsersContinuation, auth.ListUsersContinuation)(template.TemplateContinuation("home.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { @@ -111,16 +119,26 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { LogRequestContinuation(requestContext, r, w)(auth.LogoutContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) + mux.HandleFunc("GET /profile", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(profiles.GetProfileContinuation, auth.GoLoginContinuation)(template.TemplateContinuation("profile.html", true), template.TemplateContinuation("profile.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("POST /profile", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + 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) { requestContext := makeRequestContext() LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(dns.ListDNSRecordsContinuation, auth.GoLoginContinuation)(template.TemplateContinuation("dns.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) - const MAX_USER_RECORDS = 100 - var USER_OWNED_INTERNAL_FMT_DOMAINS = []string{"%s", "%s.endpoints"} mux.HandleFunc("POST /dns", func(w http.ResponseWriter, r *http.Request) { requestContext := makeRequestContext() - LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(dns.ListDNSRecordsContinuation, auth.GoLoginContinuation)(dns.CreateDNSRecordContinuation(cloudflareAdapter, MAX_USER_RECORDS, USER_OWNED_INTERNAL_FMT_DOMAINS), FailurePassingContinuation)(dns.ListDNSRecordsContinuation, dns.ListDNSRecordsContinuation)(template.TemplateContinuation("dns.html", true), template.TemplateContinuation("dns.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(dns.ListDNSRecordsContinuation, auth.GoLoginContinuation)(dns.CreateDNSRecordContinuation(cloudflareAdapter, dns.MaxUserRecords, dns.UserOwnedInternalFmtDomains), FailurePassingContinuation)(dns.ListDNSRecordsContinuation, dns.ListDNSRecordsContinuation)(template.TemplateContinuation("dns.html", true), template.TemplateContinuation("dns.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) mux.HandleFunc("POST /dns/delete", 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/args/args.go b/args/args.go index 09c96be..59eb441 100644 --- a/args/args.go +++ b/args/args.go @@ -13,6 +13,7 @@ type Arguments struct { DatabasePath string TemplatePath string StaticPath string + UploadPath string Migrate bool Scheduler bool @@ -35,6 +36,13 @@ type Arguments struct { func GetArgs() (*Arguments, error) { databasePath := flag.String("database-path", "./hatecomputers.db", "Path to the SQLite database") + + uploadPath := flag.String("upload-path", "./uploads", "Path to the uploads directory") + uploadPathValue := *uploadPath + if uploadPathValue[len(uploadPathValue)-1] != '/' { + uploadPathValue += "/" + } + templatePath := flag.String("template-path", "./templates", "Path to the template directory") staticPath := flag.String("static-path", "./static", "Path to the static directory") dnsResolvers := flag.String("dns-resolvers", "1.1.1.1:53,1.0.0.1:53", "Comma-separated list of DNS resolvers") @@ -96,6 +104,7 @@ func GetArgs() (*Arguments, error) { arguments := &Arguments{ DatabasePath: *databasePath, TemplatePath: *templatePath, + UploadPath: uploadPathValue, StaticPath: *staticPath, CloudflareToken: cloudflareToken, CloudflareZone: cloudflareZone, diff --git a/database/migrate.go b/database/migrate.go index a117480..e9e21b7 100644 --- a/database/migrate.go +++ b/database/migrate.go @@ -4,6 +4,7 @@ import ( "log" "database/sql" + _ "github.com/mattn/go-sqlite3" ) @@ -127,6 +128,40 @@ func MigrateGuestBook(dbConn *sql.DB) (*sql.DB, error) { return dbConn, nil } +func MigrateProfiles(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating profiles columns") + + userColumns := map[string]bool{} + row, err := dbConn.Query(`PRAGMA table_info(users);`) + if err != nil { + return dbConn, err + } + defer row.Close() + + for row.Next() { + var columnName string + row.Scan(nil, &columnName, nil, nil, nil, nil) + userColumns[columnName] = true + } + + columns := map[string]string{} + columns["pronouns"] = "unspecified" + columns["bio"] = "a computer hater" + columns["location"] = "earth" + columns["website"] = "https://hatecomputers.club" + columns["avatar"] = "/static/img/default-avatar.png" + + for column, defaultValue := range columns { + if userColumns[column] { + continue + } + log.Println("migrating column", column) + _, err = dbConn.Exec(`ALTER TABLE users ADD COLUMN ` + column + ` TEXT NOT NULL DEFAULT '` + defaultValue + `';`) + } + + return dbConn, nil +} + func Migrate(dbConn *sql.DB) (*sql.DB, error) { log.Println("migrating database") @@ -137,6 +172,7 @@ func Migrate(dbConn *sql.DB) (*sql.DB, error) { MigrateDomainOwners, MigrateDNSRecords, MigrateGuestBook, + MigrateProfiles, } for _, migration := range migrations { diff --git a/database/users.go b/database/users.go index 6f9456e..804b723 100644 --- a/database/users.go +++ b/database/users.go @@ -24,6 +24,11 @@ type User struct { Mail string `json:"email"` Username string `json:"preferred_username"` DisplayName string `json:"name"` + 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"` } @@ -33,13 +38,38 @@ type UserSession struct { ExpireAt time.Time `json:"expire_at"` } +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, pronouns, created_at FROM users;`) + if err != nil { + log.Println(err) + return nil, err + } + defer rows.Close() + + 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.Pronouns, &user.CreatedAt) + if err != nil { + log.Println(err) + return nil, err + } + + users = append(users, &user) + } + + 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, created_at FROM users WHERE id = ?;`, 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.CreatedAt) + 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 @@ -48,7 +78,7 @@ func GetUser(dbConn *sql.DB, id string) (*User, error) { return &user, nil } -func FindOrSaveUser(dbConn *sql.DB, user *User) (*User, error) { +func FindOrSaveBaseUser(dbConn *sql.DB, user *User) (*User, error) { log.Println("finding or saving user", user.ID) _, err := dbConn.Exec(`INSERT INTO users (id, mail, username, display_name) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET mail = excluded.mail, username = excluded.username, display_name = excluded.display_name;`, user.ID, user.Mail, user.Username, user.DisplayName) @@ -59,6 +89,17 @@ func FindOrSaveUser(dbConn *sql.DB, user *User) (*User, error) { return user, nil } +func SaveUser(dbConn *sql.DB, user *User) (*User, error) { + log.Println("saving user", user.ID) + + _, 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 + } + + return user, nil +} + func MakeUserSessionFor(dbConn *sql.DB, user *User) (*UserSession, error) { log.Println("making session for user", user.ID) diff --git a/docker-compose.yml b/docker-compose.yml index b568e87..957683f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,5 +15,6 @@ services: - ./db:/app/db - ./templates:/app/templates - ./static:/app/static + - ./uploads:/app/uploads ports: - "127.0.0.1:4455:8080" diff --git a/hcdns/server_test.go b/hcdns/server_test.go index f1b283f..4fdf03d 100644 --- a/hcdns/server_test.go +++ b/hcdns/server_test.go @@ -27,7 +27,7 @@ func setup(arguments *args.Arguments) (*sql.DB, *dns.Server, string, func()) { testUser := &database.User{ ID: "test", } - database.FindOrSaveUser(testDb, testUser) + database.FindOrSaveBaseUser(testDb, testUser) dnsArguments := arguments if dnsArguments == nil { 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/img/default-avatar.png b/static/img/default-avatar.png new file mode 100644 index 0000000..66a38c2 Binary files /dev/null and b/static/img/default-avatar.png differ 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 @@
under construction!
+current peeps in the club :D
+