// Copyright (C) 2023-2024 Umorpha Systems // SPDX-License-Identifier: AGPL-3.0-or-later package main import ( "context" "embed" "encoding/json" "fmt" "html/template" "io" "log" "mime" "mime/multipart" "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/eclipse" ) func main() { argparser := &cobra.Command{ Use: os.Args[0] + " [flags]", Short: "HTTP server", Args: cliutil.WrapPositionalArgs(cobra.NoArgs), 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", source.DefaultConfigFile, "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 := eclipse.LoadConfig(cfgFile) if err != nil { return err } db, err := cfg.DB() if err != nil { return err } defer func() { maybeSetErr(db.Close()) }() sock, err := netListen(stype, saddr) if err != nil { return err } state := &ServerState{ db: db, } 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 *eclipse.DB } //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(eclipse.JobID(jobID)) if err != nil { http.NotFound(w, r) return } if !haveSlash { httpAddSlash(w, r) return } switch file { case "": switch r.Method { case http.MethodGet: 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 http.MethodPost: bs, err := json.Marshal(job) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type")) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if mediaType != "multipart/mixed" { http.Error(w, "invalid media type", http.StatusBadRequest) return } reader := multipart.NewReader(r.Body, params["boundary"]) w.Header().Set("Content-Type", "application/json") if _, err := w.Write(bs); err != nil { log.Printf("error: %q: %v", r.URL, err) return } for { part, err := reader.NextPart() if err != nil { if err == io.EOF { break } log.Printf("error: %q: %v", r.URL, err) return } disposition, params, err := mime.ParseMediaType(part.Header.Get("Content-Disposition")) if err != nil { log.Printf("error: %q: %v", r.URL, err) return } if disposition != "attachment" { log.Printf("error: %q: invalid disposition", r.URL) return } switch { case params["filename"] == "log.txt": // TODO case params["filename"] == "status.txt": // TODO case strings.HasPrefix(params["filename"], "artifacts/"): // TODO default: log.Printf("error: %q: invalid artifact name", r.URL) return } } default: http.Error(w, "HTTP 405: only GET and POST are supported", http.StatusMethodNotAllowed) } case "log.txt": if r.Method != http.MethodGet { http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed) return } // TODO default: if r.Method != http.MethodGet { http.Error(w, "HTTP 405: only GET is supported", http.StatusMethodNotAllowed) return } http.FileServer(http.Dir(filepath.Join("/var/lib/eclipse/jobfiles", jobIDStr))).ServeHTTP(w, r) } }