Compare commits

..

No commits in common. "fa733641077253c279bc5057d0591f8d18c0ea1b" and "c26ece0c8618682f06b61afce6dc34787e1bea62" have entirely different histories.

3 changed files with 189 additions and 315 deletions

View File

@ -1,196 +0,0 @@
package main
import (
"encoding/json"
"io"
"io/fs"
"net/http"
"git.yetaga.in/alazyreader/library/media"
"tailscale.com/client/tailscale"
)
type Router struct {
static fs.FS
lib Library
rcol RecordCollection
}
type AdminRouter struct {
static fs.FS
lib Library
ts *tailscale.LocalClient
}
type handler struct {
get func()
post func()
put func()
delete func()
}
func (h handler) Handle(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodHead && h.get != nil {
h.get()
} else if req.Method == http.MethodGet && h.get != nil {
h.get()
} else if req.Method == http.MethodPost && h.post != nil {
h.post()
} else if req.Method == http.MethodPut && h.put != nil {
h.put()
} else if req.Method == http.MethodDelete && h.delete != nil {
h.delete()
} else {
badMethod(w)
}
}
func writeError[T any](t T, err error) func(w http.ResponseWriter) (T, bool) {
return func(w http.ResponseWriter) (T, bool) {
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return t, err == nil
}
}
func writeNoBody(w http.ResponseWriter, status int) {
w.WriteHeader(status)
}
func writeJSON(w http.ResponseWriter, b any, status int) {
bytes, err := json.Marshal(b)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
w.Write(bytes)
w.Write([]byte("\n"))
}
func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/records":
handler{
get: func() { getRecords(router.rcol, w, r) },
}.Handle(w, r)
case "/api/books":
handler{
get: func() { getBooks(router.lib, w, r) },
}.Handle(w, r)
default:
static(router.static).ServeHTTP(w, r)
}
}
func (router *AdminRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/whoami":
handler{
get: func() { getWhoAmI(router.ts, w, r) },
}.Handle(w, r)
case "/api/books":
handler{
get: func() { getBooks(router.lib, w, r) },
post: func() { addBook(router.lib, w, r) },
delete: func() { deleteBook(router.lib, w, r) },
}.Handle(w, r)
default:
static(router.static).ServeHTTP(w, r)
}
}
func badMethod(w http.ResponseWriter) {
writeJSON(w,
struct{ Error string }{Error: "method not supported"},
http.StatusMethodNotAllowed)
}
func getBooks(l Library, w http.ResponseWriter, r *http.Request) {
books, err := l.GetAllBooks(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, books, http.StatusOK)
}
func addBook(l Library, w http.ResponseWriter, r *http.Request) {
if r.Body == nil {
http.Error(w, "no body provided", http.StatusBadRequest)
return
}
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "error reading body", http.StatusBadRequest)
return
}
book := &media.Book{}
err = json.Unmarshal(b, book)
if err != nil {
http.Error(w, "error parsing body", http.StatusBadRequest)
return
}
err = l.AddBook(r.Context(), book)
if err != nil {
http.Error(w, "error parsing body", http.StatusBadRequest)
return
}
writeNoBody(w, http.StatusAccepted)
}
func deleteBook(l Library, w http.ResponseWriter, r *http.Request) {
if r.Body == nil {
http.Error(w, "no body provided", http.StatusBadRequest)
return
}
defer r.Body.Close()
b, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "error reading body", http.StatusBadRequest)
return
}
book := &media.Book{}
err = json.Unmarshal(b, book)
if err != nil {
http.Error(w, "error parsing body", http.StatusBadRequest)
return
}
err = l.DeleteBook(r.Context(), book)
if err != nil {
http.Error(w, "error parsing body", http.StatusBadRequest)
return
}
writeNoBody(w, http.StatusAccepted)
}
func getRecords(l RecordCollection, w http.ResponseWriter, r *http.Request) {
records, err := l.GetAllRecords(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, records, http.StatusOK)
}
func getWhoAmI(ts *tailscale.LocalClient, w http.ResponseWriter, r *http.Request) {
whois, ok := writeError(ts.WhoIs(r.Context(), r.RemoteAddr))(w)
if !ok {
return
}
writeJSON(w, struct {
Username string `json:"Username"`
DisplayName string `json:"DisplayName"`
ProfilePicURL string `json:"ProfilePicURL"`
}{
Username: whois.UserProfile.LoginName,
DisplayName: whois.UserProfile.DisplayName,
ProfilePicURL: whois.UserProfile.ProfilePicURL,
}, http.StatusOK)
}
func static(f fs.FS) http.Handler {
return http.FileServer(http.FS(f))
}

