Refactor app and store layout
All checks were successful
Publish / Test, build, and push image (push) Successful in 3m38s
All checks were successful
Publish / Test, build, and push image (push) Successful in 3m38s
This commit is contained in:
parent
d4441cda67
commit
fac53d7b85
@ -1,699 +0,0 @@
|
|||||||
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
|
|
||||||
Version 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
|
|
||||||
Version 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"), Version: s.cfg.Version}
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,313 +0,0 @@
|
|||||||
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"),
|
|
||||||
Version: "test-version",
|
|
||||||
}, 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>") || !strings.Contains(string(body), "Version test-version") {
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
22
internal/app/assets.go
Normal file
22
internal/app/assets.go
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
81
internal/app/auth.go
Normal file
81
internal/app/auth.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
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) 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 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[:])
|
||||||
|
}
|
||||||
46
internal/app/auth_test.go
Normal file
46
internal/app/auth_test.go
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
217
internal/app/handlers_admin_mutations.go
Normal file
217
internal/app/handlers_admin_mutations.go
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"archi_folio/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
65
internal/app/handlers_admin_mutations_test.go
Normal file
65
internal/app/handlers_admin_mutations_test.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
internal/app/handlers_admin_pages.go
Normal file
64
internal/app/handlers_admin_pages.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
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) 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"), Version: s.cfg.Version}
|
||||||
|
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
|
||||||
|
}
|
||||||
68
internal/app/handlers_admin_pages_test.go
Normal file
68
internal/app/handlers_admin_pages_test.go
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
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>") || !strings.Contains(string(body), "Version test-version") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
101
internal/app/handlers_public.go
Normal file
101
internal/app/handlers_public.go
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"archi_folio/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
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."})
|
||||||
|
}
|
||||||
74
internal/app/handlers_public_test.go
Normal file
74
internal/app/handlers_public_test.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
internal/app/middleware.go
Normal file
11
internal/app/middleware.go
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
41
internal/app/render.go
Normal file
41
internal/app/render.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
37
internal/app/routes.go
Normal file
37
internal/app/routes.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
26
internal/app/server.go
Normal file
26
internal/app/server.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"archi_folio/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
cfg Config
|
||||||
|
store *store.Store
|
||||||
|
templates *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
51
internal/app/test_helpers_test.go
Normal file
51
internal/app/test_helpers_test.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"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"),
|
||||||
|
Version: "test-version",
|
||||||
|
}, st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
34
internal/app/types.go
Normal file
34
internal/app/types.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "archi_folio/internal/store"
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxUploadBytes = 20 << 20
|
||||||
|
maxFormBytes = 24 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Addr string
|
||||||
|
DatabasePath string
|
||||||
|
SessionSecret string
|
||||||
|
AdminUsername string
|
||||||
|
AdminPassword string
|
||||||
|
UploadDir string
|
||||||
|
Version string
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
Version string
|
||||||
|
}
|
||||||
61
internal/app/uploads.go
Normal file
61
internal/app/uploads.go
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
53
internal/app/uploads_test.go
Normal file
53
internal/app/uploads_test.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
internal/app/validation.go
Normal file
67
internal/app/validation.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"archi_folio/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/store/auth.go
Normal file
29
internal/store/auth.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Store) AdminByUsername(ctx context.Context, username string) (AdminUser, error) {
|
||||||
|
var u AdminUser
|
||||||
|
err := s.db.QueryRowContext(ctx, `select id, username, password_hash from admin_users where username = ?`, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateSession(ctx context.Context, tokenHash string, userID int64, expiresAt time.Time) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `insert into sessions (token_hash, user_id, expires_at) values (?, ?, ?)`, tokenHash, userID, expiresAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SessionUser(ctx context.Context, tokenHash string) (AdminUser, error) {
|
||||||
|
var u AdminUser
|
||||||
|
err := s.db.QueryRowContext(ctx, `select u.id, u.username, u.password_hash from sessions s join admin_users u on u.id = s.user_id where s.token_hash = ? and s.expires_at > ?`, tokenHash, time.Now()).
|
||||||
|
Scan(&u.ID, &u.Username, &u.PasswordHash)
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `delete from sessions where token_hash = ?`, tokenHash)
|
||||||
|
return err
|
||||||
|
}
|
||||||
25
internal/store/contact.go
Normal file
25
internal/store/contact.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func (s *Store) SaveContact(ctx context.Context, name, email, message string) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `insert into contact_requests (name, email, message) values (?, ?, ?)`, name, email, message)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `select id, name, email, message, created_at from contact_requests order by created_at desc, id desc`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var requests []ContactRequest
|
||||||
|
for rows.Next() {
|
||||||
|
var r ContactRequest
|
||||||
|
if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Message, &r.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
requests = append(requests, r)
|
||||||
|
}
|
||||||
|
return requests, rows.Err()
|
||||||
|
}
|
||||||
45
internal/store/helpers.go
Normal file
45
internal/store/helpers.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
return errors.Is(err, sql.ErrNoRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolInt(v bool) int {
|
||||||
|
if v {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func SlugFromTitle(title string) string {
|
||||||
|
out := make([]rune, 0, len(title))
|
||||||
|
lastDash := false
|
||||||
|
for _, r := range title {
|
||||||
|
if r >= 'A' && r <= 'Z' {
|
||||||
|
r += 'a' - 'A'
|
||||||
|
}
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||||
|
out = append(out, r)
|
||||||
|
lastDash = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !lastDash && len(out) > 0 {
|
||||||
|
out = append(out, '-')
|
||||||
|
lastDash = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(out) > 0 && out[len(out)-1] == '-' {
|
||||||
|
out = out[:len(out)-1]
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return fmt.Sprintf("project-%d", time.Now().Unix())
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
145
internal/store/migrations.go
Normal file
145
internal/store/migrations.go
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Store) Migrate(adminUsername, adminPassword string) error {
|
||||||
|
stmts := []string{
|
||||||
|
`create table if not exists site_content (
|
||||||
|
id integer primary key check (id = 1),
|
||||||
|
hero_title text not null,
|
||||||
|
hero_subtitle text not null,
|
||||||
|
intro_title text not null,
|
||||||
|
intro_text text not null,
|
||||||
|
about_name text not null,
|
||||||
|
about_role text not null,
|
||||||
|
about_bio text not null,
|
||||||
|
email text not null,
|
||||||
|
phone text not null,
|
||||||
|
location text not null,
|
||||||
|
hero_image text not null,
|
||||||
|
about_image text not null
|
||||||
|
)`,
|
||||||
|
`create table if not exists projects (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
slug text not null unique,
|
||||||
|
title text not null,
|
||||||
|
location text not null,
|
||||||
|
year text not null,
|
||||||
|
category text not null,
|
||||||
|
description text not null,
|
||||||
|
cover_image text not null,
|
||||||
|
featured integer not null default 0,
|
||||||
|
created_at datetime not null default current_timestamp
|
||||||
|
)`,
|
||||||
|
`create table if not exists project_images (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
project_id integer not null references projects(id) on delete cascade,
|
||||||
|
path text not null,
|
||||||
|
caption text not null,
|
||||||
|
position integer not null default 0
|
||||||
|
)`,
|
||||||
|
`create table if not exists contact_requests (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
name text not null,
|
||||||
|
email text not null,
|
||||||
|
message text not null,
|
||||||
|
created_at datetime not null default current_timestamp
|
||||||
|
)`,
|
||||||
|
`create table if not exists admin_users (
|
||||||
|
id integer primary key autoincrement,
|
||||||
|
username text not null unique,
|
||||||
|
password_hash blob not null
|
||||||
|
)`,
|
||||||
|
`create table if not exists sessions (
|
||||||
|
token_hash text primary key,
|
||||||
|
user_id integer not null references admin_users(id) on delete cascade,
|
||||||
|
expires_at datetime not null
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
if _, err := s.db.Exec(stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.seed(adminUsername, adminPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) seed(adminUsername, adminPassword string) error {
|
||||||
|
var count int
|
||||||
|
if err := s.db.QueryRow(`select count(*) from site_content`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
_, err := s.db.Exec(`insert into site_content (
|
||||||
|
id, hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio,
|
||||||
|
email, phone, location, hero_image, about_image
|
||||||
|
) values (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
"Archi Folio",
|
||||||
|
"Spatial design, architecture, and interiors shaped through quiet detail.",
|
||||||
|
"Selected residential and cultural spaces",
|
||||||
|
"A compact portfolio for showing image-led architectural work, interior concepts, and project narratives.",
|
||||||
|
"Alex Morgan",
|
||||||
|
"Architect & Interior Designer",
|
||||||
|
"I design calm, functional spaces with attention to proportion, material, light, and the daily rituals of the people who use them.",
|
||||||
|
"studio@example.com",
|
||||||
|
"+44 20 0000 0000",
|
||||||
|
"London, United Kingdom",
|
||||||
|
"/static/placeholders/hero.svg",
|
||||||
|
"/static/placeholders/about.svg",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.QueryRow(`select count(*) from projects`).Scan(&count); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
projects := []Project{
|
||||||
|
{Slug: "courtyard-house", Title: "Courtyard House", Location: "Bath, UK", Year: "2025", Category: "Residential", Description: "A private house organized around a quiet internal garden, using warm timber, stone, and filtered daylight.", CoverImage: "/static/placeholders/project-1.svg", Featured: true},
|
||||||
|
{Slug: "atelier-apartment", Title: "Atelier Apartment", Location: "London, UK", Year: "2024", Category: "Interior", Description: "A compact apartment refit with integrated storage, gallery-like surfaces, and a flexible work area.", CoverImage: "/static/placeholders/project-2.svg", Featured: true},
|
||||||
|
{Slug: "gallery-room", Title: "Gallery Room", Location: "Amsterdam, NL", Year: "2024", Category: "Cultural", Description: "A small exhibition environment designed for shifting light levels, sculpture, and intimate events.", CoverImage: "/static/placeholders/project-3.svg", Featured: true},
|
||||||
|
}
|
||||||
|
for _, p := range projects {
|
||||||
|
id, err := s.CreateProject(context.Background(), p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := s.db.Exec(`insert into project_images (project_id, path, caption, position) values (?, ?, ?, ?)`, id, p.CoverImage, p.Title, i); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var user AdminUser
|
||||||
|
err = s.db.QueryRow(`select id, username, password_hash from admin_users order by id asc limit 1`).Scan(&user.ID, &user.Username, &user.PasswordHash)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
_, err = s.db.Exec(`insert into admin_users (username, password_hash) values (?, ?)`, adminUsername, hash)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if user.Username == adminUsername && bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(adminPassword)) == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(`update admin_users set username = ?, password_hash = ? where id = ?`, adminUsername, hash, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = s.db.Exec(`delete from sessions`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
122
internal/store/projects.go
Normal file
122
internal/store/projects.go
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, error) {
|
||||||
|
query := `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects`
|
||||||
|
if featuredOnly {
|
||||||
|
query += ` where featured = 1`
|
||||||
|
}
|
||||||
|
query += ` order by created_at desc, id desc`
|
||||||
|
rows, err := s.db.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var projects []Project
|
||||||
|
for rows.Next() {
|
||||||
|
var p Project
|
||||||
|
var featured int
|
||||||
|
if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.Featured = featured == 1
|
||||||
|
projects = append(projects, p)
|
||||||
|
}
|
||||||
|
return projects, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ProjectBySlug(ctx context.Context, slug string) (Project, error) {
|
||||||
|
var p Project
|
||||||
|
var featured int
|
||||||
|
err := s.db.QueryRowContext(ctx, `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects where slug = ?`, slug).
|
||||||
|
Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
p.Featured = featured == 1
|
||||||
|
p.Images, err = s.ProjectImages(ctx, p.ID)
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ProjectImages(ctx context.Context, projectID int64) ([]ProjectImage, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `select id, project_id, path, caption, position from project_images where project_id = ? order by position asc, id asc`, projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var images []ProjectImage
|
||||||
|
for rows.Next() {
|
||||||
|
var img ProjectImage
|
||||||
|
if err := rows.Scan(&img.ID, &img.ProjectID, &img.Path, &img.Caption, &img.Position); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
images = append(images, img)
|
||||||
|
}
|
||||||
|
return images, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ProjectImagesByProject(ctx context.Context) (map[int64][]ProjectImage, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, `select id, project_id, path, caption, position from project_images order by project_id asc, position asc, id asc`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
images := make(map[int64][]ProjectImage)
|
||||||
|
for rows.Next() {
|
||||||
|
var img ProjectImage
|
||||||
|
if err := rows.Scan(&img.ID, &img.ProjectID, &img.Path, &img.Caption, &img.Position); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
images[img.ProjectID] = append(images[img.ProjectID], img)
|
||||||
|
}
|
||||||
|
return images, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ProjectImageForSlug(ctx context.Context, slug string, imageID int64) (Project, ProjectImage, error) {
|
||||||
|
p, err := s.ProjectBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return Project{}, ProjectImage{}, err
|
||||||
|
}
|
||||||
|
for _, img := range p.Images {
|
||||||
|
if img.ID == imageID {
|
||||||
|
return p, img, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Project{}, ProjectImage{}, sql.ErrNoRows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateProject(ctx context.Context, p Project) (int64, error) {
|
||||||
|
res, err := s.db.ExecContext(ctx, `insert into projects (slug, title, location, year, category, description, cover_image, featured) values (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateProject(ctx context.Context, p Project) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `update projects set slug=?, title=?, location=?, year=?, category=?, description=?, cover_image=?, featured=? where id=?`,
|
||||||
|
p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured), p.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteProject(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `delete from projects where id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) AddProjectImage(ctx context.Context, projectID int64, path, caption string) error {
|
||||||
|
var pos int
|
||||||
|
_ = s.db.QueryRowContext(ctx, `select coalesce(max(position), -1) + 1 from project_images where project_id = ?`, projectID).Scan(&pos)
|
||||||
|
_, err := s.db.ExecContext(ctx, `insert into project_images (project_id, path, caption, position) values (?, ?, ?, ?)`, projectID, path, caption, pos)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteProjectImage(ctx context.Context, id int64) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `delete from project_images where id = ?`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
16
internal/store/site.go
Normal file
16
internal/store/site.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func (s *Store) SiteContent(ctx context.Context) (SiteContent, error) {
|
||||||
|
var c SiteContent
|
||||||
|
err := s.db.QueryRowContext(ctx, `select hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio, email, phone, location, hero_image, about_image from site_content where id = 1`).
|
||||||
|
Scan(&c.HeroTitle, &c.HeroSubtitle, &c.IntroTitle, &c.IntroText, &c.AboutName, &c.AboutRole, &c.AboutBio, &c.Email, &c.Phone, &c.Location, &c.HeroImage, &c.AboutImage)
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateSiteContent(ctx context.Context, c SiteContent) error {
|
||||||
|
_, err := s.db.ExecContext(ctx, `update site_content set hero_title=?, hero_subtitle=?, intro_title=?, intro_text=?, about_name=?, about_role=?, about_bio=?, email=?, phone=?, location=?, hero_image=?, about_image=? where id=1`,
|
||||||
|
c.HeroTitle, c.HeroSubtitle, c.IntroTitle, c.IntroText, c.AboutName, c.AboutRole, c.AboutBio, c.Email, c.Phone, c.Location, c.HeroImage, c.AboutImage)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@ -1,16 +1,12 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
@ -83,350 +79,3 @@ func Open(path string) (*Store, error) {
|
|||||||
func (s *Store) Close() error {
|
func (s *Store) Close() error {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Migrate(adminUsername, adminPassword string) error {
|
|
||||||
stmts := []string{
|
|
||||||
`create table if not exists site_content (
|
|
||||||
id integer primary key check (id = 1),
|
|
||||||
hero_title text not null,
|
|
||||||
hero_subtitle text not null,
|
|
||||||
intro_title text not null,
|
|
||||||
intro_text text not null,
|
|
||||||
about_name text not null,
|
|
||||||
about_role text not null,
|
|
||||||
about_bio text not null,
|
|
||||||
email text not null,
|
|
||||||
phone text not null,
|
|
||||||
location text not null,
|
|
||||||
hero_image text not null,
|
|
||||||
about_image text not null
|
|
||||||
)`,
|
|
||||||
`create table if not exists projects (
|
|
||||||
id integer primary key autoincrement,
|
|
||||||
slug text not null unique,
|
|
||||||
title text not null,
|
|
||||||
location text not null,
|
|
||||||
year text not null,
|
|
||||||
category text not null,
|
|
||||||
description text not null,
|
|
||||||
cover_image text not null,
|
|
||||||
featured integer not null default 0,
|
|
||||||
created_at datetime not null default current_timestamp
|
|
||||||
)`,
|
|
||||||
`create table if not exists project_images (
|
|
||||||
id integer primary key autoincrement,
|
|
||||||
project_id integer not null references projects(id) on delete cascade,
|
|
||||||
path text not null,
|
|
||||||
caption text not null,
|
|
||||||
position integer not null default 0
|
|
||||||
)`,
|
|
||||||
`create table if not exists contact_requests (
|
|
||||||
id integer primary key autoincrement,
|
|
||||||
name text not null,
|
|
||||||
email text not null,
|
|
||||||
message text not null,
|
|
||||||
created_at datetime not null default current_timestamp
|
|
||||||
)`,
|
|
||||||
`create table if not exists admin_users (
|
|
||||||
id integer primary key autoincrement,
|
|
||||||
username text not null unique,
|
|
||||||
password_hash blob not null
|
|
||||||
)`,
|
|
||||||
`create table if not exists sessions (
|
|
||||||
token_hash text primary key,
|
|
||||||
user_id integer not null references admin_users(id) on delete cascade,
|
|
||||||
expires_at datetime not null
|
|
||||||
)`,
|
|
||||||
}
|
|
||||||
for _, stmt := range stmts {
|
|
||||||
if _, err := s.db.Exec(stmt); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.seed(adminUsername, adminPassword)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) seed(adminUsername, adminPassword string) error {
|
|
||||||
var count int
|
|
||||||
if err := s.db.QueryRow(`select count(*) from site_content`).Scan(&count); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count == 0 {
|
|
||||||
_, err := s.db.Exec(`insert into site_content (
|
|
||||||
id, hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio,
|
|
||||||
email, phone, location, hero_image, about_image
|
|
||||||
) values (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
"Archi Folio",
|
|
||||||
"Spatial design, architecture, and interiors shaped through quiet detail.",
|
|
||||||
"Selected residential and cultural spaces",
|
|
||||||
"A compact portfolio for showing image-led architectural work, interior concepts, and project narratives.",
|
|
||||||
"Alex Morgan",
|
|
||||||
"Architect & Interior Designer",
|
|
||||||
"I design calm, functional spaces with attention to proportion, material, light, and the daily rituals of the people who use them.",
|
|
||||||
"studio@example.com",
|
|
||||||
"+44 20 0000 0000",
|
|
||||||
"London, United Kingdom",
|
|
||||||
"/static/placeholders/hero.svg",
|
|
||||||
"/static/placeholders/about.svg",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.db.QueryRow(`select count(*) from projects`).Scan(&count); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count == 0 {
|
|
||||||
projects := []Project{
|
|
||||||
{Slug: "courtyard-house", Title: "Courtyard House", Location: "Bath, UK", Year: "2025", Category: "Residential", Description: "A private house organized around a quiet internal garden, using warm timber, stone, and filtered daylight.", CoverImage: "/static/placeholders/project-1.svg", Featured: true},
|
|
||||||
{Slug: "atelier-apartment", Title: "Atelier Apartment", Location: "London, UK", Year: "2024", Category: "Interior", Description: "A compact apartment refit with integrated storage, gallery-like surfaces, and a flexible work area.", CoverImage: "/static/placeholders/project-2.svg", Featured: true},
|
|
||||||
{Slug: "gallery-room", Title: "Gallery Room", Location: "Amsterdam, NL", Year: "2024", Category: "Cultural", Description: "A small exhibition environment designed for shifting light levels, sculpture, and intimate events.", CoverImage: "/static/placeholders/project-3.svg", Featured: true},
|
|
||||||
}
|
|
||||||
for _, p := range projects {
|
|
||||||
id, err := s.CreateProject(context.Background(), p)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for i := 0; i < 3; i++ {
|
|
||||||
if _, err := s.db.Exec(`insert into project_images (project_id, path, caption, position) values (?, ?, ?, ?)`, id, p.CoverImage, p.Title, i); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
var user AdminUser
|
|
||||||
err = s.db.QueryRow(`select id, username, password_hash from admin_users order by id asc limit 1`).Scan(&user.ID, &user.Username, &user.PasswordHash)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
_, err = s.db.Exec(`insert into admin_users (username, password_hash) values (?, ?)`, adminUsername, hash)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if user.Username == adminUsername && bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(adminPassword)) == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err = s.db.Exec(`update admin_users set username = ?, password_hash = ? where id = ?`, adminUsername, hash, user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = s.db.Exec(`delete from sessions`)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) SiteContent(ctx context.Context) (SiteContent, error) {
|
|
||||||
var c SiteContent
|
|
||||||
err := s.db.QueryRowContext(ctx, `select hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio, email, phone, location, hero_image, about_image from site_content where id = 1`).
|
|
||||||
Scan(&c.HeroTitle, &c.HeroSubtitle, &c.IntroTitle, &c.IntroText, &c.AboutName, &c.AboutRole, &c.AboutBio, &c.Email, &c.Phone, &c.Location, &c.HeroImage, &c.AboutImage)
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) UpdateSiteContent(ctx context.Context, c SiteContent) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, `update site_content set hero_title=?, hero_subtitle=?, intro_title=?, intro_text=?, about_name=?, about_role=?, about_bio=?, email=?, phone=?, location=?, hero_image=?, about_image=? where id=1`,
|
|
||||||
c.HeroTitle, c.HeroSubtitle, c.IntroTitle, c.IntroText, c.AboutName, c.AboutRole, c.AboutBio, c.Email, c.Phone, c.Location, c.HeroImage, c.AboutImage)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, error) {
|
|
||||||
query := `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects`
|
|
||||||
if featuredOnly {
|
|
||||||
query += ` where featured = 1`
|
|
||||||
}
|
|
||||||
query += ` order by created_at desc, id desc`
|
|
||||||
rows, err := s.db.QueryContext(ctx, query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var projects []Project
|
|
||||||
for rows.Next() {
|
|
||||||
var p Project
|
|
||||||
var featured int
|
|
||||||
if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
p.Featured = featured == 1
|
|
||||||
projects = append(projects, p)
|
|
||||||
}
|
|
||||||
return projects, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) ProjectBySlug(ctx context.Context, slug string) (Project, error) {
|
|
||||||
var p Project
|
|
||||||
var featured int
|
|
||||||
err := s.db.QueryRowContext(ctx, `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects where slug = ?`, slug).
|
|
||||||
Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return p, err
|
|
||||||
}
|
|
||||||
p.Featured = featured == 1
|
|
||||||
p.Images, err = s.ProjectImages(ctx, p.ID)
|
|
||||||
return p, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) ProjectImages(ctx context.Context, projectID int64) ([]ProjectImage, error) {
|
|
||||||
rows, err := s.db.QueryContext(ctx, `select id, project_id, path, caption, position from project_images where project_id = ? order by position asc, id asc`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var images []ProjectImage
|
|
||||||
for rows.Next() {
|
|
||||||
var img ProjectImage
|
|
||||||
if err := rows.Scan(&img.ID, &img.ProjectID, &img.Path, &img.Caption, &img.Position); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
images = append(images, img)
|
|
||||||
}
|
|
||||||
return images, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) ProjectImagesByProject(ctx context.Context) (map[int64][]ProjectImage, error) {
|
|
||||||
rows, err := s.db.QueryContext(ctx, `select id, project_id, path, caption, position from project_images order by project_id asc, position asc, id asc`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
images := make(map[int64][]ProjectImage)
|
|
||||||
for rows.Next() {
|
|
||||||
var img ProjectImage
|
|
||||||
if err := rows.Scan(&img.ID, &img.ProjectID, &img.Path, &img.Caption, &img.Position); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
images[img.ProjectID] = append(images[img.ProjectID], img)
|
|
||||||
}
|
|
||||||
return images, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) ProjectImageForSlug(ctx context.Context, slug string, imageID int64) (Project, ProjectImage, error) {
|
|
||||||
p, err := s.ProjectBySlug(ctx, slug)
|
|
||||||
if err != nil {
|
|
||||||
return Project{}, ProjectImage{}, err
|
|
||||||
}
|
|
||||||
for _, img := range p.Images {
|
|
||||||
if img.ID == imageID {
|
|
||||||
return p, img, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Project{}, ProjectImage{}, sql.ErrNoRows
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) CreateProject(ctx context.Context, p Project) (int64, error) {
|
|
||||||
res, err := s.db.ExecContext(ctx, `insert into projects (slug, title, location, year, category, description, cover_image, featured) values (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return res.LastInsertId()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) UpdateProject(ctx context.Context, p Project) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, `update projects set slug=?, title=?, location=?, year=?, category=?, description=?, cover_image=?, featured=? where id=?`,
|
|
||||||
p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured), p.ID)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) DeleteProject(ctx context.Context, id int64) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, `delete from projects where id = ?`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) AddProjectImage(ctx context.Context, projectID int64, path, caption string) error {
|
|
||||||
var pos int
|
|
||||||
_ = s.db.QueryRowContext(ctx, `select coalesce(max(position), -1) + 1 from project_images where project_id = ?`, projectID).Scan(&pos)
|
|
||||||
_, err := s.db.ExecContext(ctx, `insert into project_images (project_id, path, caption, position) values (?, ?, ?, ?)`, projectID, path, caption, pos)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) DeleteProjectImage(ctx context.Context, id int64) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, `delete from project_images where id = ?`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) SaveContact(ctx context.Context, name, email, message string) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, `insert into contact_requests (name, email, message) values (?, ?, ?)`, name, email, message)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) {
|
|
||||||
rows, err := s.db.QueryContext(ctx, `select id, name, email, message, created_at from contact_requests order by created_at desc, id desc`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var requests []ContactRequest
|
|
||||||
for rows.Next() {
|
|
||||||
var r ContactRequest
|
|
||||||
if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Message, &r.CreatedAt); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
requests = append(requests, r)
|
|
||||||
}
|
|
||||||
return requests, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) AdminByUsername(ctx context.Context, username string) (AdminUser, error) {
|
|
||||||
var u AdminUser
|
|
||||||
err := s.db.QueryRowContext(ctx, `select id, username, password_hash from admin_users where username = ?`, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
|
|
||||||
return u, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) CreateSession(ctx context.Context, tokenHash string, userID int64, expiresAt time.Time) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, `insert into sessions (token_hash, user_id, expires_at) values (?, ?, ?)`, tokenHash, userID, expiresAt)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) SessionUser(ctx context.Context, tokenHash string) (AdminUser, error) {
|
|
||||||
var u AdminUser
|
|
||||||
err := s.db.QueryRowContext(ctx, `select u.id, u.username, u.password_hash from sessions s join admin_users u on u.id = s.user_id where s.token_hash = ? and s.expires_at > ?`, tokenHash, time.Now()).
|
|
||||||
Scan(&u.ID, &u.Username, &u.PasswordHash)
|
|
||||||
return u, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error {
|
|
||||||
_, err := s.db.ExecContext(ctx, `delete from sessions where token_hash = ?`, tokenHash)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsNotFound(err error) bool {
|
|
||||||
return errors.Is(err, sql.ErrNoRows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func boolInt(v bool) int {
|
|
||||||
if v {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func SlugFromTitle(title string) string {
|
|
||||||
out := make([]rune, 0, len(title))
|
|
||||||
lastDash := false
|
|
||||||
for _, r := range title {
|
|
||||||
if r >= 'A' && r <= 'Z' {
|
|
||||||
r += 'a' - 'A'
|
|
||||||
}
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
||||||
out = append(out, r)
|
|
||||||
lastDash = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !lastDash && len(out) > 0 {
|
|
||||||
out = append(out, '-')
|
|
||||||
lastDash = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for len(out) > 0 && out[len(out)-1] == '-' {
|
|
||||||
out = out[:len(out)-1]
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return fmt.Sprintf("project-%d", time.Now().Unix())
|
|
||||||
}
|
|
||||||
return string(out)
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user