[add] settings (pw & time offset), [fix] PWA issues, [fix] misc styling issues

This commit is contained in:
Evan Reichard 2023-09-27 18:58:47 -04:00
parent 25ab36e4b5
commit 2757f10bfc
14 changed files with 512 additions and 93 deletions

View File

@ -14,6 +14,7 @@ import (
"reichard.io/bbank/config" "reichard.io/bbank/config"
"reichard.io/bbank/database" "reichard.io/bbank/database"
"reichard.io/bbank/graph" "reichard.io/bbank/graph"
"reichard.io/bbank/utils"
) )
type API struct { type API struct {
@ -74,11 +75,13 @@ func (api *API) registerWebAppRoutes() {
render := multitemplate.NewRenderer() render := multitemplate.NewRenderer()
helperFuncs := template.FuncMap{ helperFuncs := template.FuncMap{
"GetSVGGraphData": graph.GetSVGGraphData, "GetSVGGraphData": graph.GetSVGGraphData,
"GetUTCOffsets": utils.GetUTCOffsets,
} }
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html") render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html") render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html")
render.AddFromFilesFuncs("graphs", helperFuncs, "templates/base.html", "templates/graphs.html") render.AddFromFilesFuncs("graphs", helperFuncs, "templates/base.html", "templates/graphs.html")
render.AddFromFilesFuncs("settings", helperFuncs, "templates/base.html", "templates/settings.html")
render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html") render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html")
render.AddFromFilesFuncs("documents", helperFuncs, "templates/base.html", "templates/documents.html") render.AddFromFilesFuncs("documents", helperFuncs, "templates/base.html", "templates/documents.html")
render.AddFromFilesFuncs("document", helperFuncs, "templates/base.html", "templates/document.html") render.AddFromFilesFuncs("document", helperFuncs, "templates/base.html", "templates/document.html")
@ -93,6 +96,8 @@ func (api *API) registerWebAppRoutes() {
api.Router.POST("/register", api.authFormRegister) api.Router.POST("/register", api.authFormRegister)
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home")) api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
api.Router.GET("/settings", api.authWebAppMiddleware, api.createAppResourcesRoute("settings"))
api.Router.POST("/settings", api.authWebAppMiddleware, api.editSettings)
api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity")) api.Router.GET("/activity", api.authWebAppMiddleware, api.createAppResourcesRoute("activity"))
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents")) api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document")) api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))

View File

