aboutsummaryrefslogtreecommitdiffstats
path: root/pkg
diff options
context:
space:
mode:
authorLeonardo Bishop <me@leonardobishop.net>2025-07-08 23:26:05 +0100
committerLeonardo Bishop <me@leonardobishop.net>2025-07-08 23:26:05 +0100
commitcdb75d3fcbc9339b897f8c6ff4d69a577f017393 (patch)
tree5e757cd236540c2cea9874c1bc09f19548db05d5 /pkg
parentb56101f1a11552067f594679a497ebd4cf7427d4 (diff)
Rewrite in Go
Diffstat (limited to 'pkg')
-rw-r--r--pkg/config/main.go45
-rw-r--r--pkg/config/site.go35
-rw-r--r--pkg/constants/constants.go6
-rw-r--r--pkg/index/index.go63
-rw-r--r--pkg/index/scan.go47
-rw-r--r--pkg/server/handle.go21
-rw-r--r--pkg/site/fs.go36
-rw-r--r--pkg/site/site.go150
-rw-r--r--pkg/upload/upload.go125
-rw-r--r--pkg/watcher/watcher.go3
10 files changed, 531 insertions, 0 deletions
diff --git a/pkg/config/main.go b/pkg/config/main.go
new file mode 100644
index 0000000..496adf1
--- /dev/null
+++ b/pkg/config/main.go
@@ -0,0 +1,45 @@
+package config
+
+import (
+ "os"
+
+ "github.com/go-playground/validator/v10"
+ "github.com/pelletier/go-toml/v2"
+)
+
+type MainConfig struct {
+ Listen struct {
+ Address string `validate:"required,ip"`
+ Port uint16 `validate:"required"`
+ }
+
+ Command struct {
+ Host string
+ Secret string
+
+ API struct {
+ Enable bool
+ }
+
+ Web struct {
+ Enable bool
+ }
+ }
+}
+
+func ReadMainConfig(filePath string, dst *MainConfig) error {
+ config, err := os.ReadFile(filePath)
+ if err != nil {
+ return err
+ }
+
+ if err := toml.Unmarshal(config, dst); err != nil {
+ return err
+ }
+ return nil
+}
+
+func ValidateMainConfig(cfg *MainConfig) error {
+ validate := validator.New(validator.WithRequiredStructEnabled())
+ return validate.Struct(cfg)
+}
diff --git a/pkg/config/site.go b/pkg/config/site.go
new file mode 100644
index 0000000..ffd5ddc
--- /dev/null
+++ b/pkg/config/site.go
@@ -0,0 +1,35 @@
+package config
+
+import (
+ "os"
+
+ "github.com/pelletier/go-toml/v2"
+)
+
+type SiteConfig struct {
+ Host string
+}
+
+func ReadSiteConfig(filePath string, dst *SiteConfig) error {
+ config, err := os.ReadFile(filePath)
+ if err != nil {
+ return err
+ }
+
+ if err := toml.Unmarshal(config, dst); err != nil {
+ return err
+ }
+ return nil
+}
+
+func WriteSiteConfig(filePath string, src *SiteConfig) error {
+ config, err := toml.Marshal(src)
+ if err != nil {
+ return err
+ }
+
+ if err := os.WriteFile(filePath, config, 0o644); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go
new file mode 100644
index 0000000..271b803
--- /dev/null
+++ b/pkg/constants/constants.go
@@ -0,0 +1,6 @@
+package constants
+
+var (
+ SysConfDir = "/etc/scrapbook"
+ SysDataDir = "/var/lib/scrapbook"
+)
diff --git a/pkg/index/index.go b/pkg/index/index.go
new file mode 100644
index 0000000..35423bd
--- /dev/null
+++ b/pkg/index/index.go
@@ -0,0 +1,63 @@
+package index
+
+import (
+ "maps"
+ "slices"
+ "sort"
+ "sync"
+
+ "github.com/LMBishop/scrapbook/pkg/site"
+)
+
+type SiteIndex struct {
+ mu sync.RWMutex
+ sites map[string]*site.Site
+ sitesByHost map[string]*site.Site
+}
+
+func NewSiteIndex() *SiteIndex {
+ var siteIndex SiteIndex
+ siteIndex.sites = make(map[string]*site.Site)
+ siteIndex.sitesByHost = make(map[string]*site.Site)
+ return &siteIndex
+}
+
+func (s *SiteIndex) GetSiteByHost(host string) *site.Site {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ return s.sitesByHost[host]
+}
+
+func (s *SiteIndex) GetSite(site string) *site.Site {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ return s.sites[site]
+}
+
+func (s *SiteIndex) GetSites() []*site.Site {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ sites := slices.Collect(maps.Values(s.sites))
+ sort.Slice(sites, func(i, j int) bool {
+ return sites[i].Name < sites[j].Name
+ })
+ return sites
+}
+
+func (s *SiteIndex) AddSite(site *site.Site) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.sites[site.Name] = site
+ s.updateSiteIndexes()
+}
+
+func (s *SiteIndex) updateSiteIndexes() {
+ clear(s.sitesByHost)
+ for _, site := range s.sites {
+ s.sitesByHost[site.SiteConfig.Host] = site
+ }
+}
diff --git a/pkg/index/scan.go b/pkg/index/scan.go
new file mode 100644
index 0000000..dd3d266
--- /dev/null
+++ b/pkg/index/scan.go
@@ -0,0 +1,47 @@
+package index
+
+import (
+ "fmt"
+ "log/slog"
+ "os"
+ "path"
+
+ "github.com/LMBishop/scrapbook/pkg/config"
+ "github.com/LMBishop/scrapbook/pkg/site"
+)
+
+func ScanDirectory(dir string, dst *SiteIndex) error {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return err
+ }
+
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+
+ siteName := e.Name()
+ sitePath := path.Join(dir, siteName)
+ cfg, err := readSiteConfig(sitePath)
+ if err != nil {
+ slog.Warn("failed to read site", "site", siteName, "reason", err)
+ continue
+ }
+
+ site := site.NewSite(siteName, sitePath, cfg)
+ dst.AddSite(site)
+ }
+
+ return nil
+}
+
+func readSiteConfig(dir string) (*config.SiteConfig, error) {
+ siteFile := path.Join(dir, "site.toml")
+ cfg := &config.SiteConfig{}
+ err := config.ReadSiteConfig(siteFile, cfg)
+ if err != nil {
+ return nil, fmt.Errorf("site file invalid: %s", err)
+ }
+ return cfg, nil
+}
diff --git a/pkg/server/handle.go b/pkg/server/handle.go
new file mode 100644
index 0000000..afe65ce
--- /dev/null
+++ b/pkg/server/handle.go
@@ -0,0 +1,21 @@
+package server
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/LMBishop/scrapbook/pkg/index"
+)
+
+func ServeSite(siteIndex *index.SiteIndex) func(w http.ResponseWriter, r *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ site := siteIndex.GetSiteByHost(r.Host)
+ if site == nil {
+ w.WriteHeader(http.StatusNotFound)
+ fmt.Fprintf(w, "unknown host %s", r.Host)
+ return
+ }
+
+ site.Handler.ServeHTTP(w, r)
+ }
+}
diff --git a/pkg/site/fs.go b/pkg/site/fs.go
new file mode 100644
index 0000000..a77da63
--- /dev/null
+++ b/pkg/site/fs.go
@@ -0,0 +1,36 @@
+package site
+
+import (
+ "net/http"
+ "path/filepath"
+)
+
+type siteFS struct {
+ fs http.FileSystem
+}
+
+func (sfs siteFS) Open(path string) (http.File, error) {
+ f, err := sfs.fs.Open(path)
+ if err != nil {
+ return nil, err
+ }
+
+ s, err := f.Stat()
+ if err != nil {
+ return nil, err
+ }
+
+ if s.IsDir() {
+ index := filepath.Join(path, "index.html")
+ if _, err := sfs.fs.Open(index); err != nil {
+ closeErr := f.Close()
+ if closeErr != nil {
+ return nil, closeErr
+ }
+
+ return nil, err
+ }
+ }
+
+ return f, nil
+}
diff --git a/pkg/site/site.go b/pkg/site/site.go
new file mode 100644
index 0000000..37dca5f
--- /dev/null
+++ b/pkg/site/site.go
@@ -0,0 +1,150 @@
+package site
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "time"
+
+ "github.com/LMBishop/scrapbook/pkg/config"
+)
+
+const versionRegex = "[0-9]{4}_[0-9]{2}_[0-9]{2}_[0-9]{2}_[0-9]{2}_[0-9]{2}"
+const timeFormat = "2006_01_02_15_04_05"
+
+type Site struct {
+ Name string
+ Path string
+ Handler http.Handler
+ SiteConfig *config.SiteConfig
+}
+
+func NewSite(name string, dir string, config *config.SiteConfig) *Site {
+ var site Site
+ site.Name = name
+ site.Path = dir
+ site.SiteConfig = config
+ site.Handler = http.FileServer(siteFS{http.Dir(path.Join(dir, "default"))})
+ return &site
+}
+
+func CreateNewSite(name string, baseDir string, host string) (*Site, error) {
+ dir := path.Join(baseDir, name)
+ _, err := os.Stat(dir)
+ if err == nil {
+ return nil, fmt.Errorf("site with name already exists: %s", name)
+ }
+ if !errors.Is(err, os.ErrNotExist) {
+ return nil, fmt.Errorf("failed to check site uniqueness: %w", err)
+ }
+
+ cfg := &config.SiteConfig{
+ Host: host,
+ }
+ site := NewSite(name, dir, cfg)
+
+ err = os.Mkdir(dir, 0o755)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create directory: %w", err)
+ }
+
+ err = config.WriteSiteConfig(path.Join(dir, "site.toml"), cfg)
+ if err != nil {
+ return nil, fmt.Errorf("failed to write site config: %w", err)
+ }
+
+ return site, nil
+}
+
+func (s *Site) GetCurrentPath() string {
+ return path.Join(s.Path, "default")
+}
+
+func (s *Site) GetCurrentVersion() (string, error) {
+ dir, err := filepath.EvalSymlinks(path.Join(s.Path, "default"))
+ if err != nil {
+ return "", err
+ }
+
+ return filepath.Base(dir), nil
+}
+
+func (s *Site) UpdateVersion(newVersion string) error {
+ newVersionPath := path.Join(s.Path, newVersion)
+
+ stat, err := os.Stat(newVersionPath)
+ if err != nil {
+ return err
+ }
+
+ if !stat.IsDir() {
+ return fmt.Errorf("not a directory: %s", newVersionPath)
+ }
+
+ currentVersionPath := s.GetCurrentPath()
+
+ os.Remove(currentVersionPath)
+ return os.Symlink(newVersion, currentVersionPath)
+}
+
+func (s *Site) GetAllVersions() ([]string, error) {
+ entries, err := os.ReadDir(s.Path)
+
+ if err != nil {
+ return nil, err
+ }
+
+ var versions []string
+
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+
+ match, err := regexp.MatchString(versionRegex, entry.Name())
+ if err != nil {
+ return nil, err
+ }
+
+ if match {
+ versions = append(versions, entry.Name())
+ }
+ }
+
+ return versions, err
+}
+
+func (s *Site) CreateNewVersion() (string, error) {
+ t := time.Now()
+ dirName := t.Format("2006_01_02_15_04_05")
+ newVersionDir := path.Join(s.Path, dirName)
+
+ err := os.MkdirAll(newVersionDir, os.FileMode(0o755))
+ if err != nil {
+ return "", err
+ }
+
+ return dirName, nil
+}
+
+func (s *Site) EvaluateSiteStatus() string {
+ stat, err := os.Stat(s.GetCurrentPath())
+ if err != nil || !stat.IsDir() {
+ return "inactive"
+ }
+
+ return "live"
+}
+
+func (s *Site) EvaluateSiteStatusReason() string {
+ stat, err := os.Stat(s.GetCurrentPath())
+ if err != nil || !stat.IsDir() {
+ return "This site is inacessible because no version is active"
+ }
+
+ return "This site is live"
+}
diff --git a/pkg/upload/upload.go b/pkg/upload/upload.go
new file mode 100644
index 0000000..aff7803
--- /dev/null
+++ b/pkg/upload/upload.go
@@ -0,0 +1,125 @@
+package upload
+
+import (
+ "archive/zip"
+ "fmt"
+ "io"
+ "mime/multipart"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+
+ "github.com/LMBishop/scrapbook/pkg/index"
+)
+
+func HandleUpload(siteName string, reader *multipart.Reader, index *index.SiteIndex) (string, error) {
+ s := index.GetSite(siteName)
+ if s == nil {
+ return "", fmt.Errorf("no such site: %s", siteName)
+ }
+
+ temp, err := os.CreateTemp(os.TempDir(), "scrapbook")
+ if err != nil {
+ return "", fmt.Errorf("failed to create temporary file: %w", err)
+ }
+ defer func() {
+ temp.Close()
+ os.Remove(temp.Name())
+ }()
+
+ for {
+ part, err := reader.NextPart()
+ if err == io.EOF {
+ break
+ } else if err != nil {
+ return "", fmt.Errorf("failed to read multipart stream: %w", err)
+ }
+ if part.FormName() == "upload" {
+ io.Copy(temp, part)
+ }
+ }
+
+ zipReader, err := zip.OpenReader(temp.Name())
+ if err != nil {
+ return "", fmt.Errorf("failed to open zip reader: %w", err)
+ }
+ defer zipReader.Close()
+
+ version, err := s.CreateNewVersion()
+ if err != nil {
+ return "", fmt.Errorf("failed to create new version: %w", err)
+ }
+ versionDir := path.Join(s.Path, version)
+
+ err = unzipSource(temp.Name(), versionDir)
+ if err != nil {
+ return "", fmt.Errorf("failed to unzip archive: %w", err)
+ }
+
+ err = s.UpdateVersion(version)
+ if err != nil {
+ return "", fmt.Errorf("failed to update version: %w", err)
+ }
+
+ return version, nil
+}
+
+// https://gosamples.dev/unzip-file/
+
+func unzipSource(source, destination string) error {
+ reader, err := zip.OpenReader(source)
+ if err != nil {
+ return err
+ }
+ defer reader.Close()
+
+ destination, err = filepath.Abs(destination)
+ if err != nil {
+ return err
+ }
+
+ for _, f := range reader.File {
+ err := unzipFile(f, destination)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func unzipFile(f *zip.File, destination string) error {
+ filePath := filepath.Join(destination, f.Name)
+ if !strings.HasPrefix(filePath, filepath.Clean(destination)+string(os.PathSeparator)) {
+ return fmt.Errorf("invalid file path: %s", filePath)
+ }
+
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(filePath, os.ModePerm); err != nil {
+ return err
+ }
+ return nil
+ }
+
+ if err := os.MkdirAll(filepath.Dir(filePath), os.ModePerm); err != nil {
+ return err
+ }
+
+ destinationFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
+ if err != nil {
+ return err
+ }
+ defer destinationFile.Close()
+
+ zippedFile, err := f.Open()
+ if err != nil {
+ return err
+ }
+ defer zippedFile.Close()
+
+ if _, err := io.Copy(destinationFile, zippedFile); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/pkg/watcher/watcher.go b/pkg/watcher/watcher.go
new file mode 100644
index 0000000..1017a87
--- /dev/null
+++ b/pkg/watcher/watcher.go
@@ -0,0 +1,3 @@
+package watcher
+
+// todo