From 769fb56897d2ce334bbcc8e1ac8e86d249a84b9b Mon Sep 17 00:00:00 2001 From: john Date: Sun, 3 May 2026 09:29:32 +0000 Subject: [PATCH] revert caffa117c28815dd5e8f6f09782c889003be6add revert Initial commit --- README.md | 26 - cmd/pictest/main.go | 764 ---------------------- cmd/pictest/static/app.js | 41 -- cmd/pictest/static/styles.css | 379 ----------- cmd/pictest/templates/base.gohtml | 18 - cmd/pictest/templates/feed.gohtml | 46 -- cmd/pictest/templates/login.gohtml | 21 - cmd/pictest/templates/photo_detail.gohtml | 74 --- cmd/pictest/templates/register.gohtml | 21 - go.mod | 21 - go.sum | 47 -- internal/models/models.go | 38 -- main.go | 15 - migrations/0001_init.sql | 43 -- 14 files changed, 1554 deletions(-) delete mode 100644 README.md delete mode 100644 cmd/pictest/main.go delete mode 100644 cmd/pictest/static/app.js delete mode 100644 cmd/pictest/static/styles.css delete mode 100644 cmd/pictest/templates/base.gohtml delete mode 100644 cmd/pictest/templates/feed.gohtml delete mode 100644 cmd/pictest/templates/login.gohtml delete mode 100644 cmd/pictest/templates/photo_detail.gohtml delete mode 100644 cmd/pictest/templates/register.gohtml delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 internal/models/models.go delete mode 100644 main.go delete mode 100644 migrations/0001_init.sql diff --git a/README.md b/README.md deleted file mode 100644 index 0f486f1..0000000 --- a/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Pictest - -Pictest is a Go + SQLite photo sharing site that uses server-rendered HTML, htmx, and plain JavaScript. It supports registration, login, authenticated uploads, a recent-first photo feed, photo detail modals, EXIF metadata capture, and comments. - -## Stack - -- Go 1.23+ -- SQLite via `modernc.org/sqlite` -- Server-rendered HTML templates -- htmx for partial page updates -- Plain JavaScript for modal behavior - -## Run - -1. Install Go 1.23 or newer. -2. Run `go mod download`. -3. Start the app with `go run .`. -4. Open `http://localhost:8080`. - -The app stores data in `./data/pictest.db` and uploaded photos in `./data/uploads`. - -## Notes - -- The first registered user can immediately start uploading photos. -- Photo comments are editable by their author. -- Photos can be deleted only by the uploader. diff --git a/cmd/pictest/main.go b/cmd/pictest/main.go deleted file mode 100644 index f222531..0000000 --- a/cmd/pictest/main.go +++ /dev/null @@ -1,764 +0,0 @@ -package pictest - -import ( - "bytes" - "crypto/rand" - "database/sql" - "embed" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "html/template" - "io" - "io/fs" - "mime" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - _ "modernc.org/sqlite" - - exif "github.com/rwcarlsen/goexif/exif" - "github.com/rwcarlsen/goexif/tiff" - "golang.org/x/crypto/bcrypt" - - "pictest/internal/models" -) - -//go:embed templates/*.gohtml -var templatesFS embed.FS - -//go:embed static/* -var staticFS embed.FS - -const sessionDuration = 30 * 24 * time.Hour - -type app struct { - db *sql.DB - templates map[string]*template.Template - uploadDir string - databaseNow func() time.Time -} - -type pageData struct { - Title string - CurrentUser *models.User - Photos []models.Photo - Photo *models.Photo - ExifFields []exifField - Comments []commentView - CanDelete bool - Error string - Flash string -} - -type commentView struct { - models.Comment - CanEdit bool -} - -type exifField struct { - Name string - Value string -} - -func Run() error { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - return err - } - return nil -} - -func run() error { - if err := os.MkdirAll("data/uploads", 0o755); err != nil { - return err - } - - db, err := sql.Open("sqlite", "file:data/pictest.db?_pragma=foreign_keys(1)") - if err != nil { - return err - } - defer db.Close() - - if err := initializeDatabase(db); err != nil { - return err - } - - tmpl, err := loadTemplates() - if err != nil { - return err - } - - app := &app{ - db: db, - templates: tmpl, - uploadDir: "data/uploads", - databaseNow: time.Now, - } - - mux := http.NewServeMux() - mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(mustSub(staticFS, "static"))))) - mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(app.uploadDir)))) - mux.HandleFunc("/", app.handleRoot) - mux.HandleFunc("/register", app.handleRegister) - mux.HandleFunc("/login", app.handleLogin) - mux.HandleFunc("/logout", app.handleLogout) - mux.HandleFunc("/feed", app.requireAuth(app.handleFeed)) - mux.HandleFunc("/photos", app.requireAuth(app.handlePhotos)) - mux.HandleFunc("/photos/", app.requireAuth(app.handlePhotoActions)) - mux.HandleFunc("/comments/", app.requireAuth(app.handleCommentActions)) - - server := &http.Server{ - Addr: ":8080", - Handler: loggingMiddleware(mux), - ReadHeaderTimeout: 5 * time.Second, - } - - fmt.Println("listening on http://localhost:8080") - return server.ListenAndServe() -} - -func initializeDatabase(db *sql.DB) error { - schema, err := os.ReadFile("migrations/0001_init.sql") - if err != nil { - return err - } - _, err = db.Exec(string(schema)) - return err -} - -func loadTemplates() (map[string]*template.Template, error) { - funcMap := template.FuncMap{ - "toJSON": func(value any) template.JS { - data, _ := json.Marshal(value) - return template.JS(data) - }, - } - - templates := map[string]*template.Template{} - pageFiles := []string{"login.gohtml", "register.gohtml", "feed.gohtml"} - for _, page := range pageFiles { - tmpl, err := template.New("base.gohtml").Funcs(funcMap).ParseFS(templatesFS, "templates/base.gohtml", "templates/"+page) - if err != nil { - return nil, err - } - templates[page] = tmpl - } - - partial, err := template.New("photo_detail.gohtml").Funcs(funcMap).ParseFS(templatesFS, "templates/photo_detail.gohtml") - if err != nil { - return nil, err - } - templates["photo_detail.gohtml"] = partial - - return templates, nil -} - -func mustSub(fsys embed.FS, dir string) fs.FS { - sub, err := fs.Sub(fsys, dir) - if err != nil { - panic(err) - } - return sub -} - -func loggingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - next.ServeHTTP(w, r) - fmt.Printf("%s %s %s\n", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) - }) -} - -func (a *app) requireAuth(next func(http.ResponseWriter, *http.Request, *models.User)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - user, err := a.currentUser(r) - if err != nil { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - next(w, r, user) - } -} - -func (a *app) handleRoot(w http.ResponseWriter, r *http.Request) { - if _, err := a.currentUser(r); err != nil { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - http.Redirect(w, r, "/feed", http.StatusSeeOther) -} - -func (a *app) handleRegister(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - if _, err := a.currentUser(r); err == nil { - http.Redirect(w, r, "/feed", http.StatusSeeOther) - return - } - a.render(w, "register.gohtml", pageData{}) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - username := strings.TrimSpace(r.FormValue("username")) - password := r.FormValue("password") - if err := validateCredentials(username, password); err != nil { - a.render(w, "register.gohtml", pageData{Error: err.Error()}) - return - } - - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - http.Error(w, "could not secure password", http.StatusInternalServerError) - return - } - - _, err = a.db.Exec(`INSERT INTO users(username, password_hash) VALUES(?, ?)`, username, hash) - if err != nil { - if strings.Contains(strings.ToLower(err.Error()), "unique") { - a.render(w, "register.gohtml", pageData{Error: "username already exists"}) - return - } - http.Error(w, "could not create user", http.StatusInternalServerError) - return - } - - if err := a.createSessionAndRedirect(w, r, username); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} - -func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - if _, err := a.currentUser(r); err == nil { - http.Redirect(w, r, "/feed", http.StatusSeeOther) - return - } - a.render(w, "login.gohtml", pageData{}) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - username := strings.TrimSpace(r.FormValue("username")) - password := r.FormValue("password") - - user, err := a.userByUsername(username) - if err != nil { - a.render(w, "login.gohtml", pageData{Error: "invalid username or password"}) - return - } - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { - a.render(w, "login.gohtml", pageData{Error: "invalid username or password"}) - return - } - - if err := a.createSession(w, user.ID); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/feed", http.StatusSeeOther) -} - -func (a *app) handleLogout(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if cookie, err := r.Cookie("session_id"); err == nil { - _, _ = a.db.Exec(`DELETE FROM sessions WHERE id = ?`, cookie.Value) - } - http.SetCookie(w, &http.Cookie{Name: "session_id", Value: "", Path: "/", Expires: time.Unix(0, 0), MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) - http.Redirect(w, r, "/login", http.StatusSeeOther) -} - -func (a *app) handleFeed(w http.ResponseWriter, r *http.Request, user *models.User) { - photos, err := a.photos() - if err != nil { - http.Error(w, "could not load photos", http.StatusInternalServerError) - return - } - a.render(w, "feed.gohtml", pageData{CurrentUser: user, Photos: photos}) -} - -func (a *app) handlePhotos(w http.ResponseWriter, r *http.Request, user *models.User) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - if err := r.ParseMultipartForm(32 << 20); err != nil { - http.Error(w, "invalid upload", http.StatusBadRequest) - return - } - file, header, err := r.FormFile("photo") - if err != nil { - http.Error(w, "photo file is required", http.StatusBadRequest) - return - } - defer file.Close() - - photo, err := a.savePhoto(user.ID, file, header) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - _ = photo - http.Redirect(w, r, "/feed", http.StatusSeeOther) -} - -func (a *app) handlePhotoActions(w http.ResponseWriter, r *http.Request, user *models.User) { - trimmed := strings.TrimPrefix(r.URL.Path, "/photos/") - parts := strings.Split(strings.Trim(trimmed, "/"), "/") - if len(parts) == 0 || parts[0] == "" { - http.NotFound(w, r) - return - } - - photoID, err := strconv.ParseInt(parts[0], 10, 64) - if err != nil { - http.NotFound(w, r) - return - } - - if len(parts) == 1 && r.Method == http.MethodGet { - detail, err := a.photoDetail(photoID, user.ID) - if err != nil { - http.Error(w, "photo not found", http.StatusNotFound) - return - } - a.render(w, "photo_detail.gohtml", detail) - return - } - - if len(parts) == 2 && parts[1] == "delete" && r.Method == http.MethodPost { - if err := a.deletePhoto(photoID, user.ID); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - http.Redirect(w, r, "/feed", http.StatusSeeOther) - return - } - - if len(parts) == 2 && parts[1] == "comments" && r.Method == http.MethodPost { - body := strings.TrimSpace(r.FormValue("body")) - if body == "" { - http.Error(w, "comment cannot be empty", http.StatusBadRequest) - return - } - if err := a.addComment(photoID, user.ID, body); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - detail, err := a.photoDetail(photoID, user.ID) - if err != nil { - http.Error(w, "photo not found", http.StatusNotFound) - return - } - a.render(w, "photo_detail.gohtml", detail) - return - } - - http.NotFound(w, r) -} - -func (a *app) handleCommentActions(w http.ResponseWriter, r *http.Request, user *models.User) { - trimmed := strings.TrimPrefix(r.URL.Path, "/comments/") - parts := strings.Split(strings.Trim(trimmed, "/"), "/") - if len(parts) == 0 || parts[0] == "" { - http.NotFound(w, r) - return - } - - commentID, err := strconv.ParseInt(parts[0], 10, 64) - if err != nil { - http.NotFound(w, r) - return - } - - if r.Method == http.MethodPost && len(parts) == 1 { - body := strings.TrimSpace(r.FormValue("body")) - if body == "" { - http.Error(w, "comment cannot be empty", http.StatusBadRequest) - return - } - photoID, err := a.updateComment(commentID, user.ID, body) - if err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - detail, err := a.photoDetail(photoID, user.ID) - if err != nil { - http.Error(w, "photo not found", http.StatusNotFound) - return - } - a.render(w, "photo_detail.gohtml", detail) - return - } - - http.NotFound(w, r) -} - -func (a *app) render(w http.ResponseWriter, name string, data any) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - tmpl, ok := a.templates[name] - if !ok { - http.Error(w, "template not found", http.StatusInternalServerError) - return - } - templateName := "base" - if name == "photo_detail.gohtml" { - templateName = "photo_detail" - } - if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -func (a *app) currentUser(r *http.Request) (*models.User, error) { - cookie, err := r.Cookie("session_id") - if err != nil || cookie.Value == "" { - return nil, errors.New("no session") - } - - var user models.User - var expiresAt time.Time - err = a.db.QueryRow(` - SELECT u.id, u.username, u.password_hash, u.created_at, s.expires_at - FROM sessions s - JOIN users u ON u.id = s.user_id - WHERE s.id = ? - `, cookie.Value).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.CreatedAt, &expiresAt) - if err != nil { - return nil, err - } - if time.Now().After(expiresAt) { - _, _ = a.db.Exec(`DELETE FROM sessions WHERE id = ?`, cookie.Value) - return nil, errors.New("session expired") - } - return &user, nil -} - -func (a *app) userByUsername(username string) (*models.User, error) { - var user models.User - return &user, a.db.QueryRow(`SELECT id, username, password_hash, created_at FROM users WHERE username = ?`, username).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.CreatedAt) -} - -func validateCredentials(username, password string) error { - if len(username) < 3 || len(username) > 32 { - return errors.New("username must be between 3 and 32 characters") - } - if len(password) < 8 { - return errors.New("password must be at least 8 characters") - } - return nil -} - -func (a *app) createSessionAndRedirect(w http.ResponseWriter, r *http.Request, username string) error { - user, err := a.userByUsername(username) - if err != nil { - return err - } - if err := a.createSession(w, user.ID); err != nil { - return err - } - http.Redirect(w, r, "/feed", http.StatusSeeOther) - return nil -} - -func (a *app) createSession(w http.ResponseWriter, userID int64) error { - sessionID, err := randomToken(32) - if err != nil { - return err - } - expiresAt := a.databaseNow().Add(sessionDuration) - _, err = a.db.Exec(`INSERT INTO sessions(id, user_id, expires_at) VALUES(?, ?, ?)`, sessionID, userID, expiresAt) - if err != nil { - return err - } - http.SetCookie(w, &http.Cookie{ - Name: "session_id", - Value: sessionID, - Path: "/", - Expires: expiresAt, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) - return nil -} - -func randomToken(size int) (string, error) { - buffer := make([]byte, size) - if _, err := rand.Read(buffer); err != nil { - return "", err - } - return hex.EncodeToString(buffer), nil -} - -func (a *app) photos() ([]models.Photo, error) { - rows, err := a.db.Query(` - SELECT p.id, p.user_id, u.username, p.original_filename, p.stored_filename, p.mime_type, p.width, p.height, p.exif_json, p.created_at - FROM photos p - JOIN users u ON u.id = p.user_id - ORDER BY p.created_at DESC, p.id DESC - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var photos []models.Photo - for rows.Next() { - var photo models.Photo - if err := rows.Scan(&photo.ID, &photo.UserID, &photo.Username, &photo.OriginalFilename, &photo.StoredFilename, &photo.MimeType, &photo.Width, &photo.Height, &photo.ExifJSON, &photo.CreatedAt); err != nil { - return nil, err - } - photos = append(photos, photo) - } - return photos, rows.Err() -} - -func (a *app) photoDetail(photoID int64, currentUserID int64) (*pageData, error) { - var photo models.Photo - err := a.db.QueryRow(` - SELECT p.id, p.user_id, u.username, p.original_filename, p.stored_filename, p.mime_type, p.width, p.height, p.exif_json, p.created_at - FROM photos p - JOIN users u ON u.id = p.user_id - WHERE p.id = ? - `, photoID).Scan(&photo.ID, &photo.UserID, &photo.Username, &photo.OriginalFilename, &photo.StoredFilename, &photo.MimeType, &photo.Width, &photo.Height, &photo.ExifJSON, &photo.CreatedAt) - if err != nil { - return nil, err - } - - rows, err := a.db.Query(` - SELECT c.id, c.photo_id, c.user_id, u.username, c.body, c.created_at, c.updated_at - FROM comments c - JOIN users u ON u.id = c.user_id - WHERE c.photo_id = ? - ORDER BY c.created_at ASC, c.id ASC - `, photoID) - if err != nil { - return nil, err - } - defer rows.Close() - - var comments []commentView - for rows.Next() { - var comment models.Comment - if err := rows.Scan(&comment.ID, &comment.PhotoID, &comment.UserID, &comment.Username, &comment.Body, &comment.CreatedAt, &comment.UpdatedAt); err != nil { - return nil, err - } - comments = append(comments, commentView{Comment: comment, CanEdit: comment.UserID == currentUserID}) - } - - canDelete := photo.UserID == currentUserID - return &pageData{ - Photo: &photo, - ExifFields: parseExifFields(photo.ExifJSON), - Comments: comments, - CanDelete: canDelete, - CurrentUser: &models.User{ID: currentUserID}, - }, nil -} - -func (a *app) savePhoto(userID int64, file multipart.File, header *multipart.FileHeader) (*models.Photo, error) { - content, err := io.ReadAll(io.LimitReader(file, 20<<20)) - if err != nil { - return nil, err - } - if len(content) == 0 { - return nil, errors.New("uploaded file is empty") - } - - mimeType := http.DetectContentType(content) - if !strings.HasPrefix(mimeType, "image/") { - return nil, errors.New("only image uploads are supported") - } - - config, _, err := imageConfig(content) - if err != nil { - return nil, errors.New("uploaded file is not a valid image") - } - - exifJSON, _ := extractExif(content) - storedName, err := randomToken(16) - if err != nil { - return nil, err - } - ext := filepath.Ext(filepath.Base(header.Filename)) - if ext == "" { - extensions, lookupErr := mime.ExtensionsByType(mimeType) - if lookupErr == nil && len(extensions) > 0 { - ext = extensions[0] - } - } - if ext == "" { - ext = ".img" - } - storedName = storedName + ext - - path := filepath.Join(a.uploadDir, storedName) - if err := os.WriteFile(path, content, 0o644); err != nil { - return nil, err - } - - result, err := a.db.Exec(` - INSERT INTO photos(user_id, original_filename, stored_filename, mime_type, width, height, exif_json) - VALUES(?, ?, ?, ?, ?, ?, ?) - `, userID, header.Filename, storedName, mimeType, config.Width, config.Height, exifJSON) - if err != nil { - return nil, err - } - photoID, _ := result.LastInsertId() - - return &models.Photo{ - ID: photoID, - UserID: userID, - OriginalFilename: header.Filename, - StoredFilename: storedName, - MimeType: mimeType, - Width: config.Width, - Height: config.Height, - ExifJSON: exifJSON, - CreatedAt: time.Now(), - }, nil -} - -func imageConfig(data []byte) (imageConfigResult, string, error) { - config, format, err := image.DecodeConfig(bytes.NewReader(data)) - return imageConfigResult{Width: config.Width, Height: config.Height}, format, err -} - -type imageConfigResult struct { - Width int - Height int -} - -func extractExif(data []byte) (string, error) { - x, err := exif.Decode(bytes.NewReader(data)) - if err != nil { - return "{}", nil - } - collector := exifMapCollector{values: map[string]any{}} - _ = x.Walk(&collector) - encoded, err := json.Marshal(collector.values) - if err != nil { - return "{}", nil - } - return string(encoded), nil -} - -type exifMapCollector struct { - values map[string]any -} - -func (c *exifMapCollector) Walk(name exif.FieldName, tag *tiff.Tag) error { - if tag == nil { - return nil - } - val, err := tag.StringVal() - if err != nil { - val = tag.String() - } - c.values[string(name)] = val - return nil -} - -func parseExifFields(raw string) []exifField { - if strings.TrimSpace(raw) == "" || raw == "{}" { - return nil - } - - var values map[string]any - if err := json.Unmarshal([]byte(raw), &values); err != nil { - return nil - } - - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - - fields := make([]exifField, 0, len(keys)) - for _, key := range keys { - fields = append(fields, exifField{Name: key, Value: exifValueString(values[key])}) - } - return fields -} - -func exifValueString(value any) string { - switch typed := value.(type) { - case string: - return typed - case float64: - if typed == float64(int64(typed)) { - return strconv.FormatInt(int64(typed), 10) - } - return strconv.FormatFloat(typed, 'f', -1, 64) - case bool: - return strconv.FormatBool(typed) - case nil: - return "" - default: - encoded, err := json.Marshal(typed) - if err != nil { - return fmt.Sprint(typed) - } - return string(encoded) - } -} - -func (a *app) deletePhoto(photoID, userID int64) error { - var ownerID int64 - var storedFilename string - err := a.db.QueryRow(`SELECT user_id, stored_filename FROM photos WHERE id = ?`, photoID).Scan(&ownerID, &storedFilename) - if err != nil { - return errors.New("photo not found") - } - if ownerID != userID { - return errors.New("you can only delete your own photos") - } - if _, err := a.db.Exec(`DELETE FROM photos WHERE id = ?`, photoID); err != nil { - return err - } - _ = os.Remove(filepath.Join(a.uploadDir, storedFilename)) - return nil -} - -func (a *app) addComment(photoID, userID int64, body string) error { - _, err := a.db.Exec(`INSERT INTO comments(photo_id, user_id, body) VALUES(?, ?, ?)`, photoID, userID, body) - return err -} - -func (a *app) updateComment(commentID, userID int64, body string) (int64, error) { - var photoID int64 - var ownerID int64 - err := a.db.QueryRow(`SELECT photo_id, user_id FROM comments WHERE id = ?`, commentID).Scan(&photoID, &ownerID) - if err != nil { - return 0, errors.New("comment not found") - } - if ownerID != userID { - return 0, errors.New("you can only edit your own comments") - } - _, err = a.db.Exec(`UPDATE comments SET body = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, body, commentID) - return photoID, err -} diff --git a/cmd/pictest/static/app.js b/cmd/pictest/static/app.js deleted file mode 100644 index 711e37f..0000000 --- a/cmd/pictest/static/app.js +++ /dev/null @@ -1,41 +0,0 @@ -document.addEventListener("DOMContentLoaded", () => { - const dialog = document.getElementById("photo-modal"); - - function openDialog() { - if (dialog && !dialog.open) { - dialog.showModal(); - } - } - - function closeDialog() { - if (dialog && dialog.open) { - dialog.close(); - } - } - - document.body.addEventListener("click", (event) => { - const trigger = event.target.closest("[data-modal-trigger]"); - const closer = event.target.closest("[data-modal-close]"); - if (closer) { - event.preventDefault(); - closeDialog(); - } - if (trigger) { - event.preventDefault(); - } - }); - - document.body.addEventListener("htmx:afterSwap", (event) => { - if (event.target && event.target.id === "photo-modal-content") { - openDialog(); - } - }); - - if (dialog) { - dialog.addEventListener("click", (event) => { - if (event.target === dialog) { - closeDialog(); - } - }); - } -}); diff --git a/cmd/pictest/static/styles.css b/cmd/pictest/static/styles.css deleted file mode 100644 index efd53c6..0000000 --- a/cmd/pictest/static/styles.css +++ /dev/null @@ -1,379 +0,0 @@ -:root { - color-scheme: light; - --bg: #f5efe6; - --bg-accent: #e7dbc9; - --panel: rgba(255, 255, 255, 0.72); - --panel-strong: #ffffff; - --text: #1f1b16; - --muted: #6d6258; - --accent: #9d4f24; - --accent-strong: #6f3314; - --danger: #a72d22; - --border: rgba(67, 46, 30, 0.14); - --shadow: 0 18px 60px rgba(43, 26, 11, 0.12); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - color: var(--text); - background: - radial-gradient(circle at top left, rgba(157, 79, 36, 0.18), transparent 28%), - radial-gradient(circle at top right, rgba(81, 118, 129, 0.18), transparent 28%), - linear-gradient(180deg, var(--bg), #fff8f0 48%, #f7f2eb); - min-height: 100vh; -} - -.page-shell { - width: min(1200px, calc(100% - 32px)); - margin: 0 auto; - padding: 28px 0 40px; -} - -.auth-layout { - display: grid; - grid-template-columns: 1.1fr 0.9fr; - gap: 24px; - min-height: calc(100vh - 80px); - align-items: center; -} - -.hero-panel, -.form-panel, -.upload-card, -.photo-card, -.modal-card, -.notice, -.empty-state { - background: var(--panel); - backdrop-filter: blur(18px); - border: 1px solid var(--border); - box-shadow: var(--shadow); -} - -.hero-panel, -.form-panel, -.upload-card, -.modal-card { - border-radius: 28px; - padding: 28px; -} - -.hero-panel h1, -.topbar h1 { - font-family: Georgia, "Times New Roman", serif; - font-size: clamp(2.4rem, 6vw, 4.6rem); - line-height: 0.95; - margin: 8px 0 16px; -} - -.hero-panel p { - max-width: 52ch; - font-size: 1.05rem; - color: var(--muted); -} - -.eyebrow { - text-transform: uppercase; - letter-spacing: 0.18em; - font-size: 0.76rem; - color: var(--accent-strong); - margin: 0; - font-weight: 700; -} - -.stack-form, -.upload-form, -.comment-form, -.inline-edit-form { - display: grid; - gap: 14px; -} - -label { - display: grid; - gap: 8px; - font-weight: 600; -} - -input, -textarea, -button { - font: inherit; -} - -input, -textarea { - width: 100%; - border: 1px solid var(--border); - border-radius: 16px; - padding: 14px 15px; - background: rgba(255, 255, 255, 0.88); - color: var(--text); -} - -textarea { - min-height: 120px; - resize: vertical; -} - -button { - border: 0; - border-radius: 999px; - padding: 12px 18px; - background: var(--accent); - color: white; - font-weight: 700; - cursor: pointer; - transition: transform 0.18s ease, background 0.18s ease; -} - -button:hover { - transform: translateY(-1px); - background: var(--accent-strong); -} - -button.secondary { - background: rgba(31, 27, 22, 0.08); - color: var(--text); -} - -button.secondary:hover { - background: rgba(31, 27, 22, 0.14); -} - -button.danger { - background: var(--danger); -} - -.form-footnote, -.notice, -.card-meta, -.comment-footer, -.metadata dd, -.metadata dt, -.empty-state { - color: var(--muted); -} - -.notice { - border-radius: 16px; - padding: 12px 14px; - margin-bottom: 16px; -} - -.notice.error { - border-color: rgba(167, 45, 34, 0.35); - color: #7f1f19; - background: rgba(167, 45, 34, 0.08); -} - -.topbar { - display: flex; - justify-content: space-between; - align-items: center; - gap: 16px; - margin-bottom: 22px; -} - -.upload-card { - margin-bottom: 24px; -} - -.gallery-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 18px; -} - -.photo-card { - overflow: hidden; - border-radius: 24px; -} - -.photo-link { - display: block; -} - -.photo-card img, -.modal-image { - width: 100%; - display: block; - object-fit: cover; -} - -.photo-card img { - aspect-ratio: 4 / 3; -} - -.card-meta { - display: grid; - gap: 4px; - padding: 14px 16px 18px; -} - -.photo-modal { - border: 0; - padding: 0; - width: min(1080px, calc(100vw - 20px)); - background: transparent; -} - -.photo-modal::backdrop { - background: rgba(17, 13, 9, 0.72); -} - -.modal-card { - position: relative; - max-height: 92vh; - overflow: auto; - background: #fffaf4; -} - -.modal-image-wrap { - margin-bottom: 18px; - border-radius: 22px; - overflow: hidden; - background: #f6efe7; -} - -.modal-image { - max-height: 58vh; - object-fit: contain; -} - -.close-button { - position: sticky; - top: 0; - float: right; - z-index: 1; -} - -.detail-header { - display: flex; - justify-content: space-between; - gap: 16px; - align-items: start; - margin-bottom: 18px; -} - -.metadata { - display: grid; - gap: 12px; - margin: 0 0 24px; -} - -.metadata div { - padding: 14px 16px; - border-radius: 18px; - background: rgba(31, 27, 22, 0.04); -} - -.metadata dt { - font-size: 0.82rem; - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.metadata dd, -.metadata pre { - margin: 6px 0 0; - white-space: pre-wrap; - word-break: break-word; -} - -.section-heading { - margin-bottom: 12px; -} - -.section-heading h3 { - margin: 0 0 4px; -} - -.section-heading p { - margin: 0; - color: var(--muted); -} - -.exif-card { - margin-bottom: 24px; -} - -.exif-list { - display: grid; - gap: 10px; - margin: 0; -} - -.exif-list div { - padding: 12px 14px; - border-radius: 16px; - background: rgba(31, 27, 22, 0.04); -} - -.exif-list dt { - font-size: 0.82rem; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--accent-strong); -} - -.exif-list dd { - margin: 6px 0 0; - word-break: break-word; -} - -.comments-section { - display: grid; - gap: 16px; -} - -.comments-list { - display: grid; - gap: 12px; -} - -.comment-card { - border-radius: 18px; - padding: 14px 16px; - background: rgba(31, 27, 22, 0.04); -} - -.comment-card p { - margin: 0 0 8px; -} - -.comment-footer { - display: grid; - gap: 10px; - font-size: 0.9rem; -} - -.inline-edit-form textarea { - min-height: 84px; -} - -.empty-state { - border-radius: 22px; - padding: 20px; -} - -@media (max-width: 860px) { - .auth-layout { - grid-template-columns: 1fr; - } - - .topbar, - .detail-header { - flex-direction: column; - align-items: stretch; - } - - .page-shell { - width: min(100%, calc(100% - 20px)); - } -} diff --git a/cmd/pictest/templates/base.gohtml b/cmd/pictest/templates/base.gohtml deleted file mode 100644 index c85bbb5..0000000 --- a/cmd/pictest/templates/base.gohtml +++ /dev/null @@ -1,18 +0,0 @@ -{{define "base"}} - - - - - - {{block "title" .}}Pictest{{end}} - - - - - -
- {{block "body" .}}{{end}} -
- - -{{end}} diff --git a/cmd/pictest/templates/feed.gohtml b/cmd/pictest/templates/feed.gohtml deleted file mode 100644 index 60b0eca..0000000 --- a/cmd/pictest/templates/feed.gohtml +++ /dev/null @@ -1,46 +0,0 @@ -{{define "title"}}Feed · Pictest{{end}} -{{define "body"}} -
-
-

