Initial Commit

This commit is contained in:
2023-09-18 19:57:18 -04:00
commit 1a1fb31a3c
52 changed files with 6882 additions and 0 deletions

137
api/api.go Normal file
View File

@@ -0,0 +1,137 @@
package api
import (
"crypto/rand"
"html/template"
"net/http"
"github.com/gin-contrib/multitemplate"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"reichard.io/bbank/config"
"reichard.io/bbank/database"
"reichard.io/bbank/graph"
)
type API struct {
Router *gin.Engine
Config *config.Config
DB *database.DBManager
}
func NewApi(db *database.DBManager, c *config.Config) *API {
api := &API{
Router: gin.Default(),
Config: c,
DB: db,
}
// Assets & Web App Templates
api.Router.Static("/assets", "./assets")
// Generate Secure Token
newToken, err := generateToken(64)
if err != nil {
panic("Unable to generate secure token")
}
// Configure Cookie Session Store
store := cookie.NewStore(newToken)
store.Options(sessions.Options{
MaxAge: 60 * 60 * 24,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
api.Router.Use(sessions.Sessions("token", store))
// Register Web App Route
api.registerWebAppRoutes()
// Register API Routes
apiGroup := api.Router.Group("/api")
api.registerKOAPIRoutes(apiGroup)
api.registerWebAPIRoutes(apiGroup)
return api
}
func (api *API) registerWebAppRoutes() {
// Define Templates & Helper Functions
render := multitemplate.NewRenderer()
helperFuncs := template.FuncMap{
"GetSVGGraphData": graph.GetSVGGraphData,
}
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html")
render.AddFromFilesFuncs("graphs", helperFuncs, "templates/base.html", "templates/graphs.html")
render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html")
render.AddFromFilesFuncs("documents", helperFuncs, "templates/base.html", "templates/documents.html")
api.Router.HTMLRender = render
api.Router.GET("/login", api.createAppResourcesRoute("login"))
api.Router.GET("/register", api.createAppResourcesRoute("login", gin.H{"Register": true}))
api.Router.GET("/logout", api.authWebAppMiddleware, api.authLogout)
api.Router.POST("/login", api.authFormLogin)
api.Router.POST("/register", api.authFormRegister)
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocumentFile)
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
// TODO
api.Router.GET("/activity", api.authWebAppMiddleware, baseResourceRoute("activity"))
api.Router.GET("/graphs", api.authWebAppMiddleware, baseResourceRoute("graphs"))
}
func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
koGroup := apiGroup.Group("/ko")
koGroup.GET("/info", api.serverInfo)
koGroup.POST("/users/create", api.createUser)
koGroup.GET("/users/auth", api.authAPIMiddleware, api.authorizeUser)
koGroup.PUT("/syncs/progress", api.authAPIMiddleware, api.setProgress)
koGroup.GET("/syncs/progress/:document", api.authAPIMiddleware, api.getProgress)
koGroup.POST("/documents", api.authAPIMiddleware, api.addDocuments)
koGroup.POST("/syncs/documents", api.authAPIMiddleware, api.checkDocumentsSync)
koGroup.PUT("/documents/:document/file", api.authAPIMiddleware, api.uploadDocumentFile)
koGroup.GET("/documents/:document/file", api.authAPIMiddleware, api.downloadDocumentFile)
koGroup.POST("/activity", api.authAPIMiddleware, api.addActivities)
koGroup.POST("/syncs/activity", api.authAPIMiddleware, api.checkActivitySync)
}
func (api *API) registerWebAPIRoutes(apiGroup *gin.RouterGroup) {
v1Group := apiGroup.Group("/v1")
v1Group.GET("/info", api.serverInfo)
v1Group.POST("/users", api.createUser)
v1Group.GET("/users", api.authAPIMiddleware, api.getUsers)
v1Group.POST("/documents", api.authAPIMiddleware, api.checkDocumentsSync)
v1Group.GET("/documents", api.authAPIMiddleware, api.getDocuments)
v1Group.GET("/documents/:document/file", api.authAPIMiddleware, api.downloadDocumentFile)
v1Group.PUT("/documents/:document/file", api.authAPIMiddleware, api.uploadDocumentFile)
v1Group.GET("/activity", api.authAPIMiddleware, api.getActivity)
v1Group.GET("/devices", api.authAPIMiddleware, api.getDevices)
}
func generateToken(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return nil, err
}
return b, nil
}

