diff options
| author | Leonardo Bishop <me@leonardobishop.net> | 2025-07-08 23:26:05 +0100 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.net> | 2025-07-08 23:26:05 +0100 |
| commit | cdb75d3fcbc9339b897f8c6ff4d69a577f017393 (patch) | |
| tree | 5e757cd236540c2cea9874c1bc09f19548db05d5 /pkg | |
| parent | b56101f1a11552067f594679a497ebd4cf7427d4 (diff) | |
Rewrite in Go
Diffstat (limited to 'pkg')
| -rw-r--r-- | pkg/config/main.go | 45 | ||||
| -rw-r--r-- | pkg/config/site.go | 35 | ||||
| -rw-r--r-- | pkg/constants/constants.go | 6 | ||||
| -rw-r--r-- | pkg/index/index.go | 63 | ||||
| -rw-r--r-- | pkg/index/scan.go | 47 | ||||
| -rw-r--r-- | pkg/server/handle.go | 21 | ||||
| -rw-r--r-- | pkg/site/fs.go | 36 | ||||
| -rw-r--r-- | pkg/site/site.go | 150 | ||||
| -rw-r--r-- | pkg/upload/upload.go | 125 | ||||
| -rw-r--r-- | pkg/watcher/watcher.go | 3 |
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 |
