Initial commit

This commit is contained in:
V
2026-05-16 20:30:20 +01:00
commit b0b5c3bd08
34 changed files with 2356 additions and 0 deletions
+697
View File
@@ -0,0 +1,697 @@
package app
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"archi_folio/internal/store"
"golang.org/x/crypto/bcrypt"
)
const (
maxUploadBytes = 20 << 20
maxFormBytes = 24 << 20
)
type Config struct {
Addr string
DatabasePath string
SessionSecret string
AdminUsername string
AdminPassword string
UploadDir string
}
type Server struct {
cfg Config
store *store.Store
templates *template.Template
}
type pageData struct {
Title string
Active string
Content store.SiteContent
Projects []store.Project
Project store.Project
Image store.ProjectImage
Contacts []store.ContactRequest
Admin bool
AdminTab string
Error string
Success string
CurrentPath string
}
func New(cfg Config, st *store.Store) (*Server, error) {
tmpl, err := template.ParseGlob(filepath.Join(assetRoot(), "templates", "*.html"))
if err != nil {
return nil, err
}
if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil {
return nil, err
}
return &Server{cfg: cfg, store: st, templates: tmpl}, nil
}
func projectRoot() string {
_, file, _, ok := runtime.Caller(0)
if !ok {
return "."
}
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}
func assetRoot() string {
if _, err := os.Stat(filepath.Join("web", "templates")); err == nil {
return "web"
}
return filepath.Join(projectRoot(), "web")
}
func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(assetRoot(), "static")))))
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(s.cfg.UploadDir))))
mux.HandleFunc("GET /", s.home)
mux.HandleFunc("GET /projects", s.projects)
mux.HandleFunc("GET /projects/{slug}", s.projectDetail)
mux.HandleFunc("GET /projects/{slug}/images/{imageID}/overlay", s.projectImageOverlay)
mux.HandleFunc("GET /about", s.about)
mux.HandleFunc("POST /contact", s.contact)
mux.HandleFunc("GET /admin/login", s.adminLogin)
mux.HandleFunc("POST /admin/login", s.adminLoginPost)
mux.HandleFunc("POST /admin/logout", s.adminLogout)
mux.Handle("GET /admin", s.requireAdmin(http.HandlerFunc(s.adminRedirect)))
mux.Handle("GET /admin/main", s.requireAdmin(http.HandlerFunc(s.adminMain)))
mux.Handle("GET /admin/projects", s.requireAdmin(http.HandlerFunc(s.adminProjects)))
mux.Handle("GET /admin/contact-details", s.requireAdmin(http.HandlerFunc(s.adminContactDetails)))
mux.Handle("POST /admin/content", s.requireAdmin(http.HandlerFunc(s.adminUpdateContent)))
mux.Handle("POST /admin/contact-details", s.requireAdmin(http.HandlerFunc(s.adminUpdateContactDetails)))
mux.Handle("POST /admin/projects", s.requireAdmin(http.HandlerFunc(s.adminCreateProject)))
mux.Handle("POST /admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateProject)))
mux.Handle("POST /admin/projects/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteProject)))
mux.Handle("POST /admin/projects/{id}/images", s.requireAdmin(http.HandlerFunc(s.adminAddProjectImage)))
mux.Handle("POST /admin/project-images/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteProjectImage)))
return securityHeaders(mux)
}
func (s *Server) home(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
projects, err := s.store.Projects(r.Context(), true)
if err != nil {
s.error(w, err)
return
}
s.render(w, "home.html", pageData{Title: content.HeroTitle, Active: "home", Content: content, Projects: projects, CurrentPath: r.URL.Path})
}
func (s *Server) projects(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
projects, err := s.store.Projects(r.Context(), false)
if err != nil {
s.error(w, err)
return
}
s.render(w, "projects.html", pageData{Title: "Projects", Active: "projects", Content: content, Projects: projects, CurrentPath: r.URL.Path})
}
func (s *Server) projectDetail(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
project, err := s.store.ProjectBySlug(r.Context(), r.PathValue("slug"))
if store.IsNotFound(err) {
http.NotFound(w, r)
return
}
if err != nil {
s.error(w, err)
return
}
s.render(w, "project.html", pageData{Title: project.Title, Active: "projects", Content: content, Project: project, CurrentPath: r.URL.Path})
}
func (s *Server) projectImageOverlay(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("imageID"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
project, image, err := s.store.ProjectImageForSlug(r.Context(), r.PathValue("slug"), id)
if store.IsNotFound(err) {
http.NotFound(w, r)
return
}
if err != nil {
s.error(w, err)
return
}
s.render(w, "overlay.html", pageData{Title: project.Title, Project: project, Image: image})
}
func (s *Server) about(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
s.render(w, "about.html", pageData{Title: "About", Active: "about", Content: content, CurrentPath: r.URL.Path})
}
func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.render(w, "contact_result.html", pageData{Error: "Please check the form and try again."})
return
}
name := strings.TrimSpace(r.FormValue("name"))
email := strings.TrimSpace(r.FormValue("email"))
message := strings.TrimSpace(r.FormValue("message"))
if name == "" || email == "" || message == "" || !strings.Contains(email, "@") {
s.render(w, "contact_result.html", pageData{Error: "Please provide your name, a valid email, and a short message."})
return
}
if err := s.store.SaveContact(r.Context(), name, email, message); err != nil {
s.render(w, "contact_result.html", pageData{Error: "The request could not be saved. Please try again."})
return
}
s.render(w, "contact_result.html", pageData{Success: "Thanks. Your request has been saved and the studio will review it soon."})
}
func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) {
s.render(w, "admin_login.html", pageData{Title: "Admin login"})
}
func (s *Server) adminLoginPost(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.render(w, "admin_login.html", pageData{Title: "Admin login", Error: "Invalid login request."})
return
}
user, err := s.store.AdminByUsername(r.Context(), r.FormValue("username"))
if err != nil || bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(r.FormValue("password"))) != nil {
s.render(w, "admin_login.html", pageData{Title: "Admin login", Error: "Incorrect username or password."})
return
}
token, err := randomToken()
if err != nil {
s.error(w, err)
return
}
if err := s.store.CreateSession(r.Context(), s.hashToken(token), user.ID, time.Now().Add(24*time.Hour)); err != nil {
s.error(w, err)
return
}
http.SetCookie(w, &http.Cookie{
Name: "archi_session",
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 86400,
})
http.Redirect(w, r, "/admin/main", http.StatusSeeOther)
}
func (s *Server) adminLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie("archi_session"); err == nil {
_ = s.store.DeleteSession(r.Context(), s.hashToken(cookie.Value))
}
http.SetCookie(w, &http.Cookie{Name: "archi_session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) adminRedirect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/main", http.StatusSeeOther)
}
func (s *Server) adminMain(w http.ResponseWriter, r *http.Request) {
data, err := s.adminData(r, "main")
if err != nil {
s.error(w, err)
return
}
s.renderAdmin(w, r, "admin_main.html", "admin_main_partial.html", data)
}
func (s *Server) adminProjects(w http.ResponseWriter, r *http.Request) {
data, err := s.adminData(r, "projects")
if err != nil {
s.error(w, err)
return
}
s.renderAdmin(w, r, "admin_projects.html", "admin_projects_partial.html", data)
}
func (s *Server) adminContactDetails(w http.ResponseWriter, r *http.Request) {
data, err := s.adminData(r, "contact-details")
if err != nil {
s.error(w, err)
return
}
s.renderAdmin(w, r, "admin_contact_details.html", "admin_contact_details_partial.html", data)
}
func (s *Server) adminUpdateContent(w http.ResponseWriter, r *http.Request) {
if err := parseAdminForm(w, r, maxFormBytes); err != nil {
s.redirectAdmin(w, r, "main", "content form failed")
return
}
current, err := s.store.SiteContent(r.Context())
if err != nil {
s.redirectAdmin(w, r, "main", "content could not be loaded")
return
}
content := store.SiteContent{
HeroTitle: strings.TrimSpace(r.FormValue("hero_title")),
HeroSubtitle: strings.TrimSpace(r.FormValue("hero_subtitle")),
IntroTitle: strings.TrimSpace(r.FormValue("intro_title")),
IntroText: strings.TrimSpace(r.FormValue("intro_text")),
AboutName: strings.TrimSpace(r.FormValue("about_name")),
AboutRole: strings.TrimSpace(r.FormValue("about_role")),
AboutBio: strings.TrimSpace(r.FormValue("about_bio")),
Email: current.Email,
Phone: current.Phone,
Location: current.Location,
HeroImage: r.FormValue("hero_image_current"),
AboutImage: r.FormValue("about_image_current"),
}
if err := validateContent(content); err != nil {
s.redirectAdmin(w, r, "main", err.Error())
return
}
if path, ok, err := s.saveUpload(r, "hero_image"); err != nil {
s.redirectAdmin(w, r, "main", "hero image "+err.Error())
return
} else if ok {
content.HeroImage = path
}
if path, ok, err := s.saveUpload(r, "about_image"); err != nil {
s.redirectAdmin(w, r, "main", "about image "+err.Error())
return
} else if ok {
content.AboutImage = path
}
if err := s.store.UpdateSiteContent(r.Context(), content); err != nil {
s.redirectAdmin(w, r, "main", "content could not be saved")
return
}
s.redirectAdmin(w, r, "main", "content saved")
}
func (s *Server) adminUpdateContactDetails(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "contact-details", "contact details form failed")
return
}
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.redirectAdmin(w, r, "contact-details", "contact details could not be loaded")
return
}
content.Email = strings.TrimSpace(r.FormValue("email"))
content.Phone = strings.TrimSpace(r.FormValue("phone"))
content.Location = strings.TrimSpace(r.FormValue("location"))
if err := validateContactDetails(content); err != nil {
s.redirectAdmin(w, r, "contact-details", err.Error())
return
}
if err := s.store.UpdateSiteContent(r.Context(), content); err != nil {
s.redirectAdmin(w, r, "contact-details", "contact details could not be saved")
return
}
s.redirectAdmin(w, r, "contact-details", "contact details saved")
}
func (s *Server) adminCreateProject(w http.ResponseWriter, r *http.Request) {
if err := parseAdminForm(w, r, maxFormBytes); err != nil {
s.redirectAdmin(w, r, "projects", "project form failed")
return
}
cover := "/static/placeholders/project-1.svg"
if path, ok, err := s.saveUpload(r, "cover_image"); err != nil {
s.redirectAdmin(w, r, "projects", "cover upload "+err.Error())
return
} else if ok {
cover = path
}
slug := strings.TrimSpace(r.FormValue("slug"))
if slug == "" {
slug = store.SlugFromTitle(r.FormValue("title"))
} else {
slug = store.SlugFromTitle(slug)
}
project := store.Project{
Slug: slug,
Title: strings.TrimSpace(r.FormValue("title")),
Location: strings.TrimSpace(r.FormValue("location")),
Year: strings.TrimSpace(r.FormValue("year")),
Category: strings.TrimSpace(r.FormValue("category")),
Description: strings.TrimSpace(r.FormValue("description")),
CoverImage: cover,
Featured: r.FormValue("featured") == "on",
}
if err := validateProject(project); err != nil {
s.redirectAdmin(w, r, "projects", err.Error())
return
}
id, err := s.store.CreateProject(r.Context(), project)
if err != nil {
s.redirectAdmin(w, r, "projects", "project could not be created")
return
}
_ = s.store.AddProjectImage(r.Context(), id, cover, r.FormValue("title"))
s.redirectAdmin(w, r, "projects", "project created")
}
func (s *Server) adminUpdateProject(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if err := parseAdminForm(w, r, maxFormBytes); err != nil {
s.redirectAdmin(w, r, "projects", "project form failed")
return
}
cover := r.FormValue("cover_image_current")
if path, ok, err := s.saveUpload(r, "cover_image"); err != nil {
s.redirectAdmin(w, r, "projects", "cover upload "+err.Error())
return
} else if ok {
cover = path
}
slug := strings.TrimSpace(r.FormValue("slug"))
if slug == "" {
slug = store.SlugFromTitle(r.FormValue("title"))
} else {
slug = store.SlugFromTitle(slug)
}
project := store.Project{
ID: id,
Slug: slug,
Title: strings.TrimSpace(r.FormValue("title")),
Location: strings.TrimSpace(r.FormValue("location")),
Year: strings.TrimSpace(r.FormValue("year")),
Category: strings.TrimSpace(r.FormValue("category")),
Description: strings.TrimSpace(r.FormValue("description")),
CoverImage: cover,
Featured: r.FormValue("featured") == "on",
}
if err := validateProject(project); err != nil {
s.redirectAdmin(w, r, "projects", err.Error())
return
}
err = s.store.UpdateProject(r.Context(), project)
if err != nil {
s.redirectAdmin(w, r, "projects", "project could not be saved")
return
}
s.redirectAdmin(w, r, "projects", "project saved")
}
func (s *Server) adminDeleteProject(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err == nil {
err = s.store.DeleteProject(r.Context(), id)
}
if err != nil {
s.redirectAdmin(w, r, "projects", "project could not be deleted")
return
}
s.redirectAdmin(w, r, "projects", "project deleted")
}
func (s *Server) adminAddProjectImage(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if err := parseAdminForm(w, r, maxFormBytes); err != nil {
s.redirectAdmin(w, r, "projects", "image form failed")
return
}
path, ok, err := s.saveUpload(r, "image")
if err != nil || !ok {
if err != nil {
s.redirectAdmin(w, r, "projects", "image upload "+err.Error())
return
}
s.redirectAdmin(w, r, "projects", "image upload failed")
return
}
if err := s.store.AddProjectImage(r.Context(), projectID, path, r.FormValue("caption")); err != nil {
s.redirectAdmin(w, r, "projects", "image could not be added")
return
}
s.redirectAdmin(w, r, "projects", "image added")
}
func (s *Server) adminDeleteProjectImage(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err == nil {
err = s.store.DeleteProjectImage(r.Context(), id)
}
if err != nil {
s.redirectAdmin(w, r, "projects", "image could not be deleted")
return
}
s.redirectAdmin(w, r, "projects", "image deleted")
}
func (s *Server) adminData(r *http.Request, tab string) (pageData, error) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
return pageData{}, err
}
data := pageData{Title: "Admin", Admin: true, AdminTab: tab, Content: content, Success: r.URL.Query().Get("ok"), Error: r.URL.Query().Get("err")}
if tab == "projects" {
projects, err := s.store.Projects(r.Context(), false)
if err != nil {
return pageData{}, err
}
imagesByProject, err := s.store.ProjectImagesByProject(r.Context())
if err != nil {
return pageData{}, err
}
for i := range projects {
projects[i].Images = imagesByProject[projects[i].ID]
}
data.Projects = projects
}
if tab == "contact-details" {
contacts, err := s.store.ContactRequests(r.Context())
if err != nil {
return pageData{}, err
}
data.Contacts = contacts
}
return data, nil
}
func (s *Server) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("archi_session")
if err != nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
if _, err := s.store.SessionUser(r.Context(), s.hashToken(cookie.Value)); err != nil {
http.Redirect(w, r, "/admin/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) saveUpload(r *http.Request, field string) (string, bool, error) {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
return "", false, nil
}
file, header, err := r.FormFile(field)
if errors.Is(err, http.ErrMissingFile) {
return "", false, nil
}
if err != nil {
return "", false, err
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
default:
return "", false, fmt.Errorf("unsupported image type")
}
name, err := randomToken()
if err != nil {
return "", false, err
}
filename := name + ext
target := filepath.Join(s.cfg.UploadDir, filename)
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return "", false, err
}
defer out.Close()
written, err := io.Copy(out, io.LimitReader(file, maxUploadBytes+1))
if err != nil {
return "", false, err
}
if written > maxUploadBytes {
_ = out.Close()
_ = os.Remove(target)
return "", false, fmt.Errorf("is too large")
}
return "/uploads/" + filename, true, nil
}
func parseAdminForm(w http.ResponseWriter, r *http.Request, maxBytes int64) error {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
return r.ParseMultipartForm(maxBytes)
}
return r.ParseForm()
}
func validateContent(c store.SiteContent) error {
switch {
case c.HeroTitle == "":
return errors.New("hero title is required")
case c.HeroSubtitle == "":
return errors.New("hero subtitle is required")
case c.IntroTitle == "":
return errors.New("intro title is required")
case c.IntroText == "":
return errors.New("intro text is required")
case c.AboutName == "":
return errors.New("about name is required")
case c.AboutRole == "":
return errors.New("about role is required")
case c.AboutBio == "":
return errors.New("about bio is required")
case c.HeroImage == "":
return errors.New("hero image is required")
case c.AboutImage == "":
return errors.New("about image is required")
default:
return nil
}
}
func validateContactDetails(c store.SiteContent) error {
switch {
case c.Email == "" || !strings.Contains(c.Email, "@"):
return errors.New("valid email is required")
case c.Phone == "":
return errors.New("phone is required")
case c.Location == "":
return errors.New("location is required")
default:
return nil
}
}
func validateProject(p store.Project) error {
switch {
case p.Slug == "":
return errors.New("project slug is required")
case p.Title == "":
return errors.New("project title is required")
case p.Location == "":
return errors.New("project location is required")
case p.Year == "":
return errors.New("project year is required")
case p.Category == "":
return errors.New("project category is required")
case p.Description == "":
return errors.New("project description is required")
case p.CoverImage == "":
return errors.New("project cover image is required")
default:
return nil
}
}
func (s *Server) render(w http.ResponseWriter, name string, data pageData) {
var buf bytes.Buffer
if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil {
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
func (s *Server) renderAdmin(w http.ResponseWriter, r *http.Request, fullTemplate, partialTemplate string, data pageData) {
if r.Header.Get("HX-Request") == "true" {
s.render(w, partialTemplate, data)
return
}
s.render(w, fullTemplate, data)
}
func (s *Server) error(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
func (s *Server) redirectAdmin(w http.ResponseWriter, r *http.Request, tab, message string) {
key := "err"
for _, success := range []string{" saved", " created", " deleted", " added"} {
if strings.HasSuffix(message, success) {
key = "ok"
break
}
}
http.Redirect(w, r, "/admin/"+tab+"?"+key+"="+url.QueryEscape(message), http.StatusSeeOther)
}
func randomToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func (s *Server) hashToken(token string) string {
sum := sha256.Sum256([]byte(s.cfg.SessionSecret + ":" + token))
return hex.EncodeToString(sum[:])
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
+312
View File
@@ -0,0 +1,312 @@
package app
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"archi_folio/internal/store"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
dir := t.TempDir()
st, err := store.Open(filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate("admin", "changeme"); err != nil {
t.Fatal(err)
}
srv, err := New(Config{
DatabasePath: filepath.Join(dir, "app.db"),
SessionSecret: "test-secret",
AdminUsername: "admin",
AdminPassword: "changeme",
UploadDir: filepath.Join(dir, "uploads"),
}, st)
if err != nil {
t.Fatal(err)
}
return srv
}
func TestPublicRoutes(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
for _, path := range []string{"/", "/projects", "/about", "/projects/courtyard-house"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s returned %d", path, rec.Code)
}
}
}
func TestAdminRequiresLogin(t *testing.T) {
srv := newTestServer(t)
for _, path := range []string{"/admin", "/admin/main", "/admin/projects", "/admin/contact-details"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("%s expected redirect, got %d", path, rec.Code)
}
if location := rec.Header().Get("Location"); location != "/admin/login" {
t.Fatalf("%s expected login redirect, got %q", path, location)
}
}
}
func TestAdminLogin(t *testing.T) {
srv := newTestServer(t)
form := url.Values{"username": {"admin"}, "password": {"changeme"}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", rec.Code)
}
if location := rec.Header().Get("Location"); location != "/admin/main" {
t.Fatalf("expected admin main redirect, got %q", location)
}
if len(rec.Result().Cookies()) == 0 {
t.Fatal("expected session cookie")
}
}
func TestAdminTabs(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/main" {
t.Fatalf("expected /admin to redirect to /admin/main, got %d %q", rec.Code, rec.Header().Get("Location"))
}
for _, test := range []struct {
path string
want string
}{
{"/admin/main", "Main Content"},
{"/admin/projects", "Add Project"},
{"/admin/contact-details", "Contact Requests"},
} {
req := httptest.NewRequest(http.MethodGet, test.path, nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s returned %d", test.path, rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
if !strings.Contains(string(body), test.want) || !strings.Contains(string(body), "<!doctype html>") {
t.Fatalf("%s did not render full tab page: %s", test.path, body)
}
}
}
func TestAdminHTMXTabRequestReturnsPartial(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
req := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
req.Header.Set("HX-Request", "true")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
if strings.Contains(text, "<!doctype html>") {
t.Fatalf("expected partial response, got full document: %s", text)
}
if !strings.Contains(text, `hx-swap-oob="true"`) || !strings.Contains(text, "Add Project") {
t.Fatalf("expected partial panel and out-of-band tab update: %s", text)
}
}
func TestAdminMutationsRedirectToOwningTabs(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
for _, test := range []struct {
path string
form url.Values
want string
}{
{
path: "/admin/contact-details",
form: url.Values{"email": {"studio@example.com"}, "phone": {"123"}, "location": {"London"}},
want: "/admin/contact-details?ok=contact+details+saved",
},
{
path: "/admin/content",
form: url.Values{
"hero_title": {"Hero"}, "hero_subtitle": {"Subtitle"}, "intro_title": {"Intro"}, "intro_text": {"Text"},
"about_name": {"Name"}, "about_role": {"Role"}, "about_bio": {"Bio"},
"hero_image_current": {"/static/placeholders/hero.svg"}, "about_image_current": {"/static/placeholders/about.svg"},
},
want: "/admin/main?ok=content+saved",
},
} {
req := httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(test.form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != test.want {
t.Fatalf("%s expected redirect %q, got %d %q", test.path, test.want, rec.Code, rec.Header().Get("Location"))
}
}
}
func TestAdminProjectValidation(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
form := url.Values{"title": {" "}, "location": {"London"}, "year": {"2026"}, "category": {"Residential"}, "description": {"Text"}}
req := httptest.NewRequest(http.MethodPost, "/admin/projects", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", rec.Code)
}
if location := rec.Header().Get("Location"); !strings.Contains(location, "/admin/projects?err=") || !strings.Contains(location, "project+title+is+required") {
t.Fatalf("expected validation error redirect, got %q", location)
}
}
func TestAdminRejectsSVGUpload(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
fields := map[string]string{
"title": "Upload Test",
"location": "London",
"year": "2026",
"category": "Residential",
"description": "A project",
}
for key, value := range fields {
if err := writer.WriteField(key, value); err != nil {
t.Fatal(err)
}
}
file, err := writer.CreateFormFile("cover_image", "bad.svg")
if err != nil {
t.Fatal(err)
}
if _, err := file.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`)); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/admin/projects", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("expected redirect, got %d", rec.Code)
}
if location := rec.Header().Get("Location"); !strings.Contains(location, "/admin/projects?err=") || !strings.Contains(location, "unsupported+image+type") {
t.Fatalf("expected unsupported image redirect, got %q", location)
}
}
func TestContactSubmissionPersists(t *testing.T) {
srv := newTestServer(t)
form := url.Values{"name": {"Jane"}, "email": {"jane@example.com"}, "message": {"New project"}}
req := httptest.NewRequest(http.MethodPost, "/contact", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
if !strings.Contains(string(body), "saved") {
t.Fatalf("expected success message, got %s", body)
}
requests, err := srv.store.ContactRequests(req.Context())
if err != nil {
t.Fatal(err)
}
if len(requests) != 1 || requests[0].Email != "jane@example.com" {
t.Fatalf("unexpected contact requests: %+v", requests)
}
}
func TestProjectImageOverlay(t *testing.T) {
srv := newTestServer(t)
project, err := srv.store.ProjectBySlug(t.Context(), "courtyard-house")
if err != nil {
t.Fatal(err)
}
if len(project.Images) == 0 {
t.Fatal("expected seeded project images")
}
path := "/projects/" + project.Slug + "/images/" + strconv.FormatInt(project.Images[0].ID, 10) + "/overlay"
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
if !strings.Contains(string(body), "data-overlay") || !strings.Contains(string(body), project.Images[0].Path) {
t.Fatalf("overlay fragment missing expected content: %s", body)
}
}
func loginCookie(t *testing.T, handler http.Handler) *http.Cookie {
t.Helper()
form := url.Values{"username": {"admin"}, "password": {"changeme"}}
req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
cookies := rec.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("expected login cookie")
}
return cookies[0]
}