169
api/app-routes.go Normal file
View File

@@ -0,0 +1,169 @@
package api
import (
"fmt"
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"reichard.io/bbank/database"
"reichard.io/bbank/metadata"
)
func baseResourceRoute(template string, args ...map[string]any) func(c *gin.Context) {
variables := gin.H{"RouteName": template}
if len(args) > 0 {
variables = args[0]
}
return func(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
variables["User"] = rUser
c.HTML(http.StatusOK, template, variables)
}
}
func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any) func(*gin.Context) {
// Merge Optional Template Data
var templateVars = gin.H{}
if len(args) > 0 {
templateVars = args[0]
}
templateVars["RouteName"] = routeName
return func(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
qParams := bindQueryParams(c)
templateVars["User"] = rUser
if routeName == "documents" {
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
UserID: rUser.(string),
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
})
if err != nil {
log.Info(err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
templateVars["Data"] = documents
} else if routeName == "home" {
weekly_streak, _ := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{
UserID: rUser.(string),
Window: "WEEK",
})
daily_streak, _ := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{
UserID: rUser.(string),
Window: "DAY",
})
database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, rUser.(string))
read_graph_data, err := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, rUser.(string))
if err != nil {
log.Info("HMMMM:", err)
}
templateVars["Data"] = gin.H{
"DailyStreak": daily_streak,
"WeeklyStreak": weekly_streak,
"DatabaseInfo": database_info,
"GraphData": read_graph_data,
}
}
c.HTML(http.StatusOK, routeName, templateVars)
}
}
func (api *API) getDocumentCover(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
c.AbortWithStatus(http.StatusBadRequest)
return
}
// Validate Document Exists in DB
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil {
c.AbortWithStatus(http.StatusBadRequest)
return
}
// Handle Identified Document
if document.Olid != nil {
if *document.Olid == "UNKNOWN" {
c.Redirect(http.StatusFound, "/assets/no-cover.jpg")
return
}
// Derive Path
fileName := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", *document.Olid))
safePath := filepath.Join(api.Config.DataPath, "covers", fileName)
// Validate File Exists
_, err = os.Stat(safePath)
if err != nil {
c.Redirect(http.StatusFound, "/assets/no-cover.jpg")
return
}
c.File(safePath)
return
}
/*
This is a bit convoluted because we want to ensure we set the OLID to
UNKNOWN if there are any errors. This will ideally prevent us from
hitting the OpenLibrary API multiple times in the future.
*/
var coverID string = "UNKNOWN"
var coverFilePath *string
// Identify Documents & Save Covers
coverIDs, err := metadata.GetCoverIDs(document.Title, document.Author)
if err == nil && len(coverIDs) > 0 {
coverFilePath, err = metadata.DownloadAndSaveCover(coverIDs[0], api.Config.DataPath)
if err == nil {
coverID = coverIDs[0]
}
}
// Upsert Document
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: document.ID,
Olid: &coverID,
}); err != nil {
log.Error("Document Upsert Error")
}
// Return Unknown Cover
if coverID == "UNKNOWN" {
c.Redirect(http.StatusFound, "/assets/no-cover.jpg")
return
}
c.File(*coverFilePath)
}
/*
METADATA:
- Metadata Match
- Update Metadata
*/
/*
GRAPHS:
- Streaks (Daily, Weekly, Monthly)
- Last Week Activity (Daily - Pages & Time)
- Pages Read (Daily, Weekly, Monthly)
- Reading Progress
- Average Reading Time (Daily, Weekly, Monthly)
*/

167
api/auth.go Normal file
View File

