package main
import (
"archive/tar"
"cmp"
"compress/gzip"
"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/httputil"
"net/url"
"os"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/skip2/go-qrcode"
"roseh.moe/pkg/oae2"
"roseh.moe/pkg/roseh.moe/internal/pwhash"
"roseh.moe/pkg/wordlist"
)
var (
port = flag.Int("port", 42069, "port to listen on")
selfURL = flag.String("self-url", "http://localhost:42069", "base URL of the server")
domain = flag.String("domain", "", "domain for auth cookie")
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")
holeTempDir = flag.String("hole-temp-dir", "hole", "directory to store temporary files for the wormhole")
codeDir = flag.String("code-dir", "code", "directory to serve git repos")
serverStartTime = time.Now()
)
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 {
url string
description string
}
type commandConfig struct {
url string
description string
multi bool
}
type serviceConfiguration struct {
packageRedirects map[string]packageConfig
commandRedirects map[string]commandConfig
}
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
}
var fileConfig struct {
Package []struct {
Name string `xml:",attr"`
URL string `xml:",attr"`
Description string `xml:",attr"`
} `xml:"Redirects>Package"`
Command []struct {
Name string `xml:",attr"`
URL string `xml:",attr"`
Description string `xml:",attr"`
Multi bool `xml:",attr"`
} `xml:"Redirects>Command"`
}
if err := xml.Unmarshal(fileBytes, &fileConfig); err != nil {
return nil, err
}
config := &serviceConfiguration{
packageRedirects: make(map[string]packageConfig),
commandRedirects: make(map[string]commandConfig),
}
for _, pkg := range fileConfig.Package {
config.packageRedirects[pkg.Name] = packageConfig{
url: pkg.URL,
description: pkg.Description,
}
}
for _, cmd := range fileConfig.Command {
config.commandRedirects[cmd.Name] = commandConfig{
url: cmd.URL,
description: cmd.Description,
multi: cmd.Multi,
}
}
serviceConfigMu.Lock()
serviceConfig = config
serviceConfigLastModified = newModTime
serviceConfigMu.Unlock()
return config, nil
}
var (
//go:embed templates/outline.html.template
outlineString string
outlineTemplate = template.Must(template.New("outline").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
}
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)
}
}
var (
//go:embed templates/index.html.template
indexString string
indexTemplate = template.Must(outlinedTemplate(indexString))
)
func index(w http.ResponseWriter, _ *http.Request) {
if err := indexTemplate.Execute(w, nil); err != nil {
log.Printf("Warning: index: %s", err)
}
}
var (
//go:embed templates/pong.html.template
pongString string
pongTemplate = template.Must(template.New("pong").Parse(pongString))
)
func pong(w http.ResponseWriter, _ *http.Request) {
if err := pongTemplate.Execute(w, nil); err != nil {
log.Printf("Warning: pong: %s", err)
}
}
//go:embed static
var staticFiles embed.FS
func serveStaticFile(w http.ResponseWriter, r *http.Request, path string) {
f, err := staticFiles.Open(path)
if err != nil {
notFound(w, r)
return
}
defer f.Close()
http.ServeContent(w, r, path, serverStartTime, f.(io.ReadSeeker))
}
func static(w http.ResponseWriter, r *http.Request) {
serveStaticFile(w, r, strings.TrimPrefix(r.URL.Path, "/"))
}
func favicon(w http.ResponseWriter, r *http.Request) {
serveStaticFile(w, r, "static/favicon.ico")
}
func qrHandler(w http.ResponseWriter, r *http.Request) {
sig, err := base64.RawURLEncoding.DecodeString(r.FormValue("s"))
if err != nil {
http.Error(w, fmt.Sprintf("Bad signature: %s", err), http.StatusBadRequest)
return
}
text := r.FormValue("t")
if !verify([]byte(text), sig, "qr") {
http.Error(w, "Bad signature", http.StatusBadRequest)
return
}
qr, err := qrcode.Encode(text, qrcode.Medium, 200)
if err != nil {
http.Error(w, fmt.Sprintf("failed to encode QR code: %s", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
w.Write(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 := gzip.NewWriter(tarHoleWriter)
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("Warning: failed to upload hole file: %s", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
downloadURL := *selfURL + "/wormhole/" + hole
if err := wormholeSuccessTemplate.Execute(w, wormholeSuccessTemplateArgs{
URL: downloadURL,
QR: "/qr.png?t=" + url.QueryEscape(downloadURL) + "&s=" + base64.RawURLEncoding.EncodeToString(mac([]byte(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("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("Warning: read hole file: %s", err)
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("Warning: clean hole: %s", err)
continue
}
if !stat.ModTime().Before(deadline) {
continue
}
if err := os.Remove(*holeTempDir + "/" + file.Name()); err != nil {
log.Printf("Warning: clean hole: %s", err)
continue
}
}
if err != nil {
if err != io.EOF {
return err
}
return nil
}
}
}
const macSize = sha256.Size
func mac(msg []byte, purpose string) []byte {
mac := hmac.New(sha256.New, secretKey)
io.WriteString(mac, purpose)
io.WriteString(mac, ":")
mac.Write(msg)
return mac.Sum(nil)
}
func verify(msg, messageMAC []byte, purpose string) bool {
expectedMAC := mac(msg, purpose)
return hmac.Equal(messageMAC, expectedMAC)
}
func marshalInt(x int64) []byte {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, uint64(x))
for ; len(buf) > 0 && buf[0] == 0; buf = buf[1:] {
}
return buf
}
func unmarshalInt(b []byte) (int64, bool) {
if len(b) > 8 {
return 0, false
}
buf := make([]byte, 8)
copy(buf[8-len(b):], b)
return int64(binary.BigEndian.Uint64(buf)), true
}
const yearOffset = 2025
func marshalTime(t time.Time) []byte {
year, month, day := t.UTC().Date()
return marshalInt((int64(year)-yearOffset)<<9 | int64(month)<<5 | int64(day))
}
func unmarshalTime(b []byte) (time.Time, bool) {
n, ok := unmarshalInt(b)
if !ok {
return time.Time{}, false
}
return time.Date(int(n>>9+yearOffset), time.Month(n>>5&0xf), int(n&0x1f), 0, 0, 0, 0, time.UTC), true
}
func makeToken() string {
t := marshalTime(time.Now())
return base64.RawURLEncoding.EncodeToString(append(t, mac(t, "auth")...))
}
const cookieExpiration = 7 * 24 * time.Hour
const authCookieName = "a"
func attachCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: authCookieName,
Value: makeToken(),
Path: "/",
Domain: *domain,
Expires: time.Now().Add(cookieExpiration),
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Partitioned: true,
})
}
func cookieAuth(w http.ResponseWriter, r *http.Request) bool {
cookie, err := r.Cookie(authCookieName)
if err != nil {
return false
}
authCookie, err := base64.RawURLEncoding.DecodeString(cookie.Value)
if err != nil {
return false
}
if len(authCookie) < macSize {
return false
}
msg, mac := authCookie[:len(authCookie)-macSize], authCookie[len(authCookie)-macSize:]
if !verify(msg, mac, "auth") {
return false
}
t, ok := unmarshalTime(msg)
if !ok {
return false
}
cookieAge := time.Since(t)
if cookieAge > cookieExpiration {
return false
}
if cookieAge > 24*time.Hour {
attachCookie(w)
}
return 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, auth string) bool {
otpMatches := checkOTP(auth)
hash, err := pwhash.Hash(password, notepadPassword[:pwhash.SaltSize])
if err != nil {
return false
}
return subtle.ConstantTimeCompare(hash, notepadPassword[pwhash.SaltSize:])&otpMatches != 0
}
var (
//go:embed templates/login.html.template
loginTemplateString string
loginTemplate = template.Must(outlinedTemplate(loginTemplateString))
)
type loginTemplateArgs struct {
Error bool
}
func redirectLogin(w http.ResponseWriter, r *http.Request, redirect string) {
redirect += base64.RawURLEncoding.EncodeToString(mac([]byte(redirect), "redirect"))
http.Redirect(w, r, *selfURL+"/login?redirect="+url.QueryEscape(redirect), http.StatusFound)
}
func serveLogin(w http.ResponseWriter, r *http.Request) {
if err := loginTemplate.Execute(w, loginTemplateArgs{}); err != nil {
log.Printf("Warning: login: %s", err)
}
}
func verifyRedirect(redirect string) (string, bool) {
base64MacSize := base64.RawURLEncoding.EncodedLen(macSize)
if len(redirect) < base64MacSize {
return "", false
}
redirect, macBase64 := redirect[:len(redirect)-base64MacSize], redirect[len(redirect)-base64MacSize:]
mac, err := base64.RawURLEncoding.DecodeString(macBase64)
if err != nil {
return "", false
}
if !verify([]byte(redirect), mac, "redirect") {
return "", false
}
return redirect, true
}
func login(w http.ResponseWriter, r *http.Request) {
if !checkPassword(r.FormValue("password"), r.FormValue("auth")) {
if err := loginTemplate.Execute(w, loginTemplateArgs{Error: true}); err != nil {
log.Printf("Warning: login: %s", err)
}
return
}
attachCookie(w)
redirect, ok := verifyRedirect(r.FormValue("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) {
if !cookieAuth(w, r) {
redirectLogin(w, r, "/notepad")
return
}
notepadContentsMu.Lock()
currentContent := notepadContents
notepadContentsMu.Unlock()
if err := notepadTemplate.Execute(w, notepadTemplateArgs{Content: currentContent}); err != nil {
log.Printf("Warning: notepad: %s", err)
}
}
func saveNote(w http.ResponseWriter, r *http.Request) error {
if !cookieAuth(w, r) {
return fmt.Errorf("not logged in")
}
newContent := r.FormValue("content")
notepadContentsMu.Lock()
notepadContents = newContent
notepadContentsMu.Unlock()
return nil
}
func autosave(w http.ResponseWriter, r *http.Request) {
msg := "Saved ✓"
if err := saveNote(w, r); err != nil {
msg = fmt.Sprintf("Failed to save: %s", err)
}
if err := notepadTemplate.ExecuteTemplate(w, "saveIndicator", msg); err != nil {
log.Printf("Warning: autosave: %s", err)
}
}
func cmd(w http.ResponseWriter, r *http.Request) {
serviceConfig, err := loadConfig()
if err != nil {
http.Error(w, fmt.Sprintf("load config: %s", err), http.StatusInternalServerError)
return
}
source, ok := serviceConfig.commandRedirects[r.PathValue("cmd")]
if !ok {
notFound(w, r)
return
}
if r.FormValue("go-get") == "1" {
fmt.Fprintf(w, ``, r.PathValue("cmd"), source)
} else {
http.Redirect(w, r, "https://pkg.go.dev/roseh.moe/cmd/"+r.PathValue("cmd"), http.StatusFound)
}
}
var (
//go:embed templates/pkg-index.html.template
pkgIndexString string
pkgIndexTemplate = template.Must(outlinedTemplate(pkgIndexString))
)
type pkgIndexTemplatePackage struct {
Name string
Description string
}
type pkgIndexTemplateArgs struct {
Packages []pkgIndexTemplatePackage
}
func pkgIndex(w http.ResponseWriter, r *http.Request) {
serviceConfig, err := loadConfig()
if err != nil {
log.Printf("Warning: failed to load config: %s", err)
http.Error(w, "Failed to load config", http.StatusInternalServerError)
return
}
packages := make([]string, 0, len(serviceConfig.packageRedirects))
for pkg := range serviceConfig.packageRedirects {
packages = append(packages, pkg)
}
slices.Sort(packages)
templatePackages := make([]pkgIndexTemplatePackage, len(packages))
for i, pkg := range packages {
templatePackages[i] = pkgIndexTemplatePackage{
Name: pkg,
Description: serviceConfig.packageRedirects[pkg].description,
}
}
if err := pkgIndexTemplate.Execute(w, pkgIndexTemplateArgs{Packages: templatePackages}); err != nil {
log.Printf("Warning: pkgIndex: %s", err)
}
}
func pkg(w http.ResponseWriter, r *http.Request) {
serviceConfig, err := loadConfig()
if err != nil {
log.Printf("Warning: failed to load config: %s", err)
http.Error(w, "Failed to load config", http.StatusInternalServerError)
return
}
source, ok := serviceConfig.packageRedirects[r.PathValue("pkg")]
if !ok {
notFound(w, r)
return
}
if r.FormValue("go-get") == "1" {
fmt.Fprintf(w, ``, r.PathValue("pkg"), source)
} else {
http.Redirect(w, r, "https://pkg.go.dev/roseh.moe/pkg/"+r.PathValue("pkg"), http.StatusFound)
}
}
type jellyfinReverseProxy struct {
proxy httputil.ReverseProxy
}
func (p *jellyfinReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !cookieAuth(w, r) {
redirectLogin(w, r, "https://cinema.roseh.moe")
return
}
p.proxy.ServeHTTP(w, r)
}
func main() {
flag.Parse()
if err := loadSecrets(); err != nil {
log.Fatal(err)
}
go func() {
for range time.NewTicker(2 * time.Hour).C {
if err := cleanHole(); err != nil {
log.Printf("Warning: clean hole: %s", err)
}
}
}()
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", index)
mux.HandleFunc("GET /pong", pong)
mux.HandleFunc("GET /qr.png", qrHandler)
mux.HandleFunc("GET /wormhole", wormhole)
mux.HandleFunc("POST /wormhole", wormholeUpload)
mux.HandleFunc("GET /wormhole/{hole}", wormholeDownload)
mux.HandleFunc("GET /login", serveLogin)
mux.HandleFunc("POST /login", login)
mux.HandleFunc("GET /notepad", notepad)
mux.HandleFunc("POST /notepad", autosave)
mux.HandleFunc("GET /cmd/{cmd}", cmd)
mux.HandleFunc("GET /pkg", pkgIndex)
mux.HandleFunc("GET /pkg/{pkg}", pkg)
mux.Handle("GET /code/", http.StripPrefix("/code/", http.FileServer(http.Dir(*codeDir))))
mux.HandleFunc("GET /static/", static)
mux.HandleFunc("GET /favicon.ico", favicon)
apiMux := http.NewServeMux()
apiMux.HandleFunc("PUT /api/wormhole/{name}", wormholeAPIUpload)
mux.Handle("/api/", apiMux)
mux.HandleFunc("/", notFound)
http.Handle("/", http.NewCrossOriginProtection().Handler(mux))
jellyfinURL, err := url.Parse("http://127.0.0.1:8096")
if err != nil {
log.Fatal(err)
}
http.Handle("cinema.roseh.moe/", &jellyfinReverseProxy{
proxy: httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(jellyfinURL)
r.SetXForwarded()
},
},
})
addr := fmt.Sprintf(":%d", *port)
log.Printf("Listening on %q", addr)
if *https != "" {
log.Fatal(http.ListenAndServeTLS(addr, *https+"/cert.pem", *https+"/key.pem", nil))
} else {
log.Fatal(http.ListenAndServe(addr, nil))
}
}