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("

404

текст книги не найден на сервере

обратитесь к администратору библиотеки

")) } 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("") lHref := regexp.MustCompile("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}) // })