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 (
"archive/zip"
"bufio"
"crypto/md5"
"encoding/json"
"fmt"
"io"
@ -12,14 +13,19 @@ import (
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
"github.com/itchyny/gojq"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
"reichard.io/antholume/utils"
)
type adminAction string
@ -59,12 +65,13 @@ type operationType string
const (
opUpdate operationType = "UPDATE"
opCreate operationType = "CREATE"
opDelete operationType = "DELETE"
)
type requestAdminUpdateUser struct {
User string `form:"user"`
Password string `form:"password"`
isAdmin bool `form:"is_admin"`
Password *string `form:"password"`
isAdmin *bool `form:"is_admin"`
Operation operationType `form:"operation"`
}
@ -72,6 +79,22 @@ type requestAdminLogs struct {
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) {
templateVars, _ := api.getBaseTemplateVars("admin", c)
@ -82,15 +105,18 @@ func (api *API) appPerformAdminAction(c *gin.Context) {
return
}
// TODO - Messages
switch rAdminAction.Action {
case adminMetadataMatch:
// TODO
// 1. Documents xref most recent metadata table?
// 2. Select all / deselect?
case adminCacheTables:
go api.db.CacheTempTables()
// TODO - Message
go func() {
err := api.db.CacheTempTables()
if err != nil {
log.Error("Unable to cache temp tables: ", err)
}
}()
case adminRestore:
api.processRestoreFile(rAdminAction, c)
return
@ -252,17 +278,18 @@ func (api *API) appUpdateAdminUsers(c *gin.Context) {
var err error
switch rAdminUserUpdate.Operation {
case opCreate:
err = api.createUser(rAdminUserUpdate.User, rAdminUserUpdate.Password)
err = api.createUser(rAdminUserUpdate)
case opUpdate:
err = fmt.Errorf("unimplemented")
err = api.updateUser(rAdminUserUpdate)
case opDelete:
err = api.deleteUser(rAdminUserUpdate)
default:
appErrorPage(c, http.StatusNotFound, "Unknown user operation")
return
}
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
}
@ -338,27 +365,83 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
return
}
// TODO - Store results for approval?
// Get import directory
baseDirectory := filepath.Clean(rAdminImport.Directory)
// Walk import directory & copy or import files
importDirectory := filepath.Clean(rAdminImport.Directory)
_ = filepath.WalkDir(importDirectory, func(currentPath string, f fs.DirEntry, err error) error {
// Get data directory
absoluteDataPath, _ := filepath.Abs(filepath.Join(api.cfg.DataPath, "documents"))
// 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 {
return err
}
if f.IsDir() {
return nil
}
// Get metadata
fileMeta, err := metadata.GetMetadata(currentPath)
// Get relative path
relFilePath, err := filepath.Rel(baseDirectory, currentPath)
if err != nil {
fmt.Printf("metadata error: %v\n", err)
log.Warnf("path error: %v", err)
return nil
}
// Only needed if copying
newName := deriveBaseFileName(fileMeta)
// Track imports
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
// file, err := os.Open(currentPath)
@ -367,17 +450,45 @@ func (api *API) appPerformAdminImport(c *gin.Context) {
// }
// defer file.Close()
// TODO - BasePath in DB
// TODO - Copy / Import
fmt.Printf("New File Metadata: %s\n", newName)
// Upsert document
if _, err = qtx.UpsertDocument(api.db.Ctx, database.UpsertDocumentParams{
ID: *fileMeta.PartialMD5,
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
})
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) {
@ -521,7 +632,6 @@ func (api *API) processRestoreFile(rAdminAction requestAdminAction, c *gin.Conte
c.Redirect(http.StatusFound, "/login")
}
// Restore all data
func (api *API) restoreData(zipReader *zip.Reader) error {
// Ensure Directories
api.cfg.EnsureDirectories()
@ -552,7 +662,6 @@ func (api *API) restoreData(zipReader *zip.Reader) error {
return nil
}
// Remove all data
func (api *API) removeData() error {
allPaths := []string{
"covers",
@ -575,7 +684,6 @@ func (api *API) removeData() error {
return nil
}
// Backup all data
func (api *API) createBackup(w io.Writer, directories []string) error {
ar := zip.NewWriter(w)
@ -628,7 +736,11 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
if err != nil {
return err
}
io.Copy(newDbFile, dbFile)
_, err = io.Copy(newDbFile, dbFile)
if err != nil {
return err
}
// Backup Covers & Documents
for _, dir := range directories {
@ -641,3 +753,118 @@ func (api *API) createBackup(w io.Writer, directories []string) error {
ar.Close()
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)
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
if sParams.Query != nil && sParams.Source != nil {
@ -595,7 +599,6 @@ func (api *API) appEditDocument(c *gin.Context) {
}
c.Redirect(http.StatusFound, "./")
return
}
func (api *API) appDeleteDocument(c *gin.Context) {
@ -764,6 +767,11 @@ func (api *API) appSaveNewDocument(c *gin.Context) {
// Derive Extension on MIME
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()
// Derive Filename
@ -951,7 +959,11 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats
}
// 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)
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 {
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 {
qParams.Limit = &defaultLimit

View File

@ -28,18 +28,13 @@ type authKOHeader struct {
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) {
user, err := api.db.Queries.GetUser(api.db.Ctx, username)
if err != nil {
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
}
@ -57,7 +52,7 @@ func (api *API) authKOMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Check Session First
if auth, ok := api.getSession(session); ok == true {
if auth, ok := api.getSession(session); ok {
c.Set("Authorization", auth)
c.Header("Cache-Control", "private")
c.Next()
@ -98,7 +93,7 @@ func (api *API) authOPDSMiddleware(c *gin.Context) {
user, rawPassword, hasAuth := c.Request.BasicAuth()
// Validate Auth Fields
if hasAuth != true || user == "" || rawPassword == "" {
if !hasAuth || user == "" || rawPassword == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
return
}
@ -120,7 +115,7 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Check Session
if auth, ok := api.getSession(session); ok == true {
if auth, ok := api.getSession(session); ok {
c.Set("Authorization", auth)
c.Header("Cache-Control", "private")
c.Next()
@ -129,13 +124,12 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
if data, _ := c.Get("Authorization"); data != nil {
auth := data.(authData)
if auth.IsAdmin == true {
if auth.IsAdmin {
c.Next()
return
}
@ -143,7 +137,6 @@ func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required")
c.Abort()
return
}
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) {
session := sessions.Default(c)
session.Clear()
session.Save()
if err := session.Save(); err != nil {
log.Error("unable to save session")
}
c.Redirect(http.StatusFound, "/login")
}
@ -377,7 +373,10 @@ func (api *API) getSession(session sessions.Session) (auth authData, ok bool) {
// Refresh
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
log.Info("Refreshing Session")
api.setSession(session, auth)
if err := api.setSession(session, auth); err != nil {
log.Error("unable to get session")
return
}
}
// Authorized
@ -422,7 +421,11 @@ func (api *API) rotateAllAuthHashes() error {
}
// 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)
users, err := qtx.GetUsers(api.db.Ctx)
@ -430,7 +433,8 @@ func (api *API) rotateAllAuthHashes() error {
return err
}
// Update users
// Update Users
newAuthHashCache := make(map[string]string, 0)
for _, user := range users {
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
@ -448,8 +452,8 @@ func (api *API) rotateAllAuthHashes() error {
return err
}
// Update Cache
api.userAuthCache[user.ID] = fmt.Sprintf("%x", rawAuthHash)
// Save New Hash Cache
newAuthHashCache[user.ID] = fmt.Sprintf("%x", rawAuthHash)
}
// Commit Transaction
@ -458,56 +462,9 @@ func (api *API) rotateAllAuthHashes() error {
return err
}
return nil
}
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")
// Transaction Succeeded -> Update Cache
for user, hash := range newAuthHashCache {
api.userAuthCache[user] = hash
}
return nil

View File

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

View File

@ -193,7 +193,11 @@ func (api *API) koAddActivities(c *gin.Context) {
allDocuments := getKeys(allDocumentsMap)
// 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)
// Upsert Documents
@ -316,7 +320,11 @@ func (api *API) koAddDocuments(c *gin.Context) {
}
// 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)
// Upsert Documents
@ -375,11 +383,8 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
return
}
missingDocs := []database.Document{}
deletedDocIDs := []string{}
// 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 {
log.Error("GetMissingDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
@ -387,7 +392,7 @@ func (api *API) koCheckDocumentsSync(c *gin.Context) {
}
// 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 {
log.Error("GetDeletedDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")

View File

@ -155,3 +155,14 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
fileName := strings.ReplaceAll(newFileName, "/", "")
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 ----------------------- //
// ------------------------------------------------------- //
function purgeCache() {
async function purgeCache() {
console.log("[purgeCache] Purging Cache");
return caches.keys().then(function (names) {
for (let name of names) caches.delete(name);
@ -136,7 +136,7 @@ async function handleFetch(event) {
const directive = ROUTES.find(
(item) =>
(item.route instanceof RegExp && url.match(item.route)) ||
url == item.route
url == item.route,
) || { type: CACHE_NEVER };
// Get Fallback
@ -161,11 +161,11 @@ async function handleFetch(event) {
);
case CACHE_UPDATE_SYNC:
return updateCache(event.request).catch(
(e) => currentCache || fallbackFunc(event)
(e) => currentCache || fallbackFunc(event),
);
case CACHE_UPDATE_ASYNC:
let newResponse = updateCache(event.request).catch((e) =>
fallbackFunc(event)
fallbackFunc(event),
);
return currentCache || newResponse;
@ -192,7 +192,7 @@ function handleMessage(event) {
.filter(
(item) =>
item.startsWith("/documents/") ||
item.startsWith("/reader/progress/")
item.startsWith("/reader/progress/"),
);
// Derive Unique IDs
@ -200,8 +200,8 @@ function handleMessage(event) {
new Set(
docResources
.filter((item) => item.startsWith("/documents/"))
.map((item) => item.split("/")[2])
)
.map((item) => item.split("/")[2]),
),
);
/**
@ -214,14 +214,14 @@ function handleMessage(event) {
.filter(
(id) =>
docResources.includes("/documents/" + id + "/file") &&
docResources.includes("/reader/progress/" + id)
docResources.includes("/reader/progress/" + id),
)
.map(async (id) => {
let url = "/reader/progress/" + id;
let currentCache = await caches.match(url);
let resp = await updateCache(url).catch((e) => currentCache);
return resp.json();
})
}),
);
event.source.postMessage({ id, data: cachedDocuments });
@ -233,7 +233,7 @@ function handleMessage(event) {
Promise.all([
cache.delete("/documents/" + data.id + "/file"),
cache.delete("/reader/progress/" + data.id),
])
]),
)
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
.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 {
ID string `json:"id"`
Md5 *string `json:"md5"`
Basepath *string `json:"basepath"`
Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"`
Title *string `json:"title"`

View File

@ -396,6 +396,7 @@ RETURNING *;
INSERT INTO documents (
id,
md5,
basepath,
filepath,
coverfile,
title,
@ -410,10 +411,11 @@ INSERT INTO documents (
isbn10,
isbn13
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE
SET
md5 = COALESCE(excluded.md5, md5),
basepath = COALESCE(excluded.basepath, basepath),
filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile),
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
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
`
@ -464,6 +464,7 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
err := row.Scan(
&i.ID,
&i.Md5,
&i.Basepath,
&i.Filepath,
&i.Coverfile,
&i.Title,
@ -612,7 +613,7 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS
}
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
LIMIT ?2
OFFSET ?1
@ -635,6 +636,7 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D
if err := rows.Scan(
&i.ID,
&i.Md5,
&i.Basepath,
&i.Filepath,
&i.Coverfile,
&i.Title,
@ -819,7 +821,7 @@ func (q *Queries) GetLastActivity(ctx context.Context, arg GetLastActivityParams
}
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
documents.filepath IS NOT NULL
AND documents.deleted = false
@ -848,6 +850,7 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string)
if err := rows.Scan(
&i.ID,
&i.Md5,
&i.Basepath,
&i.Filepath,
&i.Coverfile,
&i.Title,
@ -1325,6 +1328,7 @@ const upsertDocument = `-- name: UpsertDocument :one
INSERT INTO documents (
id,
md5,
basepath,
filepath,
coverfile,
title,
@ -1339,10 +1343,11 @@ INSERT INTO documents (
isbn10,
isbn13
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE
SET
md5 = COALESCE(excluded.md5, md5),
basepath = COALESCE(excluded.basepath, basepath),
filepath = COALESCE(excluded.filepath, filepath),
coverfile = COALESCE(excluded.coverfile, coverfile),
title = COALESCE(excluded.title, title),
@ -1356,12 +1361,13 @@ SET
gbid = COALESCE(excluded.gbid, gbid),
isbn10 = COALESCE(excluded.isbn10, isbn10),
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 {
ID string `json:"id"`
Md5 *string `json:"md5"`
Basepath *string `json:"basepath"`
Filepath *string `json:"filepath"`
Coverfile *string `json:"coverfile"`
Title *string `json:"title"`
@ -1381,6 +1387,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
row := q.db.QueryRowContext(ctx, upsertDocument,
arg.ID,
arg.Md5,
arg.Basepath,
arg.Filepath,
arg.Coverfile,
arg.Title,
@ -1399,6 +1406,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
err := row.Scan(
&i.ID,
&i.Md5,
&i.Basepath,
&i.Filepath,
&i.Coverfile,
&i.Title,

View File

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

View File

@ -294,5 +294,3 @@ INNER JOIN
ON ga.document_id = d.id
GROUP BY ga.document_id, ga.user_id
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/reflect2 v1.0.2 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect

View File

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

View File

@ -54,6 +54,19 @@
display: none;
}
/* ----------------------------- */
/* -------- CSS Button -------- */
/* ----------------------------- */
.css-button:checked+div {
visibility: visible;
opacity: 1;
}
.css-button+div {
visibility: hidden;
opacity: 0;
}
/* ----------------------------- */
/* ------- 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 class="flex flex-col justify-around gap-2 mr-4">
<div class="inline-flex gap-2 items-center">
<input checked type="radio" id="copy" name="type" value="COPY" />
<label for="copy">Copy</label>
<input checked type="radio" id="direct" name="type" value="DIRECT" />
<label for="direct">Direct</label>
</div>
<div class="inline-flex gap-2 items-center">
<input type="radio" id="direct" name="type" value="DIRECT" />
<label for="direct">Direct</label>
<input type="radio" id="copy" name="type" value="COPY" />
<label for="copy">Copy</label>
</div>
</div>
</div>

View File

@ -35,6 +35,7 @@
<label class="cursor-pointer" for="add-button">{{ template "svg/add" }}</label>
</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">
Permissions
</th>
@ -55,6 +56,33 @@
<td class="p-3 border-b border-gray-200">
<p>{{ $user.ID }}</p>
</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">
<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>

View File

@ -89,7 +89,7 @@
enctype="multipart/form-data"
action="./documents"
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"
type="submit">Upload File</button>
</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"
for="upload-file-button">{{ template "svg/upload" (dict "Size" 34) }}</label>
</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 }}