Initial commit

This commit is contained in:
2026-05-03 10:17:59 +01:00
commit caffa117c2
14 changed files with 1554 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# 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.
+764
View File
@@ -0,0 +1,764 @@
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
}
+41
View File
@@ -0,0 +1,41 @@
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();
}
});
}
});
+379
View File
@@ -0,0 +1,379 @@
: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));
}
}
+18
View File
@@ -0,0 +1,18 @@
{{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}}
+46
View File
@@ -0,0 +1,46 @@
{{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" .}}
+21
View File
@@ -0,0 +1,21 @@
{{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" .}}
+74
View File
@@ -0,0 +1,74 @@
{{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}}
+21
View File
@@ -0,0 +1,21 @@
{{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" .}}
+21
View File
@@ -0,0 +1,21 @@
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
)
+47
View File
@@ -0,0 +1,47 @@
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=
+38
View File
@@ -0,0 +1,38 @@
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
}
+15
View File
@@ -0,0 +1,15 @@
package main
import (
"fmt"
"os"
"pictest/cmd/pictest"
)
func main() {
if err := pictest.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+43
View File
@@ -0,0 +1,43 @@
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);