Add site create/delete/upload endpoints

This commit is contained in:
akp 2024-11-13 22:19:17 +00:00
parent 0f5c990dfe
commit b3f84c433e
No known key found for this signature in database
GPG key ID: CF8D58F3DEB20755
7 changed files with 142 additions and 144 deletions

2
go.mod
View file

@ -1,6 +1,6 @@
module github.com/codemicro/palmatum
go 1.20
go 1.23
require (
github.com/google/uuid v1.6.0

View file

@ -5,56 +5,54 @@ import (
"fmt"
"github.com/codemicro/palmatum/palmatum/internal/database"
"github.com/mattn/go-sqlite3"
"os"
"regexp"
)
func (c *Core) UpsertSite(sm *database.SiteModel) error {
var (
ErrDuplicateSlug = errors.New("slug in use")
ErrInvalidSlug = errors.New("invalid slug")
// TODO: trash all of this :(
SiteSlugValidationRegexp = regexp.MustCompile(`^([\w\-.~!$&'()*+,;=:@]{2,})$`)
)
// NOTE TO FUTURE SELF: LOCK BEFORE YOU BEGIN A TRANSACTION. :)
tx, err := c.Database.Beginx()
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
if err := database.InsertSite(tx, sm); err != nil {
var e sqlite3.Error
if errors.As(err, &e) && e.Code == sqlite3.ErrConstraint {
goto exists
}
return fmt.Errorf("insert site: %w", err)
}
// TODO: rebuild routing graph here
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
exists:
existingSite, err := database.GetSite(tx, sm.Slug)
if err != nil {
return fmt.Errorf("get existing site: %w", err)
}
if err := database.UpdateSite(tx, sm); err != nil {
return fmt.Errorf("update site: %w", err)
}
// TODO: rebuild routing graph here
if sm.ContentPath != existingSite.ContentPath {
if err := os.Remove(c.getPathOnDisk(existingSite.ContentPath)); err != nil {
return fmt.Errorf("remove old site content path: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
func ValidateSiteSlug(s string) error {
if !SiteSlugValidationRegexp.MatchString(s) {
return ErrInvalidSlug
}
return nil
}
func (c *Core) CreateSite(siteSlug string) (*database.SiteModel, error) {
if err := ValidateSiteSlug(siteSlug); err != nil {
return nil, err
}
_, err := c.Database.Exec(`INSERT INTO sites(slug) VALUES (?)`, siteSlug)
if err != nil {
var e sqlite3.Error
if errors.As(err, &e) && e.Code == sqlite3.ErrConstraint {
return nil, ErrDuplicateSlug
}
return nil, fmt.Errorf("call database: %w", err)
}
return &database.SiteModel{
Slug: siteSlug,
}, nil
}
func (c *Core) DeleteSite(siteSlug string) error {
_, err := c.Database.Exec(`DELETE FROM sites WHERE slug = ?`, siteSlug)
if err != nil {
return fmt.Errorf("call database: %w", err)
}
return nil
}
func (c *Core) UpdateSite(s *database.SiteModel) error {
_, err := c.Database.Exec(`UPDATE sites SET content_path=? WHERE slug = ?`, s.ContentPath, s.Slug)
if err != nil {
return fmt.Errorf("call database: %w", err)
}
return nil
}

View file

@ -39,8 +39,8 @@ func New(lc fx.Lifecycle, conf *config.Config) (*sqlx.DB, error) {
switch currentSchemaVersion {
case 0:
_, err = db.Exec(`CREATE TABLE sites(
"slug" varchar not null primary key,
"content_path" varchar not null
"slug" varchar primary key,
"content_path" varchar default ''
)`)
if err != nil {
return fmt.Errorf("create sites table: %w", err)

View file

@ -45,13 +45,13 @@ func newServer(args ServerArgs, addr string, handler http.Handler) *http.Server
return server
}
func BadRequestResponse(w http.ResponseWriter, message ...string) error {
func badRequestResponse(rw http.ResponseWriter, msg ...string) error {
outputMessage := "Bad Request"
if len(message) != 0 {
outputMessage = message[0]
if len(msg) != 0 {
outputMessage = msg[0]
}
w.WriteHeader(400)
_, err := w.Write([]byte(outputMessage))
rw.WriteHeader(400)
_, err := rw.Write([]byte(outputMessage))
return err
}
@ -71,3 +71,15 @@ func IsBrowser(r *http.Request) bool {
}
return false
}
type handlerWithError func(http.ResponseWriter, *http.Request) error
func handleErrors(logger *slog.Logger, he handlerWithError) http.HandlerFunc {
return func(rw http.ResponseWriter, rq *http.Request) {
if err := he(rw, rq); err != nil {
logger.Error("unhandled http error", "url", rq.URL, "error", err)
rw.WriteHeader(http.StatusInternalServerError)
_, _ = rw.Write([]byte("Internal Server Error"))
}
}
}

View file

@ -7,131 +7,116 @@ import (
"github.com/codemicro/palmatum/palmatum/internal/config"
"github.com/codemicro/palmatum/palmatum/internal/core"
"github.com/codemicro/palmatum/palmatum/internal/database"
"github.com/julienschmidt/httprouter"
"html/template"
"log/slog"
"net/http"
"os"
"path"
"regexp"
"strings"
)
func NewManagementServer(args ServerArgs) *http.Server {
return newServer(args, args.Config.HTTP.ManagementAddress, New(args.Config, args.Core))
}
func New(conf *config.Config, c *core.Core) http.Handler {
r := &routes{
config: conf,
core: c,
mux := http.NewServeMux()
mr := managementRoutes{
logger: args.Logger,
core: args.Core,
config: args.Config,
}
router := httprouter.New()
mux.HandleFunc("POST /api/site", handleErrors(args.Logger, mr.apiCreateSite))
mux.HandleFunc("POST /api/site/bundle", handleErrors(args.Logger, mr.apiUploadSiteBundle))
mux.HandleFunc("DELETE /api/site", handleErrors(args.Logger, mr.apiDeleteSite))
router.GET("/-/", r.managementIndex)
router.POST("/-/upload", r.uploadSite)
router.POST("/-/delete", r.deleteSite)
return router
return newServer(args, args.Config.HTTP.ManagementAddress, mux)
}
type routes struct {
config *config.Config
type managementRoutes struct {
logger *slog.Logger
core *core.Core
config *config.Config
}
//go:embed managementIndex.html
var managementPageTemplateSource string
var managementPageTemplate *template.Template
func (mr *managementRoutes) apiCreateSite(rw http.ResponseWriter, rq *http.Request) error {
siteSlug := rq.FormValue("slug")
siteSlug = strings.TrimSpace(siteSlug)
func init() {
managementPageTemplate = template.New("management page")
template.Must(managementPageTemplate.Parse(managementPageTemplateSource))
}
if len(siteSlug) == 0 {
_ = badRequestResponse(rw, "Invalid slug (cannot be an empty string)")
return nil
}
func (ro *routes) managementIndex(w http.ResponseWriter, _ *http.Request, _ httprouter.Params) {
dirEntries, err := os.ReadDir(ro.config.Platform.SitesDirectory)
_, err := mr.core.CreateSite(siteSlug)
if err != nil {
panic(fmt.Errorf("reading sites directory contents: %w", err))
if errors.Is(err, core.ErrInvalidSlug) || errors.Is(err, core.ErrDuplicateSlug) {
_ = badRequestResponse(rw, err.Error())
return nil
}
return fmt.Errorf("create new site: %w", err)
}
var sites []string
for _, de := range dirEntries {
sites = append(sites, de.Name())
}
var templateArgs = struct {
ActiveSites []string
}{
ActiveSites: sites,
}
w.Header().Set("Content-Type", "text/html")
if err := managementPageTemplate.Execute(w, templateArgs); err != nil {
panic(fmt.Errorf("rendering management page: %w", err))
}
rw.WriteHeader(http.StatusCreated)
return nil
}
var siteNameValidationRegexp = regexp.MustCompile(`^([\w\-.~!$&'()*+,;=:@]{2,})|(-[\w\-.~!$&'()*+,;=:@]+)$`)
func (mr *managementRoutes) apiDeleteSite(rw http.ResponseWriter, rq *http.Request) error {
siteSlug := rq.FormValue("slug")
siteSlug = strings.TrimSpace(siteSlug)
func (ro *routes) uploadSite(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
siteName := r.FormValue("siteName")
if siteName == "" {
_ = BadRequestResponse(w, "missing site name")
return
if len(siteSlug) == 0 {
_ = badRequestResponse(rw, "Invalid slug (cannot be an empty string)")
return nil
}
if !siteNameValidationRegexp.MatchString(siteName) {
_ = BadRequestResponse(w, "invalid site name")
return
if err := mr.core.DeleteSite(siteSlug); err != nil {
return fmt.Errorf("delete site: %w", err)
}
formFile, formFileHeader, err := r.FormFile("archive")
rw.WriteHeader(http.StatusOK)
return nil
}
func (mr *managementRoutes) apiUploadSiteBundle(rw http.ResponseWriter, rq *http.Request) error {
siteSlug := strings.TrimSpace(rq.FormValue("slug"))
if siteSlug == "" {
_ = badRequestResponse(rw, "Missing slug")
return nil
}
if err := core.ValidateSiteSlug(siteSlug); err != nil {
_ = badRequestResponse(rw, err.Error())
return nil
}
formFile, formFileHeader, err := rq.FormFile("bundle")
if err != nil {
if errors.Is(err, http.ErrMissingFile) {
_ = BadRequestResponse(w, "missing archive file")
return
_ = badRequestResponse(rw, "missing bundle file")
return nil
}
if err.Error() == "request Content-Type isn't multipart/form-data" {
_ = BadRequestResponse(w, "request Content-Type isn't multipart/form-data")
return
_ = badRequestResponse(rw, "request Content-Type isn't multipart/form-data")
return nil
}
panic(fmt.Errorf("loading archive request parameter: %w", err))
}
defer formFile.Close()
if formFileHeader.Size > 1000*1000*int64(ro.config.Platform.MaxUploadSizeMegabytes) {
_ = BadRequestResponse(w, fmt.Sprintf("archive too large (maximum size %dMB)", ro.config.Platform.MaxUploadSizeMegabytes))
return
if formFileHeader.Size > 1000*1000*int64(mr.config.Platform.MaxUploadSizeMegabytes) {
_ = badRequestResponse(rw, fmt.Sprintf("archive too large (maximum size %dMB)", mr.config.Platform.MaxUploadSizeMegabytes))
return nil
}
contentPath, err := ro.core.IngestSiteArchive(formFile)
contentPath, err := mr.core.IngestSiteArchive(formFile)
if err != nil {
panic(fmt.Errorf("ingesting site archive: %w", err))
return fmt.Errorf("ingest site archive site archive: %w", err)
}
if err := ro.core.UpsertSite(&database.SiteModel{
Slug: siteName,
_ = contentPath
if err := mr.core.UpdateSite(&database.SiteModel{
Slug: siteSlug,
ContentPath: contentPath,
}); err != nil {
panic(fmt.Errorf("updating site in database: %w", err))
return fmt.Errorf("update site: %w", err)
}
if IsBrowser(r) {
w.Header().Add("Location", "/-/")
w.WriteHeader(303) // 303 See Other - resets the method to GET
} else {
w.WriteHeader(204)
}
}
func (ro *routes) deleteSite(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
siteName := r.FormValue("siteName")
if !siteNameValidationRegexp.MatchString(siteName) {
_ = BadRequestResponse(w, "invalid site name")
return
}
_ = os.RemoveAll(path.Join(ro.config.Platform.SitesDirectory, siteName))
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(204)
rw.WriteHeader(http.StatusOK)
return nil
}

View file

@ -6,5 +6,8 @@ import (
func NewSitesServer(args ServerArgs) *http.Server {
// TODO: serve sites
return newServer(args, args.Config.HTTP.SitesAddress, New(args.Config, args.Core))
return newServer(args, args.Config.HTTP.SitesAddress, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
w.Write([]byte("not implemented"))
}))
}

View file

@ -27,10 +27,10 @@ func main() {
fx.ResultTags(`group:"servers"`),
),
fx.Annotate(
httpsrv.NewSitesServer,
fx.ResultTags(`group:"servers"`),
),
//fx.Annotate(
// httpsrv.NewSitesServer,
// fx.ResultTags(`group:"servers"`),
//),
),
fx.Invoke(