Compare commits

...

1 Commits

Author SHA1 Message Date
e68dfc445f feat(admin): adding user & importing
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-18 16:47:26 -04:00
21 changed files with 497 additions and 149 deletions

View File

@ -3,6 +3,7 @@ package api
import ( import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"crypto/md5"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -12,14 +13,19 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"sort"
"strings" "strings"
"time" "time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/itchyny/gojq" "github.com/itchyny/gojq"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
"reichard.io/antholume/utils"
) )
type adminAction string type adminAction string
@ -59,12 +65,13 @@ type operationType string
const ( const (
opUpdate operationType = "UPDATE" opUpdate operationType = "UPDATE"
opCreate operationType = "CREATE" opCreate operationType = "CREATE"
opDelete operationType = "DELETE"
) )
type requestAdminUpdateUser struct { type requestAdminUpdateUser struct {
User string `form:"user"` User string `form:"user"`
Password string `form:"password"` Password *string `form:"password"`
isAdmin bool `form:"is_admin"` isAdmin *bool `form:"is_admin"`
Operation operationType `form:"operation"` Operation operationType `form:"operation"`
} }
@ -72,6 +79,22 @@ type requestAdminLogs struct {
Filter string `form:"filter"` Filter string `form:"filter"`
} }
type importStatus string
const (
importFailed importStatus = "FAILED"
importSuccess importStatus = "SUCCESS"
importExists importStatus = "EXISTS"
)
type importResult struct {
ID string
Name string
Path string
Status importStatus
Error error
}
func (api *API) appPerformAdminAction(c *gin.Context) { func (api *API) appPerformAdminAction(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin", c) templateVars, _ := api.getBaseTemplateVars("admin", c)
@ -82,15 +105,18 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
return return
} }
// TODO - Messages
switch rAdminAction.Action { switch rAdminAction.Action {
case adminMetadataMatch: case adminMetadataMatch:
// TODO // TODO
// 1. Documents xref most recent metadata table? // 1. Documents xref most recent metadata table?
// 2. Select all / deselect? // 2. Select all / deselect?
case adminCacheTables: case adminCacheTables:
go api.db.CacheTempTables() go func() {
// TODO - Message err := api.db.CacheTempTables()
if err != nil {
log.Error("Unable to cache temp tables: ", err)
}
}()
case adminRestore: case adminRestore:
api.processRestoreFile(rAdminAction, c) api.processRestoreFile(rAdminAction, c)
return return
@ -252,17 +278,18 @@ func (api *API) appUpdateAdminUsers(c *gin.Context) {
var err error var err error
switch rAdminUserUpdate.Operation { switch rAdminUserUpdate.Operation {
case opCreate: case opCreate:
err = api.createUser(rAdminUserUpdate.User, rAdminUserUpdate.Password) err = api.createUser(rAdminUserUpdate)
case opUpdate: case opUpdate:
err = fmt.Errorf("unimplemented") err = api.updateUser(rAdminUserUpdate)
case opDelete:
err = api.deleteUser(rAdminUserUpdate)
default: default:
appErrorPage(c, http.StatusNotFound, "Unknown user operation") appErrorPage(c, http.StatusNotFound, "Unknown user operation")
return return
} }
if err != nil { if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create user: %v", err)) appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create or update user: %v", err))
return return
} }
@ -338,27 +365,83 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
return return
} }
// TODO - Store results for approval? // Get import directory
baseDirectory := filepath.Clean(rAdminImport.Directory)
// Walk import directory & copy or import files // Get data directory
importDirectory := filepath.Clean(rAdminImport.Directory) absoluteDataPath, _ := filepath.Abs(filepath.Join(api.cfg.DataPath, "documents"))
_ = filepath.WalkDir(importDirectory, func(currentPath string, f fs.DirEntry, err error) error {
// Validate different path
if absoluteDataPath == baseDirectory {
appErrorPage(c, http.StatusBadRequest, "Directory is the same as data path")
return
}
// Do Transaction
tx, err := api.db.DB.Begin()
if err != nil {
log.Error("Transaction Begin DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown error")
return
}
// Defer & Start Transaction
defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx)
// Track imports
importResults := make([]importResult, 0)
// Walk Directory & Import
err = filepath.WalkDir(baseDirectory, func(currentPath string, f fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
} }
if f.IsDir() { if f.IsDir() {
return nil return nil
} }
// Get metadata // Get relative path
fileMeta, err := metadata.GetMetadata(currentPath) relFilePath, err := filepath.Rel(baseDirectory, currentPath)
if err != nil { if err != nil {
fmt.Printf("metadata error: %v\n", err) log.Warnf("path error: %v", err)
return nil return nil
} }
// Only needed if copying // Track imports
newName := deriveBaseFileName(fileMeta) iResult := importResult{
Path: relFilePath,
Status: importFailed,
}
defer func() {
importResults = append(importResults, iResult)
}()
// Get metadata
fileMeta, err := metadata.GetMetadata(currentPath)
if err != nil {
log.Errorf("metadata error: %v", err)
iResult.Error = err
return nil
}
iResult.ID = *fileMeta.PartialMD5
iResult.Name = fmt.Sprintf("%s - %s", *fileMeta.Author, *fileMeta.Title)
// Check already exists
_, err = qtx.GetDocument(api.db.Ctx, *fileMeta.PartialMD5)
if err == nil {
log.Warnf("document already exists: %s", *fileMeta.PartialMD5)
iResult.Status = importExists
return nil
}
// TODO - Import Copy
// newName := deriveBaseFileName(fileMeta)
// Open File on Disk // Open File on Disk
// file, err := os.Open(currentPath) // file, err := os.Open(currentPath)
@ -367,17 +450,45 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
// } // }
// defer file.Close() // defer file.Close()
// TODO - BasePath in DB // Upsert document
// TODO - Copy / Import if _, err = qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: *fileMeta.PartialMD5,
fmt.Printf("New File Metadata: %s\n", newName) Title: fileMeta.Title,
Author: fileMeta.Author,
Description: fileMeta.Description,
Md5: fileMeta.MD5,
Words: fileMeta.WordCount,
Filepath: &relFilePath,
Basepath: &baseDirectory,
}); err != nil {
log.Errorf("UpsertDocument DB Error: %v", err)
iResult.Error = err
return nil
}
iResult.Status = importSuccess
return nil return nil
}) })
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import Failed: %v", err))
return
}
templateVars["CurrentPath"] = filepath.Clean(rAdminImport.Directory) // Commit transaction
if err := tx.Commit(); err != nil {
log.Error("Transaction Commit DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Import DB Error: %v", err))
return
}
c.HTML(http.StatusOK, "page/admin-import", templateVars) // Sort import results
sort.Slice(importResults, func(i int, j int) bool {
return importStatusPriority(importResults[i].Status) <
importStatusPriority(importResults[j].Status)
})
templateVars["Data"] = importResults
c.HTML(http.StatusOK, "page/admin-import-results", templateVars)
} }
func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) { func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Context) {
@ -521,7 +632,6 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
c.Redirect(http.StatusFound, "/login") c.Redirect(http.StatusFound, "/login")
} }
// Restore all data
func (api *API) restoreData(zipReader *zip.Reader) error { func (api *API) restoreData(zipReader *zip.Reader) error {
// Ensure Directories // Ensure Directories
api.cfg.EnsureDirectories() api.cfg.EnsureDirectories()
@ -552,7 +662,6 @@ func (api *API) restoreData(zipReader *zip.Reader) error {
return nil return nil
} }
// Remove all data
func (api *API) removeData() error { func (api *API) removeData() error {
allPaths := []string{ allPaths := []string{
"covers", "covers",
@ -575,7 +684,6 @@ func (api *API) removeData() error {
return nil return nil
} }
// Backup all data
func (api *API) createBackup(w io.Writer, directories []string) error { func (api *API) createBackup(w io.Writer, directories []string) error {
ar := zip.NewWriter(w) ar := zip.NewWriter(w)
@ -628,7 +736,11 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
if err != nil { if err != nil {
return err return err
} }
io.Copy(newDbFile, dbFile)
_, err = io.Copy(newDbFile, dbFile)
if err != nil {
return err
}
// Backup Covers & Documents // Backup Covers & Documents
for _, dir := range directories { for _, dir := range directories {
@ -641,3 +753,118 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
ar.Close() ar.Close()
return nil return nil
} }
func (api *API) createUser(createRequest requestAdminUpdateUser) error {
// Validate Necessary Parameters
if createRequest.User == "" {
return fmt.Errorf("username can't be empty")
}
if createRequest.Password == nil || *createRequest.Password == "" {
return fmt.Errorf("password can't be empty")
}
// Base Params
createParams := database.CreateUserParams{
ID: createRequest.User,
}
// Handle Admin (Explicit or False)
if createRequest.isAdmin != nil {
createParams.Admin = *createRequest.isAdmin
} else {
createParams.Admin = false
}
// Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*createRequest.Password)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
return fmt.Errorf("unable to create hashed password")
}
createParams.Pass = &hashedPassword
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return fmt.Errorf("unable to create token for user")
}
authHash := fmt.Sprintf("%x", rawAuthHash)
createParams.AuthHash = &authHash
// Create user in DB
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, createParams); err != nil {
log.Error("CreateUser DB Error:", err)
return fmt.Errorf("unable to create user")
} else if rows == 0 {
log.Warn("User Already Exists:", createParams.ID)
return fmt.Errorf("user already exists")
}
return nil
}
func (api *API) updateUser(updateRequest requestAdminUpdateUser) error {
// Validate Necessary Parameters
if updateRequest.User == "" {
return fmt.Errorf("username can't be empty")
}
if updateRequest.Password == nil && updateRequest.isAdmin == nil {
return fmt.Errorf("nothing to update")
}
// Base Params
updateParams := database.UpdateUserParams{
UserID: updateRequest.User,
}
// Handle Admin (Update or Existing)
if updateRequest.isAdmin != nil {
updateParams.Admin = *updateRequest.isAdmin
} else {
user, err := api.db.Queries.GetUser(api.db.Ctx, updateRequest.User)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err))
}
updateParams.Admin = user.Admin
}
// TODO:
// - Validate Not Last Admin
// Handle Password
if updateRequest.Password != nil {
if *updateRequest.Password == "" {
return fmt.Errorf("password can't be empty")
}
// Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*updateRequest.Password)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
return fmt.Errorf("unable to create hashed password")
}
updateParams.Password = &hashedPassword
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return fmt.Errorf("unable to create token for user")
}
authHash := fmt.Sprintf("%x", rawAuthHash)
updateParams.AuthHash = &authHash
}
// Update User
_, err := api.db.Queries.UpdateUser(api.db.Ctx, updateParams)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err))
}
return nil
}
func (api *API) deleteUser(updateRequest requestAdminUpdateUser) error {
// TODO:
// - Validate Not Last Admin
return errors.New("unimplemented")
}

