@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{{define "base"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{block "title" .}}Pictest{{end}}</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||
<script defer src="/static/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
{{block "body" .}}{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,46 +0,0 @@
|
||||
{{define "title"}}Feed · Pictest{{end}}
|
||||
{{define "body"}}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Signed in as {{.CurrentUser.Username}}</p>
|
||||
<h1>Recent photos</h1>
|
||||
</div>
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" class="secondary">Log out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<section class="upload-card">
|
||||
<h2>Upload a photo</h2>
|
||||
{{if .Flash}}<div class="notice">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="notice error">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/photos" enctype="multipart/form-data" class="upload-form">
|
||||
<input type="file" name="photo" accept="image/*" required>
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="gallery-grid" id="gallery">
|
||||
{{if .Photos}}
|
||||
{{range .Photos}}
|
||||
<article class="photo-card">
|
||||
<a href="/photos/{{.ID}}" class="photo-link" hx-get="/photos/{{.ID}}" hx-target="#photo-modal-content" hx-swap="innerHTML" data-modal-trigger>
|
||||
<img src="/uploads/{{.StoredFilename}}" alt="{{.OriginalFilename}}">
|
||||
</a>
|
||||
<div class="card-meta">
|
||||
<strong>{{.OriginalFilename}}</strong>
|
||||
<span>by {{.Username}}</span>
|
||||
<span>{{.CreatedAt.Format "02 Jan 2006 15:04"}}</span>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty-state">No photos yet. Upload the first one.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<dialog id="photo-modal" class="photo-modal">
|
||||
<div id="photo-modal-content"></div>
|
||||
</dialog>
|
||||
{{end}}
|
||||
{{template "base" .}}
|
||||
@@ -1,21 +0,0 @@
|
||||
{{define "title"}}Login · Pictest{{end}}
|
||||
{{define "body"}}
|
||||
<main class="auth-layout">
|
||||
<section class="hero-panel">
|
||||
<p class="eyebrow">Pictest</p>
|
||||
<h1>Share photographs with a clean, fast gallery.</h1>
|
||||
<p>Register to upload photos, browse the newest images first, inspect metadata, and leave comments.</p>
|
||||
</section>
|
||||
<section class="form-panel">
|
||||
<h2>Login</h2>
|
||||
{{if .Error}}<div class="notice error">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/login" class="stack-form">
|
||||
<label>Username<input name="username" autocomplete="username" required></label>
|
||||
<label>Password<input type="password" name="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
<p class="form-footnote">No account yet? <a href="/register">Create one</a></p>
|
||||
</section>
|
||||
</main>
|
||||
{{end}}
|
||||
{{template "base" .}}
|
||||
@@ -1,74 +0,0 @@
|
||||
{{define "photo_detail"}}
|
||||
<article class="modal-card">
|
||||
<button type="button" class="close-button" data-modal-close>Close</button>
|
||||
<div class="modal-image-wrap">
|
||||
<img src="/uploads/{{.Photo.StoredFilename}}" alt="{{.Photo.OriginalFilename}}" class="modal-image">
|
||||
</div>
|
||||
<div class="modal-info">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<h2>{{.Photo.OriginalFilename}}</h2>
|
||||
<p>Uploaded by {{.Photo.Username}} on {{.Photo.CreatedAt.Format "02 Jan 2006 15:04"}}</p>
|
||||
</div>
|
||||
{{if .CanDelete}}
|
||||
<form method="post" action="/photos/{{.Photo.ID}}/delete">
|
||||
<button type="submit" class="danger">Delete photo</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<dl class="metadata">
|
||||
<div><dt>Filename</dt><dd>{{.Photo.OriginalFilename}}</dd></div>
|
||||
<div><dt>Uploader</dt><dd>{{.Photo.Username}}</dd></div>
|
||||
<div><dt>Date</dt><dd>{{.Photo.CreatedAt.Format "02 Jan 2006 15:04"}}</dd></div>
|
||||
</dl>
|
||||
|
||||
<section class="exif-card">
|
||||
<div class="section-heading">
|
||||
<h3>Camera metadata</h3>
|
||||
<p>Extracted from the image file when available.</p>
|
||||
</div>
|
||||
{{if .ExifFields}}
|
||||
<dl class="exif-list">
|
||||
{{range .ExifFields}}
|
||||
<div>
|
||||
<dt>{{.Name}}</dt>
|
||||
<dd>{{.Value}}</dd>
|
||||
</div>
|
||||
{{end}}
|
||||
</dl>
|
||||
{{else}}
|
||||
<p class="empty-state">No EXIF metadata was embedded in this photo.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="comments-section">
|
||||
<h3>Comments</h3>
|
||||
<form method="post" action="/photos/{{.Photo.ID}}/comments" hx-post="/photos/{{.Photo.ID}}/comments" hx-target="#photo-modal-content" hx-swap="innerHTML" class="comment-form">
|
||||
<textarea name="body" required minlength="1" maxlength="1000" placeholder="Write a comment"></textarea>
|
||||
<button type="submit">Add comment</button>
|
||||
</form>
|
||||
<div class="comments-list">
|
||||
{{if .Comments}}
|
||||
{{range .Comments}}
|
||||
<article class="comment-card">
|
||||
<p>{{.Body}}</p>
|
||||
<div class="comment-footer">
|
||||
<span>by {{.Username}} on {{.CreatedAt.Format "02 Jan 2006 15:04"}}</span>
|
||||
{{if .CanEdit}}
|
||||
<form method="post" action="/comments/{{.ID}}" hx-post="/comments/{{.ID}}" hx-target="#photo-modal-content" hx-swap="innerHTML" class="inline-edit-form">
|
||||
<textarea name="body" required maxlength="1000">{{.Body}}</textarea>
|
||||
<button type="submit" class="secondary">Save</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="empty-state">No comments yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
@@ -1,21 +0,0 @@
|
||||
{{define "title"}}Register · Pictest{{end}}
|
||||
{{define "body"}}
|
||||
<main class="auth-layout">
|
||||
<section class="hero-panel">
|
||||
<p class="eyebrow">Pictest</p>
|
||||
<h1>Create an account to start sharing images.</h1>
|
||||
<p>Your profile becomes the uploader tag on each photo you add.</p>
|
||||
</section>
|
||||
<section class="form-panel">
|
||||
<h2>Register</h2>
|
||||
{{if .Error}}<div class="notice error">{{.Error}}</div>{{end}}
|
||||
<form method="post" action="/register" class="stack-form">
|
||||
<label>Username<input name="username" autocomplete="username" required minlength="3" maxlength="32"></label>
|
||||
<label>Password<input type="password" name="password" autocomplete="new-password" required minlength="8"></label>
|
||||
<button type="submit">Create account</button>
|
||||
</form>
|
||||
<p class="form-footnote">Already registered? <a href="/login">Log in</a></p>
|
||||
</section>
|
||||
</main>
|
||||
{{end}}
|
||||
{{template "base" .}}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user