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 0000000..60b7a10 Binary files /dev/null and b/uploads/avatars/f54386d9-f310-4c5a-a3f9-e950320479f7 differ 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