Signed in as {{.CurrentUser.Username}}

-

Recent photos

-
-
- -
-
- -
-

Upload a photo

- {{if .Flash}}
{{.Flash}}
{{end}} - {{if .Error}}
{{.Error}}
{{end}} -
- - -
-
- - - - -
-
-{{end}} -{{template "base" .}} diff --git a/cmd/pictest/templates/login.gohtml b/cmd/pictest/templates/login.gohtml deleted file mode 100644 index dd4b295..0000000 --- a/cmd/pictest/templates/login.gohtml +++ /dev/null @@ -1,21 +0,0 @@ -{{define "title"}}Login · Pictest{{end}} -{{define "body"}} -
-
-

Pictest

-

Share photographs with a clean, fast gallery.

-

Register to upload photos, browse the newest images first, inspect metadata, and leave comments.

-
-
-

Login

- {{if .Error}}
{{.Error}}
{{end}} -
- - - -
-

No account yet? Create one

-
-
-{{end}} -{{template "base" .}} diff --git a/cmd/pictest/templates/photo_detail.gohtml b/cmd/pictest/templates/photo_detail.gohtml deleted file mode 100644 index 3994d18..0000000 --- a/cmd/pictest/templates/photo_detail.gohtml +++ /dev/null @@ -1,74 +0,0 @@ -{{define "photo_detail"}} - -{{end}} diff --git a/cmd/pictest/templates/register.gohtml b/cmd/pictest/templates/register.gohtml deleted file mode 100644 index 3c533d2..0000000 --- a/cmd/pictest/templates/register.gohtml +++ /dev/null @@ -1,21 +0,0 @@ -{{define "title"}}Register · Pictest{{end}} -{{define "body"}} -
-
-