View File

@ -2,12 +2,11 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io/fs"
"log" "log"
"net"
"net/http" "net/http"
"os"
"os/signal"
"strings" "strings"
"time" "time"
@ -17,8 +16,8 @@ import (
"git.yetaga.in/alazyreader/library/media" "git.yetaga.in/alazyreader/library/media"
"github.com/kelseyhightower/envconfig" "github.com/kelseyhightower/envconfig"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet" "tailscale.com/tsnet"
"tailscale.com/util/must"
) )
func obscureStr(in string, l int) string { func obscureStr(in string, l int) string {
@ -27,135 +26,166 @@ func obscureStr(in string, l int) string {
type Library interface { type Library interface {
GetAllBooks(context.Context) ([]media.Book, error) GetAllBooks(context.Context) ([]media.Book, error)
AddBook(context.Context, *media.Book) error
DeleteBook(context.Context, *media.Book) error
} }
type RecordCollection interface { type RecordCollection interface {
GetAllRecords(context.Context) ([]media.Record, error) GetAllRecords(context.Context) ([]media.Record, error)
} }
type Router struct {
static fs.FS
lib Library
rcol RecordCollection
}
type AdminRouter struct {
static fs.FS
lib Library
ts *tailscale.LocalClient
}
func writeJSON(w http.ResponseWriter, b []byte, status int) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
w.Write(b)
w.Write([]byte("\n"))
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/api/records" {
RecordsAPIHandler(r.rcol).ServeHTTP(w, req)
return
}
if req.URL.Path == "/api/books" {
BooksAPIHandler(r.lib).ServeHTTP(w, req)
return
}
StaticHandler(r.static).ServeHTTP(w, req)
}
func (r *AdminRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
whois, _ := r.ts.WhoIs(req.Context(), req.RemoteAddr)
w.Write([]byte(fmt.Sprintf("%+v", whois.UserProfile.DisplayName)))
// StaticHandler(r.static).ServeHTTP(w, req)
}
func BooksAPIHandler(l Library) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
books, err := l.GetAllBooks(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
b, err := json.Marshal(books)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, b, http.StatusOK)
})
}
func RecordsAPIHandler(l RecordCollection) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
books, err := l.GetAllRecords(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
b, err := json.Marshal(books)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, b, http.StatusOK)
})
}
func StaticHandler(f fs.FS) http.Handler {
return http.FileServer(http.FS(f))
}
func main() { func main() {
var c config.Config var c config.Config
must.Do(envconfig.Process("library", &c)) err := envconfig.Process("library", &c)
if err != nil {
log.Fatalln(err)
}
f, err := frontend.Root()
if err != nil {
log.Fatalln(err)
}
var lib Library var lib Library
var err error
if c.DBType == "memory" { if c.DBType == "memory" {
lib = &database.Memory{} lib = &database.Memory{}
} else if c.DBType == "sql" { } else if c.DBType == "sql" {
var latest, run int if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" {
lib, latest, run, err = setupSQL(c) if c.DBPass != "" {
c.DBPass = obscureStr(c.DBPass, 3)
}
if c.DiscogsToken != "" {
c.DiscogsToken = obscureStr(c.DiscogsToken, 3)
}
log.Fatalf("vars: %+v", c)
}
sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName)
if err != nil { if err != nil {
log.Fatalf("err starting sql connection: %v", err) log.Fatalln(err)
}
err = sql.PrepareDatabase(context.Background())
if err != nil {
log.Fatalln(err)
}
latest, run, err := sql.RunMigrations(context.Background())
if err != nil {
log.Fatalln(err)
} }
log.Printf("latest migration: %d; migrations run: %d", latest, run) log.Printf("latest migration: %d; migrations run: %d", latest, run)
lib = sql
}
discogsCache, err := database.NewDiscogsCache(c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile)
if err != nil {
log.Fatalln(err)
} }
discogsCache := must.Get(database.NewDiscogsCache(
c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile,
))
frontendRoot := must.Get(frontend.Root())
adminRoot := must.Get(frontend.AdminRoot())
servers := make(chan (*http.Server), 3)
errGroup := errgroup.Group{} errGroup := errgroup.Group{}
errGroup.Go(func() error { errGroup.Go(func() error {
return start(servers)( return publicListener(8080, &Router{
publicServer(8080, &Router{ static: f,
static: frontendRoot, lib: lib,
lib: lib, rcol: discogsCache,
rcol: discogsCache, })
}))
}) })
errGroup.Go(func() error { errGroup.Go(func() error {
return start(servers)( f, _ := frontend.AdminRoot()
tailscaleListener("library-admin", &AdminRouter{ return tailscaleListener("library-admin", &AdminRouter{
static: adminRoot, static: f,
lib: lib, lib: lib,
})) })
}) })
errGroup.Go(func() error {
return shutdown(servers)
})
log.Println(errGroup.Wait()) log.Println(errGroup.Wait())
} }
func setupSQL(c config.Config) (Library, int, int, error) { func publicListener(port int, handler http.Handler) error {
if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" { log.Printf("Listening on http://0.0.0.0:%d/", port)
if c.DBPass != "" { return http.ListenAndServe(fmt.Sprintf(":%d", port), handler)
c.DBPass = obscureStr(c.DBPass, 3)
}
if c.DiscogsToken != "" {
c.DiscogsToken = obscureStr(c.DiscogsToken, 3)
}
return nil, 0, 0, fmt.Errorf("invalid config; vars provided: %+v", c)
}
sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName)
if err != nil {
return nil, 0, 0, err
}
err = sql.PrepareDatabase(context.Background())
if err != nil {
return nil, 0, 0, err
}
latest, run, err := sql.RunMigrations(context.Background())
if err != nil {
return nil, 0, 0, err
}
return sql, latest, run, nil
} }
func start(servers chan (*http.Server)) func(*http.Server, net.Listener, error) error { func tailscaleListener(hostname string, handler *AdminRouter) error {
return func(s *http.Server, l net.Listener, err error) error {
if err != nil {
return err
}
servers <- s
return s.Serve(l)
}
}
func shutdown(servers chan (*http.Server)) error {
sigint := make(chan os.Signal, 1)
signal.Notify(sigint, os.Interrupt)
<-sigint
close(servers)
var err error
for server := range servers {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
if shutdownerr := server.Shutdown(ctx); shutdownerr != nil {
err = shutdownerr
}
cancel()
}
return err
}
func publicServer(port int, handler http.Handler) (*http.Server, net.Listener, error) {
server := &http.Server{Handler: handler}
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", 8080))
if err != nil {
return nil, nil, err
}
log.Println("starting public server")
return server, ln, nil
}
func tailscaleListener(hostname string, handler *AdminRouter) (*http.Server, net.Listener, error) {
s := &tsnet.Server{ s := &tsnet.Server{
Dir: ".config/" + hostname, Dir: ".config/" + hostname,
Hostname: hostname, Hostname: hostname,
} }
defer s.Close()
ln, err := s.Listen("tcp", ":80") ln, err := s.Listen("tcp", ":80")
if err != nil { if err != nil {
return nil, nil, err fmt.Printf("%+v\n", err)
return nil
} }
handler.ts, err = s.LocalClient() handler.ts, err = s.LocalClient()
if err != nil { if err != nil {
return nil, nil, err return err
} }
server := &http.Server{Handler: handler} return (&http.Server{Handler: handler}).Serve(ln)
return server, ln, nil
} }

