227 lines
6.1 KiB
Go
227 lines
6.1 KiB
Go
// Copyright (C) 2023-2024 Umorpha Systems
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/datawire/ocibuild/pkg/cliutil"
|
|
"github.com/spf13/cobra"
|
|
|
|
source "git.mothstuff.lol/lukeshu/eclipse"
|
|
"git.mothstuff.lol/lukeshu/eclipse/lib/config"
|
|
"git.mothstuff.lol/lukeshu/eclipse/lib/store"
|
|
)
|
|
|
|
func main() {
|
|
argparser := &cobra.Command{
|
|
Use: os.Args[0] + " [flags]",
|
|
Short: "HTTP server",
|
|
|
|
SilenceErrors: true, // we'll handle this ourselves after .ExecuteContext()
|
|
SilenceUsage: true, // FlagErrorFunc will handle this
|
|
|
|
CompletionOptions: cobra.CompletionOptions{
|
|
DisableDefaultCmd: true,
|
|
},
|
|
}
|
|
argparser.SetFlagErrorFunc(cliutil.FlagErrorFunc)
|
|
argparser.SetHelpTemplate(cliutil.HelpTemplate)
|
|
|
|
var cfgFile string
|
|
argparser.Flags().StringVar(&cfgFile, "config", "/etc/eclipse.yml",
|
|
"Config file to use")
|
|
argparser.MarkFlagFilename("config", "yml", "yaml")
|
|
|
|
var socket string
|
|
argparser.Flags().StringVar(&socket, "socket", "tcp::8080",
|
|
"the socket to listen on; a \"family:address\" pair, where the\n"+
|
|
"\"family\" is one of \"tcp\", \"tcp4\", \"tcp6\", \"unix\", \"unixpacket\"\n"+
|
|
"(the usual Go net.Listen() network names), or \"fd\" (to listen\n"+
|
|
"on an already-open file descriptor). The \"address\" part for\n"+
|
|
"the \"fd\" family is one of\n"+
|
|
" 1. a file descriptor number (or the special names \"stdin\",\n"+
|
|
" \"stdout\", or \"stderr\" to refer to 0, 1, or 2\n"+
|
|
" respectively),\n"+
|
|
" 2. \"systemd:N\" to refer to the Nth file descriptor passed in\n"+
|
|
" via systemd (or \"systemd\" as shorthand for \"systemd:0\")\n"+
|
|
" (see sd_listend_fds(3)),\n"+
|
|
" 3. \"systemd:NAME\" to refer to a named file descriptor passed\n"+
|
|
" in via systemd (see sd_listend_fds_with_names(3)).\n")
|
|
|
|
argparser.RunE = func(cmd *cobra.Command, args []string) error {
|
|
stype, saddr, ok := strings.Cut(socket, ":")
|
|
if !ok {
|
|
return cliutil.FlagErrorFunc(cmd, fmt.Errorf("invalid address: %q", socket))
|
|
}
|
|
return Serve(cfgFile, stype, saddr)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if err := argparser.ExecuteContext(ctx); err != nil {
|
|
fmt.Fprintf(os.Stderr, "%v: error: %v\n", argparser.CommandPath(), err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func Serve(cfgFile, stype, saddr string) (err error) {
|
|
maybeSetErr := func(_err error) {
|
|
if err == nil && _err != nil {
|
|
err = _err
|
|
}
|
|
}
|
|
|
|
cfg, err := config.Load(cfgFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
store, err := store.Open(filepath.Join(cfg.Daemon.StateDir, "state.sqlite"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { maybeSetErr(store.Close()) }()
|
|
|
|
sock, err := netListen(stype, saddr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
state := &ServerState{
|
|
db: store,
|
|
}
|
|
|
|
router := http.NewServeMux()
|
|
router.HandleFunc("/", state.serveIndex)
|
|
router.HandleFunc("/style.css", state.serveStatic)
|
|
router.HandleFunc("/favicon.ico", state.serveStatic)
|
|
router.HandleFunc("/eclipse.tar", state.serveStatic)
|
|
router.HandleFunc("/jobs/", state.serveJobs)
|
|
|
|
log.Printf("Serving on %v...", sock.Addr())
|
|
return http.Serve(sock, router)
|
|
}
|
|
|
|
type ServerState struct {
|
|
db *store.Store
|
|
}
|
|
|
|
//go:embed style.css
|
|
var fileStyle []byte
|
|
|
|
//go:embed favicon.ico
|
|
var fileFavicon []byte
|
|
|
|
func (*ServerState) serveStatic(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
switch r.URL.Path {
|
|
case "/style.css":
|
|
w.Header().Set("Content-Type", "text/css")
|
|
_, _ = w.Write(fileStyle)
|
|
case "/favicon.ico":
|
|
w.Header().Set("Content-Type", "image/vnd.microsoft.icon")
|
|
_, _ = w.Write(fileFavicon)
|
|
case "/eclipse.tar":
|
|
w.Header().Set("Content-Type", "application/x-tar")
|
|
_, _ = w.Write(source.Tarball)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
//go:embed html
|
|
var tmplFS embed.FS
|
|
|
|
var (
|
|
tmplIndex = template.Must(template.ParseFS(tmplFS, "html/pages/index.html.tmpl", "html/lib/*.html.tmpl"))
|
|
tmplJobs = template.Must(template.ParseFS(tmplFS, "html/pages/jobs.html.tmpl", "html/lib/*.html.tmpl"))
|
|
tmplJob = template.Must(template.ParseFS(tmplFS, "html/pages/job.html.tmpl", "html/lib/*.html.tmpl"))
|
|
)
|
|
|
|
func httpAddSlash(w http.ResponseWriter, r *http.Request) {
|
|
u := &url.URL{Path: r.URL.Path + "/", RawQuery: r.URL.RawQuery}
|
|
http.Redirect(w, r, u.String(), http.StatusMovedPermanently)
|
|
}
|
|
|
|
func (*ServerState) serveIndex(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmplIndex.Execute(w, nil); err != nil {
|
|
log.Printf("error: %q: %v", r.URL, err)
|
|
}
|
|
}
|
|
|
|
func (o *ServerState) serveJobs(w http.ResponseWriter, r *http.Request) {
|
|
rest := strings.TrimPrefix(r.URL.Path, "/jobs/")
|
|
if rest == "" {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
jobs, err := o.db.ListJobs()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmplJobs.Execute(w, map[string]any{"Jobs": jobs}); err != nil {
|
|
log.Printf("error: %q: %v", r.URL, err)
|
|
}
|
|
case http.MethodPost:
|
|
// TODO
|
|
default:
|
|
http.Error(w, "HTTP 405: only GET and POST are supported", http.StatusMethodNotAllowed)
|
|
}
|
|
return
|
|
}
|
|
jobIDStr, file, haveSlash := strings.Cut(rest, "/")
|
|
jobID, err := strconv.Atoi(jobIDStr)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
job, err := o.db.GetJob(store.JobID(jobID))
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if !haveSlash {
|
|
httpAddSlash(w, r)
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
switch file {
|
|
case "":
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := tmplJob.Execute(w, map[string]any{"Job": job}); err != nil {
|
|
log.Printf("error: %q: %v", r.URL, err)
|
|
}
|
|
case "log.txt":
|
|
// TODO
|
|
default:
|
|
http.FileServer(http.Dir(filepath.Join("/var/lib/eclipse/jobfiles", jobIDStr))).ServeHTTP(w, r)
|
|
}
|
|
}
|