diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index c88d8df..0000000 --- a/internal/app/app.go +++ /dev/null @@ -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) - }) -} diff --git a/internal/app/app_test.go b/internal/app/app_test.go deleted file mode 100644 index 41a4aab..0000000 --- a/internal/app/app_test.go +++ /dev/null @@ -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), "") || !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, "") { - 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(``)); 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] -} diff --git a/internal/app/assets.go b/internal/app/assets.go new file mode 100644 index 0000000..555d56d --- /dev/null +++ b/internal/app/assets.go @@ -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") +} diff --git a/internal/app/auth.go b/internal/app/auth.go new file mode 100644 index 0000000..7a5e3fc --- /dev/null +++ b/internal/app/auth.go @@ -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[:]) +} diff --git a/internal/app/auth_test.go b/internal/app/auth_test.go new file mode 100644 index 0000000..0c7b67f --- /dev/null +++ b/internal/app/auth_test.go @@ -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") + } +} diff --git a/internal/app/handlers_admin_mutations.go b/internal/app/handlers_admin_mutations.go new file mode 100644 index 0000000..c0d3556 --- /dev/null +++ b/internal/app/handlers_admin_mutations.go @@ -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") +} diff --git a/internal/app/handlers_admin_mutations_test.go b/internal/app/handlers_admin_mutations_test.go new file mode 100644 index 0000000..afcd89d --- /dev/null +++ b/internal/app/handlers_admin_mutations_test.go @@ -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) + } +} diff --git a/internal/app/handlers_admin_pages.go b/internal/app/handlers_admin_pages.go new file mode 100644 index 0000000..7fa767d --- /dev/null +++ b/internal/app/handlers_admin_pages.go @@ -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 +} diff --git a/internal/app/handlers_admin_pages_test.go b/internal/app/handlers_admin_pages_test.go new file mode 100644 index 0000000..8ae9b92 --- /dev/null +++ b/internal/app/handlers_admin_pages_test.go @@ -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), "") || !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, "") { + 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) + } +} diff --git a/internal/app/handlers_public.go b/internal/app/handlers_public.go new file mode 100644 index 0000000..e1bf4cd --- /dev/null +++ b/internal/app/handlers_public.go @@ -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."}) +} diff --git a/internal/app/handlers_public_test.go b/internal/app/handlers_public_test.go new file mode 100644 index 0000000..4ad8e96 --- /dev/null +++ b/internal/app/handlers_public_test.go @@ -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) + } +} diff --git a/internal/app/middleware.go b/internal/app/middleware.go new file mode 100644 index 0000000..605a80d --- /dev/null +++ b/internal/app/middleware.go @@ -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) + }) +} diff --git a/internal/app/render.go b/internal/app/render.go new file mode 100644 index 0000000..b7685fa --- /dev/null +++ b/internal/app/render.go @@ -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) +} diff --git a/internal/app/routes.go b/internal/app/routes.go new file mode 100644 index 0000000..a98af51 --- /dev/null +++ b/internal/app/routes.go @@ -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) +} diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..fb493d0 --- /dev/null +++ b/internal/app/server.go @@ -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 +} diff --git a/internal/app/test_helpers_test.go b/internal/app/test_helpers_test.go new file mode 100644 index 0000000..9aaf28a --- /dev/null +++ b/internal/app/test_helpers_test.go @@ -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] +} diff --git a/internal/app/types.go b/internal/app/types.go new file mode 100644 index 0000000..a76f0cc --- /dev/null +++ b/internal/app/types.go @@ -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 +} diff --git a/internal/app/uploads.go b/internal/app/uploads.go new file mode 100644 index 0000000..09fce35 --- /dev/null +++ b/internal/app/uploads.go @@ -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() +} diff --git a/internal/app/uploads_test.go b/internal/app/uploads_test.go new file mode 100644 index 0000000..8b57d2e --- /dev/null +++ b/internal/app/uploads_test.go @@ -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(``)); 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) + } +} diff --git a/internal/app/validation.go b/internal/app/validation.go new file mode 100644 index 0000000..30a6780 --- /dev/null +++ b/internal/app/validation.go @@ -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 + } +} diff --git a/internal/store/auth.go b/internal/store/auth.go new file mode 100644 index 0000000..2b832ec --- /dev/null +++ b/internal/store/auth.go @@ -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 +} diff --git a/internal/store/contact.go b/internal/store/contact.go new file mode 100644 index 0000000..a8be098 --- /dev/null +++ b/internal/store/contact.go @@ -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() +} diff --git a/internal/store/helpers.go b/internal/store/helpers.go new file mode 100644 index 0000000..aee429c --- /dev/null +++ b/internal/store/helpers.go @@ -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) +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go new file mode 100644 index 0000000..e6bd40e --- /dev/null +++ b/internal/store/migrations.go @@ -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 +} diff --git a/internal/store/store_test.go b/internal/store/migrations_test.go similarity index 100% rename from internal/store/store_test.go rename to internal/store/migrations_test.go diff --git a/internal/store/projects.go b/internal/store/projects.go new file mode 100644 index 0000000..65c7dec --- /dev/null +++ b/internal/store/projects.go @@ -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 +} diff --git a/internal/store/site.go b/internal/store/site.go new file mode 100644 index 0000000..e587686 --- /dev/null +++ b/internal/store/site.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go index fa3cfb1..1830f98 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,16 +1,12 @@ package store import ( - "context" "database/sql" - "errors" - "fmt" "os" "path/filepath" "time" _ "github.com/mattn/go-sqlite3" - "golang.org/x/crypto/bcrypt" ) type Store struct { @@ -83,350 +79,3 @@ func Open(path string) (*Store, error) { func (s *Store) Close() error { 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) -}