1430 lines
41 KiB
Go
1430 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/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 DATA_DIR string
|
|
var LIBRARY_DIR string
|
|
var UPLOADS_DIR string
|
|
var bindAddr string
|
|
|
|
// if db_type, exist := os.LookupEnv("DB_TYPE"); exist {
|
|
// dbType = db_type
|
|
// } else {
|
|
// log.Fatalln("Not found DB_TYPE env")
|
|
// }
|
|
if env, exist := os.LookupEnv("DATA_DIR"); exist {
|
|
DATA_DIR = env
|
|
} else {
|
|
log.Fatalln("Not found DATA_DIR env")
|
|
}
|
|
if env, exist := os.LookupEnv("BIND_ADDR"); exist {
|
|
bindAddr = env
|
|
} else {
|
|
bindAddr = ":8080"
|
|
}
|
|
if env, exist := os.LookupEnv("LIBRARY_DIR"); exist {
|
|
LIBRARY_DIR = env
|
|
} else {
|
|
log.Fatalln("Not found LIBRARY_DIR env")
|
|
}
|
|
if env, exist := os.LookupEnv("UPLOADS_DIR"); exist {
|
|
UPLOADS_DIR = env
|
|
} else {
|
|
log.Fatalln("Not found UPLOADS_DIR env")
|
|
}
|
|
//if _, exist := os.LookupEnv("WHITELIST_FILE"); !exist {
|
|
//log.Fatalln("Not found WHITELIST_FILE env")
|
|
//}
|
|
|
|
log.Println("connecting...")
|
|
dbPath := DATA_DIR + "/YaBL.db"
|
|
|
|
if _, err := os.Stat(dbPath); err != nil {
|
|
file, err := os.Create(dbPath)
|
|
if err != nil {
|
|
log.Panicln("Cannot create db file")
|
|
}
|
|
file.Close()
|
|
log.Println("Created empty db file")
|
|
}
|
|
|
|
db, err := gorm.Open(sqlite.Open(DATA_DIR+"/YaBL.db"), &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!")
|
|
|
|
priv, pub := loadOrGenerateKeys(DATA_DIR + "/keys")
|
|
|
|
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_DIR + "/books/" + book.Filename)
|
|
filename = book.Filename
|
|
} else {
|
|
bookcase, err := zip.OpenReader(LIBRARY_DIR + "/" + *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(LIBRARY_DIR + "/" + *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_DIR + "/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(LIBRARY_DIR + "/" + *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_DIR + "/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_DIR + "/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_DIR + "/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(LIBRARY_DIR + "/" + *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_DIR, "books", bookUUID+".pdf")
|
|
coverPath := filepath.Join(UPLOADS_DIR, "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_DIR, "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})
|
|
// })
|