Pictest

-

Create an account to start sharing images.

-

Your profile becomes the uploader tag on each photo you add.

-
-
-

Register

- {{if .Error}}
{{.Error}}
{{end}} -
- - - -
-

Already registered? Log in

-
-
-{{end}} -{{template "base" .}} diff --git a/go.mod b/go.mod deleted file mode 100644 index 984de1b..0000000 --- a/go.mod +++ /dev/null @@ -1,21 +0,0 @@ -module pictest - -go 1.23.0 - -require ( - github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd - golang.org/x/crypto v0.35.0 - modernc.org/sqlite v1.34.5 -) - -require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/sys v0.30.0 // indirect - modernc.org/libc v1.55.3 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.8.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 34431a0..0000000 --- a/go.sum +++ /dev/null @@ -1,47 +0,0 @@ -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= -github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= -golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= -golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= -golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= -golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= -golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= -modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= -modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= -modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= -modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= -modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= -modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= -modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= -modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= -modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/models/models.go b/internal/models/models.go deleted file mode 100644 index 3840c68..0000000 --- a/internal/models/models.go +++ /dev/null @@ -1,38 +0,0 @@ -package models - -import "time" - -type User struct { - ID int64 - Username string - PasswordHash string - CreatedAt time.Time -} - -type Photo struct { - ID int64 - UserID int64 - Username string - OriginalFilename string - StoredFilename string - MimeType string - Width int - Height int - ExifJSON string - CreatedAt time.Time -} - -type Comment struct { - ID int64 - PhotoID int64 - UserID int64 - Username string - Body string - CreatedAt time.Time - UpdatedAt time.Time -} - -type PhotoDetail struct { - Photo - Comments []Comment -} diff --git a/main.go b/main.go deleted file mode 100644 index 2e08943..0000000 --- a/main.go +++ /dev/null @@ -1,15 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "pictest/cmd/pictest" -) - -func main() { - if err := pictest.Run(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} \ No newline at end of file diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql deleted file mode 100644 index 837c5c9..0000000 --- a/migrations/0001_init.sql +++ /dev/null @@ -1,43 +0,0 @@ -PRAGMA foreign_keys = ON; - -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL UNIQUE, - password_hash TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - expires_at DATETIME NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - original_filename TEXT NOT NULL, - stored_filename TEXT NOT NULL UNIQUE, - mime_type TEXT NOT NULL, - width INTEGER NOT NULL DEFAULT 0, - height INTEGER NOT NULL DEFAULT 0, - exif_json TEXT NOT NULL DEFAULT '{}', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - photo_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - body TEXT NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (photo_id) REFERENCES photos(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_photos_created_at ON photos(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_comments_photo_id_created_at ON comments(photo_id, created_at);