Add lightweight self-hosted Hugo CMS with GitHub API backend
Implements a Go-based CMS for editing Hugo posts and uploading images via the GitHub REST API, with no database and Git as the source of truth. Features: - Single admin auth with bcrypt password and signed session cookies - Dashboard, post listing with search, create/edit with EasyMDE editor - Media library for static/uploads/ with drag-drop and clipboard paste - Client-side autosave, unsaved changes warning, dark mode, responsive UI - Modular packages for auth, github, posts, media, session, and handlers Co-authored-by: codegirl007 <s.raide@gmail.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/codegirl-007/hugo-cms/internal/github"
|
||||
)
|
||||
|
||||
const uploadsDir = "static/uploads"
|
||||
|
||||
// Item represents an uploaded media file.
|
||||
type Item struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// Service manages media uploads via GitHub.
|
||||
type Service struct {
|
||||
github github.Client
|
||||
}
|
||||
|
||||
// NewService creates a media service.
|
||||
func NewService(client github.Client) *Service {
|
||||
return &Service{github: client}
|
||||
}
|
||||
|
||||
// List returns all files in the uploads directory.
|
||||
func (s *Service) List(ctx context.Context) ([]Item, error) {
|
||||
items, err := s.github.ListDirectory(ctx, uploadsDir)
|
||||
if err != nil {
|
||||
// Directory may not exist yet.
|
||||
if strings.Contains(err.Error(), "404") {
|
||||
return []Item{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("list media: %w", err)
|
||||
}
|
||||
|
||||
var media []Item
|
||||
for _, item := range items {
|
||||
if item.Type != "file" {
|
||||
continue
|
||||
}
|
||||
media = append(media, Item{
|
||||
Name: item.Name,
|
||||
Path: item.Path,
|
||||
URL: "/uploads/" + item.Name,
|
||||
Size: item.Size,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(media, func(i, j int) bool {
|
||||
return media[i].Name < media[j].Name
|
||||
})
|
||||
|
||||
return media, nil
|
||||
}
|
||||
|
||||
// Upload saves a binary file to static/uploads/ via the GitHub API.
|
||||
func (s *Service) Upload(ctx context.Context, filename string, data []byte) (*Item, error) {
|
||||
filename = sanitizeFilename(filename)
|
||||
if filename == "" {
|
||||
return nil, fmt.Errorf("invalid filename")
|
||||
}
|
||||
|
||||
filePath := path.Join(uploadsDir, filename)
|
||||
existingSHA := ""
|
||||
|
||||
if existing, err := s.github.GetFile(ctx, filePath); err == nil {
|
||||
existingSHA = existing.SHA
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Upload image: %s", filename)
|
||||
|
||||
if err := s.github.CreateOrUpdateFile(ctx, filePath, message, data, existingSHA); err != nil {
|
||||
return nil, fmt.Errorf("upload media: %w", err)
|
||||
}
|
||||
|
||||
return &Item{
|
||||
Name: filename,
|
||||
Path: filePath,
|
||||
URL: "/uploads/" + filename,
|
||||
Size: len(data),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = path.Base(strings.TrimSpace(name))
|
||||
name = strings.ToLower(name)
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// IsImage returns true if the filename has a common image extension.
|
||||
func IsImage(filename string) bool {
|
||||
lower := strings.ToLower(filename)
|
||||
for _, ext := range []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"} {
|
||||
if strings.HasSuffix(lower, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user