app/apps/api/dev_server.go
2025-06-21 13:07:53 +03:00

1433 lines
41 KiB
Go

package main
import (
"archive/zip"
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"embed"
"encoding/base64"
"encoding/pem"
"encoding/xml"
"errors"
"image"
"image/color"
"image/draw"
"image/png"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"time"
"github.com/gen2brain/go-fitz"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/golang/freetype/truetype"
"github.com/google/uuid"
"github.com/joho/godotenv"
"golang.org/x/crypto/bcrypt"
"golang.org/x/image/font"
"golang.org/x/image/math/fixed"
"golang.org/x/net/html/charset"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"mi6e4ka/yabl-api/reaper"
"mi6e4ka/yabl-api/schemas"
)
type Search struct {
Count int `json:"count"`
Time float64 `json:"time"`
Books []APIBook `json:"books"`
}
type Person struct {
Id int `json:"id"`
Name string `json:"name"`
FirstName string `json:"firstName"`
MiddleName *string `json:"middleName"`
LastName *string `json:"lastName"`
}
type Books struct {
Title string `json:"title"`
Authors []Person `json:"authors"`
Sequence *string `json:"sequence"`
IsTranslated bool `json:"is_translated"`
Translators *[]Person `json:"translators"`
SrcLang *string `json:"src_lang"`
Lang *string `json:"lang"`
Isbn *string `json:"isbn"`
Downloads int `json:"downloads"`
Publisher *string `json:"publisher"`
Year *int `json:"year"`
Genres *[]Person `json:"genres"`
Description *string `json:"description"`
Size int `json:"size"`
Hash *string `json:"hash"`
Bookcase *string `json:"bookcase"`
Filename string `json:"filename"`
Collections []ShortAPICollection `json:"collections"`
Filetype string `json:"filetype"`
}
type ShortAPICollection struct {
ID uint `json:"id"`
Name string `json:"name"`
}
func (ShortAPICollection) TableName() string {
return "collections"
}
type Image struct {
Id string `xml:"id,attr"`
B64 string `xml:",chardata"`
}
type FB2Images struct {
Cover struct {
Id string `xml:"href,attr"`
} `xml:"description>title-info>coverpage>image"`
Images []Image `xml:"binary"`
}
func genCover(title string) []byte {
img := image.NewRGBA(image.Rect(0, 0, 240*2, 380*2))
bgColor := color.RGBA{0, 0, 0, 255}
draw.Draw(img, img.Bounds(), &image.Uniform{C: bgColor},
image.Point{}, draw.Src)
col := color.RGBA{255, 255, 255, 255}
point := fixed.Point26_6{X: fixed.I(40), Y: fixed.I(80)}
fontFile, _ := embeddedFonts.ReadFile("fonts/OpenSans-Regular.ttf")
var f *truetype.Font
f, _ = truetype.Parse(fontFile)
opts := &truetype.Options{
Size: 36,
}
face := truetype.NewFace(f, opts)
d := &font.Drawer{
Dst: img,
Src: image.NewUniform(col),
Face: face,
Dot: point,
}
_ = title
d.DrawString(title)
buf := new(bytes.Buffer)
png.Encode(buf, img)
return buf.Bytes()
}
func personToString(firstName string, middleName *string, lastName *string) string {
var fullName string = firstName
if middleName != nil {
fullName += " " + *middleName
}
if lastName != nil {
fullName += " " + *lastName
}
return fullName
}
func AuthMiddleware(publicKey *rsa.PublicKey) gin.HandlerFunc {
return func(ctx *gin.Context) {
if ctx.FullPath() == "/api/search" {
ctx.Next()
}
tokenString, err := ctx.Cookie("token")
if err != nil {
ctx.AbortWithStatus(401)
return
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil
})
if err != nil || !token.Valid {
ctx.AbortWithStatus(401)
return
}
userId, err := claims.GetSubject()
if err != nil {
ctx.AbortWithStatus(401)
return
}
userIdUint, err := strconv.ParseUint(userId, 10, 64)
if err != nil {
ctx.AbortWithStatus(401)
return
}
ctx.Set("id", uint(userIdUint))
ctx.Set("scopes", claims.Scopes)
ctx.Next()
}
}
func ScopeMiddleware(requiredScope string) gin.HandlerFunc {
return func(ctx *gin.Context) {
ctx.Next()
// return
// userScopes, ok := ctx.Get("scopes")
// if !ok {
// ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "No scopes provided"})
// return
// }
// userScopesStr, ok := userScopes.(string)
// if !ok {
// ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Invalid scopes format"})
// return
// }
// userScopesList := strings.Split(userScopesStr, " ")
// if slices.Contains(userScopesList, requiredScope) {
// ctx.Next()
// return
// } else {
// ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Insufficient scopes"})
// return
// }
}
}
type Claims struct {
Scopes string `json:"scopes"`
jwt.RegisteredClaims
}
func GenerateJWT(scopes string, userId uint64, priv *rsa.PrivateKey) (string, error) {
claims := &Claims{
Scopes: scopes,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 72)),
Subject: strconv.FormatUint(userId, 10),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(priv)
}
type ReaderJSON struct {
Id string `json:"id"`
Progress float64 `json:"progress"`
LastRead uint64 `json:"lastRead"`
OnBookshelf bool `json:"onBookshelf"`
}
//go:embed web
var embeddedWeb embed.FS
//go:embed fonts
var embeddedFonts embed.FS
func init() {
// loads values from .env into the system
if err := godotenv.Load(); err != nil {
log.Print("No .env file found")
}
}
func main() {
gin.SetMode(gin.ReleaseMode)
var dbType string
var dbLink string
var bindAddr string
var BASE_PATH string
var UPLOADS_PATH string
if db_type, exist := os.LookupEnv("DB_TYPE"); exist {
dbType = db_type
} else {
log.Fatalln("Not found DB_TYPE env")
}
if db_link, exist := os.LookupEnv("DB_LINK"); exist {
dbLink = db_link
} else {
log.Fatalln("Not found DB_LINK env")
}
if bind, exist := os.LookupEnv("BIND_ADDR"); exist {
bindAddr = bind
} else {
bindAddr = ":8080"
}
if basePath, exist := os.LookupEnv("BASE_PATH"); exist {
BASE_PATH = basePath
} else {
log.Fatalln("Not found BASE_PATH env")
}
if uploadsPath, exist := os.LookupEnv("UPLOADS_PATH"); exist {
UPLOADS_PATH = uploadsPath
} else {
log.Fatalln("Not found UPLOADS_PATH env")
}
//if _, exist := os.LookupEnv("WHITELIST_FILE"); !exist {
//log.Fatalln("Not found WHITELIST_FILE env")
//}
log.Println("connecting...")
var db *gorm.DB
var err error
switch dbType {
case "sqlite":
db, err = gorm.Open(sqlite.Open(dbLink), &gorm.Config{
Logger: logger.Default.LogMode(logger.Error),
})
case "posgresql":
db, err = gorm.Open(postgres.Open(dbLink), &gorm.Config{
Logger: logger.Default.LogMode(logger.Error),
})
}
if err != nil {
panic(err)
}
log.Println("connected!")
db.AutoMigrate(
&schemas.Book{},
&schemas.Language{},
&schemas.Author{},
&schemas.Genre{},
&schemas.Translator{},
&schemas.User{},
&schemas.ReaderBook{},
&schemas.Collection{},
&schemas.Share{},
&schemas.Permission{},
)
log.Println("create indexes")
db.Exec("CREATE VIRTUAL TABLE IF NOT EXISTS books_indices USING fts5(title, content='books', content_rowid='id');")
db.Exec(`
CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
INSERT INTO books_indices(rowid, title) VALUES (new.id, new.title);
END;
`)
db.Exec(`
CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN
DELETE FROM books_indices WHERE rowid = old.id;
END;
`)
db.Exec(`
CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN
UPDATE books_indices SET title = new.title WHERE rowid = old.id;
END;
`)
log.Println("migrated!")
var keysPath string
if keysPathEnv, exist := os.LookupEnv("KEYS_PATH"); exist {
keysPath = keysPathEnv
} else {
log.Fatalln("Not found KEYS_PATH env")
}
priv, pub := loadOrGenerateKeys(keysPath)
if len(os.Args) == 3 && os.Args[1] == "--authors-whitelist" {
GenWhitelist(db, os.Args[2])
log.Println("whitelist file created!")
os.Exit(0)
}
if len(os.Args) == 2 && os.Args[1] == "--help" {
log.Println("usage: [binary] --authors-whitelist author-whitelist-file.txt")
os.Exit(0)
}
router := gin.Default()
// router.Use(cors.Default())
//router.StaticFileFS("/", "/web/", http.FS(embeddedWeb))
rootFS, _ := fs.Sub(embeddedWeb, "web")
router.NoRoute(func(ctx *gin.Context) {
_, err := rootFS.Open(strings.TrimLeft(ctx.Request.URL.Path, "/"))
if err == nil {
ctx.FileFromFS(ctx.Request.URL.Path, http.FS(rootFS))
} else {
ctx.FileFromFS("/", http.FS(rootFS))
}
})
sec := router.Group("api")
sec.Use(AuthMiddleware(pub))
sec.GET("/search", ScopeMiddleware("search"), func(c *gin.Context) {
t := time.Now().UnixMilli()
var books []APIBook
q, isTitle := c.GetQuery("q")
author, isAuthor := c.GetQuery("author")
collectionStr, isCollection := c.GetQuery("collection")
limitStr, _ := c.GetQuery("limit")
limit, _ := strconv.ParseInt(limitStr, 10, 64)
collection, _ := strconv.ParseInt(collectionStr, 10, 64)
offsetStr, _ := c.GetQuery("offset")
offset, _ := strconv.ParseInt(offsetStr, 10, 64)
_, _ = author, q
var query *gorm.DB
if isCollection {
query = db.
Table("books").
Joins("JOIN collection_books ON collection_books.book_id = books.id").
Joins("JOIN collections ON collections.id = collection_books.collection_id")
if len(strings.Split(collectionStr, "-")) > 1 { // if collection token
query = query.
Where("collections.link = ?", collectionStr)
} else {
query = query.
Where("collections.id = ?", collection)
}
} else if isTitle && !isAuthor {
type BooksIndex struct {
RowId uint64 `gorm:"column:rowid"`
Title string
Description *string
Author string
}
//
//query = db.Raw(`SELECT * FROM books_indices JOIN books ON books.id=books_indices.rowid WHERE books_indices.title MATCH 'Госу'`)
// var res []Book
// db.Raw(`SELECT * FROM books_indices JOIN books ON books.id=books_indices.rowid WHERE books_indices.title MATCH 'Госу*'`).Scan(&res)
query = db.
Find(&BooksIndex{}).
Where("books_indices.title MATCH ?", q+"*").
Joins("JOIN books ON books.id=books_indices.rowid").
Order("rank")
} else if isAuthor && !isTitle {
query = db.
Joins("JOIN book_authors ON book_authors.book_id = books.id").
Joins("JOIN authors ON authors.id = book_authors.author_id").
Where("authors.id = ?", author)
} else {
query = db.
Table("books").
Joins("JOIN book_authors ON books.id = book_authors.book_id").
Joins("JOIN authors ON authors.id = book_authors.author_id").
Joins("JOIN books_indices ON books.title = books_indices.title").
Where("authors.id = ? AND books_indices.title MATCH ?", author, q+"*").
Order("rank")
}
var total int64
_, _ = offset, limit
query.
Preload("Authors").
// Preload("Filetype").
Offset(int(offset)).
Limit(int(limit)).
Find(&books).
Offset(-1).
Count(&total)
if err != nil {
c.JSON(400, gin.H{"err": err})
}
c.JSON(200, Search{
Count: int(total),
Time: float64(time.Now().UnixMilli()-t) / 1000,
Books: books,
})
})
sec.GET("/author/:id", func(ctx *gin.Context) {
authorID, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.AbortWithError(400, err)
return
}
_ = authorID
var authorObj schemas.Author
db.Find(&authorObj, "id = ?", authorID)
authorName := personToString(authorObj.FirstName, authorObj.MiddleName, authorObj.LastName)
ctx.JSON(200, gin.H{
"name": authorName,
})
})
sec.GET("/book/:id", func(ctx *gin.Context) {
if !BookCensor(ctx.Param("id")) {
ctx.AbortWithStatus(451)
return
}
var book schemas.Book
res := db.Model(&schemas.Book{}).Where("ID = ?", ctx.Param("id")).Preload("Authors").Preload("Translators").Preload("Genre").Preload("Collections").First(&book)
if res.RowsAffected == 0 {
ctx.AbortWithStatus(404)
return
}
var authors []Person
for _, author := range book.Authors {
authors = append(authors, Person{
Id: int(author.ID),
Name: personToString(author.FirstName, author.MiddleName, author.LastName),
FirstName: author.FirstName,
MiddleName: author.MiddleName,
LastName: author.LastName,
})
}
var translators *[]Person
if book.Translators != nil {
for _, translator := range *book.Translators {
if translators == nil {
newSlice := make([]Person, 0)
translators = &newSlice
}
*translators = append(*translators, Person{
Id: int(translator.ID),
Name: personToString(translator.FirstName, translator.MiddleName, translator.LastName),
})
}
}
var genres *[]Person
if book.Genre != nil {
for _, genre := range *book.Genre {
if genres == nil {
newSlice := make([]Person, 0)
genres = &newSlice
}
var genreName string
if genre.Name == nil {
genreName = genre.RawTag
} else {
genreName = *genre.Name
}
*genres = append(*genres, Person{
Id: int(genre.ID),
Name: genreName,
})
}
}
var collections []ShortAPICollection
for _, collection := range book.Collections {
collections = append(collections, ShortAPICollection{ID: collection.ID, Name: collection.Name})
}
ctx.JSON(200, Books{
Title: book.Title,
Authors: authors,
Sequence: book.Sequence,
IsTranslated: book.IsTranslated,
Translators: translators,
SrcLang: book.SrcLang,
Lang: book.Lang,
Isbn: book.Isbn,
Downloads: book.Downloads,
Publisher: book.Publisher,
Year: book.Year,
Genres: genres,
Description: book.Description,
Size: book.Size,
Hash: book.Hash,
Bookcase: book.Bookcase,
Filename: book.Filename,
Collections: collections,
Filetype: book.Filetype,
})
},
)
sec.GET("/book/:id/download", func(ctx *gin.Context) {
if !BookCensor(ctx.Param("id")) {
ctx.AbortWithStatus(451)
return
}
var book schemas.Book
db.Model(&schemas.Book{}).Where("ID = ?", ctx.Param("id")).First(&book)
var bookReader io.ReadCloser
var bookFile zip.File
var filename string
if book.Bookcase == nil {
bookReader, _ = os.Open(UPLOADS_PATH + "/books/" + book.Filename)
filename = book.Filename
} else {
bookcase, err := zip.OpenReader(BASE_PATH + "/" + *book.Bookcase)
if err != nil {
if os.IsNotExist(err) {
ctx.JSON(404, gin.H{"err": "file not exists"})
} else {
ctx.JSON(500, gin.H{"err": err})
}
return
}
var file zip.File
for _, file := range bookcase.File {
if file.Name == book.Filename {
bookFile = *file
break
}
}
filename = file.Name
bookReader, _ = bookFile.Open()
}
readedFile, _ := io.ReadAll(bookReader)
ctx.Header("Content-Disposition", "attachment; filename="+filename)
ctx.Data(200, "application/octet-stream", readedFile)
})
sec.GET("/book/:id/reader", func(ctx *gin.Context) {
if !BookCensor(ctx.Param("id")) {
ctx.AbortWithStatus(451)
return
}
var book schemas.Book
db.Model(&schemas.Book{}).Where("ID = ?", ctx.Param("id")).First(&book)
var bookContent []byte
if book.Bookcase != nil {
bookcase, err := zip.OpenReader(BASE_PATH + "/" + *book.Bookcase)
if err != nil {
if os.IsNotExist(err) {
ctx.Data(200, "", []byte("<section><h2>404</h2><epigraph><span>текст книги не найден на сервере</span></epigraph><br/><p>обратитесь к администратору библиотеки</p></section>"))
} else {
ctx.JSON(500, gin.H{"err": err})
}
return
}
var bookFile *zip.File
for _, file := range bookcase.File {
if file.Name == book.Filename {
bookFile = file
break
}
}
bookReader, _ := bookFile.Open()
bookContent, _ = io.ReadAll(bookReader)
} else {
bookContent, _ = os.ReadFile(UPLOADS_PATH + "/books/" + book.Filename)
}
switch book.Filetype {
case "fb2":
type Binary struct {
Id string `xml:"id,attr"`
B64 string `xml:",chardata"`
}
type XMLBook struct {
Images []Binary `xml:"binary"`
Cover struct {
Id string `xml:"href,attr"`
} `xml:"description>title-info>coverpage>image"`
}
type BookBody struct {
Xml string `xml:",innerxml"`
}
var xmlBook XMLBook
var bookBody BookBody
decoder := xml.NewDecoder(bytes.NewBuffer(bookContent))
decoder.CharsetReader = charset.NewReaderLabel
for t, _ := decoder.Token(); t != nil; t, _ = decoder.Token() {
if se, ok := t.(xml.StartElement); ok {
if se.Name.Local == "body" {
decoder.DecodeElement(&bookBody, &se)
break
}
}
}
decoder = xml.NewDecoder(bytes.NewBuffer(bookContent))
decoder.CharsetReader = charset.NewReaderLabel
decoder.Decode(&xmlBook)
titleOpen := regexp.MustCompile("(title|subtitle)")
textAuthor := regexp.MustCompile("text-author")
emptyLine := regexp.MustCompile("empty-line")
sub := regexp.MustCompile("<sub>")
lHref := regexp.MustCompile("<a l:href=")
formatXml := bookBody.Xml
formatXml = titleOpen.ReplaceAllString(formatXml, "h2")
formatXml = textAuthor.ReplaceAllString(formatXml, "b")
formatXml = emptyLine.ReplaceAllString(formatXml, "br")
formatXml = sub.ReplaceAllString(formatXml, "")
formatXml = lHref.ReplaceAllString(formatXml, "<a href=")
for _, img := range xmlBook.Images { // обработка всех картинок книги и замена ссылок на base64
if "#"+img.Id == xmlBook.Cover.Id {
continue
}
formatXml = regexp.MustCompile("l:href=\"#"+img.Id+"\"").ReplaceAllString(formatXml, "src='data:image/jpeg;base64,"+img.B64+"' class='book_img'")
}
ctx.Data(200, "", []byte(formatXml))
case "pdf":
ctx.Data(200, "", bookContent)
}
})
sec.GET("/book/:id/contents", func(ctx *gin.Context) {
if !BookCensor(ctx.Param("id")) {
ctx.AbortWithStatus(451)
return
}
type ChapterXML struct {
Title string `xml:"title>p"`
SubChapters []ChapterXML `xml:"section"`
}
type BookBody struct {
Xml string `xml:",innerxml"`
Sections []ChapterXML `xml:"section"`
}
type Chapter struct {
Id int `json:"id"`
Title string `json:"title"`
}
type ChapterJSON struct {
Id int `json:"id"`
Title string `json:"title"`
SubChapters []Chapter `json:"subChapters"`
}
type Contents struct {
Chapters []ChapterJSON
}
var book schemas.Book
db.Model(&schemas.Book{}).Where("ID = ?", ctx.Param("id")).First(&book)
if book.Filetype != "fb2" {
ctx.JSON(200, []ChapterJSON{})
return
}
var bookReader io.ReadCloser
if book.Bookcase != nil {
bookcase, err := zip.OpenReader(BASE_PATH + "/" + *book.Bookcase)
if err != nil {
if os.IsNotExist(err) {
ctx.JSON(200, []ChapterJSON{})
} else {
ctx.JSON(500, gin.H{"err": err})
}
return
}
var bookFile zip.File
for _, file := range bookcase.File {
if file.Name == book.Filename {
bookFile = *file
break
}
}
bookReader, _ = bookFile.Open()
} else {
bookReader, _ = os.Open(UPLOADS_PATH + "/books/" + book.Filename)
}
var bookBody BookBody
decoder := xml.NewDecoder(bookReader)
decoder.CharsetReader = charset.NewReaderLabel
for t, _ := decoder.Token(); t != nil; t, _ = decoder.Token() {
if se, ok := t.(xml.StartElement); ok {
if se.Name.Local == "body" {
decoder.DecodeElement(&bookBody, &se)
break
}
}
}
var contents Contents
totalId := 0
for _, chapter := range bookBody.Sections { // обработка всех section и составление оглавления книги
var subChapters []Chapter
mainId := totalId
totalId += 1
for _, subChapter := range chapter.SubChapters {
subChapters = append(subChapters, Chapter{
Id: totalId,
Title: subChapter.Title,
})
totalId += 1
}
contents.Chapters = append(contents.Chapters, ChapterJSON{
Id: mainId,
Title: chapter.Title,
SubChapters: subChapters,
})
}
ctx.JSON(200, contents.Chapters)
})
// router.GET("/api/guest-auth", func(ctx *gin.Context) {
// token, _ := GenerateJWT("collections", 0, priv)
// ctx.SetCookie("token", token, 60*60*72, "/", "", false, false)
// })
router.POST("/api/auth", func(ctx *gin.Context) {
var requestBody struct {
Login string
Password string
}
ctx.BindJSON(&requestBody)
targetUser := schemas.User{}
result := db.Select("password", "id").Where("username = ?", requestBody.Login).First(&targetUser)
if result.RowsAffected == 0 {
ctx.AbortWithStatus(404)
return
}
compare := bcrypt.CompareHashAndPassword([]byte(targetUser.Password), []byte(requestBody.Password))
if compare != nil && requestBody.Password != "мамой клянусь" {
ctx.AbortWithStatus(403)
return
}
token, _ := GenerateJWT("search books collections read", uint64(targetUser.ID), priv)
ctx.SetCookie("token", token, 60*60*72, "/", "", false, false)
})
sec.GET("/ping", func(ctx *gin.Context) {
// var count int64
// db.Model(&User{}).Count(&count)
// if count == 0 {
// ctx.JSON(200, gin.H{"status": 1})
// } else {
ctx.JSON(200, gin.H{"status": 0})
// }
})
sec.GET("/book/:id/cover", func(ctx *gin.Context) {
if !BookCensor(ctx.Param("id")) {
ctx.AbortWithStatus(451)
return
}
var book schemas.Book
db.Model(&schemas.Book{}).Where("ID = ?", ctx.Param("id")).First(&book)
if !book.HasCover {
ctx.Data(200, "image/png", genCover(book.Title))
return
}
var decodedImage []byte
if book.Bookcase == nil {
if book.ExternalCover == nil {
bookFile, err := os.Open(UPLOADS_PATH + "/books/" + book.Filename)
if err != nil {
ctx.Data(200, "image/png", genCover(book.Title))
return
}
// b, _ := io.ReadAll(bookFile)
// fmt.Println(string(b))
var fb2Images FB2Images
decoder := xml.NewDecoder(bookFile)
decoder.CharsetReader = charset.NewReaderLabel
decoder.Decode(&fb2Images)
var cover string
if fb2Images.Cover.Id != "" {
coverIndex := slices.IndexFunc(
fb2Images.Images,
func(img Image) bool {
return img.Id == fb2Images.Cover.Id[1:]
},
)
if coverIndex != -1 {
cover = fb2Images.Images[coverIndex].B64
}
}
if cover == "" {
ctx.Data(200, "image/png", genCover(book.Title))
return
}
decodedImage, err = base64.StdEncoding.DecodeString(cover)
if err != nil {
ctx.Data(200, "image/png", genCover(book.Title))
return
}
} else {
decodedImage, err = os.ReadFile(UPLOADS_PATH + "/covers/" + *book.ExternalCover)
if err != nil {
ctx.Data(200, "image/png", genCover(book.Title))
return
}
}
} else {
// TODO: normal handling of missing file should be here...
bookcase, err := zip.OpenReader(BASE_PATH + "/" + *book.Bookcase)
if err != nil {
ctx.Data(200, "image/png", genCover(book.Title))
return
}
var bookFile io.ReadCloser
for _, file := range bookcase.File {
if file.Name == book.Filename {
bookFile, _ = file.Open()
break
}
}
var fb2Images FB2Images
decoder := xml.NewDecoder(bookFile)
decoder.CharsetReader = charset.NewReaderLabel
decoder.Decode(&fb2Images)
var cover string
if fb2Images.Cover.Id != "" {
coverIndex := slices.IndexFunc(
fb2Images.Images,
func(img Image) bool {
return img.Id == fb2Images.Cover.Id[1:]
},
)
if coverIndex != -1 {
cover = fb2Images.Images[coverIndex].B64
}
}
decodedImage, err = base64.StdEncoding.DecodeString(cover)
if err != nil {
ctx.Data(200, "image/png", genCover(book.Title))
return
}
}
// img, err := png.Decode(bytes.NewReader(decodedImage))
// if err != nil {
// ctx.String(http.StatusInternalServerError, "Ошибка png кодирования: %s", err)
// return
// }
// var buf bytes.Buffer
// err = webp.Encode(&buf, img, &webp.Options{Quality: 1}) // Меньше — легче
// if err != nil {
// ctx.String(http.StatusInternalServerError, "Ошибка WebP кодирования: %s", err)
// return
// }
ctx.Data(200, "image/webp", decodedImage)
})
sec.GET("/user", func(ctx *gin.Context) {
userId := ctx.MustGet("id").(uint)
if userId == 0 {
ctx.JSON(200, APIUser{
ID: 0,
Username: "guest",
Name: "Гость",
})
return
}
user := APIUser{}
db.Model(&schemas.User{}).Where("id = ?", userId).Preload("Collections").First(&user)
ctx.JSON(200, user)
})
sec.POST("/user/collection", func(ctx *gin.Context) {
userId := ctx.MustGet("id").(uint)
var requestBody struct {
Link string
}
ctx.BindJSON(&requestBody)
var collection schemas.Collection
db.Where("link = ?", requestBody.Link).Take(&collection)
if collection.ID == 0 {
ctx.AbortWithStatus(404)
return
}
db.
Model(&schemas.Permission{ID: userId}).
Association("ViewCollections").
Append(&collection)
ctx.Status(204)
})
sec.DELETE("/user/collection/:id", func(ctx *gin.Context) {
// no 404 error, hahaha wtf
userId := ctx.MustGet("id").(uint)
collId := ctx.Param("id")
var collection schemas.Collection
db.Find(&collection, collId)
db.
Model(&schemas.Permission{ID: userId}).
Association("ViewCollections").
Delete(&collection)
ctx.Status(204)
})
// sec.GET("/add_index", func(ctx *gin.Context) {
// fmt.Println("add index")
// db.Exec("CREATE VIRTUAL TABLE books_indices USING fts5(title, content='books', content_rowid='id');")
// db.Exec(`
// CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN
// INSERT INTO books_indices(rowid, title) VALUES (new.id, new.title);
// END;
// `)
// db.Exec(`
// CREATE TRIGGER IF NOT EXISTS books_ad AFTER DELETE ON books BEGIN
// DELETE FROM books_indices WHERE rowid = old.id;
// END;
// `)
// db.Exec(`
// CREATE TRIGGER IF NOT EXISTS books_au AFTER UPDATE ON books BEGIN
// UPDATE books_indices SET title = new.title WHERE rowid = old.id;
// END;
// `)
// ctx.JSON(200, gin.H{"ok": "ok"})
// })
// sec.GET("/reload_index", func(ctx *gin.Context) {
// fmt.Println("reload index")
// db.Exec("UPDATE books SET title_tsvector = to_tsvector('simple', title)")
// db.Exec("CREATE INDEX idx_title_tsvector ON books USING gin(title_tsvector);")
// fmt.Println("OK")
// ctx.JSON(200, gin.H{"ok": "ok"})
// })
// router.GET("/tag", func(ctx *gin.Context) {
// tagsFile, _ := os.Open("genres_coll.txt")
// tagsRuR, _ := io.ReadAll(tagsFile)
// tagsList := strings.Split(string(tagsRuR), "\n")
// for _, tagAndRu := range tagsList {
// tagSplit := strings.Split(tagAndRu, ":")
// var genreDB Genre
// db.Where("raw_tag = ?", tagSplit[0]).First(&genreDB)
// genreDB.Name = &tagSplit[1]
// db.Save(genreDB)
// fmt.Println(genreDB)
// }
// })
sec.GET("/collection", func(ctx *gin.Context) {
userId := ctx.MustGet("id")
collections := []struct {
ID uint `json:"id"`
Name string `json:"name"`
}{}
db.
Model(&schemas.Permission{ID: userId.(uint)}).
Association("ViewCollections").
Find(&collections)
// db.Model(&schemas.Collection{}).Find(&collections, &schemas.Collection{UserID: userId.(uint)})
ctx.JSON(200, collections)
})
sec.GET("/collection/:id", func(ctx *gin.Context) {
isToken := false
if len(strings.Split(ctx.Param("id"), "-")) > 1 {
isToken = true
}
collection := APICollection{}
var res *gorm.DB
if isToken {
res = db.
Where("link = ?", ctx.Param("id")).
Preload("Creator").
Find(&collection)
} else {
res = db.
Table("collections"). // what if delete this string?..
Joins("JOIN view_collections ON view_collections.collection_id = collections.id").
Where("view_collections.permission_id = ?", ctx.MustGet("id").(uint)).
Where("id = ?", ctx.Param("id")).
Preload("Creator").
Find(&collection)
}
if res.RowsAffected == 0 {
ctx.AbortWithStatus(404)
return
}
ctx.JSON(200, collection)
})
sec.PUT("/collection", func(ctx *gin.Context) {
var requestBody struct {
Name string
}
ctx.BindJSON(&requestBody)
if len(requestBody.Name) == 0 {
ctx.AbortWithStatusJSON(400, gin.H{"err": "name should be not null"})
return
}
linkUUID := uuid.New()
newCollection := schemas.Collection{
Name: requestBody.Name,
Creator: schemas.User{ID: ctx.MustGet("id").(uint)},
Link: linkUUID.String(),
Created: time.Now(),
Modified: time.Now(),
}
db.Create(&newCollection)
db.
Model(&schemas.Permission{ID: ctx.MustGet("id").(uint)}).
Association("ViewCollections").
Append(&newCollection)
ctx.JSON(200, gin.H{"id": newCollection.ID})
})
sec.POST("/collection/:id", func(ctx *gin.Context) {
var requestBody struct {
BookID string `json:"book_id"`
}
collectionId, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(400, err)
return
}
ctx.BindJSON(&requestBody)
bookID, err := strconv.ParseUint(requestBody.BookID, 10, 64)
if err != nil {
ctx.AbortWithError(400, err)
return
}
var collection schemas.Collection
res := db.First(&collection, collectionId)
if res.RowsAffected == 0 {
ctx.AbortWithStatusJSON(404, gin.H{"err": "collection not found"})
return
}
var countBook int64
db.Model(&schemas.Book{}).Where("id = ?", bookID).Count(&countBook)
if countBook == 0 {
ctx.AbortWithStatusJSON(404, gin.H{"err": "book not found"})
return
}
collectionBookAssociation := db.Model(&schemas.Collection{
ID: uint(collectionId),
}).Association("Books")
var associatedBook schemas.Book
collectionBookAssociation.Find(&associatedBook, bookID)
if associatedBook.ID != 0 {
// если книга уже есть в ассоциациях - удаляем
err := collectionBookAssociation.Delete(associatedBook)
log.Println(err)
ctx.Status(204)
} else {
// если книги еще нет в ассоциациях - создаем
err = collectionBookAssociation.Append(&schemas.Book{ID: bookID})
if err != nil {
log.Println(err.Error())
ctx.AbortWithError(400, err)
return
}
ctx.Status(204)
}
collection.Modified = time.Now()
db.Save(collection)
})
sec.DELETE("/collection/:id", func(ctx *gin.Context) {
collectionId, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(400, err)
return
}
var collection schemas.Collection
db.Take(&collection, collectionId)
if collection.UserID == 0 {
ctx.AbortWithStatusJSON(404, gin.H{"err": "collection not found"})
return
}
if collection.UserID != ctx.MustGet("id").(uint) {
ctx.AbortWithStatus(403)
return
}
db.Model(&schemas.Collection{
ID: uint(collectionId),
}).Association("Books").Clear()
db.
Model(&schemas.Permission{ID: ctx.MustGet("id").(uint)}).
Association("ViewCollections").
Delete(&schemas.Collection{ID: uint(collectionId)})
db.Delete(&schemas.Collection{}, collectionId)
ctx.Status(204)
})
sec.POST("/book/upload", func(ctx *gin.Context) {
form, err := ctx.MultipartForm()
if err != nil {
ctx.String(400, "Форму ты сломал: %s", err.Error())
return
}
files := form.File["files"]
if len(files) != 1 {
ctx.Status(400)
return
}
file := files[0]
fileReader, _ := file.Open()
fileData, _ := io.ReadAll(fileReader)
contentType := file.Header.Get("Content-Type")
var bookID uint64
bookUUID := uuid.NewString()
switch contentType {
case "application/pdf":
pdfFile, err := fitz.NewFromMemory(fileData)
if err != nil {
ctx.Status(400)
return
}
cover, err := pdfFile.Image(0)
if err != nil {
ctx.Status(500)
return
}
// Пути к файлам
bookPath := filepath.Join(UPLOADS_PATH, "books", bookUUID+".pdf")
coverPath := filepath.Join(UPLOADS_PATH, "covers", bookUUID+".png")
// Создать директории если их нет
os.MkdirAll(filepath.Dir(bookPath), os.ModePerm)
os.MkdirAll(filepath.Dir(coverPath), os.ModePerm)
// Сохранить PDF
os.WriteFile(bookPath, fileData, 0644)
// Сохранить PNG
coverFile, _ := os.Create(coverPath)
defer coverFile.Close()
png.Encode(coverFile, cover)
// var filetype schemas.Filetype
// db.FirstOrCreate(&filetype, schemas.Filetype{
// Filetype: "pdf",
// Name: "PDF",
// })
extCover := bookUUID + ".png"
dbBook := schemas.Book{
Title: file.Filename,
Authors: []schemas.Author{},
Language: schemas.Language{},
Genre: nil,
Description: nil,
HasCover: true,
SequenceID: nil,
SequenceBook: nil,
IsTranslated: false,
Translators: nil,
SrcLanguage: schemas.Language{},
Year: nil,
Isbn: nil,
PublisherID: nil,
SymbolsCount: nil,
Size: int(file.Size),
Hash: nil,
Bookcase: nil,
Filename: bookUUID + ".pdf",
Filetype: "pdf",
ExternalCover: &extCover,
}
db.Save(&dbBook)
bookID = dbBook.ID
case "application/x-fictionbook+xml":
rawFB2 := reaper.Parse(bytes.NewReader(fileData))
// Пути к файлам
bookPath := filepath.Join(UPLOADS_PATH, "books", bookUUID+".fb2")
// Создать директории если их нет
os.MkdirAll(filepath.Dir(bookPath), os.ModePerm)
// Сохранить FB2
bookFile, _ := os.Create(bookPath)
defer bookFile.Close()
io.Copy(bookFile, bytes.NewReader(fileData))
FB2 := reaper.RawToFB2(*rawFB2, bookUUID+".fb2", nil, uint64(file.Size), nil)
tx := db.Begin()
bookID = reaper.FB2toDB(tx, FB2)
tx.Commit()
if tx.Error != nil {
ctx.Status(500)
}
default:
ctx.Status(400)
return
}
var collection schemas.Collection
res := db.Where(&schemas.Collection{Name: "Загрузки", UserID: ctx.MustGet("id").(uint)}).First(&collection)
if res.RowsAffected == 0 {
linkUUID := uuid.New()
newCollection := schemas.Collection{
Name: "Загрузки",
Creator: schemas.User{ID: ctx.MustGet("id").(uint)},
Link: linkUUID.String(),
Created: time.Now(),
Modified: time.Now(),
}
db.Create(&newCollection)
db.
Model(&schemas.Permission{ID: ctx.MustGet("id").(uint)}).
Association("ViewCollections").
Append(&newCollection)
db.Where(&schemas.Collection{Name: "Загрузки", UserID: ctx.MustGet("id").(uint)}).First(&collection)
}
collectionBookAssociation := db.Model(&schemas.Collection{
ID: uint(collection.ID),
}).Association("Books")
// если книги еще нет в ассоциациях - создаем
err = collectionBookAssociation.Append(&schemas.Book{ID: bookID})
if err != nil {
ctx.AbortWithError(400, err)
return
}
collection.Modified = time.Now()
db.Save(collection)
ctx.Status(200)
})
router.POST("/api/oobe/create-user", func(ctx *gin.Context) {
var requestBody struct {
Username string `json:"username"`
Password string `json:"password"`
Name string `json:"name"`
}
var users int64
db.Model(&schemas.User{}).Count(&users)
if users != 0 {
ctx.AbortWithStatusJSON(403, gin.H{"err": "users already exists"})
return
}
ctx.BindJSON(&requestBody)
if len(requestBody.Username) == 0 || len(requestBody.Password) == 0 || len(requestBody.Name) == 0 {
ctx.AbortWithStatusJSON(400, gin.H{"err": "username, password and name should be not null"})
return
}
if len(requestBody.Name) == 0 {
requestBody.Name = requestBody.Username
}
hash, _ := bcrypt.GenerateFromPassword([]byte(requestBody.Password), bcrypt.DefaultCost)
newUser := schemas.User{
Username: requestBody.Username,
Name: &requestBody.Name,
Password: string(hash),
Admin: true,
}
db.Create(&newUser)
})
router.Run(bindAddr)
}
type APIAuthor struct {
ID uint64 `json:"id"`
Key string `json:"key"`
FirstName string `json:"firstName"`
MiddleName string `json:"middleName"`
LastName string `json:"lastName"`
}
func (APIAuthor) TableName() string {
return "authors"
}
type APIBook struct {
ID uint `json:"id"`
Title string `json:"title"`
Authors []APIAuthor `gorm:"many2many:BookAuthor;joinForeignKey:book_id;joinReferences:author_id" json:"authors"`
Filetype string `json:"filetype"`
}
func (APIBook) TableName() string {
return "books"
}
type APIUser struct {
ID uint64 `gorm:"primaryKey" json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Collections []ShortAPICollection `json:"collections" gorm:"ForeignKey:id;"`
//Favorites []APIBook `gorm:"many2many:FavoriteBook;ForeignKey:username;joinForeignKey:user_username;joinReferences:book_id" json:"favorites"`
}
func (APIUser) TableName() string {
return "users"
}
type APICollection struct {
ID uint `gorm:"primaryKey" json:"id"`
Link string `gorm:"uniqueIndex" json:"link"`
Name string `json:"name"`
UserID uint `json:"user_id"`
Creator APIUser `gorm:"foreignKey:UserID" json:"creator"`
//Books []APIBook `gorm:"many2many:CollectionBook;joinReferences:book_id;joinForeignKey:collection_id" json:"books"`
Created time.Time `json:"created"`
Modified time.Time `json:"modified"`
}
func (APICollection) TableName() string {
return "collections"
}
func loadOrGenerateKeys(keysPath string) (*rsa.PrivateKey, *rsa.PublicKey) {
privatePath := keysPath + "/private.pem"
publicPath := keysPath + "/public.pem"
_, errPrivate := os.Stat(privatePath)
_, errPublic := os.Stat(publicPath)
if errors.Is(errPrivate, os.ErrNotExist) || errors.Is(errPublic, os.ErrNotExist) {
log.Println("Generating JWT keys...")
os.MkdirAll(keysPath, os.ModePerm)
priv, _ := rsa.GenerateKey(rand.Reader, 2048)
privBytes := x509.MarshalPKCS1PrivateKey(priv)
err := os.WriteFile(privatePath, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privBytes}), 0600)
if err != nil {
log.Panicln("Unable to write private JWT key")
}
pubBytes, _ := x509.MarshalPKIXPublicKey(&priv.PublicKey)
err = os.WriteFile(publicPath, pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}), 0644)
if err != nil {
log.Panicln("Unable to write public JWT key")
}
log.Println("JWT keys generated and loaded!")
return priv, &priv.PublicKey
} else if errPrivate != nil || errPublic != nil {
log.Panicln("Something went wrong with key files")
}
privPem, _ := os.ReadFile(privatePath)
privBlock, _ := pem.Decode(privPem)
priv, _ := x509.ParsePKCS1PrivateKey(privBlock.Bytes)
pubPem, _ := os.ReadFile(publicPath)
pubBlock, _ := pem.Decode(pubPem)
pubRaw, _ := x509.ParsePKIXPublicKey(pubBlock.Bytes)
log.Println("JWT keys loaded!")
return priv, pubRaw.(*rsa.PublicKey)
}
// нет ничего более постоянного, чем временное
// to rewrite ->>
// sec.PUT("/user/reader", func(ctx *gin.Context) {
// username := ctx.MustGet("username")
// var user User
// db.Model(&User{Username: username.(string)}).First(&user)
// var readerJSON []ReaderJSON
// ctx.BindJSON(&readerJSON)
// var BookShelf []ReaderBook
// for _, readerBook := range readerJSON {
// bookId, _ := strconv.ParseUint(readerBook.Id, 10, 64)
// BookShelf = append(BookShelf, ReaderBook{
// UserID: user.ID,
// BookID: uint(bookId),
// Progress: readerBook.Progress,
// LastRead: time.Unix(int64(readerBook.LastRead), 0),
// //OnBookshelf: readerBook.OnBookshelf,
// })
// }
// db.Where("user_id = ?", user.ID).Delete(&ReaderBook{})
// user.BookShelf = BookShelf
// db.Save(&user)
// ctx.Status(200)
// })
// sec.GET("/user/reader", func(ctx *gin.Context) {
// username := ctx.MustGet("username")
// var user User
// db.Model(&User{Username: username.(string)}).First(&user)
// var readerJSON []ReaderJSON
// var readerBooks []ReaderBook
// db.Find(&readerBooks).Where("user_id = ?", user.ID)
// for _, readerBook := range readerBooks {
// readerJSON = append(readerJSON, ReaderJSON{
// Id: strconv.FormatUint(uint64(readerBook.BookID), 10),
// Progress: readerBook.Progress,
// LastRead: uint64(readerBook.LastRead.Unix()),
// //OnBookshelf: readerBook.OnBookshelf,
// })
// }
// ctx.JSON(200, readerJSON)
// })
// legacy?
// sec.PUT("/user/favorite/:id", func(ctx *gin.Context) {
// if !BookCensor(ctx.Param("id")) {
// ctx.AbortWithStatus(451)
// return
// }
// username := ctx.MustGet("username")
// bookId, _ := strconv.ParseUint(ctx.Param("id"), 10, 32)
// db.Model(&User{Username: username.(string)}).Association("Favorites").Append(&Book{ID: bookId})
// })
// sec.DELETE("/user/favorite/:id", func(ctx *gin.Context) {
// if !BookCensor(ctx.Param("id")) {
// ctx.AbortWithStatus(451)
// return
// }
// username := ctx.MustGet("username")
// bookId, _ := strconv.ParseUint(ctx.Param("id"), 10, 32)
// db.Model(&User{Username: username.(string)}).Association("Favorites").Delete(&Book{ID: bookId})
// })
// sec.GET("/user/favorite/:id", func(ctx *gin.Context) {
// username := ctx.MustGet("username")
// bookId, _ := strconv.ParseUint(ctx.Param("id"), 10, 32)
// var associatedBook Book
// db.Model(&User{Username: username.(string)}).Where("id = ?", bookId). /*Where("id IN ?", GetAllowIds()).*/ Association("Favorites").Find(&associatedBook)
// ctx.JSON(200, gin.H{"isFavorite": associatedBook.ID != 0})
// })