sabisan/internal/store/store.go
2026-05-16 20:30:20 +01:00

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