433 lines
14 KiB
Go
433 lines
14 KiB
Go
|
|
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 {
|
||
|
|
db *sql.DB
|
||
|
|
}
|
||
|
|
|
||
|
|
type SiteContent struct {
|
||
|
|
HeroTitle string
|
||
|
|
HeroSubtitle string
|
||
|
|
IntroTitle string
|
||
|
|
IntroText string
|
||
|
|
AboutName string
|
||
|
|
AboutRole string
|
||
|
|
AboutBio string
|
||
|
|
Email string
|
||
|
|
Phone string
|
||
|
|
Location string
|
||
|
|
HeroImage string
|
||
|
|
AboutImage string
|
||
|
|
}
|
||
|
|
|
||
|
|
type Project struct {
|
||
|
|
ID int64
|
||
|
|
Slug string
|
||
|
|
Title string
|
||
|
|
Location string
|
||
|
|
Year string
|
||
|
|
Category string
|
||
|
|
Description string
|
||
|
|
CoverImage string
|
||
|
|
Featured bool
|
||
|
|
CreatedAt time.Time
|
||
|
|
Images []ProjectImage
|
||
|
|
}
|
||
|
|
|
||
|
|
type ProjectImage struct {
|
||
|
|
ID int64
|
||
|
|
ProjectID int64
|
||
|
|
Path string
|
||
|
|
Caption string
|
||
|
|
Position int
|
||
|
|
}
|
||
|
|
|
||
|
|
type ContactRequest struct {
|
||
|
|
ID int64
|
||
|
|
Name string
|
||
|
|
Email string
|
||
|
|
Message string
|
||
|
|
CreatedAt time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
type AdminUser struct {
|
||
|
|
ID int64
|
||
|
|
Username string
|
||
|
|
PasswordHash []byte
|
||
|
|
}
|
||
|
|
|
||
|
|
func Open(path string) (*Store, error) {
|
||
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
db, err := sql.Open("sqlite3", path+"?_foreign_keys=on")
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
db.SetMaxOpenConns(1)
|
||
|
|
return &Store{db: db}, db.Ping()
|
||
|
|
}
|
||
|
|
|
||
|
|
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)
|
||
|
|
}
|