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) }) }