@@ -0,0 +1,167 @@
package api
import (
"crypto/md5"
"fmt"
"net/http"
"strings"
argon2 "github.com/alexedwards/argon2id"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"reichard.io/bbank/database"
)
type authHeader struct {
AuthUser string `header:"x-auth-user"`
AuthKey string `header:"x-auth-key"`
}
func (api *API) authorizeCredentials(username string, password string) (authorized bool) {
user, err := api.DB.Queries.GetUser(api.DB.Ctx, username)
if err != nil {
return false
}
if match, err := argon2.ComparePasswordAndHash(password, user.Pass); err != nil || match != true {
return false
}
return true
}
func (api *API) authAPIMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Utilize Session Token
if authorizedUser := session.Get("authorizedUser"); authorizedUser != nil {
c.Set("AuthorizedUser", authorizedUser)
c.Next()
return
}
var rHeader authHeader
if err := c.ShouldBindHeader(&rHeader); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Incorrect Headers"})
return
}
if rHeader.AuthUser == "" || rHeader.AuthKey == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization Headers"})
return
}
if authorized := api.authorizeCredentials(rHeader.AuthUser, rHeader.AuthKey); authorized != true {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
// Set Session Cookie
session.Set("authorizedUser", rHeader.AuthUser)
session.Save()
c.Set("AuthorizedUser", rHeader.AuthUser)
c.Next()
}
func (api *API) authWebAppMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Utilize Session Token
if authorizedUser := session.Get("authorizedUser"); authorizedUser != nil {
c.Set("AuthorizedUser", authorizedUser)
c.Next()
return
}
c.Redirect(http.StatusFound, "/login")
c.Abort()
}
func (api *API) authFormLogin(c *gin.Context) {
username := strings.TrimSpace(c.PostForm("username"))
rawPassword := strings.TrimSpace(c.PostForm("password"))
if username == "" || rawPassword == "" {
c.HTML(http.StatusUnauthorized, "login", gin.H{
"Error": "Invalid Credentials",
})
return
}
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
if authorized := api.authorizeCredentials(username, password); authorized != true {
c.HTML(http.StatusUnauthorized, "login", gin.H{
"Error": "Invalid Credentials",
})
return
}
session := sessions.Default(c)
// Set Session Cookie
session.Set("authorizedUser", username)
session.Save()
c.Redirect(http.StatusFound, "/")
}
func (api *API) authLogout(c *gin.Context) {
session := sessions.Default(c)
session.Clear()
session.Save()
c.Redirect(http.StatusFound, "/login")
}
func (api *API) authFormRegister(c *gin.Context) {
username := strings.TrimSpace(c.PostForm("username"))
rawPassword := strings.TrimSpace(c.PostForm("password"))
if username == "" || rawPassword == "" {
c.HTML(http.StatusBadRequest, "login", gin.H{
"Register": true,
"Error": "Registration Disabled or User Already Exists",
})
return
}
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
c.HTML(http.StatusBadRequest, "login", gin.H{
"Register": true,
"Error": "Registration Disabled or User Already Exists",
})
return
}
rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{
ID: username,
Pass: hashedPassword,
})
// SQL Error
if err != nil {
c.HTML(http.StatusBadRequest, "login", gin.H{
"Register": true,
"Error": "Registration Disabled or User Already Exists",
})
return
}
// User Already Exists
if rows == 0 {
c.HTML(http.StatusBadRequest, "login", gin.H{
"Register": true,
"Error": "Registration Disabled or User Already Exists",
})
return
}
session := sessions.Default(c)
// Set Session Cookie
session.Set("authorizedUser", username)
session.Save()
c.Redirect(http.StatusFound, "/")
}

593
api/ko-routes.go Normal file
View File