View File

@ -314,7 +314,11 @@ func (api *API) appGetSearch(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("search", c) templateVars, _ := api.getBaseTemplateVars("search", c)
var sParams searchParams var sParams searchParams
c.BindQuery(&sParams) err := c.BindQuery(&sParams)
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
return
}
// Only Handle Query // Only Handle Query
if sParams.Query != nil && sParams.Source != nil { if sParams.Query != nil && sParams.Source != nil {
@ -595,7 +599,6 @@ func (api *API) appEditDocument(c *gin.Context) {
} }
c.Redirect(http.StatusFound, "./") c.Redirect(http.StatusFound, "./")
return
} }
func (api *API) appDeleteDocument(c *gin.Context) { func (api *API) appDeleteDocument(c *gin.Context) {
@ -764,6 +767,11 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
// Derive Extension on MIME // Derive Extension on MIME
fileMime, err := mimetype.DetectFile(tempFilePath) fileMime, err := mimetype.DetectFile(tempFilePath)
if err != nil {
log.Warn("MIME Detect Error: ", err)
sendDownloadMessage("Unable to download file", gin.H{"Error": true})
return
}
fileExtension := fileMime.Extension() fileExtension := fileMime.Extension()
// Derive Filename // Derive Filename
@ -951,7 +959,11 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats
} }
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx) qtx := api.db.Queries.WithTx(tx)
for _, item := range documents { for _, item := range documents {
@ -1000,7 +1012,11 @@ func (api *API) getBaseTemplateVars(routeName string, c *gin.Context) (gin.H, au
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams { func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
var qParams queryParams var qParams queryParams
c.BindQuery(&qParams) err := c.BindQuery(&qParams)
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Invalid Form Bind: %v", err))
return qParams
}
if qParams.Limit == nil { if qParams.Limit == nil {
qParams.Limit = &defaultLimit qParams.Limit = &defaultLimit

View File

@ -28,18 +28,13 @@ type authKOHeader struct {
AuthKey string `header:"x-auth-key"` AuthKey string `header:"x-auth-key"`
} }
// OPDS Auth Headers
type authOPDSHeader struct {
Authorization string `header:"authorization"`
}
func (api *API) authorizeCredentials(username string, password string) (auth *authData) { func (api *API) authorizeCredentials(username string, password string) (auth *authData) {
user, err := api.db.Queries.GetUser(api.db.Ctx, username) user, err := api.db.Queries.GetUser(api.db.Ctx, username)
if err != nil { if err != nil {
return return
} }
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true { if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
return return
} }
@ -57,7 +52,7 @@ func (api *API) authKOMiddleware(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
// Check Session First // Check Session First
if auth, ok := api.getSession(session); ok == true { if auth, ok := api.getSession(session); ok {
c.Set("Authorization", auth) c.Set("Authorization", auth)
c.Header("Cache-Control", "private") c.Header("Cache-Control", "private")
c.Next() c.Next()
@ -98,7 +93,7 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
user, rawPassword, hasAuth := c.Request.BasicAuth() user, rawPassword, hasAuth := c.Request.BasicAuth()
// Validate Auth Fields // Validate Auth Fields
if hasAuth != true || user == "" || rawPassword == "" { if !hasAuth || user == "" || rawPassword == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"}) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
return return
} }
@ -120,7 +115,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
// Check Session // Check Session
if auth, ok := api.getSession(session); ok == true { if auth, ok := api.getSession(session); ok {
c.Set("Authorization", auth) c.Set("Authorization", auth)
c.Header("Cache-Control", "private") c.Header("Cache-Control", "private")
c.Next() c.Next()
@ -129,13 +124,12 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
c.Redirect(http.StatusFound, "/login") c.Redirect(http.StatusFound, "/login")
c.Abort() c.Abort()
return
} }
func (api *API) authAdminWebAppMiddleware(c *gin.Context) { func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
if data, _ := c.Get("Authorization"); data != nil { if data, _ := c.Get("Authorization"); data != nil {
auth := data.(authData) auth := data.(authData)
if auth.IsAdmin == true { if auth.IsAdmin {
c.Next() c.Next()
return return
} }
@ -143,7 +137,6 @@ func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required") appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required")
c.Abort() c.Abort()
return
} }
func (api *API) appAuthLogin(c *gin.Context) { func (api *API) appAuthLogin(c *gin.Context) {
@ -276,7 +269,10 @@ func (api *API) appAuthRegister(c *gin.Context) {
func (api *API) appAuthLogout(c *gin.Context) { func (api *API) appAuthLogout(c *gin.Context) {
session := sessions.Default(c) session := sessions.Default(c)
session.Clear() session.Clear()
session.Save() if err := session.Save(); err != nil {
log.Error("unable to save session")
}
c.Redirect(http.StatusFound, "/login") c.Redirect(http.StatusFound, "/login")
} }
@ -377,7 +373,10 @@ func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
// Refresh // Refresh
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 { if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
log.Info("Refreshing Session") log.Info("Refreshing Session")
api.setSession(session, auth) if err := api.setSession(session, auth); err != nil {
log.Error("unable to get session")
return
}
} }
// Authorized // Authorized
@ -422,7 +421,11 @@ func (api *API) rotateAllAuthHashes() error {
} }
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx) qtx := api.db.Queries.WithTx(tx)
users, err := qtx.GetUsers(api.db.Ctx) users, err := qtx.GetUsers(api.db.Ctx)
@ -430,7 +433,8 @@ func (api *API) rotateAllAuthHashes() error {
return err return err
} }
// Update users // Update Users
newAuthHashCache := make(map[string]string, 0)
for _, user := range users { for _, user := range users {
// Generate Auth Hash // Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64) rawAuthHash, err := utils.GenerateToken(64)
@ -448,8 +452,8 @@ func (api *API) rotateAllAuthHashes() error {
return err return err
} }
// Update Cache // Save New Hash Cache
api.userAuthCache[user.ID] = fmt.Sprintf("%x", rawAuthHash) newAuthHashCache[user.ID] = fmt.Sprintf("%x", rawAuthHash)
} }
// Commit Transaction // Commit Transaction
@ -458,56 +462,9 @@ func (api *API) rotateAllAuthHashes() error {
return err return err
} }
return nil // Transaction Succeeded -> Update Cache
} for user, hash := range newAuthHashCache {
api.userAuthCache[user] = hash
func (api *API) createUser(username string, rawPassword string) error {
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
if username == "" {
return fmt.Errorf("username can't be empty")
}
if rawPassword == "" {
return fmt.Errorf("password can't be empty")
}
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
return fmt.Errorf("unable to create hashed password")
}
// Generate auth hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return fmt.Errorf("unable to create token for user")
}
// Get current users
currentUsers, err := api.db.Queries.GetUsers(api.db.Ctx)
if err != nil {
return fmt.Errorf("unable to get current users")
}
// Determine if we should be admin
isAdmin := false
if len(currentUsers) == 0 {
isAdmin = true
}
// Create user in DB
authHash := fmt.Sprintf("%x", rawAuthHash)
if rows, err := api.db.Queries.CreateUser(api.db.Ctx, database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: &authHash,
Admin: isAdmin,
}); err != nil {
log.Error("CreateUser DB Error:", err)
return fmt.Errorf("unable to create user")
} else if rows == 0 {
log.Warn("User Already Exists:", username)
return fmt.Errorf("user already exists")
} }
return nil return nil

View File

@ -2,11 +2,12 @@ package api
import ( import (
"fmt" "fmt"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database" "reichard.io/antholume/database"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
) )
@ -34,8 +35,14 @@ func (api *API) createDownloadDocumentHandler(errorFunc func(*gin.Context, int,
return return
} }
// Derive Basepath
basepath := filepath.Join(api.cfg.DataPath, "documents")
if document.Basepath != nil && *document.Basepath != "" {
basepath = *document.Basepath
}
// Derive Storage Location // Derive Storage Location
filePath := filepath.Join(api.cfg.DataPath, "documents", *document.Filepath) filePath := filepath.Join(basepath, *document.Filepath)
// Validate File Exists // Validate File Exists
_, err = os.Stat(filePath) _, err = os.Stat(filePath)

View File

@ -193,7 +193,11 @@ func (api *API) koAddActivities(c *gin.Context) {
allDocuments := getKeys(allDocumentsMap) allDocuments := getKeys(allDocumentsMap)
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx) qtx := api.db.Queries.WithTx(tx)
// Upsert Documents // Upsert Documents
@ -316,7 +320,11 @@ func (api *API) koAddDocuments(c *gin.Context) {
} }
// Defer & Start Transaction // Defer & Start Transaction
defer tx.Rollback() defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx) qtx := api.db.Queries.WithTx(tx)
// Upsert Documents // Upsert Documents
@ -375,11 +383,8 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
return return
} }
missingDocs := []database.Document{}
deletedDocIDs := []string{}
// Get Missing Documents // Get Missing Documents
missingDocs, err = api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have) missingDocs, err := api.db.Queries.GetMissingDocuments(api.db.Ctx, rCheckDocs.Have)
if err != nil { if err != nil {
log.Error("GetMissingDocuments DB Error", err) log.Error("GetMissingDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request") apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@ -387,7 +392,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
} }
// Get Deleted Documents // Get Deleted Documents
deletedDocIDs, err = api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have) deletedDocIDs, err := api.db.Queries.GetDeletedDocuments(api.db.Ctx, rCheckDocs.Have)
if err != nil { if err != nil {
log.Error("GetDeletedDocuments DB Error", err) log.Error("GetDeletedDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request") apiErrorPage(c, http.StatusBadRequest, "Invalid Request")

View File

@ -155,3 +155,14 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
fileName := strings.ReplaceAll(newFileName, "/", "") fileName := strings.ReplaceAll(newFileName, "/", "")
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type)) return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
} }
func importStatusPriority(status importStatus) int {
switch status {
case importFailed:
return 1
case importExists:
return 2
default:
return 3
}
}