@ -1,6 +1,7 @@
package api package api
import ( import (
"crypto/md5"
"fmt" "fmt"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@ -9,6 +10,7 @@ import (
"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"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@ -40,6 +42,12 @@ type requestDocumentIdentify struct {
ISBN *string `form:"isbn"` ISBN *string `form:"isbn"`
} }
type requestSettingsEdit struct {
Password *string `form:"password"`
NewPassword *string `form:"new_password"`
TimeOffset *string `form:"time_offset"`
}
func baseResourceRoute(template string, args ...map[string]any) func(c *gin.Context) { func baseResourceRoute(template string, args ...map[string]any) func(c *gin.Context) {
variables := gin.H{"RouteName": template} variables := gin.H{"RouteName": template}
if len(args) > 0 { if len(args) > 0 {
@ -167,6 +175,27 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
"DatabaseInfo": database_info, "DatabaseInfo": database_info,
"GraphData": read_graph_data, "GraphData": read_graph_data,
} }
} else if routeName == "settings" {
user, err := api.DB.Queries.GetUser(api.DB.Ctx, rUser.(string))
if err != nil {
log.Error("[createAppResourcesRoute] GetUser DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string))
if err != nil {
log.Error("[createAppResourcesRoute] GetDevices DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
templateVars["Data"] = gin.H{
"Settings": gin.H{
"TimeOffset": *user.TimeOffset,
},
"Devices": devices,
}
} else if routeName == "login" { } else if routeName == "login" {
templateVars["RegistrationEnabled"] = api.Config.RegistrationEnabled templateVars["RegistrationEnabled"] = api.Config.RegistrationEnabled
} }
@ -471,6 +500,88 @@ func (api *API) identifyDocument(c *gin.Context) {
c.HTML(http.StatusOK, "document", templateVars) c.HTML(http.StatusOK, "document", templateVars)
} }
func (api *API) editSettings(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rUserSettings requestSettingsEdit
if err := c.ShouldBind(&rUserSettings); err != nil {
log.Error("[editSettings] Invalid Form Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Validate Something Exists
if rUserSettings.Password == nil && rUserSettings.NewPassword == nil && rUserSettings.TimeOffset == nil {
log.Error("[editSettings] Missing Form Values")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
templateVars := gin.H{
"User": rUser,
}
newUserSettings := database.UpdateUserParams{
UserID: rUser.(string),
}
// Set New Password
if rUserSettings.Password != nil && rUserSettings.NewPassword != nil {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.Password)))
authorized := api.authorizeCredentials(rUser.(string), password)
if authorized == true {
password := fmt.Sprintf("%x", md5.Sum([]byte(*rUserSettings.NewPassword)))
hashedPassword, err := argon2.CreateHash(password, argon2.DefaultParams)
if err != nil {
templateVars["PasswordErrorMessage"] = "Unknown Error"
} else {
templateVars["PasswordMessage"] = "Password Updated"
newUserSettings.Password = &hashedPassword
}
} else {
templateVars["PasswordErrorMessage"] = "Invalid Password"
}
}
// Set Time Offset
if rUserSettings.TimeOffset != nil {
templateVars["TimeOffsetMessage"] = "Time Offset Updated"
newUserSettings.TimeOffset = rUserSettings.TimeOffset
}
// Update User
_, err := api.DB.Queries.UpdateUser(api.DB.Ctx, newUserSettings)
if err != nil {
log.Error("[editSettings] UpdateUser DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Get User
user, err := api.DB.Queries.GetUser(api.DB.Ctx, rUser.(string))
if err != nil {
log.Error("[editSettings] GetUser DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Get Devices
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string))
if err != nil {
log.Error("[editSettings] GetDevices DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
templateVars["Data"] = gin.H{
"Settings": gin.H{
"TimeOffset": *user.TimeOffset,
},
"Devices": devices,
}
c.HTML(http.StatusOK, "settings", templateVars)
}
func bindQueryParams(c *gin.Context) queryParams { func bindQueryParams(c *gin.Context) queryParams {
var qParams queryParams var qParams queryParams
c.BindQuery(&qParams) c.BindQuery(&qParams)

View File

@ -24,7 +24,7 @@ func (api *API) authorizeCredentials(username string, password string) (authoriz
return false return false
} }
if match, err := argon2.ComparePasswordAndHash(password, user.Pass); err != nil || match != true { if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true {
return false return false
} }
@ -94,7 +94,6 @@ func (api *API) authFormLogin(c *gin.Context) {
// MD5 - KOSync Compatiblity // MD5 - KOSync Compatiblity
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword))) password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
if authorized := api.authorizeCredentials(username, password); authorized != true { if authorized := api.authorizeCredentials(username, password); authorized != true {
c.HTML(http.StatusUnauthorized, "login", gin.H{ c.HTML(http.StatusUnauthorized, "login", gin.H{
"RegistrationEnabled": api.Config.RegistrationEnabled, "RegistrationEnabled": api.Config.RegistrationEnabled,
@ -140,7 +139,7 @@ func (api *API) authFormRegister(c *gin.Context) {
rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{ rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{
ID: username, ID: username,
Pass: hashedPassword, Pass: &hashedPassword,
}) })
// SQL Error // SQL Error

View File

@ -107,7 +107,7 @@ func (api *API) createUser(c *gin.Context) {
rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{ rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{
ID: rUser.Username, ID: rUser.Username,
Pass: hashedPassword, Pass: &hashedPassword,
}) })
if err != nil { if err != nil {
log.Error("[createUser] CreateUser DB Error:", err) log.Error("[createUser] CreateUser DB Error:", err)

View File

@ -2,6 +2,5 @@
"short_name": "Book Manager", "short_name": "Book Manager",
"name": "Book Manager", "name": "Book Manager",
"theme_color": "#1F2937", "theme_color": "#1F2937",
"background_color": "#1F2937",
"display": "standalone" "display": "standalone"
} }

View File

@ -26,7 +26,7 @@ type Config struct {
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
Version: "0.0.1", Version: "0.0.2",
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")), DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
DBName: trimLowerString(getEnv("DATABASE_NAME", "book_manager")), DBName: trimLowerString(getEnv("DATABASE_NAME", "book_manager")),
DBPassword: getEnv("DATABASE_PASSWORD", ""), DBPassword: getEnv("DATABASE_PASSWORD", ""),

View File

@ -24,7 +24,7 @@ type Device struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
CreatedAt time.Time `json:"created_at"` CreatedAt string `json:"created_at"`
Sync bool `json:"sync"` Sync bool `json:"sync"`
} }
@ -90,8 +90,8 @@ type RescaledActivity struct {
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Pass string `json:"-"` Pass *string `json:"-"`
Admin bool `json:"-"` Admin bool `json:"-"`
TimeOffset string `json:"time_offset"` TimeOffset *string `json:"time_offset"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }

View File

@ -21,6 +21,14 @@ ON CONFLICT DO NOTHING;
SELECT * FROM users SELECT * FROM users
WHERE id = $user_id LIMIT 1; WHERE id = $user_id LIMIT 1;
-- name: UpdateUser :one
UPDATE users
SET
pass = COALESCE($password, pass),
time_offset = COALESCE($time_offset, time_offset)
WHERE id = $user_id
RETURNING *;
-- name: UpsertDocument :one -- name: UpsertDocument :one
INSERT INTO documents ( INSERT INTO documents (
id, id,
@ -287,11 +295,15 @@ LIMIT $limit
OFFSET $offset; OFFSET $offset;
-- name: GetDevices :many -- name: GetDevices :many
SELECT * FROM devices SELECT
WHERE user_id = $user_id devices.device_name,
ORDER BY created_at DESC CAST(DATETIME(devices.created_at, users.time_offset) AS TEXT) AS created_at,
LIMIT $limit CAST(DATETIME(MAX(activity.created_at), users.time_offset) AS TEXT) AS last_sync
OFFSET $offset; FROM activity
JOIN devices ON devices.id = activity.device_id
JOIN users ON users.id = $user_id
WHERE devices.user_id = $user_id
GROUP BY activity.device_id;
-- name: GetDocumentReadStats :one -- name: GetDocumentReadStats :one
SELECT SELECT

View File

@ -122,7 +122,7 @@ ON CONFLICT DO NOTHING
type CreateUserParams struct { type CreateUserParams struct {
ID string `json:"id"` ID string `json:"id"`
Pass string `json:"-"` Pass *string `json:"-"`
} }
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) { func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (int64, error) {
@ -376,35 +376,33 @@ func (q *Queries) GetDevice(ctx context.Context, deviceID string) (Device, error
} }
const getDevices = `-- name: GetDevices :many const getDevices = `-- name: GetDevices :many
SELECT id, user_id, device_name, created_at, sync FROM devices SELECT
WHERE user_id = ?1 devices.device_name,
ORDER BY created_at DESC CAST(DATETIME(devices.created_at, users.time_offset) AS TEXT) AS created_at,
LIMIT ?3 CAST(DATETIME(MAX(activity.created_at), users.time_offset) AS TEXT) AS last_sync
OFFSET ?2 FROM activity
JOIN devices ON devices.id = activity.device_id
JOIN users ON users.id = ?1
WHERE devices.user_id = ?1
GROUP BY activity.device_id
` `
type GetDevicesParams struct { type GetDevicesRow struct {
UserID string `json:"user_id"` DeviceName string `json:"device_name"`
Offset int64 `json:"offset"` CreatedAt string `json:"created_at"`
Limit int64 `json:"limit"` LastSync string `json:"last_sync"`
} }
func (q *Queries) GetDevices(ctx context.Context, arg GetDevicesParams) ([]Device, error) { func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) {
rows, err := q.db.QueryContext(ctx, getDevices, arg.UserID, arg.Offset, arg.Limit) rows, err := q.db.QueryContext(ctx, getDevices, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close() defer rows.Close()
var items []Device var items []GetDevicesRow
for rows.Next() { for rows.Next() {
var i Device var i GetDevicesRow
if err := rows.Scan( if err := rows.Scan(&i.DeviceName, &i.CreatedAt, &i.LastSync); err != nil {
&i.ID,
&i.UserID,
&i.DeviceName,
&i.CreatedAt,
&i.Sync,
); err != nil {
return nil, err return nil, err
} }
items = append(items, i) items = append(items, i)
@ -1272,6 +1270,34 @@ func (q *Queries) UpdateProgress(ctx context.Context, arg UpdateProgressParams)
return i, err return i, err
} }
const updateUser = `-- name: UpdateUser :one
UPDATE users
SET
pass = COALESCE(?1, pass),
time_offset = COALESCE(?2, time_offset)
WHERE id = ?3
RETURNING id, pass, admin, time_offset, created_at
`
type UpdateUserParams struct {
Password *string `json:"-"`
TimeOffset *string `json:"time_offset"`
UserID string `json:"user_id"`
}
func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) {
row := q.db.QueryRowContext(ctx, updateUser, arg.Password, arg.TimeOffset, arg.UserID)
var i User
err := row.Scan(
&i.ID,
&i.Pass,
&i.Admin,
&i.TimeOffset,
&i.CreatedAt,
)
return i, err
}
const upsertDevice = `-- name: UpsertDevice :one const upsertDevice = `-- name: UpsertDevice :one
INSERT INTO devices (id, user_id, device_name) INSERT INTO devices (id, user_id, device_name)
VALUES (?, ?, ?) VALUES (?, ?, ?)

View File

@ -9,7 +9,7 @@ sql:
out: "database" out: "database"
emit_json_tags: true emit_json_tags: true
overrides: overrides:
# Type pointers needed for JSON # Documents
- column: "documents.md5" - column: "documents.md5"
go_type: go_type:
type: "string" type: "string"
@ -63,6 +63,7 @@ sql:
type: "string" type: "string"
pointer: true pointer: true
# Metadata
- column: "metadata.title" - column: "metadata.title"
go_type: go_type:
type: "string" type: "string"
@ -92,6 +93,33 @@ sql:
type: "string" type: "string"
pointer: true pointer: true
# Devices
- column: "devices.id"
go_type:
type: "string"
- column: "devices.user_id"
go_type:
type: "string"
- column: "devices.device_name"
go_type:
type: "string"
- column: "devices.sync"
go_type:
type: "bool"
- column: "devices.created_at"
go_type:
type: "string"
# Devices
- column: "users.pass"
go_type:
type: "string"
pointer: true
- column: "users.time_offset"
go_type:
type: "string"
pointer: true
# Do not generate JSON # Do not generate JSON
- column: "documents.synced" - column: "documents.synced"
go_struct_tag: 'json:"-"' go_struct_tag: 'json:"-"'

View File

@ -2,22 +2,24 @@
<html lang="en"> <html lang="en">
<head> <head>
<link rel="manifest" href="{{ .RelBase }}./manifest.json" /> <link rel="manifest" href="{{ .RelBase }}./manifest.json" />
<meta name="theme-color" content="#F3F4F6" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1F2937" media="(prefers-color-scheme: dark)">
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" <meta name="viewport"
content="width=device-width, initial-scale=0.90, user-scalable=no"> content="width=device-width, initial-scale=0.90, user-scalable=no">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<title>Book Manager - {{block "title" .}}{{end}}</title> <title>Book Manager - {{block "title" .}}{{end}}</title>
</head> </head>
<body> <body class="bg-gray-100 dark:bg-gray-800">
<main <main
class="relative h-screen overflow-hidden bg-gray-100 dark:bg-gray-800" class="relative h-screen overflow-hidden"
> >
<div class="flex items-center justify-between w-full h-16"> <div class="flex items-center justify-between w-full h-16">
<div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6"> <div id="mobile-nav-button" class="flex flex-col z-40 relative ml-6">
<input type="checkbox" class="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0 w-12 h-12" /> <input type="checkbox" class="absolute lg:hidden z-50 -top-2 w-7 h-7 flex cursor-pointer opacity-0 w-12 h-12" />
<span class="lg:hidden w-7 h-0.5 z-40 mt-0.5 bg-white"></span> <span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-0.5 dark:bg-white"></span>
<span class="lg:hidden w-7 h-0.5 z-40 mt-1 bg-white"></span> <span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
<span class="lg:hidden w-7 h-0.5 z-40 mt-1 bg-white"></span> <span class="lg:hidden bg-black w-7 h-0.5 z-40 mt-1 dark:bg-white"></span>
<div id="menu" class="fixed -mt-6 -ml-6 lg:-mt-8 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg"> <div id="menu" class="fixed -mt-6 -ml-6 lg:-mt-8 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg">
<div class="h-16 flex justify-end lg:justify-around"> <div class="h-16 flex justify-end lg:justify-around">
@ -102,7 +104,7 @@
</div> </div>
</div> </div>
</div> </div>
<h1 class="text-xl font-bold dark:text-white px-6">{{block "header" .}}{{end}}</h1> <h1 class="text-xl font-bold dark:text-white px-6 lg:ml-44">{{block "header" .}}{{end}}</h1>
<div <div
class="relative flex items-center justify-end w-full p-4 space-x-4" class="relative flex items-center justify-end w-full p-4 space-x-4"
> >
@ -134,6 +136,15 @@
aria-orientation="vertical" aria-orientation="vertical"
aria-labelledby="options-menu" aria-labelledby="options-menu"
> >
<a
href="/settings"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
role="menuitem"
>
<span class="flex flex-col">
<span>Settings</span>
</span>
</a>
<a <a
href="/logout" href="/logout"
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600" class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"
@ -175,7 +186,6 @@
<!-- Custom Animation CSS --> <!-- Custom Animation CSS -->
<style> <style>
/* ----------------------------- */ /* ----------------------------- */
/* ------- User Dropdown ------- */ /* ------- User Dropdown ------- */
/* ----------------------------- */ /* ----------------------------- */
@ -210,7 +220,6 @@
#mobile-nav-button input:checked ~ span { #mobile-nav-button input:checked ~ span {
opacity: 1; opacity: 1;
transform: rotate(45deg) translate(2px, -2px); transform: rotate(45deg) translate(2px, -2px);
background: #FFFFFF;
} }
#mobile-nav-button input:checked ~ span:nth-last-child(3) { #mobile-nav-button input:checked ~ span:nth-last-child(3) {

View File

@ -2,9 +2,20 @@
<html lang="en"> <html lang="en">
<head> <head>
<link rel="manifest" href="./manifest.json" /> <link rel="manifest" href="./manifest.json" />
<meta
name="theme-color"
content="#F3F4F6"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#1F2937"
media="(prefers-color-scheme: dark)"
/>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<title>Book Manager - {{if .Register}}Register{{else}}Login{{end}}</title>
</head> </head>
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white"> <body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
<div class="flex flex-wrap w-full"> <div class="flex flex-wrap w-full">

217
templates/settings.html Normal file
View File

@ -0,0 +1,217 @@
{{template "base.html" .}} {{define "title"}}Settings{{end}} {{define "header"}}
<a href="./settings">Settings</a>
{{end}} {{define "content"}}
<div class="h-full w-full relative">
<div class="w-full flex flex-col md:flex-row gap-4">
<div>
<div
class="flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<svg
width="60"
fill="currentColor"
height="60"
class="text-gray-800 dark:text-gray-200"
viewBox="0 0 1792 1792"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1523 1339q-22-155-87.5-257.5t-184.5-118.5q-67 74-159.5 115.5t-195.5 41.5-195.5-41.5-159.5-115.5q-119 16-184.5 118.5t-87.5 257.5q106 150 271 237.5t356 87.5 356-87.5 271-237.5zm-243-699q0-159-112.5-271.5t-271.5-112.5-271.5 112.5-112.5 271.5 112.5 271.5 271.5 112.5 271.5-112.5 112.5-271.5zm512 256q0 182-71 347.5t-190.5 286-285.5 191.5-349 71q-182 0-348-71t-286-191-191-286-71-348 71-348 191-286 286-191 348-71 348 71 286 191 191 286 71 348z"
/>
</svg>
<p class="text-lg">{{ .User }}</p>
</div>
</div>
<div class="flex flex-col gap-4 grow">
<div
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p class="text-lg font-semibold mb-2">Change Password</p>
<form
class="flex gap-4 flex-col lg:flex-row"
action="./settings"
method="POST"
>
<div class="flex flex-col grow">
<div class="flex relative">
<span
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<svg
width="15"
height="15"
fill="currentColor"
viewBox="0 0 1792 1792"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1376 768q40 0 68 28t28 68v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h32v-320q0-185 131.5-316.5t316.5-131.5 316.5 131.5 131.5 316.5q0 26-19 45t-45 19h-64q-26 0-45-19t-19-45q0-106-75-181t-181-75-181 75-75 181v320h736z"
></path>
</svg>
</span>
<input
type="password"
id="password"
name="password"
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="Password"
/>
</div>
</div>
<div class="flex flex-col grow">
<div class="flex relative">
<span
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<svg
width="15"
height="15"
fill="currentColor"
viewBox="0 0 1792 1792"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1376 768q40 0 68 28t28 68v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h32v-320q0-185 131.5-316.5t316.5-131.5 316.5 131.5 131.5 316.5q0 26-19 45t-45 19h-64q-26 0-45-19t-19-45q0-106-75-181t-181-75-181 75-75 181v320h736z"
></path>
</svg>
</span>
<input
type="password"
id="new_password"
name="new_password"
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="New Password"
/>
</div>
</div>
<button
type="submit"
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
>
<span class="w-full">Submit</span>
</button>
</form>
{{ if .PasswordErrorMessage }}
<span class="text-red-400 text-xs">{{ .PasswordErrorMessage }}</span>
{{ else if .PasswordMessage }}
<span class="text-green-400 text-xs">{{ .PasswordMessage }}</span>
{{ end }}
</div>
<div
class="flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p class="text-lg font-semibold mb-2">Change Time Offset</p>
<form
class="flex gap-4 flex-col lg:flex-row"
action="./settings"
method="POST"
>
<div class="flex relative grow">
<span
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<svg
width="16"
height="16"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 7.25C12.4142 7.25 12.75 7.58579 12.75 8V11.6893L15.0303 13.9697C15.3232 14.2626 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2626 15.3232 13.9697 15.0303L11.4697 12.5303C11.329 12.3897 11.25 12.1989 11.25 12V8C11.25 7.58579 11.5858 7.25 12 7.25Z"
fill="white"
/>
</svg>
</span>
<select
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
id="time_offset"
name="time_offset"
>
{{ range $item := GetUTCOffsets }}
<option
{{
if
(eq
$item.Value
$.Data.Settings.TimeOffset)
}}selected{{
end
}}
value="{{ $item.Value }}"
>
{{ $item.Name }}
</option>
{{ end }}
</select>
</div>
<button
type="submit"
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
>
<span class="w-full">Submit</span>
</button>
</form>
{{ if .TimeOffsetErrorMessage }}
<span class="text-red-400 text-xs">{{ .TimeOffsetErrorMessage }}</span>
{{ else if .TimeOffsetMessage }}
<span class="text-green-400 text-xs">{{ .TimeOffsetMessage }}</span>
{{ end }}
</div>
<div
class="flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<p class="text-lg font-semibold">Devices</p>
<table class="min-w-full bg-white dark:bg-gray-700 text-sm">
<thead class="text-gray-800 dark:text-gray-400">
<tr>
<th
scope="col"
class="p-3 pl-0 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Name
</th>
<th
scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Last Sync
</th>
<th
scope="col"
class="p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"
>
Created
</th>
</tr>
</thead>
<tbody class="text-black dark:text-white">
{{ range $device := .Data.Devices }}
<tr>
<td class="p-3 pl-0">
<p>{{ $device.DeviceName }}</p>
</td>
<td class="p-3">
<p>{{ $device.LastSync }}</p>
</td>
<td class="p-3">
<p>{{ $device.CreatedAt }}</p>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
</div>
{{end}}

View File

@ -1,49 +1,51 @@
package utils package utils
import ( type UTCOffset struct {
"bytes" Name string
"crypto/md5" Value string
// "encoding/hex"
"fmt"
"io"
"os"
)
func CalculatePartialMD5(filePath string) string {
file, err := os.Open(filePath)
if err != nil {
panic(err)
} }
defer file.Close() var UTC_OFFSETS = []UTCOffset{
{Value: "-12 hours", Name: "UTC12:00"},
var step int64 = 1024 {Value: "-11 hours", Name: "UTC11:00"},
var size int64 = 1024 {Value: "-10 hours", Name: "UTC10:00"},
var buf bytes.Buffer {Value: "-9.5 hours", Name: "UTC09:30"},
{Value: "-9 hours", Name: "UTC09:00"},
for i := -1; i <= 10; i++ { {Value: "-8 hours", Name: "UTC08:00"},
byteStep := make([]byte, size) {Value: "-7 hours", Name: "UTC07:00"},
{Value: "-6 hours", Name: "UTC06:00"},
var newShift int64 = int64(i * 2) {Value: "-5 hours", Name: "UTC05:00"},
var newOffset int64 {Value: "-4 hours", Name: "UTC04:00"},
if i == -1 { {Value: "-3.5 hours", Name: "UTC03:30"},
newOffset = 0 {Value: "-3 hours", Name: "UTC03:00"},
} else { {Value: "-2 hours", Name: "UTC02:00"},
newOffset = step << newShift {Value: "-1 hours", Name: "UTC01:00"},
{Value: "0 hours", Name: "UTC±00:00"},
{Value: "+1 hours", Name: "UTC+01:00"},
{Value: "+2 hours", Name: "UTC+02:00"},
{Value: "+3 hours", Name: "UTC+03:00"},
{Value: "+3.5 hours", Name: "UTC+03:30"},
{Value: "+4 hours", Name: "UTC+04:00"},
{Value: "+4.5 hours", Name: "UTC+04:30"},
{Value: "+5 hours", Name: "UTC+05:00"},
{Value: "+5.5 hours", Name: "UTC+05:30"},
{Value: "+5.75 hours", Name: "UTC+05:45"},
{Value: "+6 hours", Name: "UTC+06:00"},
{Value: "+6.5 hours", Name: "UTC+06:30"},
{Value: "+7 hours", Name: "UTC+07:00"},
{Value: "+8 hours", Name: "UTC+08:00"},
{Value: "+8.75 hours", Name: "UTC+08:45"},
{Value: "+9 hours", Name: "UTC+09:00"},
{Value: "+9.5 hours", Name: "UTC+09:30"},
{Value: "+10 hours", Name: "UTC+10:00"},
{Value: "+10.5 hours", Name: "UTC+10:30"},
{Value: "+11 hours", Name: "UTC+11:00"},
{Value: "+12 hours", Name: "UTC+12:00"},
{Value: "+12.75 hours", Name: "UTC+12:45"},
{Value: "+13 hours", Name: "UTC+13:00"},
{Value: "+14 hours", Name: "UTC+14:00"},
} }
_, err := file.ReadAt(byteStep, newOffset) func GetUTCOffsets() []UTCOffset {
if err == io.EOF { return UTC_OFFSETS
break
}
buf.Write(byteStep)
}
allBytes := buf.Bytes()
return fmt.Sprintf("%x", md5.Sum(allBytes))
}
func main() {
fileHash := CalculatePartialMD5("test.epub")
fmt.Println("MD5: ", fileHash)
} }