@@ -0,0 +1,593 @@
package api
import (
"crypto/md5"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"reichard.io/bbank/database"
)
type activityItem struct {
DocumentID string `json:"document"`
StartTime int64 `json:"start_time"`
Duration int64 `json:"duration"`
CurrentPage int64 `json:"current_page"`
TotalPages int64 `json:"total_pages"`
}
type requestActivity struct {
DeviceID string `json:"device_id"`
Device string `json:"device"`
Activity []activityItem `json:"activity"`
}
type requestCheckActivitySync struct {
DeviceID string `json:"device_id"`
}
type requestDocument struct {
Documents []database.Document `json:"documents"`
}
type requestPosition struct {
DocumentID string `json:"document"`
Percentage float64 `json:"percentage"`
Progress string `json:"progress"`
Device string `json:"device"`
DeviceID string `json:"device_id"`
}
type requestUser struct {
Username string `json:"username"`
Password string `json:"password"`
}
type requestCheckDocumentSync struct {
DeviceID string `json:"device_id"`
Device string `json:"device"`
Have []string `json:"have"`
}
type responseCheckDocumentSync struct {
Want []string `json:"want"`
Give []database.Document `json:"give"`
Delete []string `json:"deleted"`
}
type requestDocumentID struct {
DocumentID string `uri:"document" binding:"required"`
}
var allowedExtensions []string = []string{".epub", ".html"}
func (api *API) authorizeUser(c *gin.Context) {
c.JSON(200, gin.H{
"authorized": "OK",
})
}
func (api *API) createUser(c *gin.Context) {
var rUser requestUser
if err := c.ShouldBindJSON(&rUser); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
return
}
if rUser.Username == "" || rUser.Password == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
return
}
hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
// TODO - Initial User is Admin & Enable / Disable Registration
rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{
ID: rUser.Username,
Pass: hashedPassword,
})
// SQL Error
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
return
}
// User Exists (ON CONFLICT DO NOTHING)
if rows == 0 {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "User Already Exists"})
return
}
// TODO: Struct -> JSON
c.JSON(http.StatusCreated, gin.H{
"username": rUser.Username,
})
}
func (api *API) setProgress(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rPosition requestPosition
if err := c.ShouldBindJSON(&rPosition); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Progress Data"})
return
}
// Upsert Device
device, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
ID: rPosition.DeviceID,
UserID: rUser.(string),
DeviceName: rPosition.Device,
})
if err != nil {
log.Error("Device Upsert Error:", device, err)
}
// Upsert Document
document, err := api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: rPosition.DocumentID,
})
if err != nil {
log.Error("Document Upsert Error:", document, err)
}
// Create or Replace Progress
progress, err := api.DB.Queries.UpdateProgress(api.DB.Ctx, database.UpdateProgressParams{
Percentage: rPosition.Percentage,
DocumentID: rPosition.DocumentID,
DeviceID: rPosition.DeviceID,
UserID: rUser.(string),
Progress: rPosition.Progress,
})
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// TODO: Struct -> JSON
c.JSON(http.StatusOK, gin.H{
"document": progress.DocumentID,
"timestamp": progress.CreatedAt,
})
}
func (api *API) getProgress(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
progress, err := api.DB.Queries.GetProgress(api.DB.Ctx, database.GetProgressParams{
DocumentID: rDocID.DocumentID,
UserID: rUser.(string),
})
if err != nil {
log.Error("Invalid Progress:", progress, err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
// TODO: Struct -> JSON
c.JSON(http.StatusOK, gin.H{
"document": progress.DocumentID,
"percentage": progress.Percentage,
"progress": progress.Progress,
"device": progress.DeviceName,
"device_id": progress.DeviceID,
})
}
func (api *API) addActivities(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rActivity requestActivity
if err := c.ShouldBindJSON(&rActivity); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
return
}
// Do Transaction
tx, err := api.DB.DB.Begin()
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
// Derive Unique Documents
allDocumentsMap := make(map[string]bool)
for _, item := range rActivity.Activity {
allDocumentsMap[item.DocumentID] = true
}
allDocuments := getKeys(allDocumentsMap)
// Defer & Start Transaction
defer tx.Rollback()
qtx := api.DB.Queries.WithTx(tx)
// Upsert Documents
for _, doc := range allDocuments {
_, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: doc,
})
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
}
// Upsert Device
_, err = qtx.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
ID: rActivity.DeviceID,
UserID: rUser.(string),
DeviceName: rActivity.Device,
})
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
return
}
// Add All Activity
for _, item := range rActivity.Activity {
_, err := qtx.AddActivity(api.DB.Ctx, database.AddActivityParams{
UserID: rUser.(string),
DocumentID: item.DocumentID,
DeviceID: rActivity.DeviceID,
StartTime: time.Unix(int64(item.StartTime), 0).UTC(),
Duration: int64(item.Duration),
CurrentPage: int64(item.CurrentPage),
TotalPages: int64(item.TotalPages),
})
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
return
}
}
// Commit Transaction
tx.Commit()
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
c.JSON(http.StatusOK, gin.H{
"added": len(rActivity.Activity),
})
}
func (api *API) checkActivitySync(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rCheckActivity requestCheckActivitySync
if err := c.ShouldBindJSON(&rCheckActivity); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Get Last Device Activity
lastActivity, err := api.DB.Queries.GetLastActivity(api.DB.Ctx, database.GetLastActivityParams{
UserID: rUser.(string),
DeviceID: rCheckActivity.DeviceID,
})
if err == sql.ErrNoRows {
lastActivity = time.UnixMilli(0)
} else if err != nil {
log.Error("GetLastActivity Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
c.JSON(http.StatusOK, gin.H{
"last_sync": lastActivity.Unix(),
})
}
func (api *API) addDocuments(c *gin.Context) {
var rNewDocs requestDocument
if err := c.ShouldBindJSON(&rNewDocs); err != nil {
log.Error("[addDocuments] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document(s)"})
return
}
// Do Transaction
tx, err := api.DB.DB.Begin()
if err != nil {
log.Error("[addDocuments] Unknown Transaction Error")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
// Defer & Start Transaction
defer tx.Rollback()
qtx := api.DB.Queries.WithTx(tx)
// Upsert Documents
for _, doc := range rNewDocs.Documents {
doc, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: doc.ID,
Title: doc.Title,
Author: doc.Author,
Series: doc.Series,
SeriesIndex: doc.SeriesIndex,
Lang: doc.Lang,
Description: doc.Description,
})
if err != nil {
log.Error("[addDocuments] UpsertDocument Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
_, err = qtx.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
ID: doc.ID,
Synced: true,
})
if err != nil {
log.Error("[addDocuments] UpsertDocumentSync Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
}
// Commit Transaction
tx.Commit()
c.JSON(http.StatusOK, gin.H{
"changed": len(rNewDocs.Documents),
})
}
func (api *API) checkDocumentsSync(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rCheckDocs requestCheckDocumentSync
if err := c.ShouldBindJSON(&rCheckDocs); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Upsert Device
device, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
ID: rCheckDocs.DeviceID,
UserID: rUser.(string),
DeviceName: rCheckDocs.Device,
})
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
return
}
missingDocs := []database.Document{}
deletedDocIDs := []string{}
if device.Sync == true {
// Get Missing Documents
missingDocs, err = api.DB.Queries.GetMissingDocuments(api.DB.Ctx, rCheckDocs.Have)
if err != nil {
log.Error("GetMissingDocuments Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Get Deleted Documents
deletedDocIDs, err = api.DB.Queries.GetDeletedDocuments(api.DB.Ctx, rCheckDocs.Have)
if err != nil {
log.Error("GetDeletedDocuements Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
}
// Get Wanted Documents
jsonHaves, err := json.Marshal(rCheckDocs.Have)
if err != nil {
log.Error("JSON Marshal Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
wantedDocIDs, err := api.DB.Queries.GetWantedDocuments(api.DB.Ctx, string(jsonHaves))
if err != nil {
log.Error("GetWantedDocuments Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
rCheckDocSync := responseCheckDocumentSync{
Delete: []string{},
Want: []string{},
Give: []database.Document{},
}
// Ensure Empty Array
if wantedDocIDs != nil {
rCheckDocSync.Want = wantedDocIDs
}
if missingDocs != nil {
rCheckDocSync.Give = missingDocs
}
if deletedDocIDs != nil {
rCheckDocSync.Delete = deletedDocIDs
}
c.JSON(http.StatusOK, rCheckDocSync)
}
func (api *API) uploadDocumentFile(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
fileData, err := c.FormFile("file")
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return
}
// Validate Type & Derive Extension on MIME
uploadedFile, err := fileData.Open()
fileMime, err := mimetype.DetectReader(uploadedFile)
fileExtension := fileMime.Extension()
if !slices.Contains(allowedExtensions, fileExtension) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"})
return
}
// Validate Document Exists in DB
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
return
}
// Derive Filename
var fileName string
if document.Author != nil {
fileName = fileName + *document.Author
} else {
fileName = fileName + "Unknown"
}
if document.Title != nil {
fileName = fileName + " - " + *document.Title
} else {
fileName = fileName + " - Unknown"
}
// Derive & Sanitize File Name
fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, document.ID, fileExtension))
// Generate Storage Path
safePath := filepath.Join(api.Config.DataPath, "documents", fileName)
// Save & Prevent Overwrites
_, err = os.Stat(safePath)
if os.IsNotExist(err) {
err = c.SaveUploadedFile(fileData, safePath)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return
}
}
// Get MD5 Hash
fileHash, err := getFileMD5(safePath)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
return
}
// Upsert Document
_, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: document.ID,
Md5: fileHash,
Filepath: &fileName,
})
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"})
return
}
// Update Document Sync Attribute
_, err = api.DB.Queries.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
ID: document.ID,
Synced: true,
})
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
}
func (api *API) downloadDocumentFile(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Get Document
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
return
}
if document.Filepath == nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exist"})
return
}
// Derive Storage Location
filePath := filepath.Join(api.Config.DataPath, "documents", *document.Filepath)
// Validate File Exists
_, err = os.Stat(filePath)
if os.IsNotExist(err) {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Doesn't Exists"})
return
}
// Force Download (Security)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filepath.Base(*document.Filepath)))
c.File(filePath)
}
func getKeys[M ~map[K]V, K comparable, V any](m M) []K {
r := make([]K, 0, len(m))
for k := range m {
r = append(r, k)
}
return r
}
func getFileMD5(filePath string) (*string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
hash := md5.New()
_, err = io.Copy(hash, file)
if err != nil {
return nil, err
}
fileHash := fmt.Sprintf("%x", hash.Sum(nil))
return &fileHash, nil
}

