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,62 +26,100 @@ 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
lib, latest, run, err = setupSQL(c)
if err != nil {
log.Fatalf("err starting sql connection: %v", err)
}
log.Printf("latest migration: %d; migrations run: %d", latest, run)
}
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.Go(func() error {
return start(servers)(
publicServer(8080, &Router{
static: frontendRoot,
lib: lib,
rcol: discogsCache,
}))
})
errGroup.Go(func() error {
return start(servers)(
tailscaleListener("library-admin", &AdminRouter{
static: adminRoot,
lib: lib,
}))
})
errGroup.Go(func() error {
return shutdown(servers)
})
log.Println(errGroup.Wait())
}
func setupSQL(c config.Config) (Library, int, int, error) {
if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" { if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" {
if c.DBPass != "" { if c.DBPass != "" {
c.DBPass = obscureStr(c.DBPass, 3) c.DBPass = obscureStr(c.DBPass, 3)
@ -90,72 +127,65 @@ func setupSQL(c config.Config) (Library, int, int, error) {
if c.DiscogsToken != "" { if c.DiscogsToken != "" {
c.DiscogsToken = obscureStr(c.DiscogsToken, 3) c.DiscogsToken = obscureStr(c.DiscogsToken, 3)
} }
return nil, 0, 0, fmt.Errorf("invalid config; vars provided: %+v", c) log.Fatalf("vars: %+v", c)
} }
sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName) sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName)
if err != nil { if err != nil {
return nil, 0, 0, err log.Fatalln(err)
} }
err = sql.PrepareDatabase(context.Background()) err = sql.PrepareDatabase(context.Background())
if err != nil { if err != nil {
return nil, 0, 0, err log.Fatalln(err)
} }
latest, run, err := sql.RunMigrations(context.Background()) latest, run, err := sql.RunMigrations(context.Background())
if err != nil { if err != nil {
return nil, 0, 0, err log.Fatalln(err)
} }
return sql, latest, run, nil log.Printf("latest migration: %d; migrations run: %d", latest, run)
} lib = sql
}
func start(servers chan (*http.Server)) func(*http.Server, net.Listener, error) error { discogsCache, err := database.NewDiscogsCache(c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile)
return func(s *http.Server, l net.Listener, err error) error {
if err != nil { if err != nil {
return err log.Fatalln(err)
}
servers <- s
return s.Serve(l)
} }
errGroup := errgroup.Group{}
errGroup.Go(func() error {
return publicListener(8080, &Router{
static: f,
lib: lib,
rcol: discogsCache,
})
})
errGroup.Go(func() error {
f, _ := frontend.AdminRoot()
return tailscaleListener("library-admin", &AdminRouter{
static: f,
lib: lib,
})
})
log.Println(errGroup.Wait())
} }
func shutdown(servers chan (*http.Server)) error { func publicListener(port int, handler http.Handler) error {
sigint := make(chan os.Signal, 1) log.Printf("Listening on http://0.0.0.0:%d/", port)
signal.Notify(sigint, os.Interrupt) return http.ListenAndServe(fmt.Sprintf(":%d", port), handler)
<-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) { func tailscaleListener(hostname string, handler *AdminRouter) 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])