// Copyright 2026 Rose Hogenson // // This file is part of roseh.moe. // // roseh.moe is free software: you can redistribute it and/or modify it // under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, // or (at your option) any later version. // // roseh.moe is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty // of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with roseh.moe. If not, see // . package main import ( "archive/tar" "bytes" "cmp" "compress/gzip" "context" "crypto/hkdf" "crypto/hmac" "crypto/rand" "crypto/sha1" "crypto/sha256" "crypto/subtle" "embed" "encoding/base64" "encoding/binary" "encoding/xml" "errors" "flag" "fmt" "html/template" "io" "io/fs" "log" "mime/multipart" "net/http" "net/http/cgi" "net/http/httputil" "net/url" "os" "path" "path/filepath" "slices" "strconv" "strings" "sync" "time" "unicode" "github.com/skip2/go-qrcode" "roseh.moe/pkg/oae2" "roseh.moe/pkg/roseh.moe/internal/pwhash" "roseh.moe/pkg/wordlist" ) var ( selfURL = urlFlag("url", "http://localhost:42069", "base URL of the server") secretsFile = flag.String("secrets", "secrets.xml", "secrets file") configFile = flag.String("config", "config.xml", "configuration file (XML, baby!!)") https = flag.String("https", "", "directory containing cert.pem and key.pem, or empty string to use unencrypted http") httpRedirect = flag.String("http-redirect", "", "port to serve HTTP redirect") holeTempDir = flag.String("hole-temp-dir", "/tmp/hole", "directory to store temporary files for the wormhole") nezuko = flag.String("nezuko", "/mnt/nezuko", "nezuko partition") cgit = flag.String("cgit", "", "directory containing cgit.cgi") cgitConfig = flag.String("cgit-config", "", "cgit config file") v = flag.Bool("v", true, "verbose") ) type urlValue struct { URL *url.URL } func (v urlValue) String() string { if v.URL != nil { return v.URL.String() } return "" } func (v urlValue) Set(s string) error { u, err := url.Parse(s) if err != nil { return err } *v.URL = *u return nil } func urlFlag(name, def, doc string) *url.URL { u, err := url.Parse(def) if err != nil { panic(err) } flag.Var(urlValue{u}, name, doc) return u } var notepadPassword, secretKey, authenticatorKey []byte func loadSecrets() error { fileBytes, err := os.ReadFile(*secretsFile) if err != nil { return err } var secretsConfig struct { NotepadPassword string `xml:",attr"` SecretKey string `xml:",attr"` AuthKey string `xml:",attr"` } if err := xml.Unmarshal(fileBytes, &secretsConfig); err != nil { return fmt.Errorf("%s: %s", *secretsFile, err) } if secretsConfig.NotepadPassword == "" { return fmt.Errorf("%s: missing NotepadPassword", *secretsFile) } if secretsConfig.SecretKey == "" { return fmt.Errorf("%s: missing SecretKey", *secretsFile) } if secretsConfig.AuthKey == "" { return fmt.Errorf("%s: missing AuthKey", *secretsFile) } if notepadPassword, err = base64.StdEncoding.DecodeString(secretsConfig.NotepadPassword); err != nil { return fmt.Errorf("%s: bad NotepadPassword (try base64)", *secretsFile) } if secretKey, err = base64.StdEncoding.DecodeString(secretsConfig.SecretKey); err != nil { return fmt.Errorf("%s: bad SecretKey (try base64)", *secretsFile) } if authenticatorKey, err = base64.StdEncoding.DecodeString(secretsConfig.AuthKey); err != nil { return fmt.Errorf("%s: bad AuthKey (try base64)", *secretsFile) } return nil } type packageConfig struct { Name string `xml:",attr"` URL string `xml:",attr"` Source string `xml:",attr"` Description string `xml:",attr"` } type commandConfig struct { Name string `xml:",attr"` URL string `xml:",attr"` Source string `xml:",attr"` Description string `xml:",attr"` Multi bool `xml:",attr"` } type serviceConfiguration struct { PackageRedirects []packageConfig `xml:"Redirects>Package"` CommandRedirects []commandConfig `xml:"Redirects>Command"` } var ( serviceConfigMu sync.Mutex serviceConfig *serviceConfiguration serviceConfigLastModified time.Time ) func loadConfig() (*serviceConfiguration, error) { stat, err := os.Stat(*configFile) if err != nil { return nil, err } serviceConfigMu.Lock() oldConfig := serviceConfig configLastModified := serviceConfigLastModified serviceConfigMu.Unlock() newModTime := stat.ModTime() if !newModTime.After(configLastModified) { return oldConfig, nil } log.Printf("Reloading config file...") fileBytes, err := os.ReadFile(*configFile) if err != nil { return nil, err } config := new(serviceConfiguration) if err := xml.Unmarshal(fileBytes, config); err != nil { return nil, err } slices.SortFunc(config.PackageRedirects, func(x, y packageConfig) int { return strings.Compare(x.Name, y.Name) }) slices.SortFunc(config.CommandRedirects, func(x, y commandConfig) int { return strings.Compare(x.Name, y.Name) }) serviceConfigMu.Lock() serviceConfig = config serviceConfigLastModified = newModTime serviceConfigMu.Unlock() return config, nil } func makeETag(content io.Reader) (string, error) { h := sha256.New() if _, err := io.Copy(h, content); err != nil { return "", err } return `W/"` + base64.RawURLEncoding.EncodeToString(h.Sum(nil)[:8]) + `"`, nil } var ( //go:embed templates/outline.html.template outlineString string outlineTemplate = template.Must(template.New("outline").Funcs(template.FuncMap{ "host": func() string { return selfURL.Host }, }).Parse(outlineString)) ) func outlinedTemplate(s string) (*template.Template, error) { outline, err := outlineTemplate.Clone() if err != nil { return nil, err } t, err := outline.New("body").Parse(s) if err != nil { return nil, err } return t.Lookup("outline"), nil } type staticHandler struct { content, eTag string } func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("ETag", h.eTag) http.ServeContent(w, r, "", time.Time{}, strings.NewReader(h.content)) } func staticTemplate(tmpl *template.Template) (*staticHandler, error) { buf := new(strings.Builder) if err := tmpl.Execute(buf, nil); err != nil { return nil, err } content := buf.String() eTag, err := makeETag(strings.NewReader(content)) if err != nil { return nil, err } return &staticHandler{ content: buf.String(), eTag: eTag, }, nil } var ( //go:embed templates/404.html.template notFoundString string notFoundTemplate = template.Must(template.New("notFound").Parse(notFoundString)) ) func notFound(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) type args struct { Path string } if err := notFoundTemplate.Execute(w, args{Path: r.URL.Path}); err != nil { log.Printf("Warning: notFound: %s", err) } } //go:embed templates/index.html.template var indexString string //go:embed templates/games/index.html.template var gamesIndexString string //go:embed templates/games/pong.html.template var pongString string //go:embed templates/games/rms.html.template var stallmanShooterString string //go:embed static var staticFiles embed.FS var staticFileHashes = buildStaticFileHashes() func buildStaticFileHashes() map[string]string { hashMap := make(map[string]string) if err := fs.WalkDir(staticFiles, ".", func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } f, err := staticFiles.Open(path) if err != nil { return err } defer f.Close() if hashMap[path], err = makeETag(f); err != nil { return err } return nil }); err != nil { panic(fmt.Sprintf("Build static file hash map: %s", err)) } return hashMap } func serveStaticFile(w http.ResponseWriter, r *http.Request, path string) { if eTag, ok := staticFileHashes[path]; ok { w.Header().Set("ETag", eTag) } http.ServeFileFS(w, r, staticFiles, path) } func static(w http.ResponseWriter, r *http.Request) { serveStaticFile(w, r, path.Clean(strings.TrimPrefix(r.URL.Path, "/"))) } func favicon(w http.ResponseWriter, r *http.Request) { serveStaticFile(w, r, "static/favicon.ico") } func sharedFileHandler(w http.ResponseWriter, r *http.Request) { fp, err := filepath.Localize(path.Clean(strings.TrimPrefix(r.URL.Path, "/"))) if err != nil { notFound(w, r) return } f, err := os.OpenInRoot(filepath.Join(*nezuko, "www"), fp) if err != nil { notFound(w, r) return } defer f.Close() stat, err := f.Stat() if err != nil { log.Printf("Warning: sharedFileHandler: %s", err) notFound(w, r) return } if stat.IsDir() { notFound(w, r) return } http.ServeContent(w, r, fp, stat.ModTime(), f) } func qrHandler(w http.ResponseWriter, r *http.Request) { text, ok := verify(r.FormValue("t"), "qr") if !ok { http.Error(w, "Bad signature", http.StatusBadRequest) return } qr, err := qrcode.Encode(text, qrcode.Medium, 200) if err != nil { log.Printf("Error: failed to encode QR code: %s", err) http.Error(w, "Failed to encode QR code", http.StatusInternalServerError) return } http.ServeContent(w, r, "qr.png", time.Time{}, bytes.NewReader(qr)) } var ( //go:embed templates/wormhole.html.template wormholeTemplateString string wormholeTemplate = template.Must(outlinedTemplate(wormholeTemplateString)) ) type wormholeTemplateArgs struct { Err string } func makeHole() string { const nWords = 10 buf := make([]byte, 2*nWords) rand.Read(buf) words := make([]string, nWords) for i := range words { words[i] = wordlist.Words[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] } return strings.Join(words, "-") } const blockSize = 4 * 1024 * 1024 type holeFileWriter struct { *oae2.Writer f *os.File } func (w *holeFileWriter) Close() error { return cmp.Or(w.Writer.Close(), w.f.Close()) } func writeHoleFile(filename string) (_ string, _ *holeFileWriter, err error) { hole := makeHole() primaryKey, err := hkdf.Key(sha256.New, []byte(hole), nil, "", 32) if err != nil { return "", nil, err } f, err := os.Create(fmt.Sprintf("%s/%x", *holeTempDir, primaryKey)) if err != nil { return "", nil, err } defer func() { if err != nil { f.Close() } }() w := oae2.NewWriter(f, []byte(hole), blockSize, nil) buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, uint64(len(filename))) if _, err := w.Write(buf); err != nil { return "", nil, err } if _, err := io.WriteString(w, filename); err != nil { return "", nil, err } return hole, &holeFileWriter{w, f}, nil } type offsetReader struct { io.ReadSeeker offset int } func (r *offsetReader) Seek(offset int64, whence int) (int64, error) { if whence == io.SeekStart { offset += int64(r.offset) } n, err := r.ReadSeeker.Seek(offset, whence) return n - int64(r.offset), err } type holeFileReader struct { *offsetReader f *os.File } func (r *holeFileReader) Close() error { return r.f.Close() } var errNoHole = errors.New("no such hole") func openHoleFile(hole string) (_ string, _ *holeFileReader, err error) { primaryKey, err := hkdf.Key(sha256.New, []byte(hole), nil, "", 32) if err != nil { return "", nil, err } f, err := os.Open(fmt.Sprintf("%s/%x", *holeTempDir, primaryKey)) if err != nil { if errors.Is(err, fs.ErrNotExist) { return "", nil, errNoHole } return "", nil, err } defer func() { if err != nil { f.Close() } }() r := oae2.NewReader(f, []byte(hole), blockSize, nil) buf := make([]byte, 8) if _, err := io.ReadFull(r, buf); err != nil { return "", nil, err } fileNameLen := binary.BigEndian.Uint64(buf) fileName := make([]byte, fileNameLen) if _, err := io.ReadFull(r, fileName); err != nil { return "", nil, err } return string(fileName), &holeFileReader{&offsetReader{r, 8 + int(fileNameLen)}, f}, nil } func readHoleFile(w http.ResponseWriter, req *http.Request, hole string) error { fileName, r, err := openHoleFile(hole) if err != nil { return err } defer r.Close() w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+fileName) var modTime time.Time if stat, err := r.f.Stat(); err == nil { modTime = stat.ModTime() } http.ServeContent(w, req, fileName, modTime, r) return nil } func wormhole(w http.ResponseWriter, r *http.Request) { if err := wormholeTemplate.Execute(w, wormholeTemplateArgs{}); err != nil { log.Printf("Warning: wormhole: %s", err) } } func writeHoleFileToTarFile(tarWriter *tar.Writer, hole string, now time.Time) error { fileName, f, err := openHoleFile(hole) if err != nil { return fmt.Errorf("read back hole file: %s", err) } defer f.Close() size, err := f.Seek(0, io.SeekEnd) if err != nil { return fmt.Errorf("read back hole file: %s", err) } if _, err := f.Seek(0, io.SeekStart); err != nil { return fmt.Errorf("read back hole file: %s", err) } if err := tarWriter.WriteHeader(&tar.Header{Name: "wormhole-files/" + fileName, Mode: 0644, Size: size, ModTime: now, Format: tar.FormatPAX}); err != nil { return err } if _, err := io.Copy(tarWriter, f); err != nil { return fmt.Errorf("write file to tar file: %s", err) } return nil } var errNoFile = errors.New("no file") func uploadWormhole(reader *multipart.Reader) (string, error) { var holes []string for { part, err := reader.NextPart() if err != nil { break } if part.FormName() != "file" || part.FileName() == "" { continue } hole, err := writeSingleHoleFile(part.FileName(), part) if err != nil { return "", err } holes = append(holes, hole) } if len(holes) == 0 { return "", errNoFile } if len(holes) == 1 { return holes[0], nil } tarFileHole, tarHoleWriter, err := writeHoleFile("wormhole-files.tar.gz") if err != nil { return "", fmt.Errorf("make tar file: %s", err) } defer tarHoleWriter.Close() gzipWriter, err := gzip.NewWriterLevel(tarHoleWriter, gzip.BestSpeed) if err != nil { return "", err } tarWriter := tar.NewWriter(gzipWriter) now := time.Now() if err := tarWriter.WriteHeader(&tar.Header{Name: "wormhole-files/", Mode: 0755, ModTime: now, Format: tar.FormatPAX}); err != nil { return "", fmt.Errorf("create tar file main directory: %s", err) } for _, hole := range holes { if err := writeHoleFileToTarFile(tarWriter, hole, now); err != nil { return "", err } } if err := tarWriter.Close(); err != nil { return "", fmt.Errorf("write tar file: %s", err) } if err := gzipWriter.Close(); err != nil { return "", fmt.Errorf("gzip tar file: %s", err) } if err := tarHoleWriter.Close(); err != nil { return "", fmt.Errorf("write tar hole file: %s", err) } return tarFileHole, nil } var ( //go:embed templates/wormhole-success.html.template wormholeSuccessTemplateString string wormholeSuccessTemplate = template.Must(outlinedTemplate(wormholeSuccessTemplateString)) ) type wormholeSuccessTemplateArgs struct { URL string QR string } func wormholeUpload(w http.ResponseWriter, r *http.Request) { reader, err := r.MultipartReader() if err != nil { http.Error(w, fmt.Sprintf("Not a multipart/form-data request: %s", err), http.StatusBadRequest) return } hole, err := uploadWormhole(reader) if err != nil { if err == errNoFile { w.WriteHeader(http.StatusBadRequest) if err := wormholeTemplate.Execute(w, wormholeTemplateArgs{Err: "don't forget to pick a file"}); err != nil { log.Printf("Warning: wormholeTemplate: %s", err) } return } log.Printf("Error: failed to upload hole file: %s", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } downloadURL := selfURL.String() + "/wormhole/" + hole if err := wormholeSuccessTemplate.Execute(w, wormholeSuccessTemplateArgs{ URL: downloadURL, QR: "/qr.png?t=" + url.QueryEscape(downloadURL) + mac(downloadURL, "qr"), }); err != nil { log.Printf("Warning: wormholeSend: %s", err) } } func writeSingleHoleFile(filename string, r io.Reader) (string, error) { hole, holeWriter, err := writeHoleFile(filename) if err != nil { return "", fmt.Errorf("write hole file: %s", err) } defer holeWriter.Close() if _, err := io.Copy(holeWriter, r); err != nil { return "", fmt.Errorf("write hole file: %s", err) } if err := holeWriter.Close(); err != nil { return "", fmt.Errorf("write hole file: %s", err) } return hole, nil } func wormholeAPIUpload(w http.ResponseWriter, r *http.Request) { hole, err := writeSingleHoleFile(r.PathValue("name"), r.Body) if err != nil { log.Printf("Error: failed to write hole file: %s", err) http.Error(w, "Failed to write hole file", http.StatusInternalServerError) return } resultURL := fmt.Sprintf("%s/wormhole/%s", selfURL, hole) w.Header().Set("Content-Type", "text/plain") w.Header().Set("Location", resultURL) w.WriteHeader(http.StatusCreated) fmt.Fprintln(w, resultURL) } func wormholeDownload(w http.ResponseWriter, r *http.Request) { if err := readHoleFile(w, r, r.PathValue("hole")); err != nil { if err == errNoHole { notFound(w, r) return } log.Printf("Error: failed to read hole file: %s", err) http.Error(w, "Failed to read hole file", http.StatusInternalServerError) return } } func cleanHole() error { log.Printf("Cleaning hole...") holeDir, err := os.Open(*holeTempDir) if err != nil { return err } defer holeDir.Close() deadline := time.Now().Add(-time.Hour) for { files, err := holeDir.ReadDir(1024) for _, file := range files { stat, err := file.Info() if err != nil { log.Printf("Error: clean hole: %s", err) continue } if !stat.ModTime().Before(deadline) { continue } if err := os.Remove(*holeTempDir + "/" + file.Name()); err != nil { log.Printf("Error: clean hole: %s", err) continue } } if err != nil { if err != io.EOF { return err } return nil } } } func mac(msg string, purpose string) string { mac := hmac.New(sha256.New, secretKey) io.WriteString(mac, purpose) io.WriteString(mac, ":") io.WriteString(mac, msg) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } func verify(signedMsg, purpose string) (string, bool) { macSize := base64.RawURLEncoding.EncodedLen(sha256.Size) if len(signedMsg) < macSize { return "", false } msg, msgMAC := signedMsg[:len(signedMsg)-macSize], signedMsg[len(signedMsg)-macSize:] expectedMAC := mac(msg, purpose) if !hmac.Equal([]byte(msgMAC), []byte(expectedMAC)) { return "", false } return msg, true } func marshalInt(x uint64) []byte { buf := make([]byte, 8) binary.BigEndian.PutUint64(buf, x) for ; len(buf) > 0 && buf[0] == 0; buf = buf[1:] { } return buf } func unmarshalInt(b []byte) (uint64, bool) { if len(b) > 8 { return 0, false } buf := make([]byte, 8) copy(buf[8-len(b):], b) return binary.BigEndian.Uint64(buf), true } func marshalTime(t time.Time) []byte { t = t.UTC() year, month, day := t.Date() hour, minute, second := t.Clock() u := uint64(year) u = u<<4 | uint64(month) u = u<<5 | uint64(day) u = u<<5 | uint64(hour) u = u<<6 | uint64(minute) u = u<<6 | uint64(second) return marshalInt(u) } func unmarshalTime(b []byte) (time.Time, bool) { u, ok := unmarshalInt(b) if !ok { return time.Time{}, false } second, u := int(u&0x3f), u>>6 minute, u := int(u&0x3f), u>>6 hour, u := int(u&0x1f), u>>5 day, u := int(u&0x1f), u>>5 month, u := time.Month(u&0xf), u>>4 year := int(u) return time.Date(year, month, day, hour, minute, second, 0, time.UTC), true } const authVersion = "authv2" func makeToken() string { t := base64.RawURLEncoding.EncodeToString(marshalTime(time.Now())) return t + mac(t, authVersion) } const authCookieName = "a" func attachCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: authCookieName, Value: makeToken(), Path: "/", Domain: selfURL.Hostname(), Expires: time.Now().Add(longLoginTimeout), Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, Partitioned: true, }) } func cookieAuth(r *http.Request) (time.Duration, bool) { cookie, err := r.Cookie(authCookieName) if err != nil { return 0, false } msg, ok := verify(cookie.Value, authVersion) if !ok { return 0, false } msgBytes, err := base64.RawURLEncoding.DecodeString(msg) if err != nil { return 0, false } t, ok := unmarshalTime(msgBytes) if !ok { return 0, false } age := time.Since(t) if age > longLoginTimeout { return 0, false } return age, true } func totp(t int64) []byte { ts := make([]byte, 8) binary.BigEndian.PutUint64(ts, uint64(t)) hash := hmac.New(sha1.New, authenticatorKey) hash.Write(ts) mac := hash.Sum(nil) offset := mac[len(mac)-1] & 0xf n := int32(mac[offset])&0x7f<<24 | int32(mac[offset+1])<<16 | int32(mac[offset+2])<<8 | int32(mac[offset+3]) return strconv.AppendInt(nil, int64(n%1_000_000), 10) } func checkOTP(auth string) int { now := time.Now().Unix() / 30 return subtle.ConstantTimeCompare([]byte(auth), totp(now)) | subtle.ConstantTimeCompare([]byte(auth), totp(now-1)) } func checkPassword(password string) int { hash, err := pwhash.Hash(password, notepadPassword[:pwhash.SaltSize]) if err != nil { return 0 } return subtle.ConstantTimeCompare(hash, notepadPassword[pwhash.SaltSize:]) } var ( //go:embed templates/login.html.template loginTemplateString string loginTemplate = template.Must(outlinedTemplate(loginTemplateString)) ) type loginTemplateArgs struct { Quick bool Error bool } func redirectLogin(w http.ResponseWriter, r *http.Request, redirect string) { redirect += mac(redirect, "redirect") http.Redirect(w, r, "//"+selfURL.Host+"/login?redirect="+url.QueryEscape(redirect), http.StatusSeeOther) } const ( quickLoginTimeout = 24 * time.Hour longLoginTimeout = 300 * 24 * time.Hour ) func serveLogin(w http.ResponseWriter, r *http.Request) { _, cookieOK := cookieAuth(r) if err := loginTemplate.Execute(w, loginTemplateArgs{ Quick: cookieOK, }); err != nil { log.Printf("Warning: login: %s", err) } } func login(w http.ResponseWriter, r *http.Request) { otpMatches := checkOTP(r.FormValue("auth")) passwordMatches := 1 _, cookieOK := cookieAuth(r) if !cookieOK { passwordMatches = checkPassword(r.FormValue("password")) } if otpMatches&passwordMatches == 0 { if err := loginTemplate.Execute(w, loginTemplateArgs{ Quick: cookieOK, Error: true, }); err != nil { log.Printf("Warning: login: %s", err) } return } attachCookie(w) redirect, ok := verify(r.FormValue("redirect"), "redirect") if !ok { redirect = "/" } http.Redirect(w, r, redirect, http.StatusSeeOther) } var ( //go:embed templates/note.html.template notepadString string notepadTemplate = template.Must(outlinedTemplate(notepadString)) notepadContentsMu sync.Mutex notepadContents string ) type notepadTemplateArgs struct { Content string } func notepad(w http.ResponseWriter, r *http.Request) { notepadContentsMu.Lock() currentContent := notepadContents notepadContentsMu.Unlock() if err := notepadTemplate.Execute(w, notepadTemplateArgs{Content: currentContent}); err != nil { log.Printf("Warning: notepad: %s", err) } } func autosave(w http.ResponseWriter, r *http.Request) { newContent := r.FormValue("content") notepadContentsMu.Lock() notepadContents = newContent notepadContentsMu.Unlock() if err := notepadTemplate.ExecuteTemplate(w, "saveIndicator", "Saved ✓"); err != nil { log.Printf("Warning: autosave: %s", err) } } func cmd(w http.ResponseWriter, r *http.Request) { serviceConfig, err := loadConfig() if err != nil { log.Printf("Error: failed to load config: %s", err) http.Error(w, "Failed to load config", http.StatusInternalServerError) return } sourceIdx, ok := slices.BinarySearchFunc(serviceConfig.CommandRedirects, r.PathValue("cmd"), func(cmd commandConfig, name string) int { return strings.Compare(cmd.Name, name) }) if !ok { notFound(w, r) return } if r.FormValue("go-get") == "1" { fmt.Fprintf(w, "\n", r.PathValue("cmd"), serviceConfig.CommandRedirects[sourceIdx].URL) if source := serviceConfig.CommandRedirects[sourceIdx].Source; source != "" { fmt.Fprintf(w, "\n", r.PathValue("cmd"), source) } } else { http.Redirect(w, r, "https://pkg.go.dev/roseh.moe/cmd/"+r.PathValue("cmd"), http.StatusMovedPermanently) } } var ( //go:embed templates/pkg-index.html.template pkgIndexString string pkgIndexTemplate = template.Must(outlinedTemplate(pkgIndexString)) ) type pkgIndexTemplateArgs struct { Packages []packageConfig } func pkgIndex(w http.ResponseWriter, r *http.Request) { serviceConfig, err := loadConfig() if err != nil { log.Printf("Error: failed to load config: %s", err) http.Error(w, "Failed to load config", http.StatusInternalServerError) return } if err := pkgIndexTemplate.Execute(w, pkgIndexTemplateArgs{Packages: serviceConfig.PackageRedirects}); err != nil { log.Printf("Warning: pkgIndex: %s", err) } } var ( //go:embed templates/cmd-index.html.template cmdIndexString string cmdIndexTemplate = template.Must(outlinedTemplate(cmdIndexString)) ) type cmdIndexTemplateArgs struct { Commands []commandConfig } func cmdIndex(w http.ResponseWriter, r *http.Request) { serviceConfig, err := loadConfig() if err != nil { log.Printf("Error: failed to load config: %s", err) http.Error(w, "Failed to load config", http.StatusInternalServerError) return } if err := cmdIndexTemplate.Execute(w, cmdIndexTemplateArgs{Commands: serviceConfig.CommandRedirects}); err != nil { log.Printf("Warning: pkgIndex: %s", err) } } func pkg(w http.ResponseWriter, r *http.Request) { serviceConfig, err := loadConfig() if err != nil { log.Printf("Error: failed to load config: %s", err) http.Error(w, "Failed to load config", http.StatusInternalServerError) return } sourceIdx, ok := slices.BinarySearchFunc(serviceConfig.PackageRedirects, r.PathValue("pkg"), func(pkg packageConfig, name string) int { return strings.Compare(pkg.Name, name) }) if !ok { notFound(w, r) return } if r.FormValue("go-get") == "1" { fmt.Fprintf(w, "\n", r.PathValue("pkg"), serviceConfig.PackageRedirects[sourceIdx].URL) if source := serviceConfig.PackageRedirects[sourceIdx].Source; source != "" { fmt.Fprintf(w, "\n", r.PathValue("pkg"), source) } } else { http.Redirect(w, r, "https://pkg.go.dev/roseh.moe/pkg/"+r.PathValue("pkg"), http.StatusMovedPermanently) } } type nopCloser struct { io.Writer } func (nopCloser) Close() error { return nil } func (w nopCloser) ReadFrom(r io.Reader) (int64, error) { return io.Copy(w.Writer, r) } type gzipResponseWriter struct { http.ResponseWriter w io.WriteCloser buf []byte code int } func (w *gzipResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } func shouldGzip(w http.ResponseWriter) bool { disposition, _, _ := strings.Cut(w.Header().Get("Content-Disposition"), ";") length, lengthErr := strconv.Atoi(w.Header().Get("Content-Length")) return strings.HasPrefix(w.Header().Get("Content-Type"), "text/") && !(disposition == "attachment" && w.Header().Get("Accept-Ranges") != "" || w.Header().Get("Content-Encoding") != "" || lengthErr == nil && length < 512) } func (w *gzipResponseWriter) ReadFrom(r io.Reader) (int64, error) { if w.w != nil { return io.Copy(w.w, r) } var nn int64 if len(w.Header().Values("Content-Type")) == 0 { if w.buf == nil { w.buf = make([]byte, 0, 512) } n, err := io.ReadFull(r, w.buf[len(w.buf):cap(w.buf)]) w.buf = w.buf[:len(w.buf)+n] nn += int64(n) if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { return nn, err } if len(w.buf) < 512 { return nn, nil } contentType := http.DetectContentType(w.buf) w.Header().Set("Content-Type", contentType) } if shouldGzip(w.ResponseWriter) { var err error w.w, err = gzip.NewWriterLevel(w.ResponseWriter, gzip.BestSpeed) if err != nil { return nn, err } w.Header().Set("Content-Encoding", "gzip") w.Header().Del("Content-Length") w.Header().Del("Accept-Ranges") } else { w.w = nopCloser{w.ResponseWriter} } if w.code != 0 { w.ResponseWriter.WriteHeader(w.code) } if len(w.buf) > 0 { if n, err := w.w.Write(w.buf); err != nil { return max(int64(n)-(512-nn), 0), err } } n, err := io.Copy(w.w, r) nn += n return nn, err } func (w *gzipResponseWriter) Write(p []byte) (int, error) { n, err := w.ReadFrom(bytes.NewReader(p)) return int(n), err } func (w *gzipResponseWriter) WriteHeader(statusCode int) { w.code = statusCode } func (w *gzipResponseWriter) close() { if w.w == nil { if w.code != 0 { w.ResponseWriter.WriteHeader(w.code) } w.ResponseWriter.Write(w.buf) } else { w.w.Close() } } type gzipHandler struct { h http.Handler } func (h *gzipHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if len(r.Header.Values("Range")) == 0 { for _, hdr := range r.Header.Values("Accept-Encoding") { for encoding := range strings.FieldsFuncSeq(hdr, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) { encoding, _, _ := strings.Cut(encoding, ";") if encoding == "gzip" { gzipWriter := &gzipResponseWriter{ResponseWriter: w} h.h.ServeHTTP(gzipWriter, r) gzipWriter.close() return } } } } h.h.ServeHTTP(w, r) } type authHandler struct { h http.Handler noRedirect bool } func (h *authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if age, ok := cookieAuth(r); !ok || age > quickLoginTimeout { log.Printf("Warning: ok age = %v %v", ok, age) if h.noRedirect { http.Error(w, "not logged in", http.StatusUnauthorized) } else { u := *r.URL u.Host = r.Host u.Scheme = "http" if *https != "" { u.Scheme = "https" } redirectLogin(w, r, u.String()) } return } h.h.ServeHTTP(w, r) } type timeoutHandler struct { h http.Handler timeout time.Duration } func (h *timeoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() rc := http.NewResponseController(w) deadline := time.Now().Add(h.timeout) if err := rc.SetReadDeadline(deadline); err != nil { log.Printf("Error: set read deadline: %s", err) } // Give some extra time to write the error writeDeadline := deadline.Add(time.Minute) if err := rc.SetWriteDeadline(writeDeadline); err != nil { log.Printf("Error: set write deadline: %s", err) } ctx, cancel := context.WithDeadline(ctx, deadline) defer cancel() h.h.ServeHTTP(w, r.WithContext(ctx)) } type loggingHandler struct { h http.Handler } func (h *loggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if *v { log.Printf("%q from %q", r.Method+" "+r.Host+r.URL.String(), r.RemoteAddr) } h.h.ServeHTTP(w, r) } func main() { flag.Parse() if err := loadSecrets(); err != nil { log.Fatal(err) } if err := os.MkdirAll(*holeTempDir, 0700); err != nil { log.Fatal(err) } go func() { for range time.NewTicker(2 * time.Hour).C { if err := cleanHole(); err != nil { log.Printf("Error: clean hole: %s", err) } } }() mux := http.NewServeMux() indexTemplate, err := outlinedTemplate(indexString) if err != nil { log.Fatal(err) } indexHandler, err := staticTemplate(indexTemplate) if err != nil { log.Fatal(err) } mux.Handle("GET /{$}", &timeoutHandler{h: indexHandler, timeout: 5 * time.Minute}) gamesIndexTemplate, err := outlinedTemplate(gamesIndexString) if err != nil { log.Fatal(err) } gamesIndexHandler, err := staticTemplate(gamesIndexTemplate) if err != nil { log.Fatal(err) } mux.Handle("GET /games/{$}", &timeoutHandler{h: gamesIndexHandler, timeout: 5 * time.Minute}) pongTemplate, err := template.New("pong").Parse(pongString) if err != nil { log.Fatal(err) } pongHandler, err := staticTemplate(pongTemplate) if err != nil { log.Fatal(err) } mux.Handle("GET /games/pong", &timeoutHandler{h: pongHandler, timeout: 5 * time.Minute}) stallmanShooterTemplate, err := outlinedTemplate(stallmanShooterString) if err != nil { log.Fatal(err) } stallmanShooterHandler, err := staticTemplate(stallmanShooterTemplate) if err != nil { log.Fatal(err) } mux.Handle("GET /games/rms", &timeoutHandler{h: stallmanShooterHandler, timeout: 5 * time.Minute}) mux.Handle("GET /qr.png", &timeoutHandler{h: http.HandlerFunc(qrHandler), timeout: 5 * time.Minute}) mux.Handle("GET /wormhole", &timeoutHandler{h: http.HandlerFunc(wormhole), timeout: 5 * time.Minute}) mux.HandleFunc("POST /wormhole", wormholeUpload) mux.HandleFunc("GET /wormhole/{hole}", wormholeDownload) mux.Handle("GET /login", &timeoutHandler{h: http.HandlerFunc(serveLogin), timeout: 5 * time.Minute}) mux.Handle("POST /login", &timeoutHandler{h: http.HandlerFunc(login), timeout: 5 * time.Minute}) mux.Handle("GET /notepad", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(notepad)}, timeout: 5 * time.Minute}) mux.Handle("POST /notepad", &timeoutHandler{h: &authHandler{h: http.HandlerFunc(autosave), noRedirect: true}, timeout: 5 * time.Minute}) mux.Handle("GET /cmd/{$}", &timeoutHandler{h: http.HandlerFunc(cmdIndex), timeout: 5 * time.Minute}) mux.Handle("GET /cmd/{cmd}", &timeoutHandler{h: http.HandlerFunc(cmd), timeout: 5 * time.Minute}) mux.Handle("GET /pkg/{$}", &timeoutHandler{h: http.HandlerFunc(pkgIndex), timeout: 5 * time.Minute}) mux.Handle("GET /pkg/{pkg}", &timeoutHandler{h: http.HandlerFunc(pkg), timeout: 5 * time.Minute}) mux.Handle("GET /nezuko/", &timeoutHandler{h: &authHandler{h: http.StripPrefix("/nezuko/", http.FileServer(http.Dir(*nezuko)))}, timeout: 10 * time.Minute}) mux.Handle("GET /static/", &timeoutHandler{h: http.HandlerFunc(static), timeout: 5 * time.Minute}) mux.Handle("GET /favicon.ico", &timeoutHandler{h: http.HandlerFunc(favicon), timeout: 5 * time.Minute}) apiMux := http.NewServeMux() apiMux.HandleFunc("PUT /api/wormhole/{name}", wormholeAPIUpload) mux.Handle("/api/", apiMux) mux.Handle("/", &timeoutHandler{h: http.HandlerFunc(sharedFileHandler), timeout: 5 * time.Minute}) serverMux := http.NewServeMux() serverMux.Handle(selfURL.Hostname()+"/", http.NewCrossOriginProtection().Handler(&gzipHandler{mux})) jellyfinURL, err := url.Parse("http://127.0.0.1:8096") if err != nil { log.Fatal(err) } serverMux.Handle("cinema."+selfURL.Hostname()+"/", &authHandler{h: &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(jellyfinURL) r.SetXForwarded() }, }}) codeMux := http.NewServeMux() cgitStaticFiles := http.FileServer(http.Dir(*cgit)) codeMux.Handle("/cgit.css", &timeoutHandler{h: cgitStaticFiles, timeout: 5 * time.Minute}) codeMux.Handle("/cgit.js", &timeoutHandler{h: cgitStaticFiles, timeout: 5 * time.Minute}) codeMux.Handle("/robots.txt", &timeoutHandler{h: cgitStaticFiles, timeout: 5 * time.Minute}) codeMux.Handle("/favicon.ico", &timeoutHandler{h: http.HandlerFunc(favicon), timeout: 5 * time.Minute}) cgitConfigAbsPath, err := filepath.Abs(*cgitConfig) if err != nil { log.Fatal(err) } cgitCGI := &cgi.Handler{ Path: filepath.Join(*cgit, "cgit.cgi"), Env: []string{"CGIT_CONFIG=" + cgitConfigAbsPath}, } cgitCGI.PathLocationHandler = cgitCGI codeMux.Handle("/", &timeoutHandler{h: cgitCGI, timeout: 5 * time.Minute}) serverMux.Handle("code."+selfURL.Hostname()+"/", &gzipHandler{codeMux}) transmissionURL, err := url.Parse("http://127.0.0.1:9091") if err != nil { log.Fatal(err) } serverMux.Handle("downloads."+selfURL.Hostname()+"/", &authHandler{h: &httputil.ReverseProxy{ Rewrite: func(r *httputil.ProxyRequest) { r.SetURL(transmissionURL) r.SetXForwarded() }, }}) server := &http.Server{ Handler: &loggingHandler{serverMux}, ReadHeaderTimeout: 5 * time.Minute, IdleTimeout: 10 * time.Minute, } if *httpRedirect != "" { go func() { mux := http.NewServeMux() mux.Handle("GET /.well-known/acme-challenge/", &timeoutHandler{ h: http.FileServer(http.Dir("/var/lib/acme/acme-challenge")), timeout: 5 * time.Minute, }) mux.Handle("/", &timeoutHandler{ h: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { u := *r.URL u.Scheme = selfURL.Scheme u.Host, _, _ = strings.Cut(r.Host, ":") if port := selfURL.Port(); port != "" { u.Host += ":" + port } http.Redirect(w, r, u.String(), http.StatusMovedPermanently) }), timeout: time.Minute, }) httpSrv := &http.Server{ Addr: ":" + *httpRedirect, Handler: &loggingHandler{mux}, ReadHeaderTimeout: 5 * time.Minute, IdleTimeout: 10 * time.Minute, } log.Printf("Serving HTTP redirect on %q", httpSrv.Addr) err := httpSrv.ListenAndServe() log.Printf("Error: HTTP redirect failed: %s", err) }() } port := selfURL.Port() if port == "" { port = "80" if *https != "" { port = "443" } } server.Addr = ":" + port log.Printf("Listening on %q", server.Addr) if *https != "" { log.Fatal(server.ListenAndServeTLS(*https+"/cert.pem", *https+"/key.pem")) } else { log.Fatal(server.ListenAndServe()) } }