163
api/web-routes.go Normal file
View File

@@ -0,0 +1,163 @@
package api
import (
"net/http"
argon2 "github.com/alexedwards/argon2id"
"github.com/gin-gonic/gin"
"reichard.io/bbank/database"
)
type infoResponse struct {
Authorized bool `json:"authorized"`
Version string `json:"version"`
}
type queryParams struct {
Page *int64 `form:"page"`
Limit *int64 `form:"limit"`
Document *string `form:"document"`
}
func bindQueryParams(c *gin.Context) queryParams {
var qParams queryParams
c.BindQuery(&qParams)
if qParams.Limit == nil {
var defaultValue int64 = 50
qParams.Limit = &defaultValue
} else if *qParams.Limit < 0 {
var zeroValue int64 = 0
qParams.Limit = &zeroValue
}
if qParams.Page == nil || *qParams.Page < 1 {
var oneValue int64 = 0
qParams.Page = &oneValue
}
return qParams
}
func (api *API) serverInfo(c *gin.Context) {
respData := infoResponse{
Authorized: false,
Version: api.Config.Version,
}
var rHeader authHeader
if err := c.ShouldBindHeader(&rHeader); err != nil {
c.JSON(200, respData)
return
}
if rHeader.AuthUser == "" || rHeader.AuthKey == "" {
c.JSON(200, respData)
return
}
user, err := api.DB.Queries.GetUser(api.DB.Ctx, rHeader.AuthUser)
if err != nil {
c.JSON(200, respData)
return
}
match, err := argon2.ComparePasswordAndHash(rHeader.AuthKey, user.Pass)
if err != nil || match != true {
c.JSON(200, respData)
return
}
respData.Authorized = true
c.JSON(200, respData)
}
func (api *API) getDocuments(c *gin.Context) {
qParams := bindQueryParams(c)
documents, err := api.DB.Queries.GetDocuments(api.DB.Ctx, database.GetDocumentsParams{
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
})
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
if documents == nil {
documents = []database.Document{}
}
c.JSON(http.StatusOK, documents)
}
func (api *API) getUsers(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
qParams := bindQueryParams(c)
users, err := api.DB.Queries.GetUsers(api.DB.Ctx, database.GetUsersParams{
User: rUser.(string),
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
})
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
if users == nil {
users = []database.User{}
}
c.JSON(http.StatusOK, users)
}
func (api *API) getActivity(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
qParams := bindQueryParams(c)
dbActivityParams := database.GetActivityParams{
UserID: rUser.(string),
DocFilter: false,
DocumentID: "",
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
}
if qParams.Document != nil {
dbActivityParams.DocFilter = true
dbActivityParams.DocumentID = *qParams.Document
}
activity, err := api.DB.Queries.GetActivity(api.DB.Ctx, dbActivityParams)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
if activity == nil {
activity = []database.Activity{}
}
c.JSON(http.StatusOK, activity)
}
func (api *API) getDevices(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
qParams := bindQueryParams(c)
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, database.GetDevicesParams{
UserID: rUser.(string),
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
})
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
if devices == nil {
devices = []database.Device{}
}
c.JSON(http.StatusOK, devices)
}