diff --git a/adapters/files/files_adapter.go b/adapters/files/files_adapter.go index bf3ea5f..44853e1 100644 --- a/adapters/files/files_adapter.go +++ b/adapters/files/files_adapter.go @@ -4,5 +4,6 @@ import "io" type FilesAdapter interface { CreateFile(path string, content io.Reader) (string, error) + FileExists(path string) bool DeleteFile(path string) error } diff --git a/adapters/files/filesystem/filesystem.go b/adapters/files/filesystem/filesystem.go index 726a588..e7e671f 100644 --- a/adapters/files/filesystem/filesystem.go +++ b/adapters/files/filesystem/filesystem.go @@ -35,3 +35,8 @@ func (f *FilesystemAdapter) CreateFile(path string, content io.Reader) (string, func (f *FilesystemAdapter) DeleteFile(path string) error { return os.Remove(f.BasePath + path) } + +func (f *FilesystemAdapter) FileExists(path string) bool { + _, err := os.Stat(f.BasePath + path) + return err == nil +} diff --git a/api/kennel/kennel.go b/api/kennel/kennel.go new file mode 100644 index 0000000..a68388d --- /dev/null +++ b/api/kennel/kennel.go @@ -0,0 +1,238 @@ +package kennel + +import ( + "encoding/json" + "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" + "git.hatecomputers.club/hatecomputers/hatecomputers.club/utils" +) + +const MaxCatSize = 1024 * 100 // 60KB +const CatsPath = "cats/" +const CatsPrefix = "/uploads/cats/" +const DefaultCatSpritesheet = "/static/img/cat_spritesheets/default.gif" +const MaxUserCats = 15 + +func ListUserCatsContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + userID := context.User.ID + + cats, err := database.GetUserKennelCats(context.DBConn, userID) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + (*context.TemplateData)["Cats"] = cats + return success(context, req, resp) + } +} + +func CreateCatContinuation(fileAdapter files.FilesAdapter, maxUserCats int, maxCatSize int, catsPath string, catsPrefix string, defaultCatSpritesheet 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{}, + } + + numCats, err := database.CountUserKennelCats(context.DBConn, context.User.ID) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + if numCats >= maxUserCats { + formErrors.Messages = append(formErrors.Messages, "max cats reached for user") + } + + err = req.ParseMultipartForm(int64(maxCatSize)) + if err != nil { + formErrors.Messages = append(formErrors.Messages, "cat spritesheet too large") + } + + catID := utils.RandomId() + spritesheetPath := catsPrefix + catID + + if len(formErrors.Messages) == 0 { + file, _, err := req.FormFile("spritesheet") + if file != nil && err != nil { + formErrors.Messages = append(formErrors.Messages, "error uploading spritesheet") + } else if file != nil { + defer file.Close() + reader := http.MaxBytesReader(resp, file, int64(maxCatSize)) + defer reader.Close() + + _, err = fileAdapter.CreateFile(catsPath+catID, reader) + if err != nil { + log.Println(err) + formErrors.Messages = append(formErrors.Messages, "error saving spritesheet (is it too big?)") + } + } else if file == nil && err != nil { + spritesheetPath = defaultCatSpritesheet + } + } + + link := req.FormValue("link") + description := req.FormValue("description") + name := req.FormValue("name") + + cat := &database.KennelCat{ + ID: catID, + UserID: context.User.ID, + Name: name, + Link: link, + Description: description, + Spritesheet: spritesheetPath, + } + formErrors.Messages = append(formErrors.Messages, validateCat(cat)...) + if len(formErrors.Messages) == 0 { + _, err := database.SaveKennelCat(context.DBConn, cat) + if err != nil { + log.Println(err) + formErrors.Messages = append(formErrors.Messages, "failed to save cat") + } + } + + if len(formErrors.Messages) > 0 { + (*context.TemplateData)["Error"] = formErrors + (*context.TemplateData)["CatForm"] = cat + resp.WriteHeader(http.StatusBadRequest) + + return failure(context, req, resp) + } + + formSuccess := types.BannerMessages{ + Messages: []string{"cat added."}, + } + (*context.TemplateData)["Success"] = formSuccess + return success(context, req, resp) + } + } +} + +func RemoveCatContinuation(fileAdapter files.FilesAdapter, catsPath 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 { + catID := req.FormValue("id") + + cat, err := database.GetKennelCat(context.DBConn, catID) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + if cat == nil || cat.UserID != context.User.ID { + resp.WriteHeader(http.StatusUnauthorized) + return failure(context, req, resp) + } + + err = database.DeleteKennelCat(context.DBConn, catID) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + err = fileAdapter.DeleteFile(catsPath + catID) + if err != nil && fileAdapter.FileExists(catsPath+catID) { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + return success(context, req, resp) + } + } +} + +func RingContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + order := req.URL.Query().Get("order") + + if order == "random" { + kennelCat, err := database.GetRandomKennelCat(context.DBConn) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + http.Redirect(resp, req, kennelCat.Link, http.StatusFound) + return success(context, req, resp) + } + + id := req.URL.Query().Get("id") + if id == "" { + resp.WriteHeader(http.StatusBadRequest) + return failure(context, req, resp) + } + if order != "random" && order != "next" && order != "prev" { + kennelCat, err := database.GetKennelCat(context.DBConn, id) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusNotFound) + return failure(context, req, resp) + } + http.Redirect(resp, req, kennelCat.Link, http.StatusFound) + return success(context, req, resp) + } + + nextCat, err := database.GetNextKennelCat(context.DBConn, id, order == "next") + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + http.Redirect(resp, req, nextCat.Link, http.StatusFound) + return success(context, req, resp) + } +} + +func GetKennelContinuation(context *types.RequestContext, req *http.Request, resp http.ResponseWriter) types.ContinuationChain { + return func(success types.Continuation, failure types.Continuation) types.ContinuationChain { + cats, err := database.GetKennel(context.DBConn) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + json, err := json.Marshal(cats) + if err != nil { + log.Println(err) + resp.WriteHeader(http.StatusInternalServerError) + return failure(context, req, resp) + } + + resp.Header().Set("Content-Type", "application/json") + resp.Write(json) + return success(context, req, resp) + } +} + +func validateCat(cat *database.KennelCat) []string { + errors := []string{} + + if cat.Name == "" { + errors = append(errors, "name is required") + } + if cat.Link == "" { + errors = append(errors, "link is required") + } + if !strings.HasPrefix(cat.Link, "http://") && !strings.HasPrefix(cat.Link, "https://") { + errors = append(errors, "link must be a valid URL") + } + if cat.Description == "" { + errors = append(errors, "description is required") + } + if len(cat.Description) > 100 { + errors = append(errors, "description must be less than 100 characters") + } + + return errors +} diff --git a/api/serve.go b/api/serve.go index ca8142b..e205ce5 100644 --- a/api/serve.go +++ b/api/serve.go @@ -13,6 +13,7 @@ import ( "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/kennel" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/keys" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/profiles" "git.hatecomputers.club/hatecomputers/hatecomputers.club/api/template" @@ -172,10 +173,38 @@ func MakeServer(argv *args.Arguments, dbConn *sql.DB) *http.Server { LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(hcaptcha.CaptchaVerificationContinuation, hcaptcha.CaptchaVerificationContinuation)(guestbook.SignGuestbookContinuation, FailurePassingContinuation)(guestbook.ListGuestbookContinuation, guestbook.ListGuestbookContinuation)(hcaptcha.CaptchaArgsContinuation, hcaptcha.CaptchaArgsContinuation)(template.TemplateContinuation("guestbook.html", true), template.TemplateContinuation("guestbook.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) - mux.HandleFunc("GET /{name}", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("GET /kennel/", func(w http.ResponseWriter, r *http.Request) { requestContext := makeRequestContext() - name := r.PathValue("name") - LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(IdContinuation, IdContinuation)(template.TemplateContinuation(name+".html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + LogRequestContinuation(requestContext, r, w)(kennel.GetKennelContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /kennel/cat", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + LogRequestContinuation(requestContext, r, w)(kennel.RingContinuation, FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /kennel/cats", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(kennel.ListUserCatsContinuation, auth.GoLoginContinuation)(template.TemplateContinuation("kennel_cats.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("POST /kennel/cats", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + createCatContinuation := kennel.CreateCatContinuation(uploadAdapter, kennel.MaxUserCats, kennel.MaxCatSize, kennel.CatsPath, kennel.CatsPrefix, kennel.DefaultCatSpritesheet) + LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(createCatContinuation, FailurePassingContinuation)(kennel.ListUserCatsContinuation, kennel.ListUserCatsContinuation)(template.TemplateContinuation("kennel_cats.html", true), template.TemplateContinuation("kennel_cats.html", true))(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("POST /kennel/cats/delete", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + deleteCatContinuation := kennel.RemoveCatContinuation(uploadAdapter, kennel.CatsPath) + + LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(deleteCatContinuation, FailurePassingContinuation)(kennel.ListUserCatsContinuation, FailurePassingContinuation)(template.TemplateContinuation("kennel_cats.html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) + }) + + mux.HandleFunc("GET /{template}", func(w http.ResponseWriter, r *http.Request) { + requestContext := makeRequestContext() + templateFile := r.PathValue("template") + LogRequestContinuation(requestContext, r, w)(auth.VerifySessionContinuation, FailurePassingContinuation)(IdContinuation, IdContinuation)(template.TemplateContinuation(templateFile+".html", true), FailurePassingContinuation)(LogExecutionTimeContinuation, LogExecutionTimeContinuation)(IdContinuation, IdContinuation) }) return &http.Server{ diff --git a/database/kennel.go b/database/kennel.go new file mode 100644 index 0000000..91525db --- /dev/null +++ b/database/kennel.go @@ -0,0 +1,159 @@ +package database + +import ( + "database/sql" + _ "github.com/mattn/go-sqlite3" + "log" + "time" +) + +type KennelCat struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Link string `json:"link"` + Description string `json:"description"` + Spritesheet string `json:"spritesheet"` + CreatedAt time.Time `json:"created_at"` +} + +type KennelState struct { + At time.Time `json:"at"` + EncodedState string `json:"state"` +} + +func CountUserKennelCats(db *sql.DB, userID string) (int, error) { + log.Println("counting kennel cats for user", userID) + + row := db.QueryRow("SELECT COUNT(*) FROM kennel_cat WHERE user_id = ?", userID) + var count int + err := row.Scan(&count) + if err != nil { + return 0, err + } + return count, nil +} + +func GetUserKennelCats(db *sql.DB, userID string) ([]KennelCat, error) { + log.Println("getting kennel cats for user", userID) + + rows, err := db.Query("SELECT * FROM kennel_cat WHERE user_id = ?", userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var cats []KennelCat + for rows.Next() { + var cat KennelCat + err := rows.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt) + if err != nil { + return nil, err + } + cats = append(cats, cat) + } + + return cats, nil +} + +func SaveKennelCat(db *sql.DB, cat *KennelCat) (*KennelCat, error) { + log.Println("saving kennel cat", cat.ID) + + if (cat.CreatedAt == time.Time{}) { + cat.CreatedAt = time.Now() + } + + _, err := db.Exec("INSERT OR REPLACE INTO kennel_cat (id, user_id, name, link, description, spritesheet, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", cat.ID, cat.UserID, cat.Name, cat.Link, cat.Description, cat.Spritesheet, cat.CreatedAt) + + if err != nil { + return nil, err + } + return cat, nil +} + +func GetKennelCat(db *sql.DB, catID string) (*KennelCat, error) { + log.Println("getting kennel cat", catID) + + row := db.QueryRow("SELECT * FROM kennel_cat WHERE id = ?", catID) + var cat KennelCat + err := row.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt) + if err != nil { + return nil, err + } + return &cat, nil +} + +func DeleteKennelCat(db *sql.DB, catID string) error { + log.Println("deleting kennel cat", catID) + + _, err := db.Exec("DELETE FROM kennel_cat WHERE id = ?", catID) + if err != nil { + return err + } + return nil +} + +func GetRandomKennelCat(dbConn *sql.DB) (*KennelCat, error) { + log.Println("getting random kennel cat") + + row := dbConn.QueryRow("SELECT * FROM kennel_cat ORDER BY RANDOM() LIMIT 1") + var cat KennelCat + err := row.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt) + if err != nil { + return nil, err + } + return &cat, nil +} + +func GetNextKennelCat(dbConn *sql.DB, lastID string, next bool) (*KennelCat, error) { + log.Println("getting next kennel cat") + + operation := ">" + sorted := "ASC" + if !next { + operation = "<" + sorted = "DESC" + } + + row := dbConn.QueryRow("SELECT * FROM kennel_cat WHERE id "+operation+" ? ORDER BY id "+sorted+" LIMIT 1", lastID) + + var cat KennelCat + err := row.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt) + if err != nil { + if next { + // loop "back" to the first in the ring + row = dbConn.QueryRow("SELECT * FROM kennel_cat ORDER BY id ASC LIMIT 1") + } else { + // loop "forward" to the first in the ring + row = dbConn.QueryRow("SELECT * FROM kennel_cat ORDER BY id DESC LIMIT 1") + } + err = row.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt) + } + if err != nil { + return nil, err + } + + return &cat, nil +} + +func GetKennel(dbConn *sql.DB) ([]KennelCat, error) { + log.Println("getting kennel") + + rows, err := dbConn.Query("SELECT * FROM kennel_cat") + if err != nil { + return nil, err + } + defer rows.Close() + + var cats []KennelCat + for rows.Next() { + var cat KennelCat + err := rows.Scan(&cat.ID, &cat.Name, &cat.UserID, &cat.Link, &cat.Description, &cat.Spritesheet, &cat.CreatedAt) + if err != nil { + return nil, err + } + cats = append(cats, cat) + } + + return cats, nil +} diff --git a/database/migrate.go b/database/migrate.go index e9e21b7..0c8318c 100644 --- a/database/migrate.go +++ b/database/migrate.go @@ -162,6 +162,26 @@ func MigrateProfiles(dbConn *sql.DB) (*sql.DB, error) { return dbConn, nil } +func MigrateKennel(dbConn *sql.DB) (*sql.DB, error) { + log.Println("migrating kennel tables") + + _, err := dbConn.Exec(`CREATE TABLE IF NOT EXISTS kennel_cat ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + user_id INTEGER NOT NULL, + link TEXT NOT NULL, + description TEXT NOT NULL, + spritesheet TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + );`) + if err != nil { + return dbConn, err + } + + return dbConn, nil +} + func Migrate(dbConn *sql.DB) (*sql.DB, error) { log.Println("migrating database") @@ -173,6 +193,7 @@ func Migrate(dbConn *sql.DB) (*sql.DB, error) { MigrateDNSRecords, MigrateGuestBook, MigrateProfiles, + MigrateKennel, } for _, migration := range migrations { diff --git a/static/css/colors.css b/static/css/colors.css index 46357d9..69b97db 100644 --- a/static/css/colors.css +++ b/static/css/colors.css @@ -7,6 +7,7 @@ --container-bg-light: #fff7f87a; --border-color-light: #692fcc; --error-color-light: #a83254; + --tr-color-light: #8bcefa; --background-color-dark: #333; --background-color-dark-2: #2c2c2c; @@ -16,6 +17,7 @@ --container-bg-dark: #424242ea; --border-color-dark: #956ade; --error-color-dark: #851736; + --tr-color-dark: #212a6a; } [data-theme="DARK"] { @@ -27,6 +29,7 @@ --border-color: var(--border-color-dark); --error-color: var(--error-color-dark); --confirm-color: var(--confirm-color-dark); + --tr-color: var(--tr-color-dark); } [data-theme="LIGHT"] { @@ -38,6 +41,7 @@ --border-color: var(--border-color-light); --error-color: var(--error-color-light); --confirm-color: var(--confirm-color-light); + --tr-color: var(--tr-color-light); } .error { diff --git a/static/css/table.css b/static/css/table.css index 75a961d..854f591 100644 --- a/static/css/table.css +++ b/static/css/table.css @@ -26,6 +26,6 @@ tbody tr { } tbody tr:hover { - background-color: #ff47daa0; + background-color: var(--tr-color); color: #2a2a2a; } diff --git a/static/img/cat_spritesheets/default.gif b/static/img/cat_spritesheets/default.gif new file mode 100644 index 0000000..0d264d8 Binary files /dev/null and b/static/img/cat_spritesheets/default.gif differ diff --git a/templates/base.html b/templates/base.html index 036a748..7486f45 100644 --- a/templates/base.html +++ b/templates/base.html @@ -35,6 +35,8 @@ | api keys. | + kennel. + | {{ .User.DisplayName }}. | logout. diff --git a/templates/dns.html b/templates/dns.html index 2f3f0a7..e04cbfa 100644 --- a/templates/dns.html +++ b/templates/dns.html @@ -37,7 +37,7 @@

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


- + + name. + link. + description. + spritesheet. + created at. + remove. + + {{ if (eq (len .Cats) 0) }} + + no cats found + + {{ end }} + {{ range $cat := .Cats }} + + {{ $cat.Name }} + {{ $cat.Link }} + {{ $cat.Description }} + + {{ $cat.CreatedAt }} + +
+ + +
+ + + {{ end }} + +
+
+

add cat.

+
+ + + + + + + + +
if not specified, will use the default. check it out for the format we expect. max 50KB.
+ + + +
+{{ end }}