From 7ee8ff382becd6a1a55df17e19a2a901edbf27c2 Mon Sep 17 00:00:00 2001 From: John Burton Date: Sun, 3 May 2026 10:40:02 +0100 Subject: [PATCH] Empty --- Jenkinsfile | 20 + 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 ++ scripts/ai_code_review.py | 275 ++++++++ 16 files changed, 1849 insertions(+) create mode 100644 Jenkinsfile create mode 100644 README.md create mode 100644 cmd/pictest/main.go create mode 100644 cmd/pictest/static/app.js create mode 100644 cmd/pictest/static/styles.css create mode 100644 cmd/pictest/templates/base.gohtml create mode 100644 cmd/pictest/templates/feed.gohtml create mode 100644 cmd/pictest/templates/login.gohtml create mode 100644 cmd/pictest/templates/photo_detail.gohtml create mode 100644 cmd/pictest/templates/register.gohtml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/models/models.go create mode 100644 main.go create mode 100644 migrations/0001_init.sql create mode 100644 scripts/ai_code_review.py diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..bb45d90 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,20 @@ +pipeline { + agent any + environment { + OPENAI_API_KEY = credentials('OPENAI_API_KEY') + GITEA_TOKEN = credentials('GITEA_TOKEN') + GITEA_URL = 'https://git.jb9.uk' + CODEX_MODEL = 'gpt-4' + AI_REVIEW_FAIL_ON_FINDINGS = 'false' + } + + stages { + + stage ('AI Code review') { + steps { + sh 'python3 scripts/ai_code_review.py' + } + } + + } +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f486f1 --- /dev/null +++ b/README.md @@ -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. diff --git a/cmd/pictest/main.go b/cmd/pictest/main.go new file mode 100644 index 0000000..f222531 --- /dev/null +++ b/cmd/pictest/main.go @@ -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 +} diff --git a/cmd/pictest/static/app.js b/cmd/pictest/static/app.js new file mode 100644 index 0000000..711e37f --- /dev/null +++ b/cmd/pictest/static/app.js @@ -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(); + } + }); + } +}); diff --git a/cmd/pictest/static/styles.css b/cmd/pictest/static/styles.css new file mode 100644 index 0000000..efd53c6 --- /dev/null +++ b/cmd/pictest/static/styles.css @@ -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)); + } +} diff --git a/cmd/pictest/templates/base.gohtml b/cmd/pictest/templates/base.gohtml new file mode 100644 index 0000000..c85bbb5 --- /dev/null +++ b/cmd/pictest/templates/base.gohtml @@ -0,0 +1,18 @@ +{{define "base"}} + + + + + + {{block "title" .}}Pictest{{end}} + + + + + +
+ {{block "body" .}}{{end}} +
+ + +{{end}} diff --git a/cmd/pictest/templates/feed.gohtml b/cmd/pictest/templates/feed.gohtml new file mode 100644 index 0000000..60b0eca --- /dev/null +++ b/cmd/pictest/templates/feed.gohtml @@ -0,0 +1,46 @@ +{{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 new file mode 100644 index 0000000..dd4b295 --- /dev/null +++ b/cmd/pictest/templates/login.gohtml @@ -0,0 +1,21 @@ +{{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 new file mode 100644 index 0000000..3994d18 --- /dev/null +++ b/cmd/pictest/templates/photo_detail.gohtml @@ -0,0 +1,74 @@ +{{define "photo_detail"}} + +{{end}} diff --git a/cmd/pictest/templates/register.gohtml b/cmd/pictest/templates/register.gohtml new file mode 100644 index 0000000..3c533d2 --- /dev/null +++ b/cmd/pictest/templates/register.gohtml @@ -0,0 +1,21 @@ +{{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 new file mode 100644 index 0000000..984de1b --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..34431a0 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..3840c68 --- /dev/null +++ b/internal/models/models.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..2e08943 --- /dev/null +++ b/main.go @@ -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) + } +} \ No newline at end of file diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..837c5c9 --- /dev/null +++ b/migrations/0001_init.sql @@ -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); diff --git a/scripts/ai_code_review.py b/scripts/ai_code_review.py new file mode 100644 index 0000000..fb395db --- /dev/null +++ b/scripts/ai_code_review.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 + +import json +import os +import subprocess +import sys +from typing import Optional +from urllib import error, parse, request + + +MAX_DIFF_CHARS = 12000 +STATUS_CONTEXT = "ai/code-review" + + +def run_git_command(*args: str) -> str: + result = subprocess.run( + ["git", *args], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def try_git_command(*args: str) -> Optional[str]: + try: + return run_git_command(*args) + except subprocess.CalledProcessError: + return None + + +def resolve_base_commit() -> Optional[str]: + for env_name in ("GIT_PREVIOUS_SUCCESSFUL_COMMIT", "GIT_PREVIOUS_COMMIT"): + value = os.getenv(env_name) + if value: + return value + + change_target = os.getenv("CHANGE_TARGET") + if change_target: + base = try_git_command("merge-base", "HEAD", f"origin/{change_target}") + if base: + return base + + return try_git_command("rev-parse", "HEAD~1") + + +def collect_diff() -> str: + base_commit = resolve_base_commit() + if base_commit: + diff = try_git_command("diff", f"{base_commit}..HEAD", "--") + if diff: + return diff + + staged_diff = try_git_command("diff", "--cached", "--") + if staged_diff: + return staged_diff + + working_tree_diff = try_git_command("diff", "--") + if working_tree_diff: + return working_tree_diff + + return "" + + +def get_current_commit() -> Optional[str]: + return os.getenv("GIT_COMMIT") or try_git_command("rev-parse", "HEAD") + + +def parse_repo_from_remote() -> tuple[Optional[str], Optional[str]]: + remote_url = try_git_command("remote", "get-url", "origin") + if not remote_url: + return None, None + + cleaned = remote_url.strip() + if cleaned.endswith(".git"): + cleaned = cleaned[:-4] + + if cleaned.startswith("git@") and ":" in cleaned: + cleaned = cleaned.split(":", 1)[1] + elif "://" in cleaned: + parsed = parse.urlparse(cleaned) + cleaned = parsed.path.lstrip("/") + + parts = [part for part in cleaned.split("/") if part] + if len(parts) < 2: + return None, None + + return parts[-2], parts[-1] + + +def get_gitea_repo() -> tuple[Optional[str], Optional[str]]: + owner = os.getenv("GITEA_REPO_OWNER") + name = os.getenv("GITEA_REPO_NAME") + if owner and name: + return owner, name + + return parse_repo_from_remote() + + +def post_gitea_json(api_path: str, payload: dict) -> None: + base_url = os.getenv("GITEA_URL") + token = os.getenv("GITEA_TOKEN") + if not base_url or not token: + return + + url = f"{base_url.rstrip('/')}{api_path}" + data = json.dumps(payload).encode("utf-8") + req = request.Request( + url, + data=data, + headers={ + "Authorization": f"token {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + + try: + with request.urlopen(req) as response: + response.read() + except error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Gitea API request failed: HTTP {exc.code} {details}") from exc + except error.URLError as exc: + raise RuntimeError(f"Gitea API request failed: {exc.reason}") from exc + + +def publish_commit_status(state: str, description: str) -> None: + base_url = os.getenv("GITEA_URL") + token = os.getenv("GITEA_TOKEN") + if not base_url or not token: + return + + owner, repo = get_gitea_repo() + commit = get_current_commit() + if not owner or not repo or not commit: + return + + build_url = os.getenv("BUILD_URL") + payload = { + "state": state, + "context": STATUS_CONTEXT, + "description": description[:255], + } + if build_url: + payload["target_url"] = build_url + + post_gitea_json(f"/api/v1/repos/{owner}/{repo}/statuses/{commit}", payload) + + +def publish_pr_comment(body: str) -> None: + base_url = os.getenv("GITEA_URL") + token = os.getenv("GITEA_TOKEN") + if not base_url or not token: + return + + owner, repo = get_gitea_repo() + pr_number = os.getenv("GITEA_PR_NUMBER") or os.getenv("CHANGE_ID") + if not owner or not repo or not pr_number: + return + + post_gitea_json( + f"/api/v1/repos/{owner}/{repo}/issues/{pr_number}/comments", + {"body": body}, + ) + + +def build_prompt(diff_text: str) -> str: + truncated_diff = diff_text[:MAX_DIFF_CHARS] + suffix = "" + if len(diff_text) > MAX_DIFF_CHARS: + suffix = "\n\nDiff was truncated to fit the token budget." + + return ( + "Review the following git diff. Focus on correctness, regressions, missing validation, " + "build/test issues, and security concerns. " + "No need to comment on removed code unless it seems like it would cause a problem. " + "Do not review the scripts in the scripts directory, as they are not part of the main codebase. " + "Return either 'No issues found.' or a short flat list where each item includes severity, file, and issue.\n\n" + f"{truncated_diff}{suffix}" + ) + + +def request_review(diff_text: str) -> str: + try: + from openai import OpenAI + except ImportError as exc: + raise RuntimeError( + "The 'openai' package is not installed. Install it with 'pip install openai'." + ) from exc + + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise RuntimeError("OPENAI_API_KEY is not set.") + + model = os.getenv("CODEX_MODEL") or os.getenv("OPENAI_MODEL") or "gpt-4.1" + client = OpenAI(api_key=api_key) + + response = client.responses.create( + model=model, + input=[ + { + "role": "system", + "content": [ + { + "type": "input_text", + "text": ( + "You are a strict CI code reviewer. Be concise, concrete, and prioritize real defects." + ), + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "input_text", + "text": build_prompt(diff_text), + } + ], + }, + ], + ) + + return response.output_text.strip() + + +def classify_review(review: str) -> tuple[str, str]: + normalized = (review or "").strip().lower() + if normalized == "no issues found.": + return "success", "No issues found" + return "failure", "AI review reported findings" + + +def main() -> int: + diff_text = collect_diff() + if not diff_text: + message = "No git changes detected. Skipping AI review." + print(message) + publish_commit_status("success", "No changes to review") + return 0 + + try: + review = request_review(diff_text) + except Exception as exc: + try: + publish_commit_status("error", "AI review failed") + except Exception as gitea_exc: + print(f"Gitea reporting failed: {gitea_exc}", file=sys.stderr) + print(f"AI review failed: {exc}", file=sys.stderr) + return 1 + + print("AI review result:\n") + print(review or "No issues found.") + + fail_on_findings = os.getenv("AI_REVIEW_FAIL_ON_FINDINGS", "false").lower() == "true" + state, description = classify_review(review) + + try: + publish_commit_status(state, description) + publish_pr_comment(f"AI review result:\n\n{review or 'No issues found.'}") + except Exception as exc: + print(f"Gitea reporting failed: {exc}", file=sys.stderr) + + no_findings = state == "success" + + if fail_on_findings and not no_findings: + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file