From eaa7cc8547c0bdc92d8b7cad837d2fdad98904df Mon Sep 17 00:00:00 2001 From: Elizabeth Date: Mon, 8 Apr 2024 17:04:19 -0600 Subject: [PATCH] get something up there --- Dockerfile | 2 +- .../cloudflare/cloudflare.go | 0 adapters/{ => external_dns}/external_dns.go | 0 adapters/files/files_adapter.go | 8 ++ adapters/files/filesystem/filesystem.go | 37 ++++++++ api/auth/auth.go | 14 ++- api/dns/dns.go | 6 +- api/dns/dns_test.go | 4 +- api/profiles/profiles.go | 83 ++++++++++++++++++ api/serve.go | 32 +++++-- args/args.go | 9 ++ database/migrate.go | 34 +++++++ database/users.go | 46 +++++++++- docker-compose.yml | 1 + templates/base.html | 4 +- templates/home.html | 6 +- templates/profile.html | 29 ++++++ .../f54386d9-f310-4c5a-a3f9-e950320479f7 | Bin 0 -> 29171 bytes utils/{RandomId.go => random_id.go} | 0 19 files changed, 297 insertions(+), 18 deletions(-) rename adapters/{ => external_dns}/cloudflare/cloudflare.go (100%) rename adapters/{ => external_dns}/external_dns.go (100%) create mode 100644 adapters/files/files_adapter.go create mode 100644 adapters/files/filesystem/filesystem.go create mode 100644 api/profiles/profiles.go create mode 100644 templates/profile.html create mode 100644 uploads/avatars/f54386d9-f310-4c5a-a3f9-e950320479f7 rename utils/{RandomId.go => random_id.go} (100%) 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..c54aad6 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() @@ -216,7 +228,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..bf91994 100644 --- a/api/dns/dns.go +++ b/api/dns/dns.go @@ -8,12 +8,16 @@ 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" ) +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 { 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/profiles/profiles.go b/api/profiles/profiles.go new file mode 100644 index 0000000..905b437 --- /dev/null +++ b/api/profiles/profiles.go @@ -0,0 +1,83 @@ +package profiles + +import ( + "log" + "net/http" + + "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/" + +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) 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{}, + } + + err := req.ParseMultipartForm(int64(maxAvatarSize)) + if err != nil { + log.Println(err) + + formErrors.Errors = append(formErrors.Errors, "avatar file too large") + } + + if len(formErrors.Errors) == 0 { + file, _, err := req.FormFile("avatar") + if err != nil { + formErrors.Errors = append(formErrors.Errors, "error uploading avatar") + } else { + defer file.Close() + + _, err = fileAdapter.CreateFile(avatarPath+context.User.ID, file) + if err != nil { + log.Println(err) + formErrors.Errors = append(formErrors.Errors, "error saving avatar") + } + } + } + + if len(formErrors.Errors) == 0 { + bio := req.FormValue("bio") + location := req.FormValue("location") + website := req.FormValue("website") + + context.User.Bio = bio + context.User.Location = location + context.User.Website = website + + _, err = database.SaveUser(context.DBConn, context.User) + if err != nil { + formErrors.Errors = append(formErrors.Errors, "error saving profile") + } + } + + (*context.TemplateData)["Profile"] = context.User + (*context.TemplateData)["FormError"] = formErrors + + if len(formErrors.Errors) > 0 { + log.Println(formErrors.Errors) + + resp.WriteHeader(http.StatusBadRequest) + return failure(context, req, resp) + } + + return success(context, req, resp) + } + } +} diff --git a/api/serve.go b/api/serve.go index c8775d8..6218d1b 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,14 +71,21 @@ 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))) + 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))) cloudflareAdapter := &cloudflare.CloudflareExternalDNSAdapter{ APIToken: argv.CloudflareToken, ZoneId: argv.CloudflareZone, } + uploadAdapter := &filesystem.FilesystemAdapter{ + BasePath: argv.UploadPath, + Permissions: 0777, + } + makeRequestContext := func() *types.RequestContext { return &types.RequestContext{ DBConn: dbConn, @@ -88,7 +96,7 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { 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,24 @@ 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() + LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(profiles.UpdateProfileContinuation(uploadAdapter, profiles.MaxAvatarSize, profiles.AvatarPath), auth.GoLoginContinuation)(profiles.GetProfileContinuation, FailurePassingContinuation)(template.TemplateContinuation("profile.html", true), FailurePassingContinuation)(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/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..04eae72 100644 --- a/database/migrate.go +++ b/database/migrate.go @@ -127,6 +127,39 @@ 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["bio"] = "a computer hater" + columns["location"] = "earth" + columns["website"] = "https://hatecomputers.club" + columns["avatar"] = "/files/avatars/default.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 +170,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..cb1b3d2 100644 --- a/database/users.go +++ b/database/users.go @@ -24,6 +24,10 @@ 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"` + Avatar string `json:"avatar"` CreatedAt time.Time `json:"created_at"` } @@ -36,10 +40,10 @@ type UserSession struct { 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, 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.CreatedAt) if err != nil { log.Println(err) return nil, err @@ -48,7 +52,32 @@ func GetUser(dbConn *sql.DB, id string) (*User, error) { return &user, nil } -func FindOrSaveUser(dbConn *sql.DB, user *User) (*User, error) { +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;`) + 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.CreatedAt) + if err != nil { + log.Println(err) + return nil, err + } + + users = append(users, &user) + } + + return users, nil +} + +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 +88,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 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) + 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/templates/base.html b/templates/base.html index 9f5a903..285b0dc 100644 --- a/templates/base.html +++ b/templates/base.html @@ -37,7 +37,9 @@ | api keys. | - logout, {{ .User.DisplayName }}. + {{ .User.DisplayName }}. + | + logout. {{ else }} login. diff --git a/templates/home.html b/templates/home.html index 1c03377..944216d 100644 --- a/templates/home.html +++ b/templates/home.html @@ -1,3 +1,7 @@ {{ define "content" }} -

under construction!

+

hello there!

+

current peeps:

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

{{ $user.Username }}

+ {{ end }} {{ end }} diff --git a/templates/profile.html b/templates/profile.html new file mode 100644 index 0000000..eb2091c --- /dev/null +++ b/templates/profile.html @@ -0,0 +1,29 @@ +{{ define "content" }} + +

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 new file mode 100644 index 0000000000000000000000000000000000000000..60b7a106183eaaf7e94fdc27ddaa6df251babe36 GIT binary patch literal 29171 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 literal 0 HcmV?d00001 diff --git a/utils/RandomId.go b/utils/random_id.go similarity index 100% rename from utils/RandomId.go rename to utils/random_id.go