[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 5a8bdacf4f
16 changed files with 528 additions and 99 deletions

View File

@ -40,12 +40,16 @@ In additional to the compatible KOSync API's, we add:
- Additional APIs to automatically upload reading statistics
- Automatically upload documents to the server (can download in the "Documents" view)
- Book metadata scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started))
- No JavaScript! All information is rendered server side.
- No JavaScript! All information is generated server side.
# Server
Docker Image: `docker pull gitea.va.reichard.io/evan/bookmanager:latest`
## KOSync API
The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko`
## Quick Start
```bash
@ -61,7 +65,7 @@ docker run \
gitea.va.reichard.io/evan/bookmanager:latest
```
The service is now accessible at: `http://localhost:8585`
The service is now accessible at: `http://localhost:8585`. I recommend registering an account and then disabling registration unless you expect more users.
## Configuration

View File

@ -14,6 +14,7 @@ import (
"reichard.io/bbank/config"
"reichard.io/bbank/database"
"reichard.io/bbank/graph"
"reichard.io/bbank/utils"
)
type API struct {
@ -74,11 +75,13 @@ func (api *API) registerWebAppRoutes() {
render := multitemplate.NewRenderer()
helperFuncs := template.FuncMap{
"GetSVGGraphData": graph.GetSVGGraphData,
"GetUTCOffsets": utils.GetUTCOffsets,
}
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("settings", helperFuncs, "templates/base.html", "templates/settings.html")
render.AddFromFilesFuncs("activity", helperFuncs, "templates/base.html", "templates/activity.html")
render.AddFromFilesFuncs("documents", helperFuncs, "templates/base.html", "templates/documents.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.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("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))

View File

@ -1,6 +1,7 @@
package api
import (
"crypto/md5"
"fmt"
"mime/multipart"
"net/http"
@ -9,6 +10,7 @@ import (
"strings"
"time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
@ -40,6 +42,12 @@ type requestDocumentIdentify struct {
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) {
variables := gin.H{"RouteName": template}
if len(args) > 0 {
@ -167,6 +175,27 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
"DatabaseInfo": database_info,
"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" {
templateVars["RegistrationEnabled"] = api.Config.RegistrationEnabled
}
@ -471,6 +500,88 @@ func (api *API) identifyDocument(c *gin.Context) {
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 {
var qParams queryParams
c.BindQuery(&qParams)

View File

@ -24,7 +24,7 @@ func (api *API) authorizeCredentials(username string, password string) (authoriz
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
}
@ -94,7 +94,6 @@ func (api *API) authFormLogin(c *gin.Context) {
// MD5 - KOSync Compatiblity
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
if authorized := api.authorizeCredentials(username, password); authorized != true {
c.HTML(http.StatusUnauthorized, "login", gin.H{
"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{
ID: username,
Pass: hashedPassword,
Pass: &hashedPassword,
})
// 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{
ID: rUser.Username,
Pass: hashedPassword,
Pass: &hashedPassword,
})
if err != nil {
log.Error("[createUser] CreateUser DB Error:", err)

View File

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

View File

@ -1,15 +1,21 @@
# Book Manager - SyncNinja KOReader Plugin
This is BookManagers KOReader Plugin called `syncninja.koplugin`.
This is BookManagers KOReader Plugin called `syncninja.koplugin`. Features include:
# Installation
- Syncing read activity
- Uploading documents
- Configurable sync settings
## Installation
Copy the `syncninja.koplugin` directory to the `plugins` directory for your KOReader installation. Restart KOReader and SyncNinja will be accessible via the Tools menu.
# Configuration
## Configuration
You must configure the BookManager server and credentials in SyncNinja. Afterwhich you'll have the ability to configure the sync cadence as well as whether you'd like the plugin to sync your activity, document metadata, and/or documents themselves.
# KOSync Compatibility
## KOSync Compatibility
BookManager implements API's compatible with the KOSync plugin. This means that you can utilize this server for KOSync (and it's recommended!). SyncNinja provides an easy way to merge configurations between both KOSync and itself in the menu.
The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko`. You can either use the previous mentioned merge feature to automatically configure KOSync once SyncNinja is configured, or you can manually set KOSync's server to the above.

View File

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

View File

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

View File

@ -21,6 +21,14 @@ ON CONFLICT DO NOTHING;
SELECT * FROM users
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
INSERT INTO documents (
id,
@ -287,11 +295,15 @@ LIMIT $limit
OFFSET $offset;
-- name: GetDevices :many
SELECT * FROM devices
WHERE user_id = $user_id
ORDER BY created_at DESC
LIMIT $limit
OFFSET $offset;
SELECT
devices.device_name,
CAST(DATETIME(devices.created_at, users.time_offset) AS TEXT) AS created_at,
CAST(DATETIME(MAX(activity.created_at), users.time_offset) AS TEXT) AS last_sync
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
SELECT

View File

@ -121,8 +121,8 @@ ON CONFLICT DO NOTHING
`
type CreateUserParams struct {
ID string `json:"id"`
Pass string `json:"-"`
ID string `json:"id"`
Pass *string `json:"-"`
}
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
SELECT id, user_id, device_name, created_at, sync FROM devices
WHERE user_id = ?1
ORDER BY created_at DESC
LIMIT ?3
OFFSET ?2
SELECT
devices.device_name,
CAST(DATETIME(devices.created_at, users.time_offset) AS TEXT) AS created_at,
CAST(DATETIME(MAX(activity.created_at), users.time_offset) AS TEXT) AS last_sync
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 {
UserID string `json:"user_id"`
Offset int64 `json:"offset"`
Limit int64 `json:"limit"`
type GetDevicesRow struct {
DeviceName string `json:"device_name"`
CreatedAt string `json:"created_at"`
LastSync string `json:"last_sync"`
}
func (q *Queries) GetDevices(ctx context.Context, arg GetDevicesParams) ([]Device, error) {
rows, err := q.db.QueryContext(ctx, getDevices, arg.UserID, arg.Offset, arg.Limit)
func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRow, error) {
rows, err := q.db.QueryContext(ctx, getDevices, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Device
var items []GetDevicesRow
for rows.Next() {
var i Device
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.DeviceName,
&i.CreatedAt,
&i.Sync,
); err != nil {
var i GetDevicesRow
if err := rows.Scan(&i.DeviceName, &i.CreatedAt, &i.LastSync); err != nil {
return nil, err
}
items = append(items, i)
@ -1272,6 +1270,34 @@ func (q *Queries) UpdateProgress(ctx context.Context, arg UpdateProgressParams)
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
INSERT INTO devices (id, user_id, device_name)
VALUES (?, ?, ?)

View File

@ -9,7 +9,7 @@ sql:
out: "database"
emit_json_tags: true
overrides:
# Type pointers needed for JSON
# Documents
- column: "documents.md5"
go_type:
type: "string"
@ -63,6 +63,7 @@ sql:
type: "string"
pointer: true
# Metadata
- column: "metadata.title"
go_type:
type: "string"
@ -92,6 +93,33 @@ sql:
type: "string"
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
- column: "documents.synced"
go_struct_tag: 'json:"-"'

View File

@ -2,22 +2,24 @@
<html lang="en">
<head>
<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 name="viewport"
content="width=device-width, initial-scale=0.90, user-scalable=no">
<script src="https://cdn.tailwindcss.com"></script>
<title>Book Manager - {{block "title" .}}{{end}}</title>
</head>
<body>
<body class="bg-gray-100 dark:bg-gray-800">
<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 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" />
<span class="lg:hidden w-7 h-0.5 z-40 mt-0.5 bg-white"></span>
<span class="lg:hidden w-7 h-0.5 z-40 mt-1 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-0.5 dark: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 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 class="h-16 flex justify-end lg:justify-around">
@ -102,7 +104,7 @@
</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
class="relative flex items-center justify-end w-full p-4 space-x-4"
>
@ -134,6 +136,15 @@
aria-orientation="vertical"
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
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"
@ -175,7 +186,6 @@
<!-- Custom Animation CSS -->
<style>
/* ----------------------------- */
/* ------- User Dropdown ------- */
/* ----------------------------- */
@ -210,7 +220,6 @@
#mobile-nav-button input:checked ~ span {
opacity: 1;
transform: rotate(45deg) translate(2px, -2px);
background: #FFFFFF;
}
#mobile-nav-button input:checked ~ span:nth-last-child(3) {

View File

@ -2,9 +2,20 @@
<html lang="en">
<head>
<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 name="viewport" content="width=device-width" />
<script src="https://cdn.tailwindcss.com"></script>
<title>Book Manager - {{if .Register}}Register{{else}}Login{{end}}</title>
</head>
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
<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
import (
"bytes"
"crypto/md5"
// "encoding/hex"
"fmt"
"io"
"os"
)
func CalculatePartialMD5(filePath string) string {
file, err := os.Open(filePath)
if err != nil {
panic(err)
}
defer file.Close()
var step int64 = 1024
var size int64 = 1024
var buf bytes.Buffer
for i := -1; i <= 10; i++ {
byteStep := make([]byte, size)
var newShift int64 = int64(i * 2)
var newOffset int64
if i == -1 {
newOffset = 0
} else {
newOffset = step << newShift
}
_, err := file.ReadAt(byteStep, newOffset)
if err == io.EOF {
break
}
buf.Write(byteStep)
}
allBytes := buf.Bytes()
return fmt.Sprintf("%x", md5.Sum(allBytes))
type UTCOffset struct {
Name string
Value string
}
func main() {
fileHash := CalculatePartialMD5("test.epub")
fmt.Println("MD5: ", fileHash)
var UTC_OFFSETS = []UTCOffset{
{Value: "-12 hours", Name: "UTC12:00"},
{Value: "-11 hours", Name: "UTC11:00"},
{Value: "-10 hours", Name: "UTC10:00"},
{Value: "-9.5 hours", Name: "UTC09:30"},
{Value: "-9 hours", Name: "UTC09:00"},
{Value: "-8 hours", Name: "UTC08:00"},
{Value: "-7 hours", Name: "UTC07:00"},
{Value: "-6 hours", Name: "UTC06:00"},
{Value: "-5 hours", Name: "UTC05:00"},
{Value: "-4 hours", Name: "UTC04:00"},
{Value: "-3.5 hours", Name: "UTC03:30"},
{Value: "-3 hours", Name: "UTC03:00"},
{Value: "-2 hours", Name: "UTC02:00"},
{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"},
}
func GetUTCOffsets() []UTCOffset {
return UTC_OFFSETS
}