View File

@ -153,8 +153,9 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) {
} }
allBooksQuery := fmt.Sprintf(`SELECT allBooksQuery := fmt.Sprintf(`SELECT
id, title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, id, title, authors, sortauthor, isbn10, isbn13, format,
series, volume, year, signed, description, notes, coverurl, childrens genre, publisher, series, volume, year, signed,
description, notes, coverurl, childrens
FROM %s`, m.tableName) FROM %s`, m.tableName)
books := []media.Book{} books := []media.Book{}
@ -168,8 +169,12 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) {
b := media.Book{} b := media.Book{}
var authors string var authors string
err := rows.Scan( err := rows.Scan(
&b.ID, &b.Title, &authors, &b.SortAuthor, &b.ISBN10, &b.ISBN13, &b.Format, &b.Genre, &b.Publisher, &b.ID, &b.Title, &authors,
&b.Series, &b.Volume, &b.Year, &b.Signed, &b.Description, &b.Notes, &b.CoverURL, &b.Childrens) &b.SortAuthor, &b.ISBN10, &b.ISBN13,
&b.Format, &b.Genre, &b.Publisher,
&b.Series, &b.Volume, &b.Year,
&b.Signed, &b.Description, &b.Notes,
&b.CoverURL, &b.Childrens)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -188,14 +193,25 @@ func (m *MySQL) AddBook(ctx context.Context, b *media.Book) error {
res, err := m.connection.ExecContext(ctx, ` res, err := m.connection.ExecContext(ctx, `
INSERT INTO `+m.tableName+` INSERT INTO `+m.tableName+`
( (title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series, volume, year, signed, description, notes, coverurl, childrens)
title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series,
volume, year, signed, description, notes, coverurl, childrens
)
VALUES VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
b.Title, strings.Join(b.Authors, ";"), b.SortAuthor, b.ISBN10, b.ISBN13, b.Format, b.Genre, b.Publisher, b.Series, b.Title,
b.Volume, b.Year, b.Signed, b.Description, b.Notes, b.CoverURL, b.Childrens, strings.Join(b.Authors, ";"),
b.SortAuthor,
b.ISBN10,
b.ISBN13,
b.Format,
b.Genre,
b.Publisher,
b.Series,
b.Volume,
b.Year,
b.Signed,
b.Description,
b.Notes,
b.CoverURL,
b.Childrens,
) )
if err != nil { if err != nil {
return err return err
@ -220,13 +236,41 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.Book) error {
res, err := m.connection.ExecContext(ctx, ` res, err := m.connection.ExecContext(ctx, `
UPDATE `+m.tableName+` UPDATE `+m.tableName+`
SET SET id=?
id=? title=? authors=? sortauthor=? isbn10=? isbn13=? format=? genre=? publisher=? title=?
series=? volume=? year=? signed=? description=? notes=? coverurl=? childrens=? authors=?
sortauthor=?
isbn10=?
isbn13=?
format=?
genre=?
publisher=?
series=?
volume=?
year=?
signed=?
description=?
notes=?
coverurl=?
childrens=?
WHERE id=?`, WHERE id=?`,
new.Title, strings.Join(new.Authors, ";"), new.SortAuthor, new.ISBN10, new.ISBN13, new.Format, new.Genre, new.Publisher, new.Title,
new.Series, new.Volume, new.Year, new.Signed, new.Description, new.Notes, new.CoverURL, new.Childrens, old.ID, strings.Join(new.Authors, ";"),
) new.SortAuthor,
new.ISBN10,
new.ISBN13,
new.Format,
new.Genre,
new.Publisher,
new.Series,
new.Volume,
new.Year,
new.Signed,
new.Description,
new.Notes,
new.CoverURL,
new.Childrens,
old.ID)
if err != nil { if err != nil {
return err return err
} }
@ -240,10 +284,6 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.Book) error {
return nil return nil
} }
func (m *MySQL) DeleteBook(_ context.Context, b *media.Book) error {
return nil
}
func parseMigrationFileName(filename string) (int, string, error) { func parseMigrationFileName(filename string) (int, string, error) {
sp := strings.SplitN(filename, "-", 2) sp := strings.SplitN(filename, "-", 2)
i, err := strconv.Atoi(sp[0]) i, err := strconv.Atoi(sp[0])