File diff suppressed because one or more lines are too long

View File

@ -99,7 +99,7 @@ const PRECACHE_ASSETS = [
// ----------------------- Helpers ----------------------- // // ----------------------- Helpers ----------------------- //
// ------------------------------------------------------- // // ------------------------------------------------------- //
function purgeCache() { async function purgeCache() {
console.log("[purgeCache] Purging Cache"); console.log("[purgeCache] Purging Cache");
return caches.keys().then(function (names) { return caches.keys().then(function (names) {
for (let name of names) caches.delete(name); for (let name of names) caches.delete(name);
@ -136,7 +136,7 @@ async function handleFetch(event) {
const directive = ROUTES.find( const directive = ROUTES.find(
(item) => (item) =>
(item.route instanceof RegExp && url.match(item.route)) || (item.route instanceof RegExp && url.match(item.route)) ||
url == item.route url == item.route,
) || { type: CACHE_NEVER }; ) || { type: CACHE_NEVER };
// Get Fallback // Get Fallback
@ -161,11 +161,11 @@ async function handleFetch(event) {
); );
case CACHE_UPDATE_SYNC: case CACHE_UPDATE_SYNC:
return updateCache(event.request).catch( return updateCache(event.request).catch(
(e) => currentCache || fallbackFunc(event) (e) => currentCache || fallbackFunc(event),
); );
case CACHE_UPDATE_ASYNC: case CACHE_UPDATE_ASYNC:
let newResponse = updateCache(event.request).catch((e) => let newResponse = updateCache(event.request).catch((e) =>
fallbackFunc(event) fallbackFunc(event),
); );
return currentCache || newResponse; return currentCache || newResponse;
@ -192,7 +192,7 @@ function handleMessage(event) {
.filter( .filter(
(item) => (item) =>
item.startsWith("/documents/") || item.startsWith("/documents/") ||
item.startsWith("/reader/progress/") item.startsWith("/reader/progress/"),
); );
// Derive Unique IDs // Derive Unique IDs
@ -200,8 +200,8 @@ function handleMessage(event) {
new Set( new Set(
docResources docResources
.filter((item) => item.startsWith("/documents/")) .filter((item) => item.startsWith("/documents/"))
.map((item) => item.split("/")[2]) .map((item) => item.split("/")[2]),
) ),
); );
/** /**
@ -214,14 +214,14 @@ function handleMessage(event) {
.filter( .filter(
(id) => (id) =>
docResources.includes("/documents/" + id + "/file") && docResources.includes("/documents/" + id + "/file") &&
docResources.includes("/reader/progress/" + id) docResources.includes("/reader/progress/" + id),
) )
.map(async (id) => { .map(async (id) => {
let url = "/reader/progress/" + id; let url = "/reader/progress/" + id;
let currentCache = await caches.match(url); let currentCache = await caches.match(url);
let resp = await updateCache(url).catch((e) => currentCache); let resp = await updateCache(url).catch((e) => currentCache);
return resp.json(); return resp.json();
}) }),
); );
event.source.postMessage({ id, data: cachedDocuments }); event.source.postMessage({ id, data: cachedDocuments });
@ -233,7 +233,7 @@ function handleMessage(event) {
Promise.all([ Promise.all([
cache.delete("/documents/" + data.id + "/file"), cache.delete("/documents/" + data.id + "/file"),
cache.delete("/reader/progress/" + data.id), cache.delete("/reader/progress/" + data.id),
]) ]),
) )
.then(() => event.source.postMessage({ id, data: "SUCCESS" })) .then(() => event.source.postMessage({ id, data: "SUCCESS" }))
.catch(() => event.source.postMessage({ id, data: "FAILURE" })); .catch(() => event.source.postMessage({ id, data: "FAILURE" }));

View File

@ -0,0 +1,38 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upImportBasepath, downImportBasepath)
}
func upImportBasepath(ctx context.Context, tx *sql.Tx) error {
// Determine if we have a new DB or not
isNew := ctx.Value("isNew").(bool)
if isNew {
return nil
}
// Add basepath column
_, err := tx.Exec(`ALTER TABLE documents ADD COLUMN basepath TEXT;`)
if err != nil {
return err
}
// This code is executed when the migration is applied.
return nil
}
func downImportBasepath(ctx context.Context, tx *sql.Tx) error {
// Drop basepath column
_, err := tx.Exec("ALTER documents DROP COLUMN basepath;")
if err != nil {
return err
}
return nil
}

View File

@ -30,6 +30,7 @@ type Device struct {
type Document struct { type Document struct {
ID string `json:"id"` ID string `json:"id"`
Md5 *string `json:"md5"` Md5 *string `json:"md5"`
Basepath *string `json:"basepath"`
Filepath *string `json:"filepath"` Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"` Coverfile *string `json:"coverfile"`
Title *string `json:"title"` Title *string `json:"title"`

View File

@ -396,6 +396,7 @@ RETURNING *;
INSERT INTO documents ( INSERT INTO documents (
id, id,
md5, md5,
basepath,
filepath, filepath,
coverfile, coverfile,
title, title,
@ -410,10 +411,11 @@ INSERT INTO documents (
isbn10, isbn10,
isbn13 isbn13
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE ON CONFLICT DO UPDATE
SET SET
md5 = COALESCE(excluded.md5, md5), md5 = COALESCE(excluded.md5, md5),
basepath = COALESCE(excluded.basepath, basepath),
filepath = COALESCE(excluded.filepath, filepath), filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile), coverfile = COALESCE(excluded.coverfile, coverfile),
title = COALESCE(excluded.title, title), title = COALESCE(excluded.title, title),

View File

@ -454,7 +454,7 @@ func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRo
} }
const getDocument = `-- name: GetDocument :one const getDocument = `-- name: GetDocument :one
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
WHERE id = ?1 LIMIT 1 WHERE id = ?1 LIMIT 1
` `
@ -464,6 +464,7 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,
@ -612,7 +613,7 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS
} }
const getDocuments = `-- name: GetDocuments :many const getDocuments = `-- name: GetDocuments :many
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents SELECT id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ?2 LIMIT ?2
OFFSET ?1 OFFSET ?1
@ -635,6 +636,7 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,
@ -819,7 +821,7 @@ func (q *Queries) GetLastActivity(ctx context.Context, arg GetLastActivityParams
} }
const getMissingDocuments = `-- name: GetMissingDocuments :many const getMissingDocuments = `-- name: GetMissingDocuments :many
SELECT documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents SELECT documents.id, documents.md5, documents.basepath, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents
WHERE WHERE
documents.filepath IS NOT NULL documents.filepath IS NOT NULL
AND documents.deleted = false AND documents.deleted = false
@ -848,6 +850,7 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string)
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,
@ -1325,6 +1328,7 @@ const upsertDocument = `-- name: UpsertDocument :one
INSERT INTO documents ( INSERT INTO documents (
id, id,
md5, md5,
basepath,
filepath, filepath,
coverfile, coverfile,
title, title,
@ -1339,10 +1343,11 @@ INSERT INTO documents (
isbn10, isbn10,
isbn13 isbn13
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE ON CONFLICT DO UPDATE
SET SET
md5 = COALESCE(excluded.md5, md5), md5 = COALESCE(excluded.md5, md5),
basepath = COALESCE(excluded.basepath, basepath),
filepath = COALESCE(excluded.filepath, filepath), filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile), coverfile = COALESCE(excluded.coverfile, coverfile),
title = COALESCE(excluded.title, title), title = COALESCE(excluded.title, title),
@ -1356,12 +1361,13 @@ SET
gbid = COALESCE(excluded.gbid, gbid), gbid = COALESCE(excluded.gbid, gbid),
isbn10 = COALESCE(excluded.isbn10, isbn10), isbn10 = COALESCE(excluded.isbn10, isbn10),
isbn13 = COALESCE(excluded.isbn13, isbn13) isbn13 = COALESCE(excluded.isbn13, isbn13)
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at RETURNING id, md5, basepath, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
` `
type UpsertDocumentParams struct { type UpsertDocumentParams struct {
ID string `json:"id"` ID string `json:"id"`
Md5 *string `json:"md5"` Md5 *string `json:"md5"`
Basepath *string `json:"basepath"`
Filepath *string `json:"filepath"` Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"` Coverfile *string `json:"coverfile"`
Title *string `json:"title"` Title *string `json:"title"`
@ -1381,6 +1387,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
row := q.db.QueryRowContext(ctx, upsertDocument, row := q.db.QueryRowContext(ctx, upsertDocument,
arg.ID, arg.ID,
arg.Md5, arg.Md5,
arg.Basepath,
arg.Filepath, arg.Filepath,
arg.Coverfile, arg.Coverfile,
arg.Title, arg.Title,
@ -1399,6 +1406,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.Md5, &i.Md5,
&i.Basepath,
&i.Filepath, &i.Filepath,
&i.Coverfile, &i.Coverfile,
&i.Title, &i.Title,

View File

@ -19,6 +19,7 @@ CREATE TABLE IF NOT EXISTS documents (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
md5 TEXT, md5 TEXT,
basepath TEXT,
filepath TEXT, filepath TEXT,
coverfile TEXT, coverfile TEXT,
title TEXT, title TEXT,

View File

@ -294,5 +294,3 @@ INNER JOIN
ON ga.document_id = d.id ON ga.document_id = d.id
GROUP BY ga.document_id, ga.user_id GROUP BY ga.document_id, ga.user_id
ORDER BY total_wpm DESC; ORDER BY total_wpm DESC;

1
go.mod
View File

@ -49,6 +49,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect

View File

@ -19,6 +19,10 @@ sql:
go_type: go_type:
type: "string" type: "string"
pointer: true pointer: true
- column: "documents.basepath"
go_type:
type: "string"
pointer: true
- column: "documents.coverfile" - column: "documents.coverfile"
go_type: go_type:
type: "string" type: "string"

View File

@ -54,6 +54,19 @@
display: none; display: none;
} }
/* ----------------------------- */
/* -------- CSS Button -------- */
/* ----------------------------- */
.css-button:checked+div {
visibility: visible;
opacity: 1;
}
.css-button+div {
visibility: hidden;
opacity: 0;
}
/* ----------------------------- */ /* ----------------------------- */
/* ------- User Dropdown ------- */ /* ------- User Dropdown ------- */
/* ----------------------------- */ /* ----------------------------- */

View File

@ -0,0 +1,46 @@
{{ template "base" . }}
{{ define "title" }}Admin - Import Results{{ end }}
{{ define "header" }}<a class="whitespace-pre" href="../admin">Admin - Import Results</a>{{ end }}
{{ define "content" }}
<div class="overflow-x-auto">
<div class="inline-block min-w-full overflow-hidden rounded shadow">
<table class="min-w-full leading-normal bg-white dark:bg-gray-700 text-sm">
<thead class="text-gray-800 dark:text-gray-400">
<tr>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Document</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Status</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Error</th>
</tr>
</thead>
<tbody class="text-black dark:text-white">
{{ if not .Data }}
<tr>
<td class="text-center p-3" colspan="4">No Results</td>
</tr>
{{ end }}
{{ range $result := .Data }}
<tr>
<td class="p-3 border-b border-gray-200 grid"
style="grid-template-columns: 4rem auto">
<span class="text-gray-800 dark:text-gray-400">Name:</span>
{{ if (eq $result.ID "") }}
<span>N/A</span>
{{ else }}
<a href="../documents/{{ $result.ID }}">{{ $result.Name }}</a>
{{ end }}
<span class="text-gray-800 dark:text-gray-400">File:</span>
<span>{{ $result.Path }}</span>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $result.Status }}</p>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $result.Error }}</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
{{ end }}

View File

@ -19,12 +19,12 @@
</div> </div>
<div class="flex flex-col justify-around gap-2 mr-4"> <div class="flex flex-col justify-around gap-2 mr-4">
<div class="inline-flex gap-2 items-center"> <div class="inline-flex gap-2 items-center">
<input checked type="radio" id="copy" name="type" value="COPY" /> <input checked type="radio" id="direct" name="type" value="DIRECT" />
<label for="copy">Copy</label> <label for="direct">Direct</label>
</div> </div>
<div class="inline-flex gap-2 items-center"> <div class="inline-flex gap-2 items-center">
<input type="radio" id="direct" name="type" value="DIRECT" /> <input type="radio" id="copy" name="type" value="COPY" />
<label for="direct">Direct</label> <label for="copy">Copy</label>
</div> </div>
</div> </div>
</div> </div>

View File

@ -35,6 +35,7 @@
<label class="cursor-pointer" for="add-button">{{ template "svg/add" }}</label> <label class="cursor-pointer" for="add-button">{{ template "svg/add" }}</label>
</th> </th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">User</th> <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">User</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800">Password</th>
<th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center"> <th class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800 text-center">
Permissions Permissions
</th> </th>
@ -55,6 +56,33 @@
<td class="p-3 border-b border-gray-200"> <td class="p-3 border-b border-gray-200">
<p>{{ $user.ID }}</p> <p>{{ $user.ID }}</p>
</td> </td>
<td class="border-b border-gray-200 relative px-3">
<label for="edit-{{ $user.ID }}-button" class="cursor-pointer">
<span class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Reset</span>
</label>
<input type="checkbox"
id="edit-{{ $user.ID }}-button"
class="hidden css-button" />
<div class="absolute z-30 -bottom-1.5 left-16 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<form method="POST"
action="./users"
class="flex flex gap-2 text-black dark:text-white text-sm">
<input type="text"
id="operation"
name="operation"
value="UPDATE"
class="hidden" />
<input type="password"
id="password"
name="password"
placeholder="Password"
class="p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white" />
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
type="submit">Change</button>
</form>
</div>
</td>
<td class="p-3 border-b border-gray-200 text-center min-w-40"> <td class="p-3 border-b border-gray-200 text-center min-w-40">
<span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-800 dark:bg-gray-100{{ else }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ end }}">admin</span> <span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-800 dark:bg-gray-100{{ else }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ end }}">admin</span>
<span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ else }}bg-gray-800 dark:bg-gray-100{{ end }}">user</span> <span class="px-2 py-1 rounded-md text-white dark:text-black {{ if $user.Admin }}bg-gray-400 dark:bg-gray-600 cursor-pointer{{ else }}bg-gray-800 dark:bg-gray-100{{ end }}">user</span>

View File

@ -89,7 +89,7 @@
enctype="multipart/form-data" enctype="multipart/form-data"
action="./documents" action="./documents"
class="flex flex-col gap-2"> class="flex flex-col gap-2">
<input type="file" accept=".epub" id="document_file" name="document_file"> <input type="file" accept=".epub" id="document_file" name="document_file" />
<button class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800" <button class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
type="submit">Upload File</button> type="submit">Upload File</button>
</form> </form>
@ -102,19 +102,4 @@
<label class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer" <label class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
for="upload-file-button">{{ template "svg/upload" (dict "Size" 34) }}</label> for="upload-file-button">{{ template "svg/upload" (dict "Size" 34) }}</label>
</div> </div>
<style>
.css-button:checked+div {
display: block;
opacity: 1;
}
.css-button+div {
display: none;
opacity: 0;
}
.css-button:checked+div+label {
display: none;
}
</style>
{{ end }} {{ end }}