[add] opds search, [fix] opds urls, [add] log level env var
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Evan Reichard 2023-11-25 18:38:18 -05:00
parent c3410b7833
commit ca1cce1ff1
8 changed files with 250 additions and 131 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
TODO.md
.DS_Store .DS_Store
data/ data/
build/ build/

View File

@ -88,6 +88,7 @@ The service is now accessible at: `http://localhost:8585`. I recommend registeri
| CONFIG_PATH | /config | Directory where to store SQLite's DB | | CONFIG_PATH | /config | Directory where to store SQLite's DB |
| DATA_PATH | /data | Directory where to store the documents and cover metadata | | DATA_PATH | /data | Directory where to store the documents and cover metadata |
| LISTEN_PORT | 8585 | Port the server listens at | | LISTEN_PORT | 8585 | Port the server listens at |
| LOG_LEVEL | info | Set server log level |
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) | | REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
| COOKIE_SESSION_KEY | <EMPTY> | Optional secret cookie session key (auto generated if not provided) | | COOKIE_SESSION_KEY | <EMPTY> | Optional secret cookie session key (auto generated if not provided) |
| COOKIE_SECURE | true | Set Cookie `Secure` attribute (i.e. only works over HTTPS) | | COOKIE_SECURE | true | Set Cookie `Secure` attribute (i.e. only works over HTTPS) |

View File

