From 9076095d7edc56ea44dcc66c1844659a156708e9 Mon Sep 17 00:00:00 2001 From: Rose Hogenson Date: Tue, 18 Aug 2026 17:00:21 -0700 Subject: Add Met Art page --- roseh.moe.go | 364 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) (limited to 'roseh.moe.go') diff --git a/roseh.moe.go b/roseh.moe.go index 206679b..f3c7c78 100644 --- a/roseh.moe.go +++ b/roseh.moe.go @@ -2,6 +2,7 @@ package main import ( "archive/tar" + "archive/zip" "cmp" "compress/gzip" "crypto/hkdf" @@ -10,6 +11,7 @@ import ( "crypto/sha1" "crypto/sha256" "crypto/subtle" + "database/sql" "embed" "encoding/base64" "encoding/binary" @@ -18,6 +20,8 @@ import ( "flag" "fmt" "html/template" + "image" + "image/jpeg" "io" "io/fs" "log" @@ -26,13 +30,16 @@ import ( "net/http/httputil" "net/url" "os" + "path/filepath" "slices" "strconv" "strings" "sync" "time" + _ "github.com/mattn/go-sqlite3" "github.com/skip2/go-qrcode" + "golang.org/x/image/draw" "roseh.moe/pkg/oae2" "roseh.moe/pkg/roseh.moe/internal/pwhash" "roseh.moe/pkg/wordlist" @@ -48,6 +55,8 @@ var ( 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") nezuko = flag.String("nezuko", "nezuko", "nezuko partition") + martDir = flag.String("mart-dir", "/mnt/nezuko/downloads/[metart.com] Photosets - 1999 to 2023", "met art data directory") + martDB = flag.String("mart-db", "/mnt/nezuko/files/met-art.sqlite", "met art index") serverStartTime = time.Now() ) @@ -905,6 +914,303 @@ func (h *nezukoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.srv.ServeHTTP(w, r) } +var ( + //go:embed templates/mart/index.html.template + metArtIndexString string + metArtIndexTemplate = template.Must(outlinedTemplate(metArtIndexString)) +) + +type metArtIndexTemplateArgsGirl struct { + ID int64 + Name string +} + +type metArtIndexTemplateArgs struct { + Girls []metArtIndexTemplateArgsGirl +} + +type metArtIndexHandler struct { + s *stmts +} + +func (h *metArtIndexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !stupidCache(w, r) { + return + } + rows, err := h.s.listGirls.Query() + if err != nil { + log.Printf("Warning: list girls: %s", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + var girls []metArtIndexTemplateArgsGirl + for rows.Next() { + var girl metArtIndexTemplateArgsGirl + if err := rows.Scan(&girl.ID, &girl.Name); err != nil { + log.Printf("Warning: list girls: %s", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + girls = append(girls, girl) + } + if err := rows.Err(); err != nil { + log.Printf("Warning: list girls: %s", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := metArtIndexTemplate.Execute(w, metArtIndexTemplateArgs{girls}); err != nil { + log.Printf("Warning: mart index: %s", err) + } +} + +type metArtPhotoHandler struct { + s *stmts +} + +func (h *metArtPhotoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !stupidCache(w, r) { + return + } + id, err := strconv.ParseInt(r.PathValue("photo"), 10, 64) + if err != nil { + notFound(w, r) + return + } + var path, filename string + if err := h.s.findPhoto.QueryRow(id).Scan(&path, &filename); err != nil { + if err == sql.ErrNoRows { + notFound(w, r) + return + } + log.Printf("Warning: find photo: %s", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + archive, err := zip.OpenReader(filepath.Join(*martDir, path)) + if err != nil { + log.Printf("Warning: open archive: %s", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer archive.Close() + f, err := archive.Open(filename) + if err != nil { + log.Printf("Warning: open archive file: %s", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer f.Close() + w.Header().Set("Content-Type", "image/jpeg") + if stat, err := f.Stat(); err == nil { + w.Header().Set("Content-Length", strconv.FormatInt(stat.Size(), 10)) + } + if _, err := io.Copy(w, f); err != nil { + log.Printf("Warning: met art image: %s", err) + } +} + +var ( + //go:embed templates/mart/girl.html.template + metArtGirlString string + metArtGirlTemplate = template.Must(outlinedTemplate(metArtGirlString)) +) + +type metArtGirlTemplateArgsSet struct { + ID int64 + CoverID int64 + Name string +} + +type metArtGirlTemplateArgs struct { + Name string + Sets []metArtGirlTemplateArgsSet +} + +type metArtGirlHandler struct { + s *stmts +} + +func (h *metArtGirlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !stupidCache(w, r) { + return + } + id, err := strconv.ParseInt(r.PathValue("girl"), 10, 64) + if err != nil { + notFound(w, r) + return + } + wg := new(sync.WaitGroup) + var name string + var nameErr error + wg.Go(func() { + nameErr = h.s.girlName.QueryRow(id).Scan(&name) + }) + var sets []metArtGirlTemplateArgsSet + var setsErr error + wg.Go(func() { + rows, err := h.s.listSetsForGirl.Query(id) + if err != nil { + setsErr = err + return + } + for rows.Next() { + var set metArtGirlTemplateArgsSet + if err := rows.Scan(&set.ID, &set.CoverID, &set.Name); err != nil { + setsErr = err + return + } + sets = append(sets, set) + } + setsErr = rows.Err() + }) + wg.Wait() + if nameErr != nil { + if nameErr == sql.ErrNoRows { + notFound(w, r) + return + } + log.Printf("Warning: find girl name: %s", nameErr) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if setsErr != nil { + log.Printf("Warning: list sets: %s", setsErr) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := metArtGirlTemplate.Execute(w, metArtGirlTemplateArgs{Name: name, Sets: sets}); err != nil { + log.Printf("Warning: mart girl: %s", err) + } +} + +type metArtGirlPreviewHandler struct { + s *stmts +} + +func (h *metArtGirlPreviewHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !stupidCache(w, r) { + return + } + id, err := strconv.ParseInt(r.PathValue("girl"), 10, 64) + if err != nil { + notFound(w, r) + return + } + var path, filename string + if err := h.s.findPreview.QueryRow(id).Scan(&path, &filename); err != nil { + if err == sql.ErrNoRows { + notFound(w, r) + return + } + log.Printf("Warning: find preview photo: %s", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + archive, err := zip.OpenReader(filepath.Join(*martDir, path)) + if err != nil { + log.Printf("Warning: open archive: %s", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer archive.Close() + f, err := archive.Open(filename) + if err != nil { + log.Printf("Warning: open archive file: %s", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer f.Close() + fullSize, _, err := image.Decode(f) + if err != nil { + log.Printf("Warning: open preview photo: %s", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + resized := image.NewRGBA(image.Rect(0, 0, 250, fullSize.Bounds().Dy()*250/fullSize.Bounds().Dx())) + draw.ApproxBiLinear.Scale(resized, resized.Bounds(), fullSize, fullSize.Bounds(), draw.Src, nil) + w.Header().Set("Content-Type", "image/jpeg") + if err := jpeg.Encode(w, resized, &jpeg.Options{Quality: 50}); err != nil { + log.Printf("Warning: met art preview image: %s", err) + } +} + +var ( + //go:embed templates/mart/set.html.template + metArtSetString string + metArtSetTemplate = template.Must(outlinedTemplate(metArtSetString)) +) + +type metArtSetTemplateArgs struct { + Name string + Photos []int64 +} + +type metArtSetHandler struct { + s *stmts +} + +func (h *metArtSetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if !stupidCache(w, r) { + return + } + id, err := strconv.ParseInt(r.PathValue("set"), 10, 64) + if err != nil { + notFound(w, r) + return + } + wg := new(sync.WaitGroup) + var name string + var nameErr error + wg.Go(func() { + nameErr = h.s.setName.QueryRow(id).Scan(&name) + }) + var photos []int64 + var photosErr error + wg.Go(func() { + rows, err := h.s.listPhotosForSet.Query(id) + if err != nil { + photosErr = err + return + } + for rows.Next() { + var photoID int64 + if err := rows.Scan(&photoID); err != nil { + photosErr = err + return + } + photos = append(photos, photoID) + } + photosErr = rows.Err() + }) + wg.Wait() + if nameErr != nil { + if nameErr == sql.ErrNoRows { + notFound(w, r) + return + } + log.Printf("Warning: find set name: %s", nameErr) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if photosErr != nil { + log.Printf("Warning: list set photos: %s", photosErr) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if err := metArtSetTemplate.Execute(w, metArtSetTemplateArgs{Name: name, Photos: photos}); err != nil { + log.Printf("Warning: mart set: %s", err) + } +} + +func stupidCache(w http.ResponseWriter, r *http.Request) bool { + w.Header().Set("Last-Modified", "Mon, 01 Jan 1990 00:00:00 GMT") + if _, ok := r.Header["If-Modified-Since"]; ok { + w.WriteHeader(http.StatusNotModified) + return false + } + return true +} + type jellyfinReverseProxy struct { proxy httputil.ReverseProxy } @@ -917,6 +1223,50 @@ func (p *jellyfinReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) p.proxy.ServeHTTP(w, r) } +type stmts struct { + listGirls *sql.Stmt + findPhoto *sql.Stmt + findPreview *sql.Stmt + girlName *sql.Stmt + listSetsForGirl *sql.Stmt + setName *sql.Stmt + listPhotosForSet *sql.Stmt +} + +func prepareStatements(db *sql.DB) (*stmts, error) { + s := new(stmts) + var err error + s.listGirls, err = db.Prepare("SELECT girl_id, name FROM girls ORDER BY substr(name, instr(name, ' ')+1), name") + if err != nil { + return nil, err + } + s.findPhoto, err = db.Prepare("SELECT path, filename FROM photos JOIN sets USING (set_id) WHERE photo_id = ?") + if err != nil { + return nil, err + } + s.findPreview, err = db.Prepare("SELECT path, filename FROM girls JOIN sets USING (girl_id) JOIN photos ON photo_id = cover_photo_id WHERE girl_id = ? ORDER BY date ASC, sets.name ASC LIMIT 1") + if err != nil { + return nil, err + } + s.girlName, err = db.Prepare("SELECT name FROM girls WHERE girl_id = ?") + if err != nil { + return nil, err + } + s.listSetsForGirl, err = db.Prepare("SELECT set_id, cover_photo_id, sets.name FROM girls JOIN sets USING (girl_id) WHERE girl_id = ? ORDER BY date ASC, sets.name ASC") + if err != nil { + return nil, err + } + s.setName, err = db.Prepare("SELECT name FROM sets WHERE set_id = ?") + if err != nil { + return nil, err + } + s.listPhotosForSet, err = db.Prepare("SELECT photo_id FROM sets JOIN photos USING (set_id) WHERE set_id = ? AND idx IS NOT NULL ORDER BY idx ASC") + if err != nil { + return nil, err + } + return s, nil +} + func main() { flag.Parse() @@ -924,6 +1274,15 @@ func main() { log.Fatal(err) } + db, err := sql.Open("sqlite3", *martDB) + if err != nil { + log.Fatal(err) + } + stmts, err := prepareStatements(db) + if err != nil { + log.Fatal(err) + } + go func() { for range time.NewTicker(2 * time.Hour).C { if err := cleanHole(); err != nil { @@ -951,6 +1310,11 @@ func main() { mux.HandleFunc("GET /pkg/{pkg}", pkg) mux.Handle("GET /code/", http.StripPrefix("/code/", http.FileServer(http.Dir(*codeDir)))) mux.Handle("GET /nezuko/", &nezukoHandler{http.StripPrefix("/nezuko/", http.FileServer(http.Dir(*nezuko)))}) + mux.Handle("GET /mart", &metArtIndexHandler{stmts}) + mux.Handle("GET /mart/photos/{photo}", &metArtPhotoHandler{stmts}) + mux.Handle("GET /mart/girls/{girl}", &metArtGirlHandler{stmts}) + mux.Handle("GET /mart/girls/{girl}/preview.jpeg", &metArtGirlPreviewHandler{stmts}) + mux.Handle("GET /mart/sets/{set}", &metArtSetHandler{stmts}) mux.HandleFunc("GET /static/", static) mux.HandleFunc("GET /favicon.ico", favicon) apiMux := http.NewServeMux() -- cgit v1.3.1