@ -163,11 +163,12 @@ func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) {
opdsGroup := apiGroup.Group("/opds") opdsGroup := apiGroup.Group("/opds")
// OPDS Routes // OPDS Routes
opdsGroup.GET("", api.authOPDSMiddleware, api.opdsDocuments) opdsGroup.GET("", api.authOPDSMiddleware, api.opdsEntry)
opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsDocuments) opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsEntry)
opdsGroup.GET("/search.xml", api.authOPDSMiddleware, api.opdsSearchDescription)
opdsGroup.GET("/documents", api.authOPDSMiddleware, api.opdsDocuments)
opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.getDocumentCover) opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.getDocumentCover)
opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.downloadDocument) opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.downloadDocument)
opdsGroup.GET("/search.xml", api.authOPDSMiddleware, api.opdsSearchDescription)
} }
func generateToken(n int) ([]byte, error) { func generateToken(n int) ([]byte, error) {

View File

@ -33,7 +33,7 @@ type queryParams struct {
type searchParams struct { type searchParams struct {
Query *string `form:"query"` Query *string `form:"query"`
BookType *string `form:"book_type"` Source *search.Source `form:"source"`
} }
type requestDocumentUpload struct { type requestDocumentUpload struct {
@ -64,10 +64,10 @@ type requestSettingsEdit struct {
} }
type requestDocumentAdd struct { type requestDocumentAdd struct {
ID *string `form:"id"` ID string `form:"id"`
Title *string `form:"title"` Title *string `form:"title"`
Author *string `form:"author"` Author *string `form:"author"`
BookType *string `form:"book_type"` Source search.Source `form:"source"`
} }
func (api *API) webManifest(c *gin.Context) { func (api *API) webManifest(c *gin.Context) {
@ -240,25 +240,18 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
c.BindQuery(&sParams) c.BindQuery(&sParams)
// Only Handle Query // Only Handle Query
if sParams.BookType != nil && !slices.Contains([]string{"NON_FICTION", "FICTION"}, *sParams.BookType) { if sParams.Query != nil && sParams.Source != nil {
templateVars["SearchErrorMessage"] = "Invalid Book Type"
} else if sParams.Query != nil && *sParams.Query == "" {
templateVars["SearchErrorMessage"] = "Invalid Query"
} else if sParams.BookType != nil && sParams.Query != nil {
var bType search.BookType = search.BOOK_FICTION
if *sParams.BookType == "NON_FICTION" {
bType = search.BOOK_NON_FICTION
}
// Search // Search
searchResults, err := search.SearchBook(*sParams.Query, bType) searchResults, err := search.SearchBook(*sParams.Query, *sParams.Source)
if err != nil { if err != nil {
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err)) errorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
return return
} }
templateVars["Data"] = searchResults templateVars["Data"] = searchResults
templateVars["BookType"] = *sParams.BookType templateVars["Source"] = *sParams.Source
} else if sParams.Query != nil || sParams.Source != nil {
templateVars["SearchErrorMessage"] = "Invalid Query"
} }
} else if routeName == "login" { } else if routeName == "login" {
templateVars["RegistrationEnabled"] = api.Config.RegistrationEnabled templateVars["RegistrationEnabled"] = api.Config.RegistrationEnabled
@ -762,23 +755,8 @@ func (api *API) saveNewDocument(c *gin.Context) {
return return
} }
// Validate Form Exists
if rDocAdd.ID == nil ||
rDocAdd.BookType == nil ||
rDocAdd.Title == nil ||
rDocAdd.Author == nil {
log.Error("[saveNewDocument] Missing Form Values")
errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
return
}
var bType search.BookType = search.BOOK_FICTION
if *rDocAdd.BookType == "NON_FICTION" {
bType = search.BOOK_NON_FICTION
}
// Save Book // Save Book
tempFilePath, err := search.SaveBook(*rDocAdd.ID, bType) tempFilePath, err := search.SaveBook(rDocAdd.ID, rDocAdd.Source)
if err != nil { if err != nil {
log.Warn("[saveNewDocument] Temp File Error: ", err) log.Warn("[saveNewDocument] Temp File Error: ", err)
errorPage(c, http.StatusInternalServerError, "Unable to save file.") errorPage(c, http.StatusInternalServerError, "Unable to save file.")

View File

@ -26,6 +26,40 @@ var mimeMapping map[string]string = map[string]string{
"lit": "application/x-ms-reader", "lit": "application/x-ms-reader",
} }
func (api *API) opdsEntry(c *gin.Context) {
// Build & Return XML
mainFeed := &opds.Feed{
Title: "AnthoLume OPDS Server",
Updated: time.Now().UTC(),
Links: []opds.Link{
{
Title: "Search AnthoLume",
Rel: "search",
TypeLink: "application/opensearchdescription+xml",
Href: "/api/opds/search.xml",
},
},
Entries: []opds.Entry{
{
Title: "AnthoLume - All Documents",
Content: &opds.Content{
Content: "AnthoLume - All Documents",
ContentType: "text",
},
Links: []opds.Link{
{
Href: "/api/opds/documents?limit=100",
TypeLink: "application/atom+xml;type=feed;profile=opds-catalog",
},
},
},
},
}
c.XML(http.StatusOK, mainFeed)
}
func (api *API) opdsDocuments(c *gin.Context) { func (api *API) opdsDocuments(c *gin.Context) {
var userID string var userID string
if rUser, _ := c.Get("AuthorizedUser"); rUser != nil { if rUser, _ := c.Get("AuthorizedUser"); rUser != nil {
@ -35,9 +69,17 @@ func (api *API) opdsDocuments(c *gin.Context) {
// Potential URL Parameters // Potential URL Parameters
qParams := bindQueryParams(c) qParams := bindQueryParams(c)
// Possible Query
var query *string
if qParams.Search != nil && *qParams.Search != "" {
search := "%" + *qParams.Search + "%"
query = &search
}
// Get Documents // Get Documents
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{ documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
UserID: userID, UserID: userID,
Query: query,
Offset: (*qParams.Page - 1) * *qParams.Limit, Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit, Limit: *qParams.Limit,
}) })
@ -71,7 +113,7 @@ func (api *API) opdsDocuments(c *gin.Context) {
} }
item := opds.Entry{ item := opds.Entry{
Title: fmt.Sprintf("[%3d%%] %s", int(doc.Percentage), title), Title: title,
Author: []opds.Author{ Author: []opds.Author{
{ {
Name: author, Name: author,
@ -84,12 +126,12 @@ func (api *API) opdsDocuments(c *gin.Context) {
Links: []opds.Link{ Links: []opds.Link{
{ {
Rel: "http://opds-spec.org/acquisition", Rel: "http://opds-spec.org/acquisition",
Href: fmt.Sprintf("./documents/%s/file", doc.ID), Href: fmt.Sprintf("/api/opds/documents/%s/file", doc.ID),
TypeLink: mimeMapping[fileType], TypeLink: mimeMapping[fileType],
}, },
{ {
Rel: "http://opds-spec.org/image", Rel: "http://opds-spec.org/image",
Href: fmt.Sprintf("./documents/%s/cover", doc.ID), Href: fmt.Sprintf("/api/opds/documents/%s/cover", doc.ID),
TypeLink: "image/jpeg", TypeLink: "image/jpeg",
}, },
}, },
@ -99,19 +141,15 @@ func (api *API) opdsDocuments(c *gin.Context) {
} }
} }
feedTitle := "All Documents"
if query != nil {
feedTitle = "Search Results"
}
// Build & Return XML // Build & Return XML
searchFeed := &opds.Feed{ searchFeed := &opds.Feed{
Title: "All Documents", Title: feedTitle,
Updated: time.Now().UTC(), Updated: time.Now().UTC(),
// TODO
// Links: []opds.Link{
// {
// Title: "Search AnthoLume",
// Rel: "search",
// TypeLink: "application/opensearchdescription+xml",
// Href: "search.xml",
// },
// },
Entries: allEntries, Entries: allEntries,
} }
@ -122,7 +160,7 @@ func (api *API) opdsSearchDescription(c *gin.Context) {
rawXML := `<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"> rawXML := `<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>Search AnthoLume</ShortName> <ShortName>Search AnthoLume</ShortName>
<Description>Search AnthoLume</Description> <Description>Search AnthoLume</Description>
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="./search?query={searchTerms}"/> <Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="/api/opds/documents?limit=100&search={searchTerms}"/>
</OpenSearchDescription>` </OpenSearchDescription>`
c.Data(http.StatusOK, "application/xml", []byte(rawXML)) c.Data(http.StatusOK, "application/xml", []byte(rawXML))
} }

View File

@ -3,6 +3,8 @@ package config
import ( import (
"os" "os"
"strings" "strings"
log "github.com/sirupsen/logrus"
) )
type Config struct { type Config struct {
@ -22,6 +24,7 @@ type Config struct {
RegistrationEnabled bool RegistrationEnabled bool
SearchEnabled bool SearchEnabled bool
DemoMode bool DemoMode bool
LogLevel string
// Cookie Settings // Cookie Settings
CookieSessionKey string CookieSessionKey string
@ -30,7 +33,7 @@ type Config struct {
} }
func Load() *Config { func Load() *Config {
return &Config{ c := &Config{
Version: "0.0.1", Version: "0.0.1",
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")), DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")), DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")),
@ -41,9 +44,19 @@ func Load() *Config {
DemoMode: trimLowerString(getEnv("DEMO_MODE", "false")) == "true", DemoMode: trimLowerString(getEnv("DEMO_MODE", "false")) == "true",
SearchEnabled: trimLowerString(getEnv("SEARCH_ENABLED", "false")) == "true", SearchEnabled: trimLowerString(getEnv("SEARCH_ENABLED", "false")) == "true",
CookieSessionKey: trimLowerString(getEnv("COOKIE_SESSION_KEY", "")), CookieSessionKey: trimLowerString(getEnv("COOKIE_SESSION_KEY", "")),
LogLevel: trimLowerString(getEnv("LOG_LEVEL", "info")),
CookieSecure: trimLowerString(getEnv("COOKIE_SECURE", "true")) == "true", CookieSecure: trimLowerString(getEnv("COOKIE_SECURE", "true")) == "true",
CookieHTTPOnly: trimLowerString(getEnv("COOKIE_HTTP_ONLY", "true")) == "true", CookieHTTPOnly: trimLowerString(getEnv("COOKIE_HTTP_ONLY", "true")) == "true",
} }
// Log Level
ll, err := log.ParseLevel(c.LogLevel)
if err != nil {
ll = log.InfoLevel
}
log.SetLevel(ll)
return c
} }
func getEnv(key, fallback string) string { func getEnv(key, fallback string) string {

View File

@ -2,6 +2,7 @@ package search
import ( import (
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@ -16,8 +17,8 @@ import (
type Cadence string type Cadence string
const ( const (
TOP_YEAR Cadence = "y" CADENCE_TOP_YEAR Cadence = "y"
TOP_MONTH Cadence = "m" CADENCE_TOP_MONTH Cadence = "m"
) )
type BookType int type BookType int
@ -27,6 +28,14 @@ const (
BOOK_NON_FICTION BOOK_NON_FICTION
) )
type Source string
const (
SOURCE_ANNAS_ARCHIVE Source = "Annas Archive"
SOURCE_LIBGEN_FICTION Source = "LibGen Fiction"
SOURCE_LIBGEN_NON_FICTION Source = "LibGen Non-fiction"
)
type SearchItem struct { type SearchItem struct {
ID string ID string
Title string Title string
@ -38,26 +47,89 @@ type SearchItem struct {
UploadDate string UploadDate string
} }
func SearchBook(query string, bookType BookType) ([]SearchItem, error) { type sourceDef struct {
if bookType == BOOK_FICTION { searchURL string
// Search Fiction downloadURL string
url := "https://libgen.is/fiction/?q=" + url.QueryEscape(query) + "&language=English&format=epub" parseSearchFunc func(io.ReadCloser) ([]SearchItem, error)
parseDownloadFunc func(io.ReadCloser) (string, error)
}
var sourceDefs = map[Source]sourceDef{
SOURCE_ANNAS_ARCHIVE: {
searchURL: "https://annas-archive.org/search?index=&q=%s&ext=epub&sort=&lang=en",
downloadURL: "http://libgen.li/ads.php?md5=%s",
parseSearchFunc: parseAnnasArchive,
parseDownloadFunc: parseAnnasArchiveDownloadURL,
},
SOURCE_LIBGEN_FICTION: {
searchURL: "https://libgen.is/fiction/?q=%s&language=English&format=epub",
downloadURL: "http://library.lol/fiction/%s",
parseSearchFunc: parseLibGenFiction,
parseDownloadFunc: parseLibGenDownloadURL,
},
SOURCE_LIBGEN_NON_FICTION: {
searchURL: "https://libgen.is/search.php?req=%s",
downloadURL: "http://library.lol/main/%s",
parseSearchFunc: parseLibGenNonFiction,
parseDownloadFunc: parseLibGenDownloadURL,
},
}
func SearchBook(query string, source Source) ([]SearchItem, error) {
def := sourceDefs[source]
log.Debug("[SearchBook] Source: ", def)
url := fmt.Sprintf(def.searchURL, url.QueryEscape(query))
body, err := getPage(url) body, err := getPage(url)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return parseLibGenFiction(body) return def.parseSearchFunc(body)
} else if bookType == BOOK_NON_FICTION { }
// Search NonFiction
url := "https://libgen.is/search.php?req=" + url.QueryEscape(query) func SaveBook(id string, source Source) (string, error) {
def := sourceDefs[source]
log.Debug("[SaveBook] Source: ", def)
url := fmt.Sprintf(def.downloadURL, id)
body, err := getPage(url) body, err := getPage(url)
if err != nil { if err != nil {
return nil, err return "", err
} }
return parseLibGenNonFiction(body)
} else { bookURL, err := def.parseDownloadFunc(body)
return nil, errors.New("Invalid Book Type") if err != nil {
log.Error("[SaveBook] Parse Download URL Error: ", err)
return "", errors.New("Download Failure")
} }
// Create File
tempFile, err := os.CreateTemp("", "book")
if err != nil {
log.Error("[SaveBook] File Create Error: ", err)
return "", errors.New("File Failure")
}
defer tempFile.Close()
// Download File
log.Info("[SaveBook] Downloading Book: ", bookURL)
resp, err := http.Get(bookURL)
if err != nil {
os.Remove(tempFile.Name())
log.Error("[SaveBook] Cover URL API Failure")
return "", errors.New("API Failure")
}
defer resp.Body.Close()
// Copy File to Disk
log.Info("[SaveBook] Saving Book")
_, err = io.Copy(tempFile, resp.Body)
if err != nil {
os.Remove(tempFile.Name())
log.Error("[SaveBook] File Copy Error")
return "", errors.New("File Failure")
}
return tempFile.Name(), nil
} }
func GoodReadsMostRead(c Cadence) ([]SearchItem, error) { func GoodReadsMostRead(c Cadence) ([]SearchItem, error) {
@ -87,57 +159,9 @@ func GetBookURL(id string, bookType BookType) (string, error) {
return parseLibGenDownloadURL(body) return parseLibGenDownloadURL(body)
} }
func SaveBook(id string, bookType BookType) (string, error) {
// Derive Info URL
var infoURL string
if bookType == BOOK_FICTION {
infoURL = "http://library.lol/fiction/" + id
} else if bookType == BOOK_NON_FICTION {
infoURL = "http://library.lol/main/" + id
}
// Parse & Derive Download URL
body, err := getPage(infoURL)
if err != nil {
return "", err
}
bookURL, err := parseLibGenDownloadURL(body)
if err != nil {
log.Error("[SaveBook] Parse Download URL Error: ", err)
return "", errors.New("Download Failure")
}
// Create File
tempFile, err := os.CreateTemp("", "book")
if err != nil {
log.Error("[SaveBook] File Create Error: ", err)
return "", errors.New("File Failure")
}
defer tempFile.Close()
// Download File
log.Info("[SaveBook] Downloading Book")
resp, err := http.Get(bookURL)
if err != nil {
os.Remove(tempFile.Name())
log.Error("[SaveBook] Cover URL API Failure")
return "", errors.New("API Failure")
}
defer resp.Body.Close()
// Copy File to Disk
log.Info("[SaveBook] Saving Book")
_, err = io.Copy(tempFile, resp.Body)
if err != nil {
os.Remove(tempFile.Name())
log.Error("[SaveBook] File Copy Error")
return "", errors.New("File Failure")
}
return tempFile.Name(), nil
}
func getPage(page string) (io.ReadCloser, error) { func getPage(page string) (io.ReadCloser, error) {
log.Debug("[getPage] ", page)
// Set 10s Timeout // Set 10s Timeout
client := http.Client{ client := http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
@ -292,3 +316,66 @@ func parseGoodReads(body io.ReadCloser) ([]SearchItem, error) {
// Return Results // Return Results
return allEntries, nil return allEntries, nil
} }
func parseAnnasArchiveDownloadURL(body io.ReadCloser) (string, error) {
// Parse
defer body.Close()
doc, _ := goquery.NewDocumentFromReader(body)
// Return Download URL
downloadURL, exists := doc.Find("body > table > tbody > tr > td > a").Attr("href")
if exists == false {
return "", errors.New("Download URL not found")
}
return "http://libgen.li/" + downloadURL, nil
}
func parseAnnasArchive(body io.ReadCloser) ([]SearchItem, error) {
// Parse
defer body.Close()
doc, err := goquery.NewDocumentFromReader(body)
if err != nil {
return nil, err
}
// Normalize Results
var allEntries []SearchItem
doc.Find("form > div.w-full > div.w-full > div > div.justify-center").Each(func(ix int, rawBook *goquery.Selection) {
// Parse Details
details := rawBook.Find("div:nth-child(2) > div:nth-child(1)").Text()
detailsSplit := strings.Split(details, ", ")
// Invalid Details
if len(detailsSplit) < 3 {
return
}
language := detailsSplit[0]
fileType := detailsSplit[1]
fileSize := detailsSplit[2]
// Get Title & Author
title := rawBook.Find("h3").Text()
author := rawBook.Find("div:nth-child(2) > div:nth-child(4)").Text()
// Parse MD5
itemHref, _ := rawBook.Find("a").Attr("href")
hrefArray := strings.Split(itemHref, "/")
id := hrefArray[len(hrefArray)-1]
item := SearchItem{
ID: id,
Title: title,
Author: author,
Language: language,
FileType: fileType,
FileSize: fileSize,
}
allEntries = append(allEntries, item)
})
// Return Results
return allEntries, nil
}

View File

@ -36,7 +36,6 @@
/> />
</div> </div>
</div> </div>
<div class="flex relative min-w-[12em]"> <div class="flex relative min-w-[12em]">
<span <span
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm" class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
@ -61,11 +60,12 @@
</span> </span>
<select <select
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent" class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
id="book_type" id="source"
name="book_type" name="source"
> >
<option value="FICTION">Fiction</option> <option value="Annas Archive">Annas Archive</option>
<option value="NON_FICTION">Non-Fiction</option> <option value="LibGen Fiction">LibGen Fiction</option>
<option value="LibGen Non-fiction">LibGen Non-fiction</option>
</select> </select>
</div> </div>
@ -136,9 +136,9 @@
<input <input
class="hidden" class="hidden"
type="text" type="text"
id="book_type" id="source"
name="book_type" name="source"
value="{{ $.BookType }}" value="{{ $.Source }}"
/> />
<input <input
class="hidden" class="hidden"