145 Commits

Author SHA1 Message Date
75c872264f chore: remove unnecessary crap ai added
Some checks failed
continuous-integration/drone/pr Build is failing
2026-04-03 19:46:05 -04:00
0930054847 more reader
Some checks failed
continuous-integration/drone/pr Build is failing
2026-04-03 13:45:17 -04:00
aa812c6917 wip reader migration 2026-04-03 12:15:48 -04:00
8ec3349b7c chore(api): update to allow CRUD progress and activity in v1 2026-04-03 10:37:50 -04:00
decc3f0195 fix: toast theme & error msgs 2026-04-03 10:08:13 -04:00
b13f9b362c theme draft 2 (done?) 2026-03-22 17:21:34 -04:00
6c2c4f6b8b remove dumb auth 2026-03-22 17:21:34 -04:00
d38392ac9a theme draft 1 2026-03-22 17:21:34 -04:00
63ad73755d wip 22 2026-03-22 17:21:34 -04:00
784e53c557 wip 21 2026-03-22 17:21:34 -04:00
9ed63b2695 wip 20 2026-03-22 17:21:34 -04:00
27e651c4f5 wip 19 2026-03-22 17:21:34 -04:00
7e96e41ba4 wip 18 2026-03-22 17:21:33 -04:00
ee1d62858b wip 17 2026-03-22 17:21:33 -04:00
4d133994ab wip 16 2026-03-22 17:21:33 -04:00
ba919bbde4 wip 15 2026-03-22 17:21:33 -04:00
197a1577c2 wip 14 2026-03-22 17:21:33 -04:00
fd9afe86b0 wip 13 2026-03-22 17:21:33 -04:00
93707ff513 wip 12 2026-03-22 17:21:33 -04:00
75e0228fe0 wip 11 2026-03-22 17:21:33 -04:00
b1b8eb297e wip 10 2026-03-22 17:21:33 -04:00
7c47f2d2eb wip 9 2026-03-22 17:21:33 -04:00
c46dcb440d wip 8 2026-03-22 17:21:33 -04:00
5cb17bace7 wip 7 2026-03-22 17:21:32 -04:00
ecf77fd105 wip 6 2026-03-22 17:21:32 -04:00
e289d1a29b wip 5 2026-03-22 17:21:32 -04:00
3e9a193d08 wip 4 2026-03-22 17:21:32 -04:00
4306d86080 wip 3 2026-03-22 17:21:32 -04:00
d40f8fc375 wip 2 2026-03-22 17:21:32 -04:00
c84bc2522e wip 1 2026-03-22 17:21:32 -04:00
0704b5d650 fix: book search
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-22 17:21:16 -04:00
4c1789fc16 fix: doc parsing
All checks were successful
continuous-integration/drone Build is passing
2026-01-24 13:33:09 -05:00
082f7e926c fix: fix annas archive url 2026-01-24 13:33:01 -05:00
6031cf06d4 chore: update nix flake 2026-01-24 13:25:52 -05:00
8fd2aeb6a2 chore: add various tests 2025-12-13 14:04:32 -05:00
bc076a4f44 fix: metadata count test
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-20 17:02:10 -05:00
f9f23f2d3f fix: word count calculation
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-12 19:13:04 -05:00
3cff965393 fix: annas archive parsing
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-17 17:04:46 -04:00
7937890acd fix: docker build
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-10 13:18:37 -04:00
938dd69e5e chore(db): use context & add db helper 2025-08-10 13:17:51 -04:00
7c92c346fa feat(utils): add pkg utils 2025-08-10 13:17:44 -04:00
456b6e457c chore: update go & flake
Some checks failed
continuous-integration/drone/push Build is failing
2025-08-07 17:42:41 -04:00
d304421798 hm
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-05 18:17:47 -04:00
0fe52bc541 fix: search parsing
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-05 16:46:06 -04:00
49f3d53170 chore: nix flake
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-05 15:21:44 -04:00
57f81e5dd7 fix(api): ko json content type
All checks were successful
continuous-integration/drone/push Build is passing
2025-05-13 12:37:45 -04:00
162adfbe16 feat: basic toc
All checks were successful
continuous-integration/drone/push Build is passing
2025-04-26 10:19:00 -04:00
e2cfdb3a0c update cicd
All checks were successful
continuous-integration/drone/push Build is passing
2025-03-14 08:36:01 -04:00
acf4119d9a fix(sql): document user stats
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone Build was killed
2025-01-25 15:03:07 -05:00
f6dd8cee50 fix(streaks): incorrect calculation logic
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-02 19:27:50 -05:00
a981d98ba5 feat(admin): basic log filter
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-01 19:48:51 -05:00
a193f97d29 perf(db): incremental user streaks cache
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-01 18:58:46 -05:00
841b29c425 improve(search): progress & retries
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-01 17:04:41 -05:00
3d61d0f5ef perf(db): incremental document stats cache
All checks were successful
continuous-integration/drone/push Build is passing
2024-12-01 12:48:25 -05:00
5e388730a5 formatting: lua plugin 2024-12-01 11:28:33 -05:00
0a1dfeab65 fix(search): set user agent for dl
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-13 22:32:16 -04:00
d4c8e4d2da fix(search): broken parser & download source
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-11 11:02:46 -04:00
bbd3a00102 tests(db): additional document tests 2024-08-10 09:26:30 -04:00
3a633235ea tests(db): add additional tests & comments
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-16 20:00:41 -04:00
9809a09d2e chore(prettier): format templates
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-16 18:04:43 -04:00
f37bff365f chore(templates): prettier plugin & tables 2024-06-16 17:08:10 -04:00
77527bfb05 chore(templates): add better template loading
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-27 20:20:47 -04:00
8de6fed5df fix(ui): document add styling 2024-05-27 14:01:10 -04:00
f9277d3b32 feat(admin): handle user deletion
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-27 13:32:40 -04:00
db9629a618 chore(lint): address linter
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-26 19:56:59 -04:00
546600db93 feat(admin): handle user demotion & promotion
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-25 21:12:07 -04:00
7c6acad689 chore(templates): component-ize things
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-25 20:04:26 -04:00
5482899075 feat(admin): adding user & importing 2024-05-25 20:02:57 -04:00
5a64ff7029 fix(tz): incorrect local_time function use
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-06 20:56:30 -04:00
a7ecb1a6f8 fix(tz): add tzdata to docker image
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-06 09:39:04 -04:00
2d206826d6 add(admin): add user
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-11 22:20:41 -07:00
f1414e3e4e fix(timezones): move from utc offsets to timezones
This fixed various issues related to calculating streaks, etc. Now we
appropriately handle time as it was, vs as it is relative to an offset.
2024-03-11 22:20:21 -07:00
8e81acd381 fix(users): update user stomped on admin
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-10 21:48:43 -04:00
6c6a6dd329 feat(api): first user is admin
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-25 19:40:36 -05:00
c4602c8c3b chore(db): update sqlc 2024-02-25 19:01:34 -05:00
fe81b57a34 tests(db): migrate to testify
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-25 15:13:53 -05:00
a69b7452ce chore(dev): dynamically load templates during dev
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-25 14:54:50 -05:00
75ed394f8d tests(all): improve tests, refactor(api): saving books
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-24 20:45:26 -05:00
803c187a00 fix(logs): ios pretty logs & overflow scroll 2024-02-24 17:07:12 -05:00
da1baeb4cd feat(reader): upgrade epubjs & add restrictive iframe CSP 2024-02-19 16:45:35 -05:00
5865fe3c13 feat(db): button up migrations
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-01 20:05:35 -05:00
4a5464853b fix(graph): fix stretchy text on graph
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-01 19:07:24 -05:00
622dcd5702 fix(settings): auth hash accidentally overridden
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-28 22:23:37 -05:00
a86e2520ef feat(logs): jq filtering, feat(import): directory picker, refactor(admin): move routes to seperate file
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-28 22:11:36 -05:00
b1cfd16627 feat(restore): rotate auth hash on restore
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-28 11:38:44 -05:00
015ca30ac5 feat(auth): add auth hash (allows purging sessions & more)
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-28 11:21:06 -05:00
9792a6ff19 refactor(managers): privatize manager struct fields
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-27 14:56:01 -05:00
8c4c1022c3 refactor(errors): handle api / app errors better
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-26 22:07:30 -05:00
fd8b6bcdc1 feat(logging): improve logging & migrate to json logger
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-26 20:45:07 -05:00
0bbd5986cb add: db migrations & update
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-25 19:22:57 -05:00
45cef2f4af chore(formatting): djlint templates
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-24 21:40:14 -05:00
e33a64db96 fix: potential null query
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-24 18:43:33 -05:00
35ca021649 add: more statistics
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-23 23:00:51 -05:00
760b9ca0a0 fix: downloads, fix: logging space
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-22 18:03:01 -05:00
c9edcd8f5a [add] progress performance debugging
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-21 12:50:25 -05:00
2d63a7d109 [perf] dont immediately update view cache
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-21 11:29:26 -05:00
9bd6bf7727 [fix] docker cicd build
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-20 15:32:04 -05:00
f0a2d2cf69 [add] better log page, [add] admin users page, [add] admin nav
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-20 15:03:32 -05:00
a65750ae21 [chore] rename package, [chore] rename vars
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-10 20:23:36 -05:00
14b930781e [add] username in http access logs
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-09 21:36:36 -05:00
8a8f12c07a [fix] export directories
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-09 21:18:12 -05:00
c5b181dda4 [add] admin panel, [add] better logging
All checks were successful
continuous-integration/drone/push Build is passing
2024-01-09 21:08:40 -05:00
d3d89b36f6 [refactor] app routes, [add] progress table
All checks were successful
continuous-integration/drone/push Build is passing
2023-12-31 23:13:39 -05:00
a69f20d5a9 [fix] daily stats bug
All checks were successful
continuous-integration/drone/push Build is passing
2023-12-30 10:30:12 -05:00
c66a6c8499 [add] parse local isbn metadata
All checks were successful
continuous-integration/drone/push Build is passing
2023-12-30 10:18:43 -05:00
3057b86002 [add] progress streaming
All checks were successful
continuous-integration/drone/push Build is passing
2023-12-01 07:35:51 -05:00
2c240f2f5c [add] cache fonts
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-29 06:15:44 -05:00
39fd7ab1f1 [fix] login error 2023-11-28 23:11:12 -05:00
e9f2e3a5a0 [fix] assets regression
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-28 22:26:29 -05:00
a34906c266 [chore] embed filesystem
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-28 22:01:49 -05:00
756db7a493 [refactor] template handling
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-28 20:05:50 -05:00
bb837dd30e [fix] service worker route regex bug, [add] device selector / creator
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-26 21:41:17 -05:00
e823a794cf [fix] SyncNinja status message 2023-11-26 15:51:47 -05:00
3c6f3ae237 [add] favicon
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-25 19:21:18 -05:00
ca1cce1ff1 [add] opds search, [fix] opds urls, [add] log level env var
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-25 18:38:18 -05:00
c3410b7833 [fix] version
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2023-11-18 10:14:57 -05:00
1403bae036 [add] pagination
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2023-11-17 23:10:59 -05:00
af41946a65 [add] git link
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-17 21:40:59 -05:00
243ae2a001 [add] document search
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-17 21:24:19 -05:00
d94e79f39c [fix] syncninja koreader nil error
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 22:53:41 -05:00
856bc7e2e6 [fix] xpath & cfi resolution
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-07 19:19:06 -05:00
5cc1e2d71c [fix] wonky xpath issues
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-06 07:12:24 -05:00
ffc5462326 [fix] opds no redirect - KOReader OPDS compatibility
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 21:38:10 -05:00
3cbe4b1c0d [fix] null pointer deref
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 21:20:15 -05:00
c213b3b09f [fix] wakelock detection
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 19:27:43 -05:00
7d45bb0253 [add] logo & banner, [fix] mobile alignment issue
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 13:20:19 -05:00
a8bcd0f588 [add] rename to AnthoLume
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-04 19:55:38 -04:00
bc3e9cbaf0 [add] update assets & slight rearrangements
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-04 13:12:05 -04:00
e6ad51ed70 [add] cleanup and minify
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-04 12:27:35 -04:00
cce0ef2de1 [fix] book stat dom
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-04 00:04:31 -04:00
71898c39e7 [improve] web reader activity & progress tracking
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-03 23:43:08 -04:00
985b6e0851 [fix] percentage regression, [add] individual doc & user update (performance) 2023-11-03 21:37:26 -04:00
425f469097 Merge pull request 'Migrate Pages -> Percentages' (#2) from remove_pages into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: evan/BookManager#2
2023-11-03 23:50:40 +00:00
761163d666 [add] migrate to percentages vs pages
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
[add] task runner
[fix] calculate word count on upload
[remove] unused queries
2023-11-03 19:38:35 -04:00
67dedaa886 [add] demo readme link
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-31 17:45:33 -04:00
5f1de4ec67 [fix] 0 page issue, [add] wpm logging
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-31 17:30:42 -04:00
d27b9061bb [add] demo mode 2023-10-31 06:28:22 -04:00
0f271ac2fb [fix] copy error
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-30 23:35:37 -04:00
20560ed246 [fix] negative current word values
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-30 19:36:04 -04:00
aacf5a7195 [fix] login PWA styling, [add] login local link, [add] home local link
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-30 19:23:38 -04:00
5880d3beb6 [fix] handle sw unsupported, [fix] sw install / upgrade, [add] local file upload / delete
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-30 18:25:43 -04:00
0917172d1c Merge pull request 'Service Worker & Offline Reader' (#1) from add_service_worker into master
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: evan/BookManager#1
2023-10-30 01:27:18 +00:00
f74c81dc9b [add] service worker & offline reader
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-10-29 21:17:47 -04:00
1b8b5060f1 [fix] server word count, [add] client word count
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-26 21:50:05 -04:00
b9b9ad2098 [add] better search error handling 2023-10-26 06:20:56 -04:00
366 changed files with 38670 additions and 10356 deletions

14
.djlintrc Normal file
View File

@@ -0,0 +1,14 @@
{
"profile": "golang",
"indent": 2,
"close_void_tags": true,
"format_attribute_template_tags": true,
"format_js": true,
"js": {
"indent_size": 2
},
"format_css": true,
"css": {
"indent_size": 2
}
}

View File

@@ -1,33 +1,34 @@
kind: pipeline
type: kubernetes
type: docker
name: default
trigger:
branch:
- master
steps:
# Unit Tests
- name: unit test
- name: tests
image: golang
commands:
- make tests_unit
- make tests
# Integration Tests (Every Month)
- name: integration test
image: golang
# Fetch tags
- name: fetch tags
image: alpine/git
commands:
- make tests_integration
when:
event:
- cron
cron:
- integration-test
- git fetch --tags
# Publish Dev Docker Image
- name: publish_docker
# Publish docker image
- name: publish docker
image: plugins/docker
settings:
repo: gitea.va.reichard.io/evan/bookmanager
repo: gitea.va.reichard.io/evan/antholume
registry: gitea.va.reichard.io
tags:
- dev
custom_dns:
- 8.8.8.8
username:
from_secret: docker_username
password:

2
.envrc
View File

@@ -1 +1 @@
use nix
use flake

3
.gitignore vendored
View File

@@ -1,4 +1,7 @@
TODO.md
.DS_Store
data/
build/
.direnv/
cover.html
node_modules

3
.prettierrc Normal file
View File

@@ -0,0 +1,3 @@
{
"plugins": ["prettier-plugin-go-template"]
}

75
AGENTS.md Normal file
View File

@@ -0,0 +1,75 @@
# AnthoLume Agent Guide
## 1) Working Style
- Keep changes targeted.
- Do not refactor broadly unless the task requires it.
- Validate only what is relevant to the change when practical.
- If a fix will require substantial refactoring or wide-reaching changes, stop and ask first.
## 2) Hard Rules
- Never edit generated files directly.
- Never write ad-hoc SQL.
- For Go error wrapping, use `fmt.Errorf("message: %w", err)`.
- Do not use `github.com/pkg/errors`.
## 3) Generated Code
### OpenAPI
Edit:
- `api/v1/openapi.yaml`
Regenerate:
- `go generate ./api/v1/generate.go`
- `cd frontend && bun run generate:api`
Notes:
- If you add response headers in `api/v1/openapi.yaml` (for example `Set-Cookie`), `oapi-codegen` will generate typed response header structs in `api/v1/api.gen.go`; update the handler response values to populate those headers explicitly.
Examples of generated files:
- `api/v1/api.gen.go`
- `frontend/src/generated/**/*.ts`
### SQLC
Edit:
- `database/query.sql`
Regenerate:
- `sqlc generate`
## 4) Backend / Assets
### Common commands
- Dev server: `make dev`
- Direct dev run: `CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve`
- Tests: `make tests`
- Tailwind asset build: `make build_tailwind`
### Notes
- The Go server embeds `templates/*` and `assets/*`.
- Root Tailwind output is built to `assets/style.css`.
- Be mindful of whether a change affects the embedded server-rendered app, the React frontend, or both.
- SQLite timestamps are stored as RFC3339 strings (usually with a trailing `Z`); prefer `parseTime` / `parseTimePtr` instead of ad-hoc `time.Parse` layouts.
## 5) Frontend
For frontend-specific implementation notes and commands, also read:
- `frontend/AGENTS.md`
## 6) Regeneration Summary
- Go API: `go generate ./api/v1/generate.go`
- Frontend API client: `cd frontend && bun run generate:api`
- SQLC: `sqlc generate`
## 7) Updating This File
After completing a task, update this `AGENTS.md` if you learned something general that would help future agents.
Rules for updates:
- Add only repository-wide guidance.
- Do not add one-off task history.
- Keep updates short, concrete, and organized.
- Place new guidance in the most relevant section.
- If the new information would help future agents avoid repeated mistakes, add it proactively.

View File

@@ -1,26 +1,27 @@
# Certificate Store
FROM alpine AS certs
RUN apk update && apk add ca-certificates
# Certificates & Timezones
FROM alpine AS alpine
RUN apk update && apk add --no-cache ca-certificates tzdata
# Build Image
FROM golang:1.20 AS build
FROM golang:1.24 AS build
# Create Package Directory
RUN mkdir -p /opt/antholume
# Copy Source
WORKDIR /src
COPY . .
# Create Package Directory
RUN mkdir -p /opt/bookmanager
# Compile
RUN go build -o /opt/bookmanager/server; \
cp -a ./templates /opt/bookmanager/templates; \
cp -a ./assets /opt/bookmanager/assets;
RUN go build \
-ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" \
-o /opt/antholume/server
# Create Image
FROM busybox:1.36
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
COPY --from=build /opt/bookmanager /opt/bookmanager
WORKDIR /opt/bookmanager
COPY --from=alpine /etc/ssl/certs /etc/ssl/certs
COPY --from=alpine /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /opt/antholume /opt/antholume
WORKDIR /opt/antholume
EXPOSE 8585
ENTRYPOINT ["/opt/bookmanager/server", "serve"]
ENTRYPOINT ["/opt/antholume/server", "serve"]

View File

@@ -1,13 +1,13 @@
# Certificate Store
FROM alpine AS certs
RUN apk update && apk add ca-certificates
# Certificates & Timezones
FROM alpine AS alpine
RUN apk update && apk add --no-cache ca-certificates tzdata
# Build Image
FROM --platform=$BUILDPLATFORM golang:1.20 AS build
FROM --platform=$BUILDPLATFORM golang:1.21 AS build
# Create Package Directory
WORKDIR /src
RUN mkdir -p /opt/bookmanager
RUN mkdir -p /opt/antholume
# Cache Dependencies & Compile
ARG TARGETOS
@@ -15,14 +15,15 @@ ARG TARGETARCH
RUN --mount=target=. \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /opt/bookmanager/server; \
cp -a ./templates /opt/bookmanager/templates; \
cp -a ./assets /opt/bookmanager/assets;
GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
-ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" \
-o /opt/antholume/server
# Create Image
FROM busybox:1.36
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
COPY --from=build /opt/bookmanager /opt/bookmanager
WORKDIR /opt/bookmanager
COPY --from=alpine /etc/ssl/certs /etc/ssl/certs
COPY --from=alpine /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /opt/antholume /opt/antholume
WORKDIR /opt/antholume
EXPOSE 8585
ENTRYPOINT ["/opt/bookmanager/server", "serve"]
ENTRYPOINT ["/opt/antholume/server", "serve"]

View File

@@ -1,42 +1,48 @@
build_local: build_tailwind
go mod download
rm -r ./build
rm -r ./build || true
mkdir -p ./build
cp -a ./templates ./build/templates
cp -a ./assets ./build/assets
env GOOS=linux GOARCH=amd64 go build -o ./build/server_linux_amd64
env GOOS=linux GOARCH=arm64 go build -o ./build/server_linux_arm64
env GOOS=darwin GOARCH=arm64 go build -o ./build/server_darwin_arm64
env GOOS=darwin GOARCH=amd64 go build -o ./build/server_darwin_amd64
env GOOS=linux GOARCH=amd64 go build -ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" -o ./build/server_linux_amd64
env GOOS=linux GOARCH=arm64 go build -ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" -o ./build/server_linux_arm64
env GOOS=darwin GOARCH=arm64 go build -ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" -o ./build/server_darwin_arm64
env GOOS=darwin GOARCH=amd64 go build -ldflags "-X reichard.io/antholume/config.version=`git describe --tags`" -o ./build/server_darwin_amd64
docker_build_local: build_tailwind
docker build -t bookmanager:latest .
docker build -t antholume:latest .
docker_build_release_dev: build_tailwind
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t gitea.va.reichard.io/evan/bookmanager:dev \
-t gitea.va.reichard.io/evan/antholume:dev \
-f Dockerfile-BuildKit \
--push .
docker_build_release_latest: build_tailwind
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t gitea.va.reichard.io/evan/bookmanager:latest \
-t gitea.va.reichard.io/evan/bookmanager:`git describe --tags` \
-t gitea.va.reichard.io/evan/antholume:latest \
-t gitea.va.reichard.io/evan/antholume:`git describe --tags` \
-f Dockerfile-BuildKit \
--push .
build_tailwind:
tailwind build -o ./assets/style.css
tailwindcss build -o ./assets/style.css --minify
dev: build_tailwind
GIN_MODE=release \
CONFIG_PATH=./data \
DATA_PATH=./data \
SEARCH_ENABLED=true \
REGISTRATION_ENABLED=true \
COOKIE_SECURE=false \
COOKIE_AUTH_KEY=1234 \
LOG_LEVEL=debug go run main.go serve
clean:
rm -rf ./build
tests_integration:
go test -v -tags=integration -coverpkg=./... ./metadata
tests_unit:
SET_TEST=set_val go test -v -coverpkg=./... ./...
tests:
SET_TEST=set_val go test -coverpkg=./... ./... -coverprofile=./cover.out
go tool cover -html=./cover.out -o ./cover.html
rm ./cover.out

135
README.md
View File

@@ -1,100 +1,109 @@
# Book Manager
<p><img align="center" src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/banner.png"></p>
<p align="center">
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/login.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/login.png" width="19%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/login.png" width="19%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/home.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/home.png" width="19%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/home.png" width="19%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/documents.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/documents.png" width="19%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/documents.png" width="19%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/document.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/document.png" width="19%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/document.png" width="19%">
</a>
<a href="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/metadata.png">
<img src="https://gitea.va.reichard.io/evan/BookManager/raw/branch/master/screenshots/pwa/metadata.png" width="19%">
<a href="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png">
<img src="https://gitea.va.reichard.io/evan/AnthoLume/raw/branch/master/screenshots/pwa/metadata.png" width="19%">
</a>
</p>
<p align="center">Screenshots</p>
<p align="center">
<a href="https://gitea.va.reichard.io/evan/BookManager/src/branch/master/screenshots/web/README.md">Web App</a> - <a href="https://gitea.va.reichard.io/evan/BookManager/src/branch/master/screenshots/pwa/README.md">PWA</a>
<strong><a href="https://gitea.va.reichard.io/evan/AnthoLume/src/branch/master/screenshots">Screenshots</a></strong> •
<strong><a href="https://antholume-demo.cloud.reichard.io/">Demo Server</a></strong>
</p>
<p align="center"><strong>user:</strong> demo • <strong>pass:</strong> demo</p>
<p align="center">
<a href="https://drone.va.reichard.io/evan/BookManager" target="_blank">
<img src="https://drone.va.reichard.io/api/badges/evan/BookManager/status.svg">
<a href="https://drone.va.reichard.io/evan/AnthoLume" target="_blank">
<img src="https://drone.va.reichard.io/api/badges/evan/AnthoLume/status.svg">
</a>
</p>
---
This is BookManager! Will probably be renamed at some point. This repository contains:
AnthoLume is a Progressive Web App (PWA) that manages your EPUB documents, provides an EPUB reader, and tracks your reading activity! It also has a [KOReader KOSync](https://github.com/koreader/koreader-sync-server) compatible API, and a [KOReader](https://github.com/koreader/koreader) Plugin used to sync activity from your Kindle. Some additional features include:
- Web App / Progressive Web App (PWA)
- [KOReader](https://github.com/koreader/koreader) Plugin (See `client` subfolder)
- [KOReader KOSync](https://github.com/koreader/koreader-sync-server) compatible API
- OPDS API endpoint that provides access to the uploaded documents
- OPDS API Endpoint
- Local / Offline Reader (via ServiceWorker)
- Metadata Scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started))
- Words / Minute (WPM) Tracking & Leaderboard (Amongst Server Users)
In additional to the compatible KOSync API's, we add:
While some features require JavaScript (Service Worker & EPUB Reader), we make an effort to limit JavaScript usage. Outside of the two aforementioned features, no JavaScript is used.
- Additional APIs to automatically upload reading statistics
- Upload documents to the server (can download in the "Documents" view or via OPDS)
- Book metadata scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started))
- No JavaScript for the main app! All information is generated server side with go templates.
- JavaScript is used for the ePub reader. Goals to make it service worker to enable a complete offline PWA reading experience.
## Server
# Server
Docker Image: `docker pull gitea.va.reichard.io/evan/antholume:latest`
Docker Image: `docker pull gitea.va.reichard.io/evan/bookmanager:latest`
### Local / Offline Reader
## KOSync API
The Local / Offline reader allows you to use any AnthoLume server as a standalone offline accessible reading app! Some features:
- Add local EPUB documents
- Read both local and any cached server documents
- Maintains progress for all types of documents (server / local)
- Uploads any progress or activity for cached server documents once the internet is accessible
### KOSync API
The KOSync compatible API endpoint is located at: `http(s)://<SERVER>/api/ko`
## OPDS API
### OPDS API
The OPDS API endpoint is located at: `http(s)://<SERVER>/api/opds`
## Quick Start
### Quick Start
**NOTE**: If you're accessing your instance over HTTP (not HTTPS), you must set `COOKIE_SECURE=false`, otherwise you will not be able to login.
```bash
# Make Data Directory
mkdir -p bookmanager_data
mkdir -p antholume_data
# Run Server
docker run \
-p 8585:8585 \
-e COOKIE_SECURE=false \
-e REGISTRATION_ENABLED=true \
-v ./bookmanager_data:/config \
-v ./bookmanager_data:/data \
gitea.va.reichard.io/evan/bookmanager:latest
-v ./antholume_data:/config \
-v ./antholume_data:/data \
gitea.va.reichard.io/evan/antholume:latest
```
The service is now accessible at: `http://localhost:8585`. I recommend registering an account and then disabling registration unless you expect more users.
## Configuration
### Configuration
| Environment Variable | Default Value | Description |
| -------------------- | ------------- | -------------------------------------------------------------------- |
| DATABASE_TYPE | SQLite | Currently only "SQLite" is supported |
| DATABASE_NAME | bbank | The database name, or in SQLite's case, the filename |
| DATABASE_PASSWORD | <EMPTY> | Currently not used. Placeholder for potential alternative DB support |
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
| LISTEN_PORT | 8585 | Port the server listens at |
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
| COOKIE_SESSION_KEY | <EMPTY> | Optional secret cookie session key (auto generated if not provided) |
| COOKIE_SECURE | true | Set Cookie `Secure` attribute (i.e. only works over HTTPS) |
| COOKIE_HTTP_ONLY | true | Set Cookie `HttpOnly` attribute (i.e. inacessible via JavaScript) |
| Environment Variable | Default Value | Description |
| -------------------- | ------------- | -------------------------------------------------------------------------- |
| DATABASE_TYPE | SQLite | Currently only "SQLite" is supported |
| DATABASE_NAME | antholume | The database name, or in SQLite's case, the filename |
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
| LISTEN_PORT | 8585 | Port the server listens at |
| LOG_LEVEL | info | Set server log level |
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
| COOKIE_AUTH_KEY | <EMPTY> | Optional secret cookie authentication key (auto generated if not provided) |
| COOKIE_ENC_KEY | <EMPTY> | Optional secret cookie encryption key (16 or 32 bytes) |
| COOKIE_SECURE | true | Set Cookie `Secure` attribute (i.e. only works over HTTPS) |
| COOKIE_HTTP_ONLY | true | Set Cookie `HttpOnly` attribute (i.e. inacessible via JavaScript) |
## Security
### Authentication
- _Web App / PWA_ - Session based token (7 day expiry, refresh after 6 days)
- _KOSync & SyncNinja API_ - Header based (KOSync compatibility)
- _KOSync & SyncNinja API_ - Header based - `X-Auth-User` & `X-Auth-Key` (KOSync compatibility)
- _OPDS API_ - Basic authentication (KOReader OPDS compatibility)
### Notes
@@ -103,26 +112,32 @@ The service is now accessible at: `http://localhost:8585`. I recommend registeri
- The native KOSync plugin sends an MD5 hash of the password. Due to that:
- We store an Argon2 hash _and_ per-password salt of the MD5 hashed original password
# Client (KOReader Plugin)
## Client (KOReader Plugin)
See documentation in the `client` subfolder: [SyncNinja](https://gitea.va.reichard.io/evan/BookManager/src/branch/master/client/)
See documentation in the `client` subfolder: [SyncNinja](https://gitea.va.reichard.io/evan/AnthoLume/src/branch/master/client/)
# Development
## Development
SQLC Generation (v1.21.0):
SQLC Generation (v1.26.0):
```bash
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
~/go/bin/sqlc generate
```
Goose Migrations:
```bash
go install github.com/pressly/goose/v3/cmd/goose@latest
```
Run Development:
```bash
CONFIG_PATH=./data DATA_PATH=./data go run main.go serve
CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve
```
# Building
## Building
The `Dockerfile` and `Makefile` contain the build information:
@@ -136,6 +151,16 @@ make docker_build_local
# Build Docker & Push Latest or Dev (Linux - arm64 & amd64)
make docker_build_release_latest
make docker_build_release_dev
# Generate Tailwind CSS
make build_tailwind
# Clean Local Build
make clean
# Tests (Unit & Integration - Google Books API)
make tests_unit
make tests_integration
```
## Notes

BIN
antholume Executable file

Binary file not shown.

View File

@@ -1,9 +1,15 @@
package api
import (
"crypto/rand"
"context"
"fmt"
"html/template"
"io/fs"
"net/http"
"path"
"path/filepath"
"strings"
"time"
"github.com/gin-contrib/multitemplate"
"github.com/gin-contrib/sessions"
@@ -11,145 +17,357 @@ import (
"github.com/gin-gonic/gin"
"github.com/microcosm-cc/bluemonday"
log "github.com/sirupsen/logrus"
"reichard.io/bbank/config"
"reichard.io/bbank/database"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
"reichard.io/antholume/utils"
)
type API struct {
Router *gin.Engine
Config *config.Config
DB *database.DBManager
HTMLPolicy *bluemonday.Policy
db *database.DBManager
cfg *config.Config
assets fs.FS
httpServer *http.Server
templates map[string]*template.Template
userAuthCache map[string]string
}
func NewApi(db *database.DBManager, c *config.Config) *API {
var htmlPolicy = bluemonday.StrictPolicy()
func NewApi(db *database.DBManager, c *config.Config, assets fs.FS) *API {
api := &API{
HTMLPolicy: bluemonday.StrictPolicy(),
Router: gin.Default(),
Config: c,
DB: db,
db: db,
cfg: c,
assets: assets,
templates: make(map[string]*template.Template),
userAuthCache: make(map[string]string),
}
// Assets & Web App Templates
api.Router.Static("/assets", "./assets")
// Create router
router := gin.New()
// Generate Secure Token
// Add server
api.httpServer = &http.Server{
Handler: router,
Addr: (":" + c.ListenPort),
}
// Add global logging middleware
router.Use(loggingMiddleware)
// Add global template loader middleware (develop)
if c.Version == "develop" {
log.Info("utilizing debug template loader")
router.Use(api.templateMiddleware(router))
}
// Assets & web app templates
assetsDir, _ := fs.Sub(assets, "assets")
router.StaticFS("/assets", http.FS(assetsDir))
// Generate auth token
var newToken []byte
var err error
if c.CookieSessionKey != "" {
log.Info("[NewApi] Utilizing Environment Cookie Session Key")
newToken = []byte(c.CookieSessionKey)
if c.CookieAuthKey != "" {
log.Info("utilizing environment cookie auth key")
newToken = []byte(c.CookieAuthKey)
} else {
log.Info("[NewApi] Generating Cookie Session Key")
newToken, err = generateToken(64)
log.Info("generating cookie auth key")
newToken, err = utils.GenerateToken(64)
if err != nil {
panic("Unable to generate secure token")
log.Panic("unable to generate cookie auth key")
}
}
// Configure Cookie Session Store
// Set enc token
store := cookie.NewStore(newToken)
if c.CookieEncKey != "" {
if len(c.CookieEncKey) == 16 || len(c.CookieEncKey) == 32 {
log.Info("utilizing environment cookie encryption key")
store = cookie.NewStore(newToken, []byte(c.CookieEncKey))
} else {
log.Panic("invalid cookie encryption key (must be 16 or 32 bytes)")
}
}
// Configure cookie session store
store.Options(sessions.Options{
MaxAge: 60 * 60 * 24 * 7,
Secure: c.CookieSecure,
HttpOnly: c.CookieHTTPOnly,
SameSite: http.SameSiteStrictMode,
})
api.Router.Use(sessions.Sessions("token", store))
router.Use(sessions.Sessions("token", store))
// Register Web App Route
api.registerWebAppRoutes()
// Register web app route
api.registerWebAppRoutes(router)
// Register API Routes
apiGroup := api.Router.Group("/api")
// Register API routes
apiGroup := router.Group("/api")
api.registerKOAPIRoutes(apiGroup)
api.registerOPDSRoutes(apiGroup)
return api
}
func (api *API) registerWebAppRoutes() {
// Define Templates & Helper Functions
render := multitemplate.NewRenderer()
helperFuncs := template.FuncMap{
"GetSVGGraphData": getSVGGraphData,
"GetUTCOffsets": getUTCOffsets,
"NiceSeconds": niceSeconds,
func (api *API) Start() error {
return api.httpServer.ListenAndServe()
}
// Handler returns the underlying http.Handler for the Gin router
func (api *API) Handler() http.Handler {
return api.httpServer.Handler
}
func (api *API) Stop() error {
// Stop server
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := api.httpServer.Shutdown(ctx)
if err != nil {
return err
}
render.AddFromFiles("error", "templates/error.html")
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
render.AddFromFilesFuncs("reader", helperFuncs, "templates/reader-base.html", "templates/reader.html")
render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html")
render.AddFromFilesFuncs("search", helperFuncs, "templates/base.html", "templates/search.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")
// Close DB
return api.db.DB.Close()
}
api.Router.HTMLRender = render
func (api *API) registerWebAppRoutes(router *gin.Engine) {
// Generate templates
router.HTMLRender = *api.generateTemplates()
api.Router.GET("/manifest.json", api.webManifest)
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)
// Static assets (required @ root)
router.GET("/manifest.json", api.appWebManifest)
router.GET("/favicon.ico", api.appFaviconIcon)
router.GET("/sw.js", api.appServiceWorker)
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.POST("/documents", api.authWebAppMiddleware, api.uploadNewDocument)
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
api.Router.GET("/documents/:document/reader", api.authWebAppMiddleware, api.documentReader)
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocument)
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.editDocument)
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.identifyDocument)
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.deleteDocument)
// Local / offline static pages (no template, no auth)
router.GET("/local", api.appLocalDocuments)
// Behind Configuration Flag
if api.Config.SearchEnabled {
api.Router.GET("/search", api.authWebAppMiddleware, api.createAppResourcesRoute("search"))
api.Router.POST("/search", api.authWebAppMiddleware, api.saveNewDocument)
// Reader (reader page, document progress, devices)
router.GET("/reader", api.appDocumentReader)
router.GET("/reader/devices", api.authWebAppMiddleware, api.appGetDevices)
router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.appGetDocumentProgress)
// Web app
router.GET("/", api.authWebAppMiddleware, api.appGetHome)
router.GET("/activity", api.authWebAppMiddleware, api.appGetActivity)
router.GET("/progress", api.authWebAppMiddleware, api.appGetProgress)
router.GET("/documents", api.authWebAppMiddleware, api.appGetDocuments)
router.GET("/documents/:document", api.authWebAppMiddleware, api.appGetDocument)
router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.createGetCoverHandler(appErrorPage))
router.GET("/documents/:document/file", api.authWebAppMiddleware, api.createDownloadDocumentHandler(appErrorPage))
router.GET("/login", api.appGetLogin)
router.GET("/logout", api.authWebAppMiddleware, api.appAuthLogout)
router.GET("/register", api.appGetRegister)
router.GET("/settings", api.authWebAppMiddleware, api.appGetSettings)
router.GET("/admin/logs", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminLogs)
router.GET("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminImport)
router.POST("/admin/import", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminImport)
router.GET("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdminUsers)
router.POST("/admin/users", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appUpdateAdminUsers)
router.GET("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appGetAdmin)
router.POST("/admin", api.authWebAppMiddleware, api.authAdminWebAppMiddleware, api.appPerformAdminAction)
router.POST("/login", api.appAuthLogin)
router.POST("/register", api.appAuthRegister)
// Demo mode enabled configuration
if api.cfg.DemoMode {
router.POST("/documents", api.authWebAppMiddleware, api.appDemoModeError)
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDemoModeError)
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appDemoModeError)
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appDemoModeError)
router.POST("/settings", api.authWebAppMiddleware, api.appDemoModeError)
} else {
router.POST("/documents", api.authWebAppMiddleware, api.appUploadNewDocument)
router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.appDeleteDocument)
router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.appEditDocument)
router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.appIdentifyDocument)
router.POST("/settings", api.authWebAppMiddleware, api.appEditSettings)
}
// Search enabled configuration
if api.cfg.SearchEnabled {
router.GET("/search", api.authWebAppMiddleware, api.appGetSearch)
router.POST("/search", api.authWebAppMiddleware, api.appSaveNewDocument)
}
}
func (api *API) registerKOAPIRoutes(apiGroup *gin.RouterGroup) {
koGroup := apiGroup.Group("/ko")
koGroup.POST("/users/create", api.createUser)
koGroup.GET("/users/auth", api.authKOMiddleware, api.authorizeUser)
// KO sync routes (webapp uses - progress & activity)
koGroup.GET("/documents/:document/file", api.authKOMiddleware, api.createDownloadDocumentHandler(apiErrorPage))
koGroup.GET("/syncs/progress/:document", api.authKOMiddleware, api.koGetProgress)
koGroup.GET("/users/auth", api.authKOMiddleware, api.koAuthorizeUser)
koGroup.POST("/activity", api.authKOMiddleware, api.koAddActivities)
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.koCheckActivitySync)
koGroup.POST("/users/create", api.koAuthRegister)
koGroup.PUT("/syncs/progress", api.authKOMiddleware, api.koSetProgress)
koGroup.PUT("/syncs/progress", api.authKOMiddleware, api.setProgress)
koGroup.GET("/syncs/progress/:document", api.authKOMiddleware, api.getProgress)
koGroup.POST("/documents", api.authKOMiddleware, api.addDocuments)
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.checkDocumentsSync)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.uploadExistingDocument)
koGroup.GET("/documents/:document/file", api.authKOMiddleware, api.downloadDocument)
koGroup.POST("/activity", api.authKOMiddleware, api.addActivities)
koGroup.POST("/syncs/activity", api.authKOMiddleware, api.checkActivitySync)
// Demo mode enabled configuration
if api.cfg.DemoMode {
koGroup.POST("/documents", api.authKOMiddleware, api.koDemoModeJSONError)
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.koDemoModeJSONError)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.koDemoModeJSONError)
} else {
koGroup.POST("/documents", api.authKOMiddleware, api.koAddDocuments)
koGroup.POST("/syncs/documents", api.authKOMiddleware, api.koCheckDocumentsSync)
koGroup.PUT("/documents/:document/file", api.authKOMiddleware, api.koUploadExistingDocument)
}
}
func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) {
opdsGroup := apiGroup.Group("/opds")
opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsDocuments)
// OPDS routes
opdsGroup.GET("", api.authOPDSMiddleware, api.opdsEntry)
opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsEntry)
opdsGroup.GET("/search.xml", api.authOPDSMiddleware, api.opdsSearchDescription)
opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.downloadDocument)
opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.getDocumentCover)
opdsGroup.GET("/documents", api.authOPDSMiddleware, api.opdsDocuments)
opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.createGetCoverHandler(apiErrorPage))
opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.createDownloadDocumentHandler(apiErrorPage))
}
func generateToken(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return nil, err
func (api *API) generateTemplates() *multitemplate.Renderer {
// Define templates & helper functions
render := multitemplate.NewRenderer()
templates := make(map[string]*template.Template)
helperFuncs := template.FuncMap{
"dict": dict,
"slice": slice,
"fields": fields,
"getSVGGraphData": getSVGGraphData,
"getTimeZones": getTimeZones,
"hasPrefix": strings.HasPrefix,
"niceNumbers": niceNumbers,
"niceSeconds": niceSeconds,
}
return b, nil
// Load Base
b, err := fs.ReadFile(api.assets, "templates/base.tmpl")
if err != nil {
log.Errorf("error reading base template: %v", err)
return &render
}
// Parse Base
baseTemplate, err := template.New("base").Funcs(helperFuncs).Parse(string(b))
if err != nil {
log.Errorf("error parsing base template: %v", err)
return &render
}
// Load SVGs
err = api.loadTemplates("svg", baseTemplate, templates, false)
if err != nil {
log.Errorf("error loading svg templates: %v", err)
return &render
}
// Load Components
err = api.loadTemplates("component", baseTemplate, templates, false)
if err != nil {
log.Errorf("error loading component templates: %v", err)
return &render
}
// Load Pages
err = api.loadTemplates("page", baseTemplate, templates, true)
if err != nil {
log.Errorf("error loading page templates: %v", err)
return &render
}
// Populate Renderer
api.templates = templates
for templateName, templateValue := range templates {
render.Add(templateName, templateValue)
}
return &render
}
func (api *API) loadTemplates(
basePath string,
baseTemplate *template.Template,
allTemplates map[string]*template.Template,
cloneBase bool,
) error {
// Load Templates (Pluralize)
templateDirectory := fmt.Sprintf("templates/%ss", basePath)
allFiles, err := fs.ReadDir(api.assets, templateDirectory)
if err != nil {
return fmt.Errorf("unable to read template dir %s: %w", templateDirectory, err)
}
// Generate Templates
for _, item := range allFiles {
templateFile := item.Name()
templatePath := path.Join(templateDirectory, templateFile)
templateName := fmt.Sprintf("%s/%s", basePath, strings.TrimSuffix(templateFile, filepath.Ext(templateFile)))
// Read Template
b, err := fs.ReadFile(api.assets, templatePath)
if err != nil {
return fmt.Errorf("unable to read template %s: %w", templateName, err)
}
// Clone? (Pages - Don't Stomp)
if cloneBase {
baseTemplate = template.Must(baseTemplate.Clone())
}
// Parse Template
baseTemplate, err = baseTemplate.New(templateName).Parse(string(b))
if err != nil {
return fmt.Errorf("unable to parse template %s: %w", templateName, err)
}
allTemplates[templateName] = baseTemplate
}
return nil
}
func (api *API) templateMiddleware(router *gin.Engine) gin.HandlerFunc {
return func(c *gin.Context) {
router.HTMLRender = *api.generateTemplates()
c.Next()
}
}
func loggingMiddleware(c *gin.Context) {
// Start timer
startTime := time.Now()
// Process request
c.Next()
// End timer
endTime := time.Now()
latency := endTime.Sub(startTime).Round(time.Microsecond)
// Log data
logData := log.Fields{
"type": "access",
"ip": c.ClientIP(),
"latency": latency.String(),
"status": c.Writer.Status(),
"method": c.Request.Method,
"path": c.Request.URL.Path,
}
// Get username
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
// Log user
if auth.UserName != "" {
logData["user"] = auth.UserName
}
// Log result
log.WithFields(logData).Info(fmt.Sprintf("%s %s", c.Request.Method, c.Request.URL.Path))
}

949
api/app-admin-routes.go Normal file
View File

@@ -0,0 +1,949 @@
package api
import (
"archive/zip"
"bufio"
"context"
"crypto/md5"
"encoding/json"
"fmt"
"io"
"io/fs"
"mime/multipart"
"net/http"
"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"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
"reichard.io/antholume/utils"
)
type adminAction string
const (
adminBackup adminAction = "BACKUP"
adminRestore adminAction = "RESTORE"
adminMetadataMatch adminAction = "METADATA_MATCH"
adminCacheTables adminAction = "CACHE_TABLES"
)
type requestAdminAction struct {
Action adminAction `form:"action"`
// Backup Action
BackupTypes []backupType `form:"backup_types"`
// Restore Action
RestoreFile *multipart.FileHeader `form:"restore_file"`
}
type importType string
const (
importDirect importType = "DIRECT"
importCopy importType = "COPY"
)
type requestAdminImport struct {
Directory string `form:"directory"`
Select string `form:"select"`
Type importType `form:"type"`
}
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"`
Operation operationType `form:"operation"`
}
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)
var rAdminAction requestAdminAction
if err := c.ShouldBind(&rAdminAction); err != nil {
log.Error("Invalid Form Bind: ", err)
appErrorPage(c, http.StatusBadRequest, "Invalid or missing form values")
return
}
switch rAdminAction.Action {
case adminMetadataMatch:
// TODO
// 1. Documents xref most recent metadata table?
// 2. Select all / deselect?
case adminCacheTables:
go func() {
err := api.db.CacheTempTables(c)
if err != nil {
log.Error("Unable to cache temp tables: ", err)
}
}()
case adminRestore:
api.processRestoreFile(rAdminAction, c)
return
case adminBackup:
// Vacuum
_, err := api.db.DB.ExecContext(c, "VACUUM;")
if err != nil {
log.Error("Unable to vacuum DB: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to vacuum database")
return
}
// Set Headers
c.Header("Content-type", "application/octet-stream")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"AnthoLumeBackup_%s.zip\"", time.Now().Format("20060102150405")))
// Stream Backup ZIP Archive
c.Stream(func(w io.Writer) bool {
var directories []string
for _, item := range rAdminAction.BackupTypes {
if item == backupCovers {
directories = append(directories, "covers")
} else if item == backupDocuments {
directories = append(directories, "documents")
}
}
err := api.createBackup(c, w, directories)
if err != nil {
log.Error("Backup Error: ", err)
}
return false
})
return
}
c.HTML(http.StatusOK, "page/admin", templateVars)
}
func (api *API) appGetAdmin(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin", c)
c.HTML(http.StatusOK, "page/admin", templateVars)
}
func (api *API) appGetAdminLogs(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin-logs", c)
var rAdminLogs requestAdminLogs
if err := c.ShouldBindQuery(&rAdminLogs); err != nil {
log.Error("Invalid URI Bind")
appErrorPage(c, http.StatusNotFound, "Invalid URI parameters")
return
}
rAdminLogs.Filter = strings.TrimSpace(rAdminLogs.Filter)
var jqFilter *gojq.Code
var basicFilter string
if strings.HasPrefix(rAdminLogs.Filter, "\"") && strings.HasSuffix(rAdminLogs.Filter, "\"") {
basicFilter = rAdminLogs.Filter[1 : len(rAdminLogs.Filter)-1]
} else if rAdminLogs.Filter != "" {
parsed, err := gojq.Parse(rAdminLogs.Filter)
if err != nil {
log.Error("Unable to parse JQ filter")
appErrorPage(c, http.StatusNotFound, "Unable to parse JQ filter")
return
}
jqFilter, err = gojq.Compile(parsed)
if err != nil {
log.Error("Unable to compile JQ filter")
appErrorPage(c, http.StatusNotFound, "Unable to compile JQ filter")
return
}
}
// Open Log File
logPath := filepath.Join(api.cfg.ConfigPath, "logs/antholume.log")
logFile, err := os.Open(logPath)
if err != nil {
appErrorPage(c, http.StatusBadRequest, "Missing AnthoLume log file")
return
}
defer logFile.Close()
// Log Lines
var logLines []string
scanner := bufio.NewScanner(logFile)
for scanner.Scan() {
rawLog := scanner.Text()
// Attempt JSON Pretty
var jsonMap map[string]any
err := json.Unmarshal([]byte(rawLog), &jsonMap)
if err != nil {
logLines = append(logLines, scanner.Text())
continue
}
// Parse JSON
rawData, err := json.MarshalIndent(jsonMap, "", " ")
if err != nil {
logLines = append(logLines, scanner.Text())
continue
}
// Basic Filter
if basicFilter != "" && strings.Contains(string(rawData), basicFilter) {
logLines = append(logLines, string(rawData))
continue
}
// No JQ Filter
if jqFilter == nil {
continue
}
// Error or nil
result, _ := jqFilter.Run(jsonMap).Next()
if _, ok := result.(error); ok {
logLines = append(logLines, string(rawData))
continue
} else if result == nil {
continue
}
// Attempt filtered json
filteredData, err := json.MarshalIndent(result, "", " ")
if err == nil {
rawData = filteredData
}
logLines = append(logLines, string(rawData))
}
templateVars["Data"] = logLines
templateVars["Filter"] = rAdminLogs.Filter
c.HTML(http.StatusOK, "page/admin-logs", templateVars)
}
func (api *API) appGetAdminUsers(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin-users", c)
users, err := api.db.Queries.GetUsers(c)
if err != nil {
log.Error("GetUsers DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUsers DB Error: %v", err))
return
}
templateVars["Data"] = users
c.HTML(http.StatusOK, "page/admin-users", templateVars)
}
func (api *API) appUpdateAdminUsers(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin-users", c)
var rUpdate requestAdminUpdateUser
if err := c.ShouldBind(&rUpdate); err != nil {
log.Error("Invalid URI Bind")
appErrorPage(c, http.StatusNotFound, "Invalid user parameters")
return
}
// Ensure Username
if rUpdate.User == "" {
appErrorPage(c, http.StatusInternalServerError, "User cannot be empty")
return
}
var err error
switch rUpdate.Operation {
case opCreate:
err = api.createUser(c, rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
case opUpdate:
err = api.updateUser(c, rUpdate.User, rUpdate.Password, rUpdate.IsAdmin)
case opDelete:
err = api.deleteUser(c, rUpdate.User)
default:
appErrorPage(c, http.StatusNotFound, "Unknown user operation")
return
}
if err != nil {
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("Unable to create or update user: %v", err))
return
}
users, err := api.db.Queries.GetUsers(c)
if err != nil {
log.Error("GetUsers DB Error: ", err)
appErrorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetUsers DB Error: %v", err))
return
}
templateVars["Data"] = users
c.HTML(http.StatusOK, "page/admin-users", templateVars)
}
func (api *API) appGetAdminImport(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin-import", c)
var rImportFolder requestAdminImport
if err := c.ShouldBindQuery(&rImportFolder); err != nil {
log.Error("Invalid URI Bind")
appErrorPage(c, http.StatusNotFound, "Invalid directory")
return
}
if rImportFolder.Select != "" {
templateVars["SelectedDirectory"] = rImportFolder.Select
c.HTML(http.StatusOK, "page/admin-import", templateVars)
return
}
// Default Path
if rImportFolder.Directory == "" {
dPath, err := filepath.Abs(api.cfg.DataPath)
if err != nil {
log.Error("Absolute filepath error: ", rImportFolder.Directory)
appErrorPage(c, http.StatusNotFound, "Unable to get data directory absolute path")
return
}
rImportFolder.Directory = dPath
}
entries, err := os.ReadDir(rImportFolder.Directory)
if err != nil {
log.Error("Invalid directory: ", rImportFolder.Directory)
appErrorPage(c, http.StatusNotFound, "Invalid directory")
return
}
allDirectories := []string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
allDirectories = append(allDirectories, e.Name())
}
templateVars["CurrentPath"] = filepath.Clean(rImportFolder.Directory)
templateVars["Data"] = allDirectories
c.HTML(http.StatusOK, "page/admin-import", templateVars)
}
func (api *API) appPerformAdminImport(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("admin-import", c)
var rAdminImport requestAdminImport
if err := c.ShouldBind(&rAdminImport); err != nil {
log.Error("Invalid URI Bind")
appErrorPage(c, http.StatusNotFound, "Invalid directory")
return
}
// Get import directory
importDirectory := filepath.Clean(rAdminImport.Directory)
// Get data directory
absoluteDataPath, _ := filepath.Abs(filepath.Join(api.cfg.DataPath, "documents"))
// Validate different path
if absoluteDataPath == importDirectory {
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(importDirectory, func(importPath string, f fs.DirEntry, err error) error {
if err != nil {
return err
}
if f.IsDir() {
return nil
}
// Get relative path
basePath := importDirectory
relFilePath, err := filepath.Rel(importDirectory, importPath)
if err != nil {
log.Warnf("path error: %v", err)
return nil
}
// Track imports
iResult := importResult{
Path: relFilePath,
Status: importFailed,
}
defer func() {
importResults = append(importResults, iResult)
}()
// Get metadata
fileMeta, err := metadata.GetMetadata(importPath)
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(c, *fileMeta.PartialMD5)
if err == nil {
log.Warnf("document already exists: %s", *fileMeta.PartialMD5)
iResult.Status = importExists
return nil
}
// Import Copy
if rAdminImport.Type == importCopy {
// Derive & Sanitize File Name
relFilePath = deriveBaseFileName(fileMeta)
safePath := filepath.Join(api.cfg.DataPath, "documents", relFilePath)
// Open Source File
srcFile, err := os.Open(importPath)
if err != nil {
log.Errorf("unable to open current file: %v", err)
iResult.Error = err
return nil
}
defer srcFile.Close()
// Open Destination File
destFile, err := os.Create(safePath)
if err != nil {
log.Errorf("unable to open destination file: %v", err)
iResult.Error = err
return nil
}
defer destFile.Close()
// Copy File
if _, err = io.Copy(destFile, srcFile); err != nil {
log.Errorf("unable to save file: %v", err)
iResult.Error = err
return nil
}
// Update Base & Path
basePath = filepath.Join(api.cfg.DataPath, "documents")
iResult.Path = relFilePath
}
// Upsert document
if _, err = qtx.UpsertDocument(c, database.UpsertDocumentParams{
ID: *fileMeta.PartialMD5,
Title: fileMeta.Title,
Author: fileMeta.Author,
Description: fileMeta.Description,
Md5: fileMeta.MD5,
Words: fileMeta.WordCount,
Filepath: &relFilePath,
Basepath: &basePath,
}); 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
}
// 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
}
// 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) {
// Validate Type & Derive Extension on MIME
uploadedFile, err := rAdminAction.RestoreFile.Open()
if err != nil {
log.Error("File Error: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to open file")
return
}
fileMime, err := mimetype.DetectReader(uploadedFile)
if err != nil {
log.Error("MIME Error")
appErrorPage(c, http.StatusInternalServerError, "Unable to detect filetype")
return
}
fileExtension := fileMime.Extension()
// Validate Extension
if !slices.Contains([]string{".zip"}, fileExtension) {
log.Error("Invalid FileType: ", fileExtension)
appErrorPage(c, http.StatusBadRequest, "Invalid filetype")
return
}
// Create Temp File
tempFile, err := os.CreateTemp("", "restore")
if err != nil {
log.Warn("Temp File Create Error: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to create temp file")
return
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
// Save Temp
err = c.SaveUploadedFile(rAdminAction.RestoreFile, tempFile.Name())
if err != nil {
log.Error("File Error: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to save file")
return
}
// ZIP Info
fileInfo, err := tempFile.Stat()
if err != nil {
log.Error("File Error: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to read file")
return
}
// Create ZIP Reader
zipReader, err := zip.NewReader(tempFile, fileInfo.Size())
if err != nil {
log.Error("ZIP Error: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to read zip")
return
}
// Validate ZIP Contents
hasDBFile := false
hasUnknownFile := false
for _, file := range zipReader.File {
fileName := strings.TrimPrefix(file.Name, "/")
if fileName == "antholume.db" {
hasDBFile = true
break
} else if !strings.HasPrefix(fileName, "covers/") && !strings.HasPrefix(fileName, "documents/") {
hasUnknownFile = true
break
}
}
// Invalid ZIP
if !hasDBFile {
log.Error("Invalid ZIP File - Missing DB")
appErrorPage(c, http.StatusInternalServerError, "Invalid Restore ZIP - Missing DB")
return
} else if hasUnknownFile {
log.Error("Invalid ZIP File - Invalid File(s)")
appErrorPage(c, http.StatusInternalServerError, "Invalid Restore ZIP - Invalid File(s)")
return
}
// Create Backup File
backupFilePath := filepath.Join(api.cfg.ConfigPath, fmt.Sprintf("backups/AnthoLumeBackup_%s.zip", time.Now().Format("20060102150405")))
backupFile, err := os.Create(backupFilePath)
if err != nil {
log.Error("Unable to create backup file: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to create backup file")
return
}
defer backupFile.Close()
// Save Backup File
w := bufio.NewWriter(backupFile)
err = api.createBackup(c, w, []string{"covers", "documents"})
if err != nil {
log.Error("Unable to save backup file: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to save backup file")
return
}
// Remove Data
err = api.removeData()
if err != nil {
log.Error("Unable to delete data: ", err)
appErrorPage(c, http.StatusInternalServerError, "Unable to delete data")
return
}
// Restore Data
err = api.restoreData(zipReader)
if err != nil {
appErrorPage(c, http.StatusInternalServerError, "Unable to restore data")
log.Panic("Unable to restore data: ", err)
}
// Reinit DB
if err := api.db.Reload(c); err != nil {
appErrorPage(c, http.StatusInternalServerError, "Unable to reload DB")
log.Panicf("Unable to reload DB: %v", err)
}
// Rotate Auth Hashes
if err := api.rotateAllAuthHashes(c); err != nil {
appErrorPage(c, http.StatusInternalServerError, "Unable to rotate hashes")
log.Panicf("Unable to rotate auth hashes: %v", err)
}
// Redirect to login page
c.Redirect(http.StatusFound, "/login")
}
func (api *API) restoreData(zipReader *zip.Reader) error {
// Ensure Directories
api.cfg.EnsureDirectories()
// Restore Data
for _, file := range zipReader.File {
rc, err := file.Open()
if err != nil {
return err
}
defer rc.Close()
destPath := filepath.Join(api.cfg.DataPath, file.Name)
destFile, err := os.Create(destPath)
if err != nil {
log.Errorf("error creating destination file: %v", err)
return err
}
defer destFile.Close()
// Copy the contents from the zip file to the destination file.
if _, err := io.Copy(destFile, rc); err != nil {
log.Errorf("Error copying file contents: %v", err)
return err
}
}
return nil
}
func (api *API) removeData() error {
allPaths := []string{
"covers",
"documents",
"antholume.db",
"antholume.db-wal",
"antholume.db-shm",
}
for _, name := range allPaths {
fullPath := filepath.Join(api.cfg.DataPath, name)
err := os.RemoveAll(fullPath)
if err != nil {
log.Errorf("Unable to delete %s: %v", name, err)
return err
}
}
return nil
}
func (api *API) createBackup(ctx context.Context, w io.Writer, directories []string) error {
// Vacuum DB
_, err := api.db.DB.ExecContext(ctx, "VACUUM;")
if err != nil {
return fmt.Errorf("Unable to vacuum database: %w", err)
}
ar := zip.NewWriter(w)
exportWalker := func(currentPath string, f fs.DirEntry, err error) error {
if err != nil {
return err
}
if f.IsDir() {
return nil
}
// Open File on Disk
file, err := os.Open(currentPath)
if err != nil {
return err
}
defer file.Close()
// Derive Export Structure
fileName := filepath.Base(currentPath)
folderName := filepath.Base(filepath.Dir(currentPath))
// Create File in Export
newF, err := ar.Create(filepath.Join(folderName, fileName))
if err != nil {
return err
}
// Copy File in Export
_, err = io.Copy(newF, file)
if err != nil {
return err
}
return nil
}
// Get DB Path
fileName := fmt.Sprintf("%s.db", api.cfg.DBName)
dbLocation := filepath.Join(api.cfg.ConfigPath, fileName)
// Copy Database File
dbFile, err := os.Open(dbLocation)
if err != nil {
return err
}
defer dbFile.Close()
newDbFile, err := ar.Create(fileName)
if err != nil {
return err
}
_, err = io.Copy(newDbFile, dbFile)
if err != nil {
return err
}
// Backup Covers & Documents
for _, dir := range directories {
err = filepath.WalkDir(filepath.Join(api.cfg.DataPath, dir), exportWalker)
if err != nil {
return err
}
}
ar.Close()
return nil
}
func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) {
allUsers, err := api.db.Queries.GetUsers(ctx)
if err != nil {
return false, fmt.Errorf("GetUsers DB Error: %w", err)
}
hasAdmin := false
for _, user := range allUsers {
if user.Admin && user.ID != userID {
hasAdmin = true
break
}
}
return !hasAdmin, nil
}
func (api *API) createUser(ctx context.Context, user string, rawPassword *string, isAdmin *bool) error {
// Validate Necessary Parameters
if rawPassword == nil || *rawPassword == "" {
return fmt.Errorf("password can't be empty")
}
// Base Params
createParams := database.CreateUserParams{
ID: user,
}
// Handle Admin (Explicit or False)
if isAdmin != nil {
createParams.Admin = *isAdmin
} else {
createParams.Admin = false
}
// Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*rawPassword)))
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(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(ctx context.Context, user string, rawPassword *string, isAdmin *bool) error {
// Validate Necessary Parameters
if rawPassword == nil && isAdmin == nil {
return fmt.Errorf("nothing to update")
}
// Base Params
updateParams := database.UpdateUserParams{
UserID: user,
}
// Handle Admin (Update or Existing)
if isAdmin != nil {
updateParams.Admin = *isAdmin
} else {
user, err := api.db.Queries.GetUser(ctx, user)
if err != nil {
return fmt.Errorf("GetUser DB Error: %w", err)
}
updateParams.Admin = user.Admin
}
// Check Admins - Disallow Demotion
if isLast, err := api.isLastAdmin(ctx, user); err != nil {
return err
} else if isLast && !updateParams.Admin {
return fmt.Errorf("unable to demote %s - last admin", user)
}
// Handle Password
if rawPassword != nil {
if *rawPassword == "" {
return fmt.Errorf("password can't be empty")
}
// Parse Password
password := fmt.Sprintf("%x", md5.Sum([]byte(*rawPassword)))
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(ctx, updateParams)
if err != nil {
return fmt.Errorf("UpdateUser DB Error: %w", err)
}
return nil
}
func (api *API) deleteUser(ctx context.Context, user string) error {
// Check Admins
if isLast, err := api.isLastAdmin(ctx, user); err != nil {
return err
} else if isLast {
return fmt.Errorf("unable to delete %s - last admin", user)
}
// Create Backup File
backupFilePath := filepath.Join(api.cfg.ConfigPath, fmt.Sprintf("backups/AnthoLumeBackup_%s.zip", time.Now().Format("20060102150405")))
backupFile, err := os.Create(backupFilePath)
if err != nil {
return err
}
defer backupFile.Close()
// Save Backup File (DB Only)
w := bufio.NewWriter(backupFile)
err = api.createBackup(ctx, w, []string{})
if err != nil {
return err
}
// Delete User
_, err = api.db.Queries.DeleteUser(ctx, user)
if err != nil {
return fmt.Errorf("DeleteUser DB Error: %w", err)
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
package api
import (
"context"
"crypto/md5"
"fmt"
"net/http"
@@ -11,39 +12,49 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"reichard.io/bbank/database"
"reichard.io/antholume/database"
"reichard.io/antholume/utils"
)
// Authorization Data
type authData struct {
UserName string
IsAdmin bool
AuthHash string
}
// KOSync API Auth Headers
type authKOHeader struct {
AuthUser string `header:"x-auth-user"`
AuthKey string `header:"x-auth-key"`
}
// OPDS Auth Headers
type authOPDSHeader struct {
Authorization string `header:"authorization"`
}
func (api *API) authorizeCredentials(username string, password string) (authorized bool) {
user, err := api.DB.Queries.GetUser(api.DB.Ctx, username)
func (api *API) authorizeCredentials(ctx context.Context, username string, password string) (auth *authData) {
user, err := api.db.Queries.GetUser(ctx, username)
if err != nil {
return false
return
}
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || match != true {
return false
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
return
}
return true
// Update auth cache
api.userAuthCache[user.ID] = *user.AuthHash
return &authData{
UserName: user.ID,
IsAdmin: user.Admin,
AuthHash: *user.AuthHash,
}
}
func (api *API) authKOMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Check Session First
if user, ok := getSession(session); ok == true {
c.Set("AuthorizedUser", user)
if auth, ok := api.getSession(c, session); ok {
c.Set("Authorization", auth)
c.Header("Cache-Control", "private")
c.Next()
return
@@ -61,17 +72,18 @@ func (api *API) authKOMiddleware(c *gin.Context) {
return
}
if authorized := api.authorizeCredentials(rHeader.AuthUser, rHeader.AuthKey); authorized != true {
authData := api.authorizeCredentials(c, rHeader.AuthUser, rHeader.AuthKey)
if authData == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
if err := setSession(session, rHeader.AuthUser); err != nil {
if err := api.setSession(session, *authData); err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
c.Set("AuthorizedUser", rHeader.AuthUser)
c.Set("Authorization", *authData)
c.Header("Cache-Control", "private")
c.Next()
}
@@ -82,19 +94,20 @@ 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
}
// Validate Auth
password := fmt.Sprintf("%x", md5.Sum([]byte(rawPassword)))
if authorized := api.authorizeCredentials(user, password); authorized != true {
authData := api.authorizeCredentials(c, user, password)
if authData == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
c.Set("AuthorizedUser", user)
c.Set("Authorization", *authData)
c.Header("Cache-Control", "private")
c.Next()
}
@@ -103,8 +116,8 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
session := sessions.Default(c)
// Check Session
if user, ok := getSession(session); ok == true {
c.Set("AuthorizedUser", user)
if auth, ok := api.getSession(c, session); ok {
c.Set("Authorization", auth)
c.Header("Cache-Control", "private")
c.Next()
return
@@ -112,38 +125,47 @@ func (api *API) authWebAppMiddleware(c *gin.Context) {
c.Redirect(http.StatusFound, "/login")
c.Abort()
return
}
func (api *API) authFormLogin(c *gin.Context) {
func (api *API) authAdminWebAppMiddleware(c *gin.Context) {
if data, _ := c.Get("Authorization"); data != nil {
auth := data.(authData)
if auth.IsAdmin {
c.Next()
return
}
}
appErrorPage(c, http.StatusUnauthorized, "Admin Permissions Required")
c.Abort()
}
func (api *API) appAuthLogin(c *gin.Context) {
templateVars, _ := api.getBaseTemplateVars("login", c)
username := strings.TrimSpace(c.PostForm("username"))
rawPassword := strings.TrimSpace(c.PostForm("password"))
if username == "" || rawPassword == "" {
c.HTML(http.StatusUnauthorized, "login", gin.H{
"RegistrationEnabled": api.Config.RegistrationEnabled,
"Error": "Invalid Credentials",
})
templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return
}
// 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,
"Error": "Invalid Credentials",
})
authData := api.authorizeCredentials(c, username, password)
if authData == nil {
templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return
}
// Set Session
session := sessions.Default(c)
if err := setSession(session, username); err != nil {
c.HTML(http.StatusUnauthorized, "login", gin.H{
"RegistrationEnabled": api.Config.RegistrationEnabled,
"Error": "Unknown Error",
})
if err := api.setSession(session, *authData); err != nil {
templateVars["Error"] = "Invalid Credentials"
c.HTML(http.StatusUnauthorized, "page/login", templateVars)
return
}
@@ -151,60 +173,93 @@ func (api *API) authFormLogin(c *gin.Context) {
c.Redirect(http.StatusFound, "/")
}
func (api *API) authFormRegister(c *gin.Context) {
if !api.Config.RegistrationEnabled {
errorPage(c, http.StatusUnauthorized, "Nice try. Registration is disabled.")
func (api *API) appAuthRegister(c *gin.Context) {
if !api.cfg.RegistrationEnabled {
appErrorPage(c, http.StatusUnauthorized, "Nice try. Registration is disabled.")
return
}
templateVars, _ := api.getBaseTemplateVars("login", c)
templateVars["Register"] = true
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",
})
templateVars["Error"] = "Invalid User or Password"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
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",
})
templateVars["Error"] = "Registration Disabled or User Already Exists"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
return
}
rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
})
// SQL Error
// Generate auth hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
c.HTML(http.StatusBadRequest, "login", gin.H{
"Register": true,
"Error": "Registration Disabled or User Already Exists",
})
log.Error("Failed to generate user token: ", err)
templateVars["Error"] = "Failed to Create User"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
return
}
// User Already Exists
if rows == 0 {
c.HTML(http.StatusBadRequest, "login", gin.H{
"Register": true,
"Error": "Registration Disabled or User Already Exists",
})
// Get current users
currentUsers, err := api.db.Queries.GetUsers(c)
if err != nil {
log.Error("Failed to check all users: ", err)
templateVars["Error"] = "Failed to Create User"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
return
}
// Set Session
// 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(c, database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: &authHash,
Admin: isAdmin,
}); err != nil {
log.Error("CreateUser DB Error:", err)
templateVars["Error"] = "Registration Disabled or User Already Exists"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
return
} else if rows == 0 {
log.Warn("User Already Exists:", username)
templateVars["Error"] = "Registration Disabled or User Already Exists"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
return
}
// Get user
user, err := api.db.Queries.GetUser(c, username)
if err != nil {
log.Error("GetUser DB Error:", err)
templateVars["Error"] = "Registration Disabled or User Already Exists"
c.HTML(http.StatusBadRequest, "page/login", templateVars)
return
}
// Set session
auth := authData{
UserName: user.ID,
IsAdmin: user.Admin,
AuthHash: *user.AuthHash,
}
session := sessions.Default(c)
if err := setSession(session, username); err != nil {
errorPage(c, http.StatusUnauthorized, "Unauthorized.")
if err := api.setSession(session, auth); err != nil {
appErrorPage(c, http.StatusUnauthorized, "Unauthorized.")
return
}
@@ -212,33 +267,206 @@ func (api *API) authFormRegister(c *gin.Context) {
c.Redirect(http.StatusFound, "/")
}
func (api *API) authLogout(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")
}
func getSession(session sessions.Session) (user string, ok bool) {
// Check Session
func (api *API) koAuthRegister(c *gin.Context) {
if !api.cfg.RegistrationEnabled {
c.AbortWithStatus(http.StatusConflict)
return
}
var rUser requestUser
if err := c.ShouldBindJSON(&rUser); err != nil {
log.Error("Invalid JSON Bind")
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
return
}
if rUser.Username == "" || rUser.Password == "" {
log.Error("Invalid User - Empty Username or Password")
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
return
}
// Generate password hash
hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams)
if err != nil {
log.Error("Argon2 Hash Failure:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
return
}
// Generate auth hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
log.Error("Failed to generate user token: ", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
return
}
// Get current users
currentUsers, err := api.db.Queries.GetUsers(c)
if err != nil {
log.Error("Failed to check all users: ", err)
apiErrorPage(c, http.StatusBadRequest, "Failed to Create User")
return
}
// Determine if we should be admin
isAdmin := false
if len(currentUsers) == 0 {
isAdmin = true
}
// Create user
authHash := fmt.Sprintf("%x", rawAuthHash)
if rows, err := api.db.Queries.CreateUser(c, database.CreateUserParams{
ID: rUser.Username,
Pass: &hashedPassword,
AuthHash: &authHash,
Admin: isAdmin,
}); err != nil {
log.Error("CreateUser DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid User Data")
return
} else if rows == 0 {
log.Error("User Already Exists:", rUser.Username)
apiErrorPage(c, http.StatusBadRequest, "User Already Exists")
return
}
c.JSON(http.StatusCreated, gin.H{
"username": rUser.Username,
})
}
func (api *API) getSession(ctx context.Context, session sessions.Session) (auth authData, ok bool) {
// Get Session
authorizedUser := session.Get("authorizedUser")
if authorizedUser == nil {
return "", false
isAdmin := session.Get("isAdmin")
expiresAt := session.Get("expiresAt")
authHash := session.Get("authHash")
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
return
}
// Create Auth Object
auth = authData{
UserName: authorizedUser.(string),
IsAdmin: isAdmin.(bool),
AuthHash: authHash.(string),
}
// Validate Auth Hash
correctAuthHash, err := api.getUserAuthHash(ctx, auth.UserName)
if err != nil || correctAuthHash != auth.AuthHash {
return
}
// Refresh
expiresAt := session.Get("expiresAt")
if expiresAt != nil && expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
log.Info("[getSession] Refreshing Session")
setSession(session, authorizedUser.(string))
if expiresAt.(int64)-time.Now().Unix() < 60*60*24 {
log.Info("Refreshing Session")
if err := api.setSession(session, auth); err != nil {
log.Error("unable to get session")
return
}
}
return authorizedUser.(string), true
// Authorized
return auth, true
}
func setSession(session sessions.Session, user string) error {
func (api *API) setSession(session sessions.Session, auth authData) error {
// Set Session Cookie
session.Set("authorizedUser", user)
session.Set("authorizedUser", auth.UserName)
session.Set("isAdmin", auth.IsAdmin)
session.Set("expiresAt", time.Now().Unix()+(60*60*24*7))
session.Set("authHash", auth.AuthHash)
return session.Save()
}
func (api *API) getUserAuthHash(ctx context.Context, username string) (string, error) {
// Return Cache
if api.userAuthCache[username] != "" {
return api.userAuthCache[username], nil
}
// Get DB
user, err := api.db.Queries.GetUser(ctx, username)
if err != nil {
log.Error("GetUser DB Error:", err)
return "", err
}
// Update Cache
api.userAuthCache[username] = *user.AuthHash
return api.userAuthCache[username], nil
}
func (api *API) rotateAllAuthHashes(ctx context.Context) error {
// Do Transaction
tx, err := api.db.DB.Begin()
if err != nil {
log.Error("Transaction Begin DB Error: ", err)
return err
}
// Defer & Start Transaction
defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx)
users, err := qtx.GetUsers(ctx)
if err != nil {
return err
}
// Update Users
newAuthHashCache := make(map[string]string, 0)
for _, user := range users {
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return err
}
// Update User
authHash := fmt.Sprintf("%x", rawAuthHash)
if _, err = qtx.UpdateUser(ctx, database.UpdateUserParams{
UserID: user.ID,
AuthHash: &authHash,
Admin: user.Admin,
}); err != nil {
return err
}
// Save New Hash Cache
newAuthHashCache[user.ID] = fmt.Sprintf("%x", rawAuthHash)
}
// Commit Transaction
if err := tx.Commit(); err != nil {
log.Error("Transaction Commit DB Error: ", err)
return err
}
// Transaction Succeeded -> Update Cache
for user, hash := range newAuthHashCache {
api.userAuthCache[user] = hash
}
return nil
}

151
api/common.go Normal file
View File

@@ -0,0 +1,151 @@
package api
import (
"fmt"
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
)
func (api *API) createDownloadDocumentHandler(errorFunc func(*gin.Context, int, string)) func(*gin.Context) {
return func(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("Invalid URI Bind")
errorFunc(c, http.StatusBadRequest, "Invalid Request")
return
}
// Get Document
document, err := api.db.Queries.GetDocument(c, rDoc.DocumentID)
if err != nil {
log.Error("GetDocument DB Error:", err)
errorFunc(c, http.StatusBadRequest, "Unknown Document")
return
}
if document.Filepath == nil {
log.Error("Document Doesn't Have File:", rDoc.DocumentID)
errorFunc(c, http.StatusBadRequest, "Document Doesn't Exist")
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(basepath, *document.Filepath)
// Validate File Exists
_, err = os.Stat(filePath)
if os.IsNotExist(err) {
log.Error("File should but doesn't exist: ", err)
errorFunc(c, http.StatusBadRequest, "Document Doesn't Exist")
return
}
// Force Download
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(*document.Filepath)))
c.File(filePath)
}
}
func (api *API) createGetCoverHandler(errorFunc func(*gin.Context, int, string)) func(*gin.Context) {
return func(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("Invalid URI Bind")
errorFunc(c, http.StatusNotFound, "Invalid cover.")
return
}
// Validate Document Exists in DB
document, err := api.db.Queries.GetDocument(c, rDoc.DocumentID)
if err != nil {
log.Error("GetDocument DB Error:", err)
errorFunc(c, http.StatusInternalServerError, fmt.Sprintf("GetDocument DB Error: %v", err))
return
}
// Handle Identified Document
if document.Coverfile != nil {
if *document.Coverfile == "UNKNOWN" {
c.FileFromFS("assets/images/no-cover.jpg", http.FS(api.assets))
return
}
// Derive Path
safePath := filepath.Join(api.cfg.DataPath, "covers", *document.Coverfile)
// Validate File Exists
_, err = os.Stat(safePath)
if err != nil {
log.Error("File should but doesn't exist: ", err)
c.FileFromFS("assets/images/no-cover.jpg", http.FS(api.assets))
return
}
c.File(safePath)
return
}
// Attempt Metadata
var coverDir string = filepath.Join(api.cfg.DataPath, "covers")
var coverFile string = "UNKNOWN"
// Identify Documents & Save Covers
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
Title: document.Title,
Author: document.Author,
})
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
firstResult := metadataResults[0]
// Save Cover
fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false)
if err == nil {
coverFile = *fileName
}
// Store First Metadata Result
if _, err = api.db.Queries.AddMetadata(c, database.AddMetadataParams{
DocumentID: document.ID,
Title: firstResult.Title,
Author: firstResult.Author,
Description: firstResult.Description,
Gbid: firstResult.ID,
Olid: nil,
Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13,
}); err != nil {
log.Error("AddMetadata DB Error:", err)
}
}
// Upsert Document
if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
ID: document.ID,
Coverfile: &coverFile,
}); err != nil {
log.Warn("UpsertDocument DB Error:", err)
}
// Return Unknown Cover
if coverFile == "UNKNOWN" {
c.FileFromFS("assets/images/no-cover.jpg", http.FS(api.assets))
return
}
coverFilePath := filepath.Join(coverDir, coverFile)
c.File(coverFilePath)
}
}

View File

@@ -10,15 +10,12 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"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"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
)
type activityItem struct {
@@ -74,132 +71,91 @@ type requestDocumentID struct {
DocumentID string `uri:"document" binding:"required"`
}
func (api *API) authorizeUser(c *gin.Context) {
c.JSON(200, gin.H{
func (api *API) koAuthorizeUser(c *gin.Context) {
koJSON(c, 200, gin.H{
"authorized": "OK",
})
}
func (api *API) createUser(c *gin.Context) {
if !api.Config.RegistrationEnabled {
c.AbortWithStatus(http.StatusConflict)
return
func (api *API) koSetProgress(c *gin.Context) {
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
var rUser requestUser
if err := c.ShouldBindJSON(&rUser); err != nil {
log.Error("[createUser] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
return
}
if rUser.Username == "" || rUser.Password == "" {
log.Error("[createUser] Invalid User - Empty Username or Password")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
return
}
hashedPassword, err := argon2.CreateHash(rUser.Password, argon2.DefaultParams)
if err != nil {
log.Error("[createUser] Argon2 Hash Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
return
}
rows, err := api.DB.Queries.CreateUser(api.DB.Ctx, database.CreateUserParams{
ID: rUser.Username,
Pass: &hashedPassword,
})
if err != nil {
log.Error("[createUser] CreateUser DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid User Data"})
return
}
// User Exists
if rows == 0 {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "User Already Exists"})
return
}
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 {
log.Error("[setProgress] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Progress Data"})
log.Error("Invalid JSON Bind")
apiErrorPage(c, http.StatusBadRequest, "Invalid Progress Data")
return
}
// Upsert Device
if _, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
if _, err := api.db.Queries.UpsertDevice(c, database.UpsertDeviceParams{
ID: rPosition.DeviceID,
UserID: rUser.(string),
UserID: auth.UserName,
DeviceName: rPosition.Device,
LastSynced: time.Now().UTC().Format(time.RFC3339),
}); err != nil {
log.Error("[setProgress] UpsertDevice DB Error:", err)
log.Error("UpsertDevice DB Error:", err)
}
// Upsert Document
if _, err := api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
if _, err := api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
ID: rPosition.DocumentID,
}); err != nil {
log.Error("[setProgress] UpsertDocument DB Error:", err)
log.Error("UpsertDocument DB Error:", err)
}
// Create or Replace Progress
progress, err := api.DB.Queries.UpdateProgress(api.DB.Ctx, database.UpdateProgressParams{
progress, err := api.db.Queries.UpdateProgress(c, database.UpdateProgressParams{
Percentage: rPosition.Percentage,
DocumentID: rPosition.DocumentID,
DeviceID: rPosition.DeviceID,
UserID: rUser.(string),
UserID: auth.UserName,
Progress: rPosition.Progress,
})
if err != nil {
log.Error("[setProgress] UpdateProgress DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
log.Error("UpdateProgress DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
return
}
c.JSON(http.StatusOK, gin.H{
koJSON(c, http.StatusOK, gin.H{
"document": progress.DocumentID,
"timestamp": progress.CreatedAt,
})
}
func (api *API) getProgress(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
func (api *API) koGetProgress(c *gin.Context) {
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
var rDocID requestDocumentID
if err := c.ShouldBindUri(&rDocID); err != nil {
log.Error("[getProgress] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
log.Error("Invalid URI Bind")
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
return
}
progress, err := api.DB.Queries.GetProgress(api.DB.Ctx, database.GetProgressParams{
progress, err := api.db.Queries.GetDocumentProgress(c, database.GetDocumentProgressParams{
DocumentID: rDocID.DocumentID,
UserID: rUser.(string),
UserID: auth.UserName,
})
if err == sql.ErrNoRows {
// Not Found
c.JSON(http.StatusOK, gin.H{})
koJSON(c, http.StatusOK, gin.H{})
return
} else if err != nil {
log.Error("[getProgress] GetProgress DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
log.Error("GetDocumentProgress DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Document")
return
}
c.JSON(http.StatusOK, gin.H{
koJSON(c, http.StatusOK, gin.H{
"document": progress.DocumentID,
"percentage": progress.Percentage,
"progress": progress.Progress,
@@ -208,21 +164,24 @@ func (api *API) getProgress(c *gin.Context) {
})
}
func (api *API) addActivities(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
func (api *API) koAddActivities(c *gin.Context) {
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
var rActivity requestActivity
if err := c.ShouldBindJSON(&rActivity); err != nil {
log.Error("[addActivity] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
log.Error("Invalid JSON Bind")
apiErrorPage(c, http.StatusBadRequest, "Invalid Activity")
return
}
// Do Transaction
tx, err := api.DB.DB.Begin()
tx, err := api.db.DB.Begin()
if err != nil {
log.Error("[addActivities] Transaction Begin DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
log.Error("Transaction Begin DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
return
}
@@ -234,140 +193,143 @@ func (api *API) addActivities(c *gin.Context) {
allDocuments := getKeys(allDocumentsMap)
// Defer & Start Transaction
defer tx.Rollback()
qtx := api.DB.Queries.WithTx(tx)
defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx)
// Upsert Documents
for _, doc := range allDocuments {
if _, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
if _, err := qtx.UpsertDocument(c, database.UpsertDocumentParams{
ID: doc,
}); err != nil {
log.Error("[addActivities] UpsertDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
log.Error("UpsertDocument DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Document")
return
}
}
// Upsert Device
if _, err = qtx.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
if _, err = qtx.UpsertDevice(c, database.UpsertDeviceParams{
ID: rActivity.DeviceID,
UserID: rUser.(string),
UserID: auth.UserName,
DeviceName: rActivity.Device,
LastSynced: time.Now().UTC().Format(time.RFC3339),
}); err != nil {
log.Error("[addActivities] UpsertDevice DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
log.Error("UpsertDevice DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Device")
return
}
// Add All Activity
for _, item := range rActivity.Activity {
if _, 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().Format(time.RFC3339),
Duration: int64(item.Duration),
Page: int64(item.Page),
Pages: int64(item.Pages),
if _, err := qtx.AddActivity(c, database.AddActivityParams{
UserID: auth.UserName,
DocumentID: item.DocumentID,
DeviceID: rActivity.DeviceID,
StartTime: time.Unix(int64(item.StartTime), 0).UTC().Format(time.RFC3339),
Duration: int64(item.Duration),
StartPercentage: float64(item.Page) / float64(item.Pages),
EndPercentage: float64(item.Page+1) / float64(item.Pages),
}); err != nil {
log.Error("[addActivities] AddActivity DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Activity"})
log.Error("AddActivity DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Activity")
return
}
}
// Commit Transaction
if err := tx.Commit(); err != nil {
log.Error("[addActivities] Transaction Commit DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
log.Error("Transaction Commit DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
return
}
// Update Temp Tables
go func() {
log.Info("[addActivities] Caching Temp Tables")
if err := api.DB.CacheTempTables(); err != nil {
log.Warn("[addActivities] CacheTempTables Failure: ", err)
}
}()
c.JSON(http.StatusOK, gin.H{
koJSON(c, http.StatusOK, gin.H{
"added": len(rActivity.Activity),
})
}
func (api *API) checkActivitySync(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
func (api *API) koCheckActivitySync(c *gin.Context) {
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
var rCheckActivity requestCheckActivitySync
if err := c.ShouldBindJSON(&rCheckActivity); err != nil {
log.Error("[checkActivitySync] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
log.Error("Invalid JSON Bind")
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
return
}
// Upsert Device
if _, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
if _, err := api.db.Queries.UpsertDevice(c, database.UpsertDeviceParams{
ID: rCheckActivity.DeviceID,
UserID: rUser.(string),
UserID: auth.UserName,
DeviceName: rCheckActivity.Device,
LastSynced: time.Now().UTC().Format(time.RFC3339),
}); err != nil {
log.Error("[checkActivitySync] UpsertDevice DB Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
log.Error("UpsertDevice DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Device")
return
}
// Get Last Device Activity
lastActivity, err := api.DB.Queries.GetLastActivity(api.DB.Ctx, database.GetLastActivityParams{
UserID: rUser.(string),
lastActivity, err := api.db.Queries.GetLastActivity(c, database.GetLastActivityParams{
UserID: auth.UserName,
DeviceID: rCheckActivity.DeviceID,
})
if err == sql.ErrNoRows {
lastActivity = time.UnixMilli(0).Format(time.RFC3339)
} else if err != nil {
log.Error("[checkActivitySync] GetLastActivity DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
log.Error("GetLastActivity DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
return
}
// Parse Time
parsedTime, err := time.Parse(time.RFC3339, lastActivity)
if err != nil {
log.Error("[checkActivitySync] Time Parse Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
log.Error("Time Parse Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
return
}
c.JSON(http.StatusOK, gin.H{
koJSON(c, http.StatusOK, gin.H{
"last_sync": parsedTime.Unix(),
})
}
func (api *API) addDocuments(c *gin.Context) {
func (api *API) koAddDocuments(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)"})
log.Error("Invalid JSON Bind")
apiErrorPage(c, http.StatusBadRequest, "Invalid Document(s)")
return
}
// Do Transaction
tx, err := api.DB.DB.Begin()
tx, err := api.db.DB.Begin()
if err != nil {
log.Error("[addDocuments] Transaction Begin DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
log.Error("Transaction Begin DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
return
}
// Defer & Start Transaction
defer tx.Rollback()
qtx := api.DB.Queries.WithTx(tx)
defer func() {
if err := tx.Rollback(); err != nil {
log.Error("DB Rollback Error:", err)
}
}()
qtx := api.db.Queries.WithTx(tx)
// Upsert Documents
for _, doc := range rNewDocs.Documents {
doc, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
_, err := qtx.UpsertDocument(c, database.UpsertDocumentParams{
ID: doc.ID,
Title: api.sanitizeInput(doc.Title),
Author: api.sanitizeInput(doc.Author),
@@ -377,90 +339,78 @@ func (api *API) addDocuments(c *gin.Context) {
Description: api.sanitizeInput(doc.Description),
})
if err != nil {
log.Error("[addDocuments] UpsertDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
log.Error("UpsertDocument DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Document")
return
}
if _, err = qtx.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
ID: doc.ID,
Synced: true,
}); err != nil {
log.Error("[addDocuments] UpdateDocumentSync DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
}
// Commit Transaction
if err := tx.Commit(); err != nil {
log.Error("[addDocuments] Transaction Commit DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Error"})
log.Error("Transaction Commit DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Error")
return
}
c.JSON(http.StatusOK, gin.H{
koJSON(c, http.StatusOK, gin.H{
"changed": len(rNewDocs.Documents),
})
}
func (api *API) checkDocumentsSync(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
func (api *API) koCheckDocumentsSync(c *gin.Context) {
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
var rCheckDocs requestCheckDocumentSync
if err := c.ShouldBindJSON(&rCheckDocs); err != nil {
log.Error("[checkDocumentsSync] Invalid JSON Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
log.Error("Invalid JSON Bind")
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
return
}
// Upsert Device
device, err := api.DB.Queries.UpsertDevice(api.DB.Ctx, database.UpsertDeviceParams{
_, err := api.db.Queries.UpsertDevice(c, database.UpsertDeviceParams{
ID: rCheckDocs.DeviceID,
UserID: rUser.(string),
UserID: auth.UserName,
DeviceName: rCheckDocs.Device,
LastSynced: time.Now().UTC().Format(time.RFC3339),
})
if err != nil {
log.Error("[checkDocumentsSync] UpsertDevice DB Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Device"})
log.Error("UpsertDevice DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Device")
return
}
missingDocs := []database.Document{}
deletedDocIDs := []string{}
// Get Missing Documents
missingDocs, err := api.db.Queries.GetMissingDocuments(c, rCheckDocs.Have)
if err != nil {
log.Error("GetMissingDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
return
}
if device.Sync == true {
// Get Missing Documents
missingDocs, err = api.DB.Queries.GetMissingDocuments(api.DB.Ctx, rCheckDocs.Have)
if err != nil {
log.Error("[checkDocumentsSync] GetMissingDocuments DB 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("[checkDocumentsSync] GetDeletedDocuments DB Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
// Get Deleted Documents
deletedDocIDs, err := api.db.Queries.GetDeletedDocuments(c, rCheckDocs.Have)
if err != nil {
log.Error("GetDeletedDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
return
}
// Get Wanted Documents
jsonHaves, err := json.Marshal(rCheckDocs.Have)
if err != nil {
log.Error("[checkDocumentsSync] JSON Marshal Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
log.Error("JSON Marshal Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
return
}
wantedDocs, err := api.DB.Queries.GetWantedDocuments(api.DB.Ctx, string(jsonHaves))
wantedDocs, err := api.db.Queries.GetWantedDocuments(c, string(jsonHaves))
if err != nil {
log.Error("[checkDocumentsSync] GetWantedDocuments DB Error", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
log.Error("GetWantedDocuments DB Error", err)
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
return
}
@@ -497,159 +447,116 @@ func (api *API) checkDocumentsSync(c *gin.Context) {
rCheckDocSync.Delete = deletedDocIDs
}
c.JSON(http.StatusOK, rCheckDocSync)
koJSON(c, http.StatusOK, rCheckDocSync)
}
func (api *API) uploadExistingDocument(c *gin.Context) {
func (api *API) koUploadExistingDocument(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[uploadExistingDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
log.Error("Invalid URI Bind")
apiErrorPage(c, http.StatusBadRequest, "Invalid Request")
return
}
// Open Form File
fileData, err := c.FormFile("file")
if err != nil {
log.Error("[uploadExistingDocument] File Error:", err)
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([]string{".epub", ".html"}, fileExtension) {
log.Error("[uploadExistingDocument] Invalid FileType:", fileExtension)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Filetype"})
log.Error("File Error:", err)
apiErrorPage(c, http.StatusBadRequest, "File error")
return
}
// Validate Document Exists in DB
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
document, err := api.db.Queries.GetDocument(c, rDoc.DocumentID)
if err != nil {
log.Error("[uploadExistingDocument] GetDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
log.Error("GetDocument DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Unknown Document")
return
}
// Open File
uploadedFile, err := fileData.Open()
if err != nil {
log.Error("Unable to open file")
apiErrorPage(c, http.StatusBadRequest, "Unable to open file")
return
}
// Check Support
docType, err := metadata.GetDocumentTypeReader(uploadedFile)
if err != nil {
log.Error("Unsupported file")
apiErrorPage(c, http.StatusBadRequest, "Unsupported file")
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"
}
// Remove Slashes
fileName = strings.ReplaceAll(fileName, "/", "")
// Derive & Sanitize File Name
fileName = "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, document.ID, fileExtension))
fileName := deriveBaseFileName(&metadata.MetadataInfo{
Type: *docType,
PartialMD5: &document.ID,
Title: document.Title,
Author: document.Author,
})
// Generate Storage Path
safePath := filepath.Join(api.Config.DataPath, "documents", fileName)
basePath := filepath.Join(api.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
// Save & Prevent Overwrites
_, err = os.Stat(safePath)
if os.IsNotExist(err) {
err = c.SaveUploadedFile(fileData, safePath)
if err != nil {
log.Error("[uploadExistingDocument] Save Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
log.Error("Save Failure:", err)
apiErrorPage(c, http.StatusBadRequest, "File Error")
return
}
}
// Get MD5 Hash
fileHash, err := getFileMD5(safePath)
// Acquire Metadata
metadataInfo, err := metadata.GetMetadata(safePath)
if err != nil {
log.Error("[uploadExistingDocument] Hash Failure:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "File Error"})
log.Errorf("Unable to acquire metadata: %v", err)
apiErrorPage(c, http.StatusBadRequest, "Unable to acquire metadata")
return
}
// Upsert Document
if _, err = api.DB.Queries.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
if _, err = api.db.Queries.UpsertDocument(c, database.UpsertDocumentParams{
ID: document.ID,
Md5: fileHash,
Md5: metadataInfo.MD5,
Words: metadataInfo.WordCount,
Filepath: &fileName,
Basepath: &basePath,
}); err != nil {
log.Error("[uploadExistingDocument] UpsertDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Document Error"})
log.Error("UpsertDocument DB Error:", err)
apiErrorPage(c, http.StatusBadRequest, "Document Error")
return
}
// Update Document Sync Attribute
if _, err = api.DB.Queries.UpdateDocumentSync(api.DB.Ctx, database.UpdateDocumentSyncParams{
ID: document.ID,
Synced: true,
}); err != nil {
log.Error("[uploadExistingDocument] UpdateDocumentSync DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Document"})
return
}
c.JSON(http.StatusOK, gin.H{
koJSON(c, http.StatusOK, gin.H{
"status": "ok",
})
}
func (api *API) downloadDocument(c *gin.Context) {
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[downloadDocument] Invalid URI Bind")
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return
}
func (api *API) koDemoModeJSONError(c *gin.Context) {
apiErrorPage(c, http.StatusUnauthorized, "Not Allowed in Demo Mode")
}
// Get Document
document, err := api.DB.Queries.GetDocument(api.DB.Ctx, rDoc.DocumentID)
if err != nil {
log.Error("[downloadDocument] GetDocument DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Unknown Document"})
return
}
if document.Filepath == nil {
log.Error("[downloadDocument] Document Doesn't Have File:", rDoc.DocumentID)
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) {
log.Error("[downloadDocument] File Doesn't Exist:", rDoc.DocumentID)
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 apiErrorPage(c *gin.Context, errorCode int, errorMessage string) {
c.AbortWithStatusJSON(errorCode, gin.H{"error": errorMessage})
}
func (api *API) sanitizeInput(val any) *string {
switch v := val.(type) {
case *string:
if v != nil {
newString := html.UnescapeString(api.HTMLPolicy.Sanitize(string(*v)))
newString := html.UnescapeString(htmlPolicy.Sanitize(string(*v)))
return &newString
}
case string:
if v != "" {
newString := html.UnescapeString(api.HTMLPolicy.Sanitize(string(v)))
newString := html.UnescapeString(htmlPolicy.Sanitize(string(v)))
return &newString
}
}
@@ -682,3 +589,10 @@ func getFileMD5(filePath string) (*string, error) {
return &fileHash, nil
}
// koJSON forces koJSON Content-Type to only return `application/json`. This is addressing
// the following issue: https://github.com/koreader/koreader/issues/13629
func koJSON(c *gin.Context, code int, obj any) {
c.Header("Content-Type", "application/json")
c.JSON(code, obj)
}

View File

@@ -8,8 +8,9 @@ import (
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"reichard.io/bbank/database"
"reichard.io/bbank/opds"
"reichard.io/antholume/database"
"reichard.io/antholume/opds"
"reichard.io/antholume/pkg/ptr"
)
var mimeMapping map[string]string = map[string]string{
@@ -26,23 +27,66 @@ var mimeMapping map[string]string = map[string]string{
"lit": "application/x-ms-reader",
}
func (api *API) opdsDocuments(c *gin.Context) {
var userID string
if rUser, _ := c.Get("AuthorizedUser"); rUser != nil {
userID = rUser.(string)
func (api *API) opdsEntry(c *gin.Context) {
// Build & Return XML
mainFeed := &opds.Feed{
Title: "AnthoLume OPDS Server",
Updated: time.Now().UTC(),
Links: []opds.Link{
{
Title: "Search AnthoLume",
Rel: "search",
TypeLink: "application/opensearchdescription+xml",
Href: "/api/opds/search.xml",
},
},
Entries: []opds.Entry{
{
Title: "AnthoLume - All Documents",
Content: &opds.Content{
Content: "AnthoLume - All Documents",
ContentType: "text",
},
Links: []opds.Link{
{
Href: "/api/opds/documents",
TypeLink: "application/atom+xml;type=feed;profile=opds-catalog",
},
},
},
},
}
// Potential URL Parameters
qParams := bindQueryParams(c)
c.XML(http.StatusOK, mainFeed)
}
func (api *API) opdsDocuments(c *gin.Context) {
var auth authData
if data, _ := c.Get("Authorization"); data != nil {
auth = data.(authData)
}
// Potential URL Parameters (Default Pagination - 100)
qParams := bindQueryParams(c, 100)
// Possible Query
var query *string
if qParams.Search != nil && *qParams.Search != "" {
search := "%" + *qParams.Search + "%"
query = &search
}
// Get Documents
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
UserID: userID,
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
documents, err := api.db.Queries.GetDocumentsWithStats(c, database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
Query: query,
Deleted: ptr.Of(false),
Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit,
})
if err != nil {
log.Error("[opdsDocuments] GetDocumentsWithStats DB Error:", err)
log.Error("GetDocumentsWithStats DB Error:", err)
c.AbortWithStatus(http.StatusBadRequest)
return
}
@@ -55,26 +99,41 @@ func (api *API) opdsDocuments(c *gin.Context) {
splitFilepath := strings.Split(*doc.Filepath, ".")
fileType := splitFilepath[len(splitFilepath)-1]
title := "N/A"
if doc.Title != nil {
title = *doc.Title
}
author := "N/A"
if doc.Author != nil {
author = *doc.Author
}
description := "N/A"
if doc.Description != nil {
description = *doc.Description
}
item := opds.Entry{
Title: fmt.Sprintf("[%3d%%] %s", int(doc.Percentage.(float64)), *doc.Title),
Title: title,
Author: []opds.Author{
{
Name: *doc.Author,
Name: author,
},
},
Content: &opds.Content{
Content: *doc.Description,
Content: description,
ContentType: "text",
},
Links: []opds.Link{
{
Rel: "http://opds-spec.org/acquisition",
Href: fmt.Sprintf("./documents/%s/file", doc.ID),
Href: fmt.Sprintf("/api/opds/documents/%s/file", doc.ID),
TypeLink: mimeMapping[fileType],
},
{
Rel: "http://opds-spec.org/image",
Href: fmt.Sprintf("./documents/%s/cover", doc.ID),
Href: fmt.Sprintf("/api/opds/documents/%s/cover", doc.ID),
TypeLink: "image/jpeg",
},
},
@@ -84,19 +143,15 @@ func (api *API) opdsDocuments(c *gin.Context) {
}
}
feedTitle := "All Documents"
if query != nil {
feedTitle = "Search Results"
}
// Build & Return XML
searchFeed := &opds.Feed{
Title: "All Documents",
Title: feedTitle,
Updated: time.Now().UTC(),
// TODO
// Links: []opds.Link{
// {
// Title: "Search Book Manager",
// Rel: "search",
// TypeLink: "application/opensearchdescription+xml",
// Href: "search.xml",
// },
// },
Entries: allEntries,
}
@@ -105,9 +160,9 @@ func (api *API) opdsDocuments(c *gin.Context) {
func (api *API) opdsSearchDescription(c *gin.Context) {
rawXML := `<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>Search Book Manager</ShortName>
<Description>Search Book Manager</Description>
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="./search?query={searchTerms}"/>
<ShortName>Search AnthoLume</ShortName>
<Description>Search AnthoLume</Description>
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="/api/opds/documents?search={searchTerms}"/>
</OpenSearchDescription>`
c.Data(http.StatusOK, "application/xml", []byte(rawXML))
}

76
api/streamer.go Normal file
View File

@@ -0,0 +1,76 @@
package api
import (
"bytes"
"html/template"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type streamer struct {
templates map[string]*template.Template
writer gin.ResponseWriter
mutex sync.Mutex
completeCh chan struct{}
}
func (api *API) newStreamer(c *gin.Context, data string) *streamer {
stream := &streamer{
writer: c.Writer,
templates: api.templates,
completeCh: make(chan struct{}),
}
// Set Headers
header := stream.writer.Header()
header.Set("Transfer-Encoding", "chunked")
header.Set("Content-Type", "text/html; charset=utf-8")
header.Set("X-Content-Type-Options", "nosniff")
stream.writer.WriteHeader(http.StatusOK)
// Send Open Element Tags
stream.write(data)
// Keep Alive
go func() {
closeCh := stream.writer.CloseNotify()
for {
select {
case <-stream.completeCh:
return
case <-closeCh:
return
default:
stream.write("<!-- ping -->")
time.Sleep(2 * time.Second)
}
}
}()
return stream
}
func (stream *streamer) write(str string) {
stream.mutex.Lock()
stream.writer.WriteString(str)
stream.writer.(http.Flusher).Flush()
stream.mutex.Unlock()
}
func (stream *streamer) send(templateName string, templateVars gin.H) {
t := stream.templates[templateName]
buf := &bytes.Buffer{}
_ = t.ExecuteTemplate(buf, templateName, templateVars)
stream.write(buf.String())
}
func (stream *streamer) close(data string) {
// Send Close Element Tags
stream.write(data)
// Close
close(stream.completeCh)
}

View File

@@ -1,63 +1,61 @@
package api
import (
"errors"
"fmt"
"math"
"path/filepath"
"reflect"
"strings"
"reichard.io/bbank/database"
"reichard.io/bbank/graph"
"reichard.io/antholume/database"
"reichard.io/antholume/graph"
"reichard.io/antholume/metadata"
)
type UTCOffset struct {
Name string
Value string
}
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
// getTimeZones returns a string slice of IANA timezones.
func getTimeZones() []string {
return []string{
"Africa/Cairo",
"Africa/Johannesburg",
"Africa/Lagos",
"Africa/Nairobi",
"America/Adak",
"America/Anchorage",
"America/Buenos_Aires",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Mexico_City",
"America/New_York",
"America/Nuuk",
"America/Phoenix",
"America/Puerto_Rico",
"America/Sao_Paulo",
"America/St_Johns",
"America/Toronto",
"Asia/Dubai",
"Asia/Hong_Kong",
"Asia/Kolkata",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Tokyo",
"Atlantic/Azores",
"Australia/Melbourne",
"Australia/Sydney",
"Europe/Berlin",
"Europe/London",
"Europe/Moscow",
"Europe/Paris",
"Pacific/Auckland",
"Pacific/Honolulu",
}
}
// niceSeconds takes in an int (in seconds) and returns a string readable
// representation. For example 1928371 -> "22d 7h 39m 31s".
// Deprecated: Use formatters.FormatDuration
func niceSeconds(input int64) (result string) {
if input == 0 {
return "N/A"
@@ -86,7 +84,29 @@ func niceSeconds(input int64) (result string) {
return
}
// Convert Database Array -> Int64 Array
// niceNumbers takes in an int and returns a string representation. For example
// 19823 -> "19.8k".
// Deprecated: Use formatters.FormatNumber
func niceNumbers(input int64) string {
if input == 0 {
return "0"
}
abbreviations := []string{"", "k", "M", "B", "T"}
abbrevIndex := int(math.Log10(float64(input)) / 3)
scaledNumber := float64(input) / math.Pow(10, float64(abbrevIndex*3))
if scaledNumber >= 100 {
return fmt.Sprintf("%.0f%s", scaledNumber, abbreviations[abbrevIndex])
} else if scaledNumber >= 10 {
return fmt.Sprintf("%.1f%s", scaledNumber, abbreviations[abbrevIndex])
} else {
return fmt.Sprintf("%.2f%s", scaledNumber, abbreviations[abbrevIndex])
}
}
// getSVGGraphData builds SVGGraphData from the provided stats, width and height.
// It is used exclusively in templates to generate the daily read stats graph.
func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
var intData []int64
for _, item := range inputData {
@@ -95,3 +115,74 @@ func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, sv
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
}
// dict returns a map[string]any dict. Each pair of two is a key & value
// respectively. It's primarily utilized in templates.
func dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, errors.New("invalid dict call")
}
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
}
// fields returns a map[string]any of the provided struct. It's primarily
// utilized in templates.
func fields(value any) (map[string]any, error) {
v := reflect.Indirect(reflect.ValueOf(value))
if v.Kind() != reflect.Struct {
return nil, fmt.Errorf("%T is not a struct", value)
}
m := make(map[string]any)
t := v.Type()
for i := 0; i < t.NumField(); i++ {
sv := t.Field(i)
m[sv.Name] = v.Field(i).Interface()
}
return m, nil
}
// slice returns a slice of the provided arguments. It's primarily utilized in
// templates.
func slice(elements ...any) []any {
return elements
}
// deriveBaseFileName builds the base filename for a given MetadataInfo object.
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
// Derive New FileName
var newFileName string
if *metadataInfo.Author != "" {
newFileName = newFileName + *metadataInfo.Author
} else {
newFileName = newFileName + "Unknown"
}
if *metadataInfo.Title != "" {
newFileName = newFileName + " - " + *metadataInfo.Title
} else {
newFileName = newFileName + " - Unknown"
}
// Remove Slashes
fileName := strings.ReplaceAll(newFileName, "/", "")
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
}
// importStatusPriority returns the order priority for import status in the UI.
func importStatusPriority(status importStatus) int {
switch status {
case importFailed:
return 1
case importExists:
return 2
default:
return 3
}
}

View File

@@ -1,12 +1,35 @@
package api
import "testing"
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNiceSeconds(t *testing.T) {
want := "22d 7h 39m 31s"
nice := niceSeconds(1928371)
wantOne := "22d 7h 39m 31s"
wantNA := "N/A"
if nice != want {
t.Fatalf(`Expected: %v, Got: %v`, want, nice)
}
niceOne := niceSeconds(1928371)
niceNA := niceSeconds(0)
assert.Equal(t, wantOne, niceOne, "should be nice seconds")
assert.Equal(t, wantNA, niceNA, "should be nice NA")
}
func TestNiceNumbers(t *testing.T) {
wantMillions := "198M"
wantThousands := "19.8k"
wantThousandsTwo := "1.98k"
wantZero := "0"
niceMillions := niceNumbers(198236461)
niceThousands := niceNumbers(19823)
niceThousandsTwo := niceNumbers(1984)
niceZero := niceNumbers(0)
assert.Equal(t, wantMillions, niceMillions, "should be nice millions")
assert.Equal(t, wantThousands, niceThousands, "should be nice thousands")
assert.Equal(t, wantThousandsTwo, niceThousandsTwo, "should be nice thousands")
assert.Equal(t, wantZero, niceZero, "should be nice zero")
}

151
api/v1/activity.go Normal file
View File

@@ -0,0 +1,151 @@
package v1
import (
"context"
"time"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
)
// GET /activity
func (s *Server) GetActivity(ctx context.Context, request GetActivityRequestObject) (GetActivityResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetActivity401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
docFilter := false
if request.Params.DocFilter != nil {
docFilter = *request.Params.DocFilter
}
documentID := ""
if request.Params.DocumentId != nil {
documentID = *request.Params.DocumentId
}
offset := int64(0)
if request.Params.Offset != nil {
offset = *request.Params.Offset
}
limit := int64(100)
if request.Params.Limit != nil {
limit = *request.Params.Limit
}
activities, err := s.db.Queries.GetActivity(ctx, database.GetActivityParams{
UserID: auth.UserName,
DocFilter: docFilter,
DocumentID: documentID,
Offset: offset,
Limit: limit,
})
if err != nil {
return GetActivity500JSONResponse{Code: 500, Message: err.Error()}, nil
}
apiActivities := make([]Activity, len(activities))
for i, a := range activities {
// Convert StartTime from interface{} to string
startTimeStr := ""
if a.StartTime != nil {
if str, ok := a.StartTime.(string); ok {
startTimeStr = str
}
}
apiActivities[i] = Activity{
DocumentId: a.DocumentID,
DeviceId: a.DeviceID,
StartTime: startTimeStr,
Title: a.Title,
Author: a.Author,
Duration: a.Duration,
StartPercentage: float32(a.StartPercentage),
EndPercentage: float32(a.EndPercentage),
ReadPercentage: float32(a.ReadPercentage),
}
}
response := ActivityResponse{
Activities: apiActivities,
}
return GetActivity200JSONResponse(response), nil
}
// POST /activity
func (s *Server) CreateActivity(ctx context.Context, request CreateActivityRequestObject) (CreateActivityResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return CreateActivity401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return CreateActivity400JSONResponse{Code: 400, Message: "Request body is required"}, nil
}
tx, err := s.db.DB.Begin()
if err != nil {
log.Error("Transaction Begin DB Error:", err)
return CreateActivity500JSONResponse{Code: 500, Message: "Database error"}, nil
}
committed := false
defer func() {
if committed {
return
}
if rollbackErr := tx.Rollback(); rollbackErr != nil {
log.Debug("Transaction Rollback DB Error:", rollbackErr)
}
}()
qtx := s.db.Queries.WithTx(tx)
allDocumentsMap := make(map[string]struct{})
for _, item := range request.Body.Activity {
allDocumentsMap[item.DocumentId] = struct{}{}
}
for documentID := range allDocumentsMap {
if _, err := qtx.UpsertDocument(ctx, database.UpsertDocumentParams{ID: documentID}); err != nil {
log.Error("UpsertDocument DB Error:", err)
return CreateActivity400JSONResponse{Code: 400, Message: "Invalid document"}, nil
}
}
if _, err := qtx.UpsertDevice(ctx, database.UpsertDeviceParams{
ID: request.Body.DeviceId,
UserID: auth.UserName,
DeviceName: request.Body.DeviceName,
LastSynced: time.Now().UTC().Format(time.RFC3339),
}); err != nil {
log.Error("UpsertDevice DB Error:", err)
return CreateActivity400JSONResponse{Code: 400, Message: "Invalid device"}, nil
}
for _, item := range request.Body.Activity {
if _, err := qtx.AddActivity(ctx, database.AddActivityParams{
UserID: auth.UserName,
DocumentID: item.DocumentId,
DeviceID: request.Body.DeviceId,
StartTime: time.Unix(item.StartTime, 0).UTC().Format(time.RFC3339),
Duration: item.Duration,
StartPercentage: float64(item.Page) / float64(item.Pages),
EndPercentage: float64(item.Page+1) / float64(item.Pages),
}); err != nil {
log.Error("AddActivity DB Error:", err)
return CreateActivity400JSONResponse{Code: 400, Message: "Invalid activity"}, nil
}
}
if err := tx.Commit(); err != nil {
log.Error("Transaction Commit DB Error:", err)
return CreateActivity500JSONResponse{Code: 500, Message: "Database error"}, nil
}
committed = true
response := CreateActivityResponse{Added: int64(len(request.Body.Activity))}
return CreateActivity200JSONResponse(response), nil
}

1070
api/v1/admin.go Normal file

File diff suppressed because it is too large Load Diff

152
api/v1/admin_test.go Normal file
View File

@@ -0,0 +1,152 @@
package v1
import (
"bytes"
"context"
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
argon2 "github.com/alexedwards/argon2id"
"github.com/stretchr/testify/require"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
)
func createAdminTestUser(t *testing.T, db *database.DBManager, username, password string) {
t.Helper()
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
require.NoError(t, err)
authHash := "test-auth-hash"
_, err = db.Queries.CreateUser(context.Background(), database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: &authHash,
Admin: true,
})
require.NoError(t, err)
}
func loginAdminTestUser(t *testing.T, srv *Server, username, password string) *http.Cookie {
t.Helper()
body, err := json.Marshal(LoginRequest{Username: username, Password: password})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
cookies := w.Result().Cookies()
require.Len(t, cookies, 1)
return cookies[0]
}
func TestGetLogsPagination(t *testing.T) {
configPath := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(configPath, "logs"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(configPath, "logs", "antholume.log"), []byte(
"{\"level\":\"info\",\"msg\":\"one\"}\n"+
"plain two\n"+
"{\"level\":\"error\",\"msg\":\"three\"}\n"+
"plain four\n",
), 0o644))
cfg := &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: configPath,
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef",
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
db := database.NewMgr(cfg)
srv := NewServer(db, cfg, nil)
createAdminTestUser(t, db, "admin", "password")
cookie := loginAdminTestUser(t, srv, "admin", "password")
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/logs?page=2&limit=2", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp LogsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.NotNil(t, resp.Logs)
require.Len(t, *resp.Logs, 2)
require.NotNil(t, resp.Page)
require.Equal(t, int64(2), *resp.Page)
require.NotNil(t, resp.Limit)
require.Equal(t, int64(2), *resp.Limit)
require.NotNil(t, resp.Total)
require.Equal(t, int64(4), *resp.Total)
require.Nil(t, resp.NextPage)
require.NotNil(t, resp.PreviousPage)
require.Equal(t, int64(1), *resp.PreviousPage)
require.Contains(t, (*resp.Logs)[0], "three")
require.Contains(t, (*resp.Logs)[1], "plain four")
}
func TestGetLogsPaginationWithBasicFilter(t *testing.T) {
configPath := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(configPath, "logs"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(configPath, "logs", "antholume.log"), []byte(
"{\"level\":\"info\",\"msg\":\"match-1\"}\n"+
"{\"level\":\"info\",\"msg\":\"skip\"}\n"+
"plain match-2\n"+
"{\"level\":\"info\",\"msg\":\"match-3\"}\n",
), 0o644))
cfg := &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: configPath,
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef",
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
db := database.NewMgr(cfg)
srv := NewServer(db, cfg, nil)
createAdminTestUser(t, db, "admin", "password")
cookie := loginAdminTestUser(t, srv, "admin", "password")
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/logs?filter=%22match%22&page=1&limit=2", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var resp LogsResponse
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.NotNil(t, resp.Logs)
require.Len(t, *resp.Logs, 2)
require.NotNil(t, resp.Total)
require.Equal(t, int64(3), *resp.Total)
require.NotNil(t, resp.NextPage)
require.Equal(t, int64(2), *resp.NextPage)
}

4146
api/v1/api.gen.go Normal file

File diff suppressed because it is too large Load Diff

286
api/v1/auth.go Normal file
View File

@@ -0,0 +1,286 @@
package v1
import (
"context"
"crypto/md5"
"fmt"
"net/http"
"time"
argon2 "github.com/alexedwards/argon2id"
"github.com/gorilla/sessions"
log "github.com/sirupsen/logrus"
)
// POST /auth/login
func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginResponseObject, error) {
if request.Body == nil {
return Login400JSONResponse{Code: 400, Message: "Invalid request body"}, nil
}
req := *request.Body
if req.Username == "" || req.Password == "" {
return Login400JSONResponse{Code: 400, Message: "Invalid credentials"}, nil
}
// MD5 - KOSync compatibility
password := fmt.Sprintf("%x", md5.Sum([]byte(req.Password)))
// Verify credentials
user, err := s.db.Queries.GetUser(ctx, req.Username)
if err != nil {
return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil
}
if match, err := argon2.ComparePasswordAndHash(password, *user.Pass); err != nil || !match {
return Login401JSONResponse{Code: 401, Message: "Invalid credentials"}, nil
}
if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil {
return Login500JSONResponse{Code: 500, Message: err.Error()}, nil
}
return Login200JSONResponse{
Body: LoginResponse{
Username: user.ID,
IsAdmin: user.Admin,
},
Headers: Login200ResponseHeaders{
SetCookie: s.getSetCookieFromContext(ctx),
},
}, nil
}
// POST /auth/register
func (s *Server) Register(ctx context.Context, request RegisterRequestObject) (RegisterResponseObject, error) {
if !s.cfg.RegistrationEnabled {
return Register403JSONResponse{Code: 403, Message: "Registration is disabled"}, nil
}
if request.Body == nil {
return Register400JSONResponse{Code: 400, Message: "Invalid request body"}, nil
}
req := *request.Body
if req.Username == "" || req.Password == "" {
return Register400JSONResponse{Code: 400, Message: "Invalid user or password"}, nil
}
currentUsers, err := s.db.Queries.GetUsers(ctx)
if err != nil {
return Register500JSONResponse{Code: 500, Message: "Failed to create user"}, nil
}
isAdmin := len(currentUsers) == 0
if err := s.createUser(ctx, req.Username, &req.Password, &isAdmin); err != nil {
return Register400JSONResponse{Code: 400, Message: err.Error()}, nil
}
user, err := s.db.Queries.GetUser(ctx, req.Username)
if err != nil {
return Register500JSONResponse{Code: 500, Message: "Failed to load created user"}, nil
}
if err := s.saveUserSession(ctx, user.ID, user.Admin, *user.AuthHash); err != nil {
return Register500JSONResponse{Code: 500, Message: err.Error()}, nil
}
return Register201JSONResponse{
Body: LoginResponse{
Username: user.ID,
IsAdmin: user.Admin,
},
Headers: Register201ResponseHeaders{
SetCookie: s.getSetCookieFromContext(ctx),
},
}, nil
}
// POST /auth/logout
func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (LogoutResponseObject, error) {
_, ok := s.getSessionFromContext(ctx)
if !ok {
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
r := s.getRequestFromContext(ctx)
w := s.getResponseWriterFromContext(ctx)
if r == nil || w == nil {
return Logout401JSONResponse{Code: 401, Message: "Internal context error"}, nil
}
session, err := s.getCookieSession(r)
if err != nil {
return Logout401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
session.Values = make(map[any]any)
if err := session.Save(r, w); err != nil {
return Logout401JSONResponse{Code: 401, Message: "Failed to logout"}, nil
}
return Logout200Response{}, nil
}
// GET /auth/me
func (s *Server) GetMe(ctx context.Context, request GetMeRequestObject) (GetMeResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetMe401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
return GetMe200JSONResponse{
Username: auth.UserName,
IsAdmin: auth.IsAdmin,
}, nil
}
func (s *Server) saveUserSession(ctx context.Context, username string, isAdmin bool, authHash string) error {
r := s.getRequestFromContext(ctx)
w := s.getResponseWriterFromContext(ctx)
if r == nil || w == nil {
return fmt.Errorf("internal context error")
}
session, err := s.getCookieSession(r)
if err != nil {
return fmt.Errorf("unauthorized")
}
session.Values["authorizedUser"] = username
session.Values["isAdmin"] = isAdmin
session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7)
session.Values["authHash"] = authHash
if err := session.Save(r, w); err != nil {
return fmt.Errorf("failed to create session")
}
return nil
}
func (s *Server) getCookieSession(r *http.Request) (*sessions.Session, error) {
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
if s.cfg.CookieEncKey != "" {
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
}
}
session, err := store.Get(r, "token")
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
session.Options.SameSite = http.SameSiteLaxMode
session.Options.HttpOnly = true
session.Options.Secure = s.cfg.CookieSecure
return session, nil
}
// getSessionFromContext extracts authData from context
func (s *Server) getSessionFromContext(ctx context.Context) (authData, bool) {
auth, ok := ctx.Value("auth").(authData)
if !ok {
return authData{}, false
}
return auth, true
}
// isAdmin checks if a user has admin privileges
func (s *Server) isAdmin(ctx context.Context) bool {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return false
}
return auth.IsAdmin
}
// getRequestFromContext extracts the HTTP request from context
func (s *Server) getRequestFromContext(ctx context.Context) *http.Request {
r, ok := ctx.Value("request").(*http.Request)
if !ok {
return nil
}
return r
}
// getResponseWriterFromContext extracts the response writer from context
func (s *Server) getResponseWriterFromContext(ctx context.Context) http.ResponseWriter {
w, ok := ctx.Value("response").(http.ResponseWriter)
if !ok {
return nil
}
return w
}
func (s *Server) getSetCookieFromContext(ctx context.Context) string {
w := s.getResponseWriterFromContext(ctx)
if w == nil {
return ""
}
return w.Header().Get("Set-Cookie")
}
// getSession retrieves auth data from the session cookie
func (s *Server) getSession(r *http.Request) (auth authData, ok bool) {
// Get session from cookie store
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
if s.cfg.CookieEncKey != "" {
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
} else {
log.Error("invalid cookie encryption key (must be 16 or 32 bytes)")
return authData{}, false
}
}
session, err := store.Get(r, "token")
if err != nil {
return authData{}, false
}
// Get session values
authorizedUser := session.Values["authorizedUser"]
isAdmin := session.Values["isAdmin"]
expiresAt := session.Values["expiresAt"]
authHash := session.Values["authHash"]
if authorizedUser == nil || isAdmin == nil || expiresAt == nil || authHash == nil {
return authData{}, false
}
auth = authData{
UserName: authorizedUser.(string),
IsAdmin: isAdmin.(bool),
AuthHash: authHash.(string),
}
// Validate auth hash
ctx := r.Context()
correctAuthHash, err := s.getUserAuthHash(ctx, auth.UserName)
if err != nil || correctAuthHash != auth.AuthHash {
return authData{}, false
}
return auth, true
}
// getUserAuthHash retrieves the user's auth hash from DB or cache
func (s *Server) getUserAuthHash(ctx context.Context, username string) (string, error) {
user, err := s.db.Queries.GetUser(ctx, username)
if err != nil {
return "", err
}
return *user.AuthHash, nil
}
// authData represents authenticated user information
type authData struct {
UserName string
IsAdmin bool
AuthHash string
}

228
api/v1/auth_test.go Normal file
View File

@@ -0,0 +1,228 @@
package v1
import (
"bytes"
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
argon2 "github.com/alexedwards/argon2id"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
)
type AuthTestSuite struct {
suite.Suite
db *database.DBManager
cfg *config.Config
srv *Server
}
func (suite *AuthTestSuite) setupConfig() *config.Config {
return &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: "/tmp",
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef",
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
}
func TestAuth(t *testing.T) {
suite.Run(t, new(AuthTestSuite))
}
func (suite *AuthTestSuite) SetupTest() {
suite.cfg = suite.setupConfig()
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg, nil)
}
func (suite *AuthTestSuite) createTestUser(username, password string) {
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
suite.Require().NoError(err)
authHash := "test-auth-hash"
_, err = suite.db.Queries.CreateUser(suite.T().Context(), database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: &authHash,
Admin: true,
})
suite.Require().NoError(err)
}
func (suite *AuthTestSuite) assertSessionCookie(cookie *http.Cookie) {
suite.Require().NotNil(cookie)
suite.Equal("token", cookie.Name)
suite.NotEmpty(cookie.Value)
suite.True(cookie.HttpOnly)
}
func (suite *AuthTestSuite) login(username, password string) *http.Cookie {
reqBody := LoginRequest{
Username: username,
Password: password,
}
body, err := json.Marshal(reqBody)
suite.Require().NoError(err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code, "login should return 200")
var resp LoginResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1, "should have session cookie")
suite.assertSessionCookie(cookies[0])
return cookies[0]
}
func (suite *AuthTestSuite) TestAPILogin() {
suite.createTestUser("testuser", "testpass")
reqBody := LoginRequest{
Username: "testuser",
Password: "testpass",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
var resp LoginResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("testuser", resp.Username)
cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1)
suite.assertSessionCookie(cookies[0])
}
func (suite *AuthTestSuite) TestAPILoginInvalidCredentials() {
reqBody := LoginRequest{
Username: "testuser",
Password: "wrongpass",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}
func (suite *AuthTestSuite) TestAPIRegister() {
reqBody := LoginRequest{
Username: "newuser",
Password: "newpass",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusCreated, w.Code)
var resp LoginResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("newuser", resp.Username)
suite.True(resp.IsAdmin, "first registered user should mirror legacy admin bootstrap behavior")
cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1, "register should set a session cookie")
suite.assertSessionCookie(cookies[0])
user, err := suite.db.Queries.GetUser(suite.T().Context(), "newuser")
suite.Require().NoError(err)
suite.True(user.Admin)
}
func (suite *AuthTestSuite) TestAPIRegisterDisabled() {
suite.cfg.RegistrationEnabled = false
suite.srv = NewServer(suite.db, suite.cfg, nil)
reqBody := LoginRequest{
Username: "newuser",
Password: "newpass",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusForbidden, w.Code)
}
func (suite *AuthTestSuite) TestAPILogout() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1)
suite.Equal("token", cookies[0].Name)
}
func (suite *AuthTestSuite) TestAPIGetMe() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
var resp UserData
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("testuser", resp.Username)
}
func (suite *AuthTestSuite) TestAPIGetMeUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}

827
api/v1/documents.go Normal file
View File

@@ -0,0 +1,827 @@
package v1
import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/metadata"
)
// GET /documents
func (s *Server) GetDocuments(ctx context.Context, request GetDocumentsRequestObject) (GetDocumentsResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetDocuments401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
page := int64(1)
if request.Params.Page != nil {
page = *request.Params.Page
}
limit := int64(9)
if request.Params.Limit != nil {
limit = *request.Params.Limit
}
search := ""
if request.Params.Search != nil {
search = "%" + *request.Params.Search + "%"
}
rows, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
Query: &search,
Deleted: ptrOf(false),
Offset: (page - 1) * limit,
Limit: limit,
},
)
if err != nil {
return GetDocuments500JSONResponse{Code: 500, Message: err.Error()}, nil
}
total := int64(len(rows))
var nextPage *int64
var previousPage *int64
if page*limit < total {
nextPage = ptrOf(page + 1)
}
if page > 1 {
previousPage = ptrOf(page - 1)
}
apiDocuments := make([]Document, len(rows))
for i, row := range rows {
apiDocuments[i] = Document{
Id: row.ID,
Title: *row.Title,
Author: *row.Author,
Description: row.Description,
Isbn10: row.Isbn10,
Isbn13: row.Isbn13,
Words: row.Words,
Filepath: row.Filepath,
Percentage: ptrOf(float32(row.Percentage)),
TotalTimeSeconds: ptrOf(row.TotalTimeSeconds),
Wpm: ptrOf(float32(row.Wpm)),
SecondsPerPercent: ptrOf(row.SecondsPerPercent),
LastRead: parseInterfaceTime(row.LastRead),
CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
Deleted: false, // Default, should be overridden if available
}
}
response := DocumentsResponse{
Documents: apiDocuments,
Total: total,
Page: page,
Limit: limit,
NextPage: nextPage,
PreviousPage: previousPage,
Search: request.Params.Search,
}
return GetDocuments200JSONResponse(response), nil
}
// GET /documents/{id}
func (s *Server) GetDocument(ctx context.Context, request GetDocumentRequestObject) (GetDocumentResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
// Use GetDocumentsWithStats to get document with stats
docs, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
ID: &request.Id,
Deleted: ptrOf(false),
Offset: 0,
Limit: 1,
},
)
if err != nil || len(docs) == 0 {
return GetDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
doc := docs[0]
apiDoc := Document{
Id: doc.ID,
Title: *doc.Title,
Author: *doc.Author,
Description: doc.Description,
Isbn10: doc.Isbn10,
Isbn13: doc.Isbn13,
Words: doc.Words,
Filepath: doc.Filepath,
Percentage: ptrOf(float32(doc.Percentage)),
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
Wpm: ptrOf(float32(doc.Wpm)),
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
LastRead: parseInterfaceTime(doc.LastRead),
CreatedAt: time.Now(), // Will be overwritten if we had a proper created_at from DB
UpdatedAt: time.Now(), // Will be overwritten if we had a proper updated_at from DB
Deleted: false, // Default, should be overridden if available
}
response := DocumentResponse{
Document: apiDoc,
}
return GetDocument200JSONResponse(response), nil
}
// POST /documents/{id}
func (s *Server) EditDocument(ctx context.Context, request EditDocumentRequestObject) (EditDocumentResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return EditDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return EditDocument400JSONResponse{Code: 400, Message: "Missing request body"}, nil
}
// Validate document exists and get current state
currentDoc, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
return EditDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
// Validate at least one editable field is provided
if request.Body.Title == nil &&
request.Body.Author == nil &&
request.Body.Description == nil &&
request.Body.Isbn10 == nil &&
request.Body.Isbn13 == nil &&
request.Body.CoverGbid == nil {
return EditDocument400JSONResponse{Code: 400, Message: "No editable fields provided"}, nil
}
// Handle cover via Google Books ID
var coverFileName *string
if request.Body.CoverGbid != nil {
coverDir := filepath.Join(s.cfg.DataPath, "covers")
fileName, err := metadata.CacheCoverWithContext(ctx, *request.Body.CoverGbid, coverDir, request.Id, true)
if err == nil {
coverFileName = fileName
}
}
// Update document with provided editable fields only
_, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: request.Id,
Title: request.Body.Title,
Author: request.Body.Author,
Description: request.Body.Description,
Isbn10: request.Body.Isbn10,
Isbn13: request.Body.Isbn13,
Coverfile: coverFileName,
// Preserve existing values for non-editable fields
Md5: currentDoc.Md5,
Basepath: currentDoc.Basepath,
Filepath: currentDoc.Filepath,
Words: currentDoc.Words,
})
if err != nil {
log.Error("UpsertDocument DB Error:", err)
return EditDocument500JSONResponse{Code: 500, Message: "Failed to update document"}, nil
}
// Use GetDocumentsWithStats to get document with stats for the response
docs, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
ID: &request.Id,
Deleted: ptrOf(false),
Offset: 0,
Limit: 1,
},
)
if err != nil || len(docs) == 0 {
return EditDocument404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
doc := docs[0]
apiDoc := Document{
Id: doc.ID,
Title: *doc.Title,
Author: *doc.Author,
Description: doc.Description,
Isbn10: doc.Isbn10,
Isbn13: doc.Isbn13,
Words: doc.Words,
Filepath: doc.Filepath,
Percentage: ptrOf(float32(doc.Percentage)),
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
Wpm: ptrOf(float32(doc.Wpm)),
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
LastRead: parseInterfaceTime(doc.LastRead),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Deleted: false,
}
response := DocumentResponse{
Document: apiDoc,
}
return EditDocument200JSONResponse(response), nil
}
// deriveBaseFileName builds the base filename for a given MetadataInfo object.
func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string {
// Derive New FileName
var newFileName string
if metadataInfo.Author != nil && *metadataInfo.Author != "" {
newFileName = newFileName + *metadataInfo.Author
} else {
newFileName = newFileName + "Unknown"
}
if metadataInfo.Title != nil && *metadataInfo.Title != "" {
newFileName = newFileName + " - " + *metadataInfo.Title
} else {
newFileName = newFileName + " - Unknown"
}
// Remove Slashes
fileName := strings.ReplaceAll(newFileName, "/", "")
return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type))
}
// parseInterfaceTime converts an interface{} to time.Time for SQLC queries
func parseInterfaceTime(t any) *time.Time {
if t == nil {
return nil
}
switch v := t.(type) {
case string:
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return nil
}
return &parsed
case time.Time:
return &v
default:
return nil
}
}
// serveNoCover serves the default no-cover image from assets
func (s *Server) serveNoCover() (fs.File, string, int64, error) {
// Try to open the no-cover image from assets
file, err := s.assets.Open("assets/images/no-cover.jpg")
if err != nil {
return nil, "", 0, err
}
// Get file info
info, err := file.Stat()
if err != nil {
file.Close()
return nil, "", 0, err
}
return file, "image/jpeg", info.Size(), nil
}
// openFileReader opens a file and returns it as an io.ReaderCloser
func openFileReader(path string) (*os.File, error) {
return os.Open(path)
}
// GET /documents/{id}/cover
func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverRequestObject) (GetDocumentCoverResponseObject, error) {
// Authentication is handled by middleware, which also adds auth data to context
// This endpoint just serves the cover image
// Validate Document Exists in DB
document, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
log.Error("GetDocument DB Error:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
var coverFile fs.File
var contentType string
var contentLength int64
var needMetadataFetch bool
// Handle Identified Document
if document.Coverfile != nil {
if *document.Coverfile == "UNKNOWN" {
// Serve no-cover image
file, ct, size, err := s.serveNoCover()
if err != nil {
log.Error("Failed to open no-cover image:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
}
coverFile = file
contentType = ct
contentLength = size
needMetadataFetch = true
} else {
// Derive Path
coverPath := filepath.Join(s.cfg.DataPath, "covers", *document.Coverfile)
// Validate File Exists
fileInfo, err := os.Stat(coverPath)
if os.IsNotExist(err) {
log.Error("Cover file should but doesn't exist: ", err)
// Serve no-cover image
file, ct, size, err := s.serveNoCover()
if err != nil {
log.Error("Failed to open no-cover image:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
}
coverFile = file
contentType = ct
contentLength = size
needMetadataFetch = true
} else {
// Open the cover file
file, err := openFileReader(coverPath)
if err != nil {
log.Error("Failed to open cover file:", err)
return GetDocumentCover500JSONResponse{Code: 500, Message: "Failed to open cover"}, nil
}
coverFile = file
contentLength = fileInfo.Size()
// Determine content type based on file extension
contentType = "image/jpeg"
if strings.HasSuffix(coverPath, ".png") {
contentType = "image/png"
}
}
}
} else {
needMetadataFetch = true
}
// Attempt Metadata fetch if needed
var cachedCoverFile string = "UNKNOWN"
var coverDir string = filepath.Join(s.cfg.DataPath, "covers")
if needMetadataFetch {
// Create context with timeout for metadata service calls
metadataCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Identify Documents & Save Covers
metadataResults, err := metadata.SearchMetadataWithContext(metadataCtx, metadata.SOURCE_GBOOK, metadata.MetadataInfo{
Title: document.Title,
Author: document.Author,
})
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
firstResult := metadataResults[0]
// Save Cover
fileName, err := metadata.CacheCoverWithContext(metadataCtx, *firstResult.ID, coverDir, document.ID, false)
if err == nil {
cachedCoverFile = *fileName
}
// Store First Metadata Result
if _, err = s.db.Queries.AddMetadata(ctx, database.AddMetadataParams{
DocumentID: document.ID,
Title: firstResult.Title,
Author: firstResult.Author,
Description: firstResult.Description,
Gbid: firstResult.ID,
Olid: nil,
Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13,
}); err != nil {
log.Error("AddMetadata DB Error:", err)
}
}
// Upsert Document
if _, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: document.ID,
Coverfile: &cachedCoverFile,
}); err != nil {
log.Warn("UpsertDocument DB Error:", err)
}
// Update cover file if we got a new cover
if cachedCoverFile != "UNKNOWN" {
coverPath := filepath.Join(coverDir, cachedCoverFile)
fileInfo, err := os.Stat(coverPath)
if err != nil {
log.Error("Failed to stat cached cover:", err)
// Keep the no-cover image
} else {
file, err := openFileReader(coverPath)
if err != nil {
log.Error("Failed to open cached cover:", err)
// Keep the no-cover image
} else {
_ = coverFile.Close() // Close the previous file
coverFile = file
contentLength = fileInfo.Size()
// Determine content type based on file extension
contentType = "image/jpeg"
if strings.HasSuffix(coverPath, ".png") {
contentType = "image/png"
}
}
}
}
}
return &GetDocumentCover200Response{
Body: coverFile,
ContentLength: contentLength,
ContentType: contentType,
}, nil
}
// POST /documents/{id}/cover
func (s *Server) UploadDocumentCover(ctx context.Context, request UploadDocumentCoverRequestObject) (UploadDocumentCoverResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return UploadDocumentCover401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Missing request body"}, nil
}
// Validate document exists
_, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
return UploadDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
// Read multipart form
form, err := request.Body.ReadForm(32 << 20) // 32MB max
if err != nil {
log.Error("ReadForm error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read form"}, nil
}
// Get file from form
fileField := form.File["cover_file"]
if len(fileField) == 0 {
return UploadDocumentCover400JSONResponse{Code: 400, Message: "No file provided"}, nil
}
file := fileField[0]
// Validate file extension
if !strings.HasSuffix(strings.ToLower(file.Filename), ".jpg") && !strings.HasSuffix(strings.ToLower(file.Filename), ".png") {
return UploadDocumentCover400JSONResponse{Code: 400, Message: "Only JPG and PNG files are allowed"}, nil
}
// Open file
f, err := file.Open()
if err != nil {
log.Error("Open file error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to open file"}, nil
}
defer f.Close()
// Read file content
data, err := io.ReadAll(f)
if err != nil {
log.Error("Read file error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
}
// Validate actual content type
contentType := http.DetectContentType(data)
allowedTypes := map[string]bool{
"image/jpeg": true,
"image/png": true,
}
if !allowedTypes[contentType] {
return UploadDocumentCover400JSONResponse{
Code: 400,
Message: fmt.Sprintf("Invalid file type: %s. Only JPG and PNG files are allowed.", contentType),
}, nil
}
// Derive storage path
coverDir := filepath.Join(s.cfg.DataPath, "covers")
fileName := fmt.Sprintf("%s%s", request.Id, strings.ToLower(filepath.Ext(file.Filename)))
safePath := filepath.Join(coverDir, fileName)
// Save file
err = os.WriteFile(safePath, data, 0644)
if err != nil {
log.Error("Save file error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Unable to save cover"}, nil
}
// Upsert document with new cover
_, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: request.Id,
Coverfile: &fileName,
})
if err != nil {
log.Error("UpsertDocument DB error:", err)
return UploadDocumentCover500JSONResponse{Code: 500, Message: "Failed to save cover"}, nil
}
// Use GetDocumentsWithStats to get document with stats for the response
docs, err := s.db.Queries.GetDocumentsWithStats(
ctx,
database.GetDocumentsWithStatsParams{
UserID: auth.UserName,
ID: &request.Id,
Deleted: ptrOf(false),
Offset: 0,
Limit: 1,
},
)
if err != nil || len(docs) == 0 {
return UploadDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
doc := docs[0]
apiDoc := Document{
Id: doc.ID,
Title: *doc.Title,
Author: *doc.Author,
Description: doc.Description,
Isbn10: doc.Isbn10,
Isbn13: doc.Isbn13,
Words: doc.Words,
Filepath: doc.Filepath,
Percentage: ptrOf(float32(doc.Percentage)),
TotalTimeSeconds: ptrOf(doc.TotalTimeSeconds),
Wpm: ptrOf(float32(doc.Wpm)),
SecondsPerPercent: ptrOf(doc.SecondsPerPercent),
LastRead: parseInterfaceTime(doc.LastRead),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Deleted: false,
}
response := DocumentResponse{
Document: apiDoc,
}
return UploadDocumentCover200JSONResponse(response), nil
}
// GET /documents/{id}/file
func (s *Server) GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error) {
// Authentication is handled by middleware, which also adds auth data to context
// This endpoint just serves the document file download
// Get Document
document, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
log.Error("GetDocument DB Error:", err)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
if document.Filepath == nil {
log.Error("Document Doesn't Have File:", request.Id)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
}
// Derive Basepath
basepath := filepath.Join(s.cfg.DataPath, "documents")
if document.Basepath != nil && *document.Basepath != "" {
basepath = *document.Basepath
}
// Derive Storage Location
filePath := filepath.Join(basepath, *document.Filepath)
// Validate File Exists
fileInfo, err := os.Stat(filePath)
if os.IsNotExist(err) {
log.Error("File should but doesn't exist:", err)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
}
// Open file
file, err := os.Open(filePath)
if err != nil {
log.Error("Failed to open document file:", err)
return GetDocumentFile500JSONResponse{Code: 500, Message: "Failed to open document"}, nil
}
return &GetDocumentFile200Response{
Body: file,
ContentLength: fileInfo.Size(),
Filename: filepath.Base(*document.Filepath),
}, nil
}
// POST /documents
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
_, ok := s.getSessionFromContext(ctx)
if !ok {
return CreateDocument401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return CreateDocument400JSONResponse{Code: 400, Message: "Missing request body"}, nil
}
// Read multipart form
form, err := request.Body.ReadForm(32 << 20) // 32MB max memory
if err != nil {
log.Error("ReadForm error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read form"}, nil
}
// Get file from form
fileField := form.File["document_file"]
if len(fileField) == 0 {
return CreateDocument400JSONResponse{Code: 400, Message: "No file provided"}, nil
}
file := fileField[0]
// Validate file extension
if !strings.HasSuffix(strings.ToLower(file.Filename), ".epub") {
return CreateDocument400JSONResponse{Code: 400, Message: "Only EPUB files are allowed"}, nil
}
// Open file
f, err := file.Open()
if err != nil {
log.Error("Open file error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to open file"}, nil
}
defer f.Close()
// Read file content
data, err := io.ReadAll(f)
if err != nil {
log.Error("Read file error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to read file"}, nil
}
// Validate actual content type
contentType := http.DetectContentType(data)
if contentType != "application/epub+zip" && contentType != "application/zip" {
return CreateDocument400JSONResponse{
Code: 400,
Message: fmt.Sprintf("Invalid file type: %s. Only EPUB files are allowed.", contentType),
}, nil
}
// Create temp file to get metadata
tempFile, err := os.CreateTemp("", "book")
if err != nil {
log.Error("Temp file create error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to create temp file"}, nil
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
// Write data to temp file
if _, err := tempFile.Write(data); err != nil {
log.Error("Write temp file error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to write temp file"}, nil
}
// Get metadata using metadata package
metadataInfo, err := metadata.GetMetadata(tempFile.Name())
if err != nil {
log.Error("GetMetadata error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to acquire metadata"}, nil
}
// Check if already exists
_, err = s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
if err == nil {
// Document already exists
existingDoc, _ := s.db.Queries.GetDocument(ctx, *metadataInfo.PartialMD5)
apiDoc := Document{
Id: existingDoc.ID,
Title: *existingDoc.Title,
Author: *existingDoc.Author,
Description: existingDoc.Description,
Isbn10: existingDoc.Isbn10,
Isbn13: existingDoc.Isbn13,
Words: existingDoc.Words,
Filepath: existingDoc.Filepath,
CreatedAt: parseTime(existingDoc.CreatedAt),
UpdatedAt: parseTime(existingDoc.UpdatedAt),
Deleted: existingDoc.Deleted,
}
response := DocumentResponse{
Document: apiDoc,
}
return CreateDocument200JSONResponse(response), nil
}
// Derive & sanitize file name
fileName := deriveBaseFileName(metadataInfo)
basePath := filepath.Join(s.cfg.DataPath, "documents")
safePath := filepath.Join(basePath, fileName)
// Save file to storage
err = os.WriteFile(safePath, data, 0644)
if err != nil {
log.Error("Save file error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Unable to save file"}, nil
}
// Upsert document
doc, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: *metadataInfo.PartialMD5,
Title: metadataInfo.Title,
Author: metadataInfo.Author,
Description: metadataInfo.Description,
Md5: metadataInfo.MD5,
Words: metadataInfo.WordCount,
Filepath: &fileName,
Basepath: &basePath,
})
if err != nil {
log.Error("UpsertDocument DB error:", err)
return CreateDocument500JSONResponse{Code: 500, Message: "Failed to save document"}, nil
}
apiDoc := Document{
Id: doc.ID,
Title: *doc.Title,
Author: *doc.Author,
Description: doc.Description,
Isbn10: doc.Isbn10,
Isbn13: doc.Isbn13,
Words: doc.Words,
Filepath: doc.Filepath,
CreatedAt: parseTime(doc.CreatedAt),
UpdatedAt: parseTime(doc.UpdatedAt),
Deleted: doc.Deleted,
}
response := DocumentResponse{
Document: apiDoc,
}
return CreateDocument200JSONResponse(response), nil
}
// GetDocumentCover200Response is a custom response type that allows setting content type
type GetDocumentCover200Response struct {
Body io.Reader
ContentLength int64
ContentType string
}
func (response GetDocumentCover200Response) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", response.ContentType)
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.WriteHeader(200)
if closer, ok := response.Body.(io.Closer); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}
// GetDocumentFile200Response is a custom response type that allows setting filename for download
type GetDocumentFile200Response struct {
Body io.Reader
ContentLength int64
Filename string
}
func (response GetDocumentFile200Response) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/octet-stream")
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", response.Filename))
w.WriteHeader(200)
if closer, ok := response.Body.(io.Closer); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}

178
api/v1/documents_test.go Normal file
View File

@@ -0,0 +1,178 @@
package v1
import (
"bytes"
"crypto/md5"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
argon2 "github.com/alexedwards/argon2id"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
"reichard.io/antholume/pkg/ptr"
)
type DocumentsTestSuite struct {
suite.Suite
db *database.DBManager
cfg *config.Config
srv *Server
}
func (suite *DocumentsTestSuite) setupConfig() *config.Config {
return &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: "/tmp",
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef",
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
}
func TestDocuments(t *testing.T) {
suite.Run(t, new(DocumentsTestSuite))
}
func (suite *DocumentsTestSuite) SetupTest() {
suite.cfg = suite.setupConfig()
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg, nil)
}
func (suite *DocumentsTestSuite) createTestUser(username, password string) {
suite.authTestSuiteHelper(username, password)
}
func (suite *DocumentsTestSuite) login(username, password string) *http.Cookie {
return suite.authLoginHelper(username, password)
}
func (suite *DocumentsTestSuite) authTestSuiteHelper(username, password string) {
// MD5 hash for KOSync compatibility (matches existing system)
md5Hash := fmt.Sprintf("%x", md5.Sum([]byte(password)))
// Then argon2 hash the MD5
hashedPassword, err := argon2.CreateHash(md5Hash, argon2.DefaultParams)
suite.Require().NoError(err)
_, err = suite.db.Queries.CreateUser(suite.T().Context(), database.CreateUserParams{
ID: username,
Pass: &hashedPassword,
AuthHash: ptr.Of("test-auth-hash"),
Admin: true,
})
suite.Require().NoError(err)
}
func (suite *DocumentsTestSuite) authLoginHelper(username, password string) *http.Cookie {
reqBody := LoginRequest{Username: username, Password: password}
body, err := json.Marshal(reqBody)
suite.Require().NoError(err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
cookies := w.Result().Cookies()
suite.Require().Len(cookies, 1)
return cookies[0]
}
func (suite *DocumentsTestSuite) TestAPIGetDocuments() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents?page=1&limit=9", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
var resp DocumentsResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal(int64(1), resp.Page)
suite.Equal(int64(9), resp.Limit)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentsUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}
func (suite *DocumentsTestSuite) TestAPIGetDocument() {
suite.createTestUser("testuser", "testpass")
docID := "test-doc-1"
_, err := suite.db.Queries.UpsertDocument(suite.T().Context(), database.UpsertDocumentParams{
ID: docID,
Title: ptr.Of("Test Document"),
Author: ptr.Of("Test Author"),
})
suite.Require().NoError(err)
cookie := suite.login("testuser", "testpass")
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+docID, nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusOK, w.Code)
var resp DocumentResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal(docID, resp.Document.Id)
suite.Equal("Test Document", resp.Document.Title)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentNotFound() {
suite.createTestUser("testuser", "testpass")
cookie := suite.login("testuser", "testpass")
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/non-existent", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusNotFound, w.Code)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentCoverUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/cover", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentFileUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/file", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}

3
api/v1/generate.go Normal file
View File

@@ -0,0 +1,3 @@
package v1
//go:generate oapi-codegen -config oapi-codegen.yaml openapi.yaml

226
api/v1/home.go Normal file
View File

@@ -0,0 +1,226 @@
package v1
import (
"context"
"sort"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
"reichard.io/antholume/graph"
)
// GET /home
func (s *Server) GetHome(ctx context.Context, request GetHomeRequestObject) (GetHomeResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetHome401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
// Get database info
dbInfo, err := s.db.Queries.GetDatabaseInfo(ctx, auth.UserName)
if err != nil {
log.Error("GetDatabaseInfo DB Error:", err)
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
}
// Get streaks
streaks, err := s.db.Queries.GetUserStreaks(ctx, auth.UserName)
if err != nil {
log.Error("GetUserStreaks DB Error:", err)
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
}
// Get graph data
graphData, err := s.db.Queries.GetDailyReadStats(ctx, auth.UserName)
if err != nil {
log.Error("GetDailyReadStats DB Error:", err)
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
}
// Get user statistics
userStats, err := s.db.Queries.GetUserStatistics(ctx)
if err != nil {
log.Error("GetUserStatistics DB Error:", err)
return GetHome500JSONResponse{Code: 500, Message: "Database error"}, nil
}
// Build response
response := HomeResponse{
DatabaseInfo: DatabaseInfo{
DocumentsSize: dbInfo.DocumentsSize,
ActivitySize: dbInfo.ActivitySize,
ProgressSize: dbInfo.ProgressSize,
DevicesSize: dbInfo.DevicesSize,
},
Streaks: StreaksResponse{
Streaks: convertStreaks(streaks),
},
GraphData: GraphDataResponse{
GraphData: convertGraphData(graphData),
},
UserStatistics: arrangeUserStatistics(userStats),
}
return GetHome200JSONResponse(response), nil
}
// GET /home/streaks
func (s *Server) GetStreaks(ctx context.Context, request GetStreaksRequestObject) (GetStreaksResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetStreaks401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
streaks, err := s.db.Queries.GetUserStreaks(ctx, auth.UserName)
if err != nil {
log.Error("GetUserStreaks DB Error:", err)
return GetStreaks500JSONResponse{Code: 500, Message: "Database error"}, nil
}
response := StreaksResponse{
Streaks: convertStreaks(streaks),
}
return GetStreaks200JSONResponse(response), nil
}
// GET /home/graph
func (s *Server) GetGraphData(ctx context.Context, request GetGraphDataRequestObject) (GetGraphDataResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetGraphData401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
graphData, err := s.db.Queries.GetDailyReadStats(ctx, auth.UserName)
if err != nil {
log.Error("GetDailyReadStats DB Error:", err)
return GetGraphData500JSONResponse{Code: 500, Message: "Database error"}, nil
}
response := GraphDataResponse{
GraphData: convertGraphData(graphData),
}
return GetGraphData200JSONResponse(response), nil
}
// GET /home/statistics
func (s *Server) GetUserStatistics(ctx context.Context, request GetUserStatisticsRequestObject) (GetUserStatisticsResponseObject, error) {
_, ok := s.getSessionFromContext(ctx)
if !ok {
return GetUserStatistics401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
userStats, err := s.db.Queries.GetUserStatistics(ctx)
if err != nil {
log.Error("GetUserStatistics DB Error:", err)
return GetUserStatistics500JSONResponse{Code: 500, Message: "Database error"}, nil
}
response := arrangeUserStatistics(userStats)
return GetUserStatistics200JSONResponse(response), nil
}
func convertStreaks(streaks []database.UserStreak) []UserStreak {
result := make([]UserStreak, len(streaks))
for i, streak := range streaks {
result[i] = UserStreak{
Window: streak.Window,
MaxStreak: streak.MaxStreak,
MaxStreakStartDate: streak.MaxStreakStartDate,
MaxStreakEndDate: streak.MaxStreakEndDate,
CurrentStreak: streak.CurrentStreak,
CurrentStreakStartDate: streak.CurrentStreakStartDate,
CurrentStreakEndDate: streak.CurrentStreakEndDate,
}
}
return result
}
func convertGraphData(graphData []database.GetDailyReadStatsRow) []GraphDataPoint {
result := make([]GraphDataPoint, len(graphData))
for i, data := range graphData {
result[i] = GraphDataPoint{
Date: data.Date,
MinutesRead: data.MinutesRead,
}
}
return result
}
func arrangeUserStatistics(userStatistics []database.GetUserStatisticsRow) UserStatisticsResponse {
// Sort by WPM for each period
sortByWPM := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) float64) []LeaderboardEntry {
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
sort.SliceStable(sorted, func(i, j int) bool {
return getter(sorted[i]) > getter(sorted[j])
})
result := make([]LeaderboardEntry, len(sorted))
for i, item := range sorted {
result[i] = LeaderboardEntry{UserId: item.UserID, Value: getter(item)}
}
return result
}
// Sort by duration (seconds) for each period
sortByDuration := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
sort.SliceStable(sorted, func(i, j int) bool {
return getter(sorted[i]) > getter(sorted[j])
})
result := make([]LeaderboardEntry, len(sorted))
for i, item := range sorted {
result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))}
}
return result
}
// Sort by words for each period
sortByWords := func(stats []database.GetUserStatisticsRow, getter func(database.GetUserStatisticsRow) int64) []LeaderboardEntry {
sorted := append([]database.GetUserStatisticsRow(nil), stats...)
sort.SliceStable(sorted, func(i, j int) bool {
return getter(sorted[i]) > getter(sorted[j])
})
result := make([]LeaderboardEntry, len(sorted))
for i, item := range sorted {
result[i] = LeaderboardEntry{UserId: item.UserID, Value: float64(getter(item))}
}
return result
}
return UserStatisticsResponse{
Wpm: LeaderboardData{
All: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.TotalWpm }),
Year: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.YearlyWpm }),
Month: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.MonthlyWpm }),
Week: sortByWPM(userStatistics, func(s database.GetUserStatisticsRow) float64 { return s.WeeklyWpm }),
},
Duration: LeaderboardData{
All: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalSeconds }),
Year: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlySeconds }),
Month: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlySeconds }),
Week: sortByDuration(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklySeconds }),
},
Words: LeaderboardData{
All: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.TotalWordsRead }),
Year: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.YearlyWordsRead }),
Month: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.MonthlyWordsRead }),
Week: sortByWords(userStatistics, func(s database.GetUserStatisticsRow) int64 { return s.WeeklyWordsRead }),
},
}
}
// GetSVGGraphData generates SVG bezier path for graph visualization
func GetSVGGraphData(inputData []GraphDataPoint, svgWidth int, svgHeight int) graph.SVGGraphData {
// Convert to int64 slice expected by graph package
intData := make([]int64, len(inputData))
for i, data := range inputData {
intData[i] = int64(data.MinutesRead)
}
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
}

6
api/v1/oapi-codegen.yaml Normal file
View File

@@ -0,0 +1,6 @@
package: v1
generate:
std-http-server: true
strict-server: true
models: true
output: api.gen.go

1977
api/v1/openapi.yaml Normal file

File diff suppressed because it is too large Load Diff

163
api/v1/progress.go Normal file
View File

@@ -0,0 +1,163 @@
package v1
import (
"context"
"math"
"time"
log "github.com/sirupsen/logrus"
"reichard.io/antholume/database"
)
// GET /progress
func (s *Server) GetProgressList(ctx context.Context, request GetProgressListRequestObject) (GetProgressListResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetProgressList401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
page := int64(1)
if request.Params.Page != nil {
page = *request.Params.Page
}
limit := int64(15)
if request.Params.Limit != nil {
limit = *request.Params.Limit
}
filter := database.GetProgressParams{
UserID: auth.UserName,
Offset: (page - 1) * limit,
Limit: limit,
}
if request.Params.Document != nil && *request.Params.Document != "" {
filter.DocFilter = true
filter.DocumentID = *request.Params.Document
}
progress, err := s.db.Queries.GetProgress(ctx, filter)
if err != nil {
log.Error("GetProgress DB Error:", err)
return GetProgressList500JSONResponse{Code: 500, Message: "Database error"}, nil
}
total := int64(len(progress))
var nextPage *int64
var previousPage *int64
// Calculate total pages
totalPages := int64(math.Ceil(float64(total) / float64(limit)))
if page < totalPages {
nextPage = ptrOf(page + 1)
}
if page > 1 {
previousPage = ptrOf(page - 1)
}
apiProgress := make([]Progress, len(progress))
for i, row := range progress {
apiProgress[i] = Progress{
Title: row.Title,
Author: row.Author,
DeviceName: &row.DeviceName,
Percentage: &row.Percentage,
DocumentId: &row.DocumentID,
UserId: &row.UserID,
CreatedAt: parseTimePtr(row.CreatedAt),
}
}
response := ProgressListResponse{
Progress: &apiProgress,
Page: &page,
Limit: &limit,
NextPage: nextPage,
PreviousPage: previousPage,
Total: &total,
}
return GetProgressList200JSONResponse(response), nil
}
// GET /progress/{id}
func (s *Server) GetProgress(ctx context.Context, request GetProgressRequestObject) (GetProgressResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
row, err := s.db.Queries.GetDocumentProgress(ctx, database.GetDocumentProgressParams{
UserID: auth.UserName,
DocumentID: request.Id,
})
if err != nil {
log.Error("GetDocumentProgress DB Error:", err)
return GetProgress404JSONResponse{Code: 404, Message: "Progress not found"}, nil
}
apiProgress := Progress{
DeviceName: &row.DeviceName,
DeviceId: &row.DeviceID,
Percentage: &row.Percentage,
Progress: &row.Progress,
DocumentId: &row.DocumentID,
UserId: &row.UserID,
CreatedAt: parseTimePtr(row.CreatedAt),
}
response := ProgressResponse{
Progress: &apiProgress,
}
return GetProgress200JSONResponse(response), nil
}
// PUT /progress
func (s *Server) UpdateProgress(ctx context.Context, request UpdateProgressRequestObject) (UpdateProgressResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return UpdateProgress401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return UpdateProgress400JSONResponse{Code: 400, Message: "Request body is required"}, nil
}
if _, err := s.db.Queries.UpsertDevice(ctx, database.UpsertDeviceParams{
ID: request.Body.DeviceId,
UserID: auth.UserName,
DeviceName: request.Body.DeviceName,
LastSynced: time.Now().UTC().Format(time.RFC3339),
}); err != nil {
log.Error("UpsertDevice DB Error:", err)
return UpdateProgress500JSONResponse{Code: 500, Message: "Database error"}, nil
}
if _, err := s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: request.Body.DocumentId,
}); err != nil {
log.Error("UpsertDocument DB Error:", err)
return UpdateProgress500JSONResponse{Code: 500, Message: "Database error"}, nil
}
progress, err := s.db.Queries.UpdateProgress(ctx, database.UpdateProgressParams{
Percentage: request.Body.Percentage,
DocumentID: request.Body.DocumentId,
DeviceID: request.Body.DeviceId,
UserID: auth.UserName,
Progress: request.Body.Progress,
})
if err != nil {
log.Error("UpdateProgress DB Error:", err)
return UpdateProgress400JSONResponse{Code: 400, Message: "Invalid request"}, nil
}
response := UpdateProgressResponse{
DocumentId: progress.DocumentID,
Timestamp: parseTime(progress.CreatedAt),
}
return UpdateProgress200JSONResponse(response), nil
}

59
api/v1/search.go Normal file
View File

@@ -0,0 +1,59 @@
package v1
import (
"context"
"reichard.io/antholume/search"
log "github.com/sirupsen/logrus"
)
// GET /search
func (s *Server) GetSearch(ctx context.Context, request GetSearchRequestObject) (GetSearchResponseObject, error) {
if request.Params.Query == "" {
return GetSearch400JSONResponse{Code: 400, Message: "Invalid query"}, nil
}
query := request.Params.Query
source := string(request.Params.Source)
// Validate source
if source != "LibGen" && source != "Annas Archive" {
return GetSearch400JSONResponse{Code: 400, Message: "Invalid source"}, nil
}
searchResults, err := search.SearchBook(query, search.Source(source))
if err != nil {
log.Error("Search Error:", err)
return GetSearch500JSONResponse{Code: 500, Message: "Search error"}, nil
}
apiResults := make([]SearchItem, len(searchResults))
for i, item := range searchResults {
apiResults[i] = SearchItem{
Id: ptrOf(item.ID),
Title: ptrOf(item.Title),
Author: ptrOf(item.Author),
Language: ptrOf(item.Language),
Series: ptrOf(item.Series),
FileType: ptrOf(item.FileType),
FileSize: ptrOf(item.FileSize),
UploadDate: ptrOf(item.UploadDate),
}
}
response := SearchResponse{
Results: apiResults,
Source: source,
Query: query,
}
return GetSearch200JSONResponse(response), nil
}
// POST /search
func (s *Server) PostSearch(ctx context.Context, request PostSearchRequestObject) (PostSearchResponseObject, error) {
// This endpoint is used by the SSR template to queue a download
// For the API, we just return success - the actual download happens via /documents POST
return PostSearch200Response{}, nil
}

99
api/v1/server.go Normal file
View File

@@ -0,0 +1,99 @@
package v1
import (
"context"
"encoding/json"
"io/fs"
"net/http"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
)
var _ StrictServerInterface = (*Server)(nil)
type Server struct {
mux *http.ServeMux
db *database.DBManager
cfg *config.Config
assets fs.FS
}
// NewServer creates a new native HTTP server
func NewServer(db *database.DBManager, cfg *config.Config, assets fs.FS) *Server {
s := &Server{
mux: http.NewServeMux(),
db: db,
cfg: cfg,
assets: assets,
}
// Create strict handler with authentication middleware
strictHandler := NewStrictHandler(s, []StrictMiddlewareFunc{s.authMiddleware})
s.mux = HandlerFromMuxWithBaseURL(strictHandler, s.mux, "/api/v1").(*http.ServeMux)
return s
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
// authMiddleware adds authentication context to requests
func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) StrictHandlerFunc {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, request any) (any, error) {
// Store request and response in context for all handlers
ctx = context.WithValue(ctx, "request", r)
ctx = context.WithValue(ctx, "response", w)
// Skip auth for public auth and info endpoints - cover and file require auth via cookies
if operationID == "Login" || operationID == "Register" || operationID == "GetInfo" {
return handler(ctx, w, r, request)
}
auth, ok := s.getSession(r)
if !ok {
// Write 401 response directly
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
json.NewEncoder(w).Encode(ErrorResponse{Code: 401, Message: "Unauthorized"})
return nil, nil
}
// Check admin status for admin-only endpoints
adminEndpoints := []string{
"GetAdmin",
"PostAdminAction",
"GetUsers",
"UpdateUser",
"GetImportDirectory",
"PostImport",
"GetImportResults",
"GetLogs",
}
for _, adminEndpoint := range adminEndpoints {
if operationID == adminEndpoint && !auth.IsAdmin {
// Write 403 response directly
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(403)
json.NewEncoder(w).Encode(ErrorResponse{Code: 403, Message: "Admin privileges required"})
return nil, nil
}
}
// Store auth in context for handlers to access
ctx = context.WithValue(ctx, "auth", auth)
return handler(ctx, w, r, request)
}
}
// GetInfo returns server information
func (s *Server) GetInfo(ctx context.Context, request GetInfoRequestObject) (GetInfoResponseObject, error) {
return GetInfo200JSONResponse{
Version: s.cfg.Version,
SearchEnabled: s.cfg.SearchEnabled,
RegistrationEnabled: s.cfg.RegistrationEnabled,
}, nil
}

58
api/v1/server_test.go Normal file
View File

@@ -0,0 +1,58 @@
package v1
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
"reichard.io/antholume/config"
"reichard.io/antholume/database"
)
type ServerTestSuite struct {
suite.Suite
db *database.DBManager
cfg *config.Config
srv *Server
}
func TestServer(t *testing.T) {
suite.Run(t, new(ServerTestSuite))
}
func (suite *ServerTestSuite) SetupTest() {
suite.cfg = &config.Config{
ListenPort: "8080",
DBType: "memory",
DBName: "test",
ConfigPath: "/tmp",
CookieAuthKey: "test-auth-key-32-bytes-long-enough",
CookieEncKey: "0123456789abcdef",
CookieSecure: false,
CookieHTTPOnly: true,
Version: "test",
DemoMode: false,
RegistrationEnabled: true,
}
suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg, nil)
}
func (suite *ServerTestSuite) TestNewServer() {
suite.NotNil(suite.srv)
suite.NotNil(suite.srv.mux)
suite.NotNil(suite.srv.db)
suite.NotNil(suite.srv.cfg)
}
func (suite *ServerTestSuite) TestServerServeHTTP() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}

157
api/v1/settings.go Normal file
View File

@@ -0,0 +1,157 @@
package v1
import (
"context"
"crypto/md5"
"fmt"
"reichard.io/antholume/database"
argon2id "github.com/alexedwards/argon2id"
)
// GET /settings
func (s *Server) GetSettings(ctx context.Context, request GetSettingsRequestObject) (GetSettingsResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return GetSettings401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
user, err := s.db.Queries.GetUser(ctx, auth.UserName)
if err != nil {
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
devices, err := s.db.Queries.GetDevices(ctx, auth.UserName)
if err != nil {
return GetSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
apiDevices := make([]Device, len(devices))
for i, device := range devices {
apiDevices[i] = Device{
Id: &device.ID,
DeviceName: &device.DeviceName,
CreatedAt: parseTimePtr(device.CreatedAt),
LastSynced: parseTimePtr(device.LastSynced),
}
}
response := SettingsResponse{
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Timezone: user.Timezone,
Devices: &apiDevices,
}
return GetSettings200JSONResponse(response), nil
}
// authorizeCredentials verifies if credentials are valid
func (s *Server) authorizeCredentials(ctx context.Context, username string, password string) bool {
user, err := s.db.Queries.GetUser(ctx, username)
if err != nil {
return false
}
// Try argon2 hash comparison
if match, err := argon2id.ComparePasswordAndHash(password, *user.Pass); err == nil && match {
return true
}
return false
}
// PUT /settings
func (s *Server) UpdateSettings(ctx context.Context, request UpdateSettingsRequestObject) (UpdateSettingsResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx)
if !ok {
return UpdateSettings401JSONResponse{Code: 401, Message: "Unauthorized"}, nil
}
if request.Body == nil {
return UpdateSettings400JSONResponse{Code: 400, Message: "Request body is required"}, nil
}
user, err := s.db.Queries.GetUser(ctx, auth.UserName)
if err != nil {
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
updateParams := database.UpdateUserParams{
UserID: auth.UserName,
Admin: auth.IsAdmin,
}
// Update password if provided
if request.Body.NewPassword != nil {
if request.Body.Password == nil {
return UpdateSettings400JSONResponse{Code: 400, Message: "Current password is required to set new password"}, nil
}
// Verify current password - first try bcrypt (new format), then argon2, then MD5 (legacy format)
currentPasswordMatched := false
// Try argon2 (current format)
if !currentPasswordMatched {
currentPassword := fmt.Sprintf("%x", md5.Sum([]byte(*request.Body.Password)))
if match, err := argon2id.ComparePasswordAndHash(currentPassword, *user.Pass); err == nil && match {
currentPasswordMatched = true
}
}
if !currentPasswordMatched {
return UpdateSettings400JSONResponse{Code: 400, Message: "Invalid current password"}, nil
}
// Hash new password with argon2
newPassword := fmt.Sprintf("%x", md5.Sum([]byte(*request.Body.NewPassword)))
hashedPassword, err := argon2id.CreateHash(newPassword, argon2id.DefaultParams)
if err != nil {
return UpdateSettings500JSONResponse{Code: 500, Message: "Failed to hash password"}, nil
}
updateParams.Password = &hashedPassword
}
// Update timezone if provided
if request.Body.Timezone != nil {
updateParams.Timezone = request.Body.Timezone
}
// If nothing to update, return error
if request.Body.NewPassword == nil && request.Body.Timezone == nil {
return UpdateSettings400JSONResponse{Code: 400, Message: "At least one field must be provided"}, nil
}
// Update user
_, err = s.db.Queries.UpdateUser(ctx, updateParams)
if err != nil {
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
// Get updated settings to return
user, err = s.db.Queries.GetUser(ctx, auth.UserName)
if err != nil {
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
devices, err := s.db.Queries.GetDevices(ctx, auth.UserName)
if err != nil {
return UpdateSettings500JSONResponse{Code: 500, Message: err.Error()}, nil
}
apiDevices := make([]Device, len(devices))
for i, device := range devices {
apiDevices[i] = Device{
Id: &device.ID,
DeviceName: &device.DeviceName,
CreatedAt: parseTimePtr(device.CreatedAt),
LastSynced: parseTimePtr(device.LastSynced),
}
}
response := SettingsResponse{
User: UserData{Username: auth.UserName, IsAdmin: auth.IsAdmin},
Timezone: user.Timezone,
Devices: &apiDevices,
}
return UpdateSettings200JSONResponse(response), nil
}

84
api/v1/utils.go Normal file
View File

@@ -0,0 +1,84 @@
package v1
import (
"encoding/json"
"net/http"
"net/url"
"strconv"
"time"
)
// writeJSON writes a JSON response (deprecated - used by tests only)
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
writeJSONError(w, http.StatusInternalServerError, "Failed to encode response")
}
}
// writeJSONError writes a JSON error response (deprecated - used by tests only)
func writeJSONError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, ErrorResponse{
Code: status,
Message: message,
})
}
// QueryParams represents parsed query parameters (deprecated - used by tests only)
type QueryParams struct {
Page int64
Limit int64
Search *string
}
// parseQueryParams parses URL query parameters (deprecated - used by tests only)
func parseQueryParams(query url.Values, defaultLimit int64) QueryParams {
page, _ := strconv.ParseInt(query.Get("page"), 10, 64)
if page == 0 {
page = 1
}
limit, _ := strconv.ParseInt(query.Get("limit"), 10, 64)
if limit == 0 {
limit = defaultLimit
}
search := query.Get("search")
var searchPtr *string
if search != "" {
searchPtr = ptrOf("%" + search + "%")
}
return QueryParams{
Page: page,
Limit: limit,
Search: searchPtr,
}
}
// ptrOf returns a pointer to the given value
func ptrOf[T any](v T) *T {
return &v
}
// parseTime parses a string to time.Time
func parseTime(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
if t.IsZero() {
t, _ = time.Parse("2006-01-02T15:04:05", s)
}
return t
}
// parseTimePtr parses an interface{} (from SQL) to *time.Time
func parseTimePtr(v interface{}) *time.Time {
if v == nil {
return nil
}
if s, ok := v.(string); ok {
t := parseTime(s)
if t.IsZero() {
return nil
}
return &t
}
return nil
}

76
api/v1/utils_test.go Normal file
View File

@@ -0,0 +1,76 @@
package v1
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/suite"
)
type UtilsTestSuite struct {
suite.Suite
}
func TestUtils(t *testing.T) {
suite.Run(t, new(UtilsTestSuite))
}
func (suite *UtilsTestSuite) TestWriteJSON() {
w := httptest.NewRecorder()
data := map[string]string{"test": "value"}
writeJSON(w, http.StatusOK, data)
suite.Equal("application/json", w.Header().Get("Content-Type"))
suite.Equal(http.StatusOK, w.Code)
var resp map[string]string
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal("value", resp["test"])
}
func (suite *UtilsTestSuite) TestWriteJSONError() {
w := httptest.NewRecorder()
writeJSONError(w, http.StatusBadRequest, "test error")
suite.Equal(http.StatusBadRequest, w.Code)
var resp ErrorResponse
suite.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
suite.Equal(http.StatusBadRequest, resp.Code)
suite.Equal("test error", resp.Message)
}
func (suite *UtilsTestSuite) TestParseQueryParams() {
query := make(map[string][]string)
query["page"] = []string{"2"}
query["limit"] = []string{"15"}
query["search"] = []string{"test"}
params := parseQueryParams(query, 9)
suite.Equal(int64(2), params.Page)
suite.Equal(int64(15), params.Limit)
suite.NotNil(params.Search)
}
func (suite *UtilsTestSuite) TestParseQueryParamsDefaults() {
query := make(map[string][]string)
params := parseQueryParams(query, 9)
suite.Equal(int64(1), params.Page)
suite.Equal(int64(9), params.Limit)
suite.Nil(params.Search)
}
func (suite *UtilsTestSuite) TestPtrOf() {
value := "test"
ptr := ptrOf(value)
suite.NotNil(ptr)
suite.Equal("test", *ptr)
}

122
assets/common.js Normal file
View File

@@ -0,0 +1,122 @@
/**
* Custom Service Worker Convenience Functions Wrapper
**/
const SW = (function () {
// Helper Function
function randomID() {
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))))
.toString(16)
.toUpperCase()
);
}
// Variables
let swInstance = null;
let outstandingMessages = {};
navigator.serviceWorker?.addEventListener("message", ({ data }) => {
let { id } = data;
data = data.data;
console.log("[SW] Received Message:", { id, data });
if (!outstandingMessages[id])
return console.warn("[SW] Invalid Outstanding Message:", { id, data });
outstandingMessages[id](data);
delete outstandingMessages[id];
});
async function install() {
if (!navigator.serviceWorker)
throw new Error("Service Worker Not Supported");
// Register Service Worker
swInstance = await navigator.serviceWorker.register("/sw.js");
swInstance.onupdatefound = (data) =>
console.log("[SW.install] Update Found:", data);
// Wait for Registration / Update
let serviceWorker =
swInstance.installing || swInstance.waiting || swInstance.active;
// Await Installation
await new Promise((resolve) => {
serviceWorker.onstatechange = (data) => {
console.log("[SW.install] State Change:", serviceWorker.state);
if (["installed", "activated"].includes(serviceWorker.state)) resolve();
};
console.log("[SW.install] Current State:", serviceWorker.state);
if (["installed", "activated"].includes(serviceWorker.state)) resolve();
});
}
function send(data) {
if (!swInstance?.active) return Promise.reject("Inactive Service Worker");
let id = randomID();
let msgPromise = new Promise((resolve) => {
outstandingMessages[id] = resolve;
});
swInstance.active.postMessage({ id, data });
return msgPromise;
}
return { install, send };
})();
/**
* Custom IndexedDB Convenience Functions Wrapper
**/
const IDB = (function () {
if (!idbKeyval)
return console.error(
"[IDB] idbKeyval not found - Did you load idb-keyval?"
);
let { get, del, entries, update, keys } = idbKeyval;
return {
async set(key, newValue) {
let changeObj = {};
await update(key, (oldValue) => {
if (oldValue != null) changeObj.oldValue = oldValue;
changeObj.newValue = newValue;
return newValue;
});
return changeObj;
},
get(key, defaultValue) {
return get(key).then((resp) => {
return defaultValue && resp == null ? defaultValue : resp;
});
},
del(key) {
return del(key);
},
find(keyRegExp, includeValues = false) {
if (!(keyRegExp instanceof RegExp)) throw new Error("Invalid RegExp");
if (!includeValues)
return keys().then((allKeys) =>
allKeys.filter((key) => keyRegExp.test(key))
);
return entries().then((allItems) => {
const matchingKeys = allItems.filter((keyVal) =>
keyRegExp.test(keyVal[0])
);
return matchingKeys.reduce((obj, keyVal) => {
const [key, val] = keyVal;
obj[key] = val;
return obj;
}, {});
});
},
};
})();

BIN
assets/icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
assets/icons/icon512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

Before

Width:  |  Height:  |  Size: 511 KiB

After

Width:  |  Height:  |  Size: 511 KiB

View File

Before

Width:  |  Height:  |  Size: 699 KiB

After

Width:  |  Height:  |  Size: 699 KiB

View File

Before

Width:  |  Height:  |  Size: 462 KiB

After

Width:  |  Height:  |  Size: 462 KiB

View File

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 457 KiB

View File

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

78
assets/index.js Normal file
View File

@@ -0,0 +1,78 @@
// Install Service Worker
async function installServiceWorker() {
// Attempt Installation
await SW.install()
.then(() => console.log("[installServiceWorker] Service Worker Installed"))
.catch((e) =>
console.log("[installServiceWorker] Service Worker Install Error:", e)
);
}
// Flush Cached Progress & Activity
async function flushCachedData() {
let allProgress = await IDB.find(/^PROGRESS-/, true);
let allActivity = await IDB.get("ACTIVITY");
console.log("[flushCachedData] Flushing Data:", { allProgress, allActivity });
Object.entries(allProgress).forEach(([id, progressEvent]) => {
flushProgress(progressEvent)
.then(() => {
console.log("[flushCachedData] Progress Flush Success:", id);
return IDB.del(id);
})
.catch((e) => {
console.log("[flushCachedData] Progress Flush Failure:", id, e);
});
});
if (!allActivity) return;
flushActivity(allActivity)
.then(() => {
console.log("[flushCachedData] Activity Flush Success");
return IDB.del("ACTIVITY");
})
.catch((e) => {
console.log("[flushCachedData] Activity Flush Failure", e);
});
}
function flushActivity(activityEvent) {
console.log("[flushActivity] Flushing Activity...");
// Flush Activity
return fetch("/api/ko/activity", {
method: "POST",
body: JSON.stringify(activityEvent),
}).then(async (r) =>
console.log("[flushActivity] Flushed Activity:", {
response: r,
json: await r.json(),
data: activityEvent,
})
);
}
function flushProgress(progressEvent) {
console.log("[flushProgress] Flushing Progress...");
// Flush Progress
return fetch("/api/ko/syncs/progress", {
method: "PUT",
body: JSON.stringify(progressEvent),
}).then(async (r) =>
console.log("[flushProgress] Flushed Progress:", {
response: r,
json: await r.json(),
data: progressEvent,
})
);
}
// Event Listeners
window.addEventListener("online", flushCachedData);
// Initial Load
flushCachedData();
installServiceWorker();

1
assets/lib/epub.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
assets/lib/idb-keyval.min.js vendored Normal file
View File

@@ -0,0 +1 @@
function _slicedToArray(t,n){return _arrayWithHoles(t)||_iterableToArrayLimit(t,n)||_unsupportedIterableToArray(t,n)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(t,n){if(t){if("string"==typeof t)return _arrayLikeToArray(t,n);var r=Object.prototype.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?_arrayLikeToArray(t,n):void 0}}function _arrayLikeToArray(t,n){(null==n||n>t.length)&&(n=t.length);for(var r=0,e=new Array(n);r<n;r++)e[r]=t[r];return e}function _iterableToArrayLimit(t,n){var r=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=r){var e,o,u=[],i=!0,a=!1;try{for(r=r.call(t);!(i=(e=r.next()).done)&&(u.push(e.value),!n||u.length!==n);i=!0);}catch(t){a=!0,o=t}finally{try{i||null==r.return||r.return()}finally{if(a)throw o}}return u}}function _arrayWithHoles(t){if(Array.isArray(t))return t}function _typeof(t){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},_typeof(t)}!function(t,n){"object"===("undefined"==typeof exports?"undefined":_typeof(exports))&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).idbKeyval={})}(this,(function(t){"use strict";function n(t){return new Promise((function(n,r){t.oncomplete=t.onsuccess=function(){return n(t.result)},t.onabort=t.onerror=function(){return r(t.error)}}))}function r(t,r){var e=indexedDB.open(t);e.onupgradeneeded=function(){return e.result.createObjectStore(r)};var o=n(e);return function(t,n){return o.then((function(e){return n(e.transaction(r,t).objectStore(r))}))}}var e;function o(){return e||(e=r("keyval-store","keyval")),e}function u(t,r){return t.openCursor().onsuccess=function(){this.result&&(r(this.result),this.result.continue())},n(t.transaction)}t.clear=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readwrite",(function(t){return t.clear(),n(t.transaction)}))},t.createStore=r,t.del=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return r.delete(t),n(r.transaction)}))},t.delMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return t.forEach((function(t){return r.delete(t)})),n(r.transaction)}))},t.entries=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(r){if(r.getAll&&r.getAllKeys)return Promise.all([n(r.getAllKeys()),n(r.getAll())]).then((function(t){var n=_slicedToArray(t,2),r=n[0],e=n[1];return r.map((function(t,n){return[t,e[n]]}))}));var e=[];return t("readonly",(function(t){return u(t,(function(t){return e.push([t.key,t.value])})).then((function(){return e}))}))}))},t.get=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readonly",(function(r){return n(r.get(t))}))},t.getMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readonly",(function(r){return Promise.all(t.map((function(t){return n(r.get(t))})))}))},t.keys=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(t){if(t.getAllKeys)return n(t.getAllKeys());var r=[];return u(t,(function(t){return r.push(t.key)})).then((function(){return r}))}))},t.promisifyRequest=n,t.set=function(t,r){var e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:o();return e("readwrite",(function(e){return e.put(r,t),n(e.transaction)}))},t.setMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return t.forEach((function(t){return r.put(t[1],t[0])})),n(r.transaction)}))},t.update=function(t,r){var e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:o();return e("readwrite",(function(e){return new Promise((function(o,u){e.get(t).onsuccess=function(){try{e.put(r(this.result),t),o(n(e.transaction))}catch(t){u(t)}}}))}))},t.values=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(t){if(t.getAll)return n(t.getAll());var r=[];return u(t,(function(t){return r.push(t.value)})).then((function(){return r}))}))},Object.defineProperty(t,"__esModule",{value:!0})}));

2
assets/lib/no-sleep.min.js vendored Normal file

File diff suppressed because one or more lines are too long

281
assets/local/index.htm Normal file
View File

@@ -0,0 +1,281 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta
name="theme-color"
content="#F3F4F6"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#1F2937"
media="(prefers-color-scheme: dark)"
/>
<title>AnthoLume - Local</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" />
<!-- Libraries -->
<script src="/assets/lib/jszip.min.js"></script>
<script src="/assets/lib/epub.min.js"></script>
<script src="/assets/lib/idb-keyval.min.js"></script>
<!-- Local -->
<script src="/assets/common.js"></script>
<script src="/assets/index.js"></script>
<script src="/assets/local/index.js"></script>
<style>
/* ----------------------------- */
/* -------- PWA Styling -------- */
/* ----------------------------- */
html,
body {
overscroll-behavior-y: none;
margin: 0px;
}
html {
height: calc(100% + env(safe-area-inset-bottom));
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
env(safe-area-inset-left);
}
main {
height: calc(100dvh - 4rem - env(safe-area-inset-top));
}
#container {
padding-bottom: calc(5em + env(safe-area-inset-bottom) * 2);
}
/* No Scrollbar - IE, Edge, Firefox */
* {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* No Scrollbar - WebKit */
*::-webkit-scrollbar {
display: none;
}
.css-button:checked + div {
display: block;
opacity: 1;
}
.css-button + div {
display: none;
opacity: 0;
}
.css-button:checked + div + label {
display: none;
}
</style>
</head>
<body class="bg-gray-100 dark:bg-gray-800">
<div class="flex items-center justify-between w-full h-16">
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-48">
Local Documents
</h1>
</div>
<main class="relative overflow-hidden">
<div
id="container"
class="h-[100dvh] px-4 overflow-auto md:px-6 lg:mx-48"
>
<div
id="online"
class="rounded text-black dark:text-white bg-white dark:bg-gray-700 text-center p-3 mb-4"
>
You're Online:
<a
href="/"
class="p-2 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
>Go Home</a
>
</div>
<div
id="message"
class="rounded text-white bg-white dark:bg-gray-700 text-center p-3 mb-4"
>
Loading...
</div>
<div
id="items"
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
></div>
<div
class="fixed bottom-6 right-6 rounded-full flex items-center justify-center"
>
<input
type="checkbox"
id="add-file-button"
class="hidden css-button"
/>
<div
class="rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2"
>
<div class="flex flex-col gap-2">
<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"
>
Add File
</button>
</div>
<label for="add-file-button">
<div
class="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
>
Cancel
</div>
</label>
</div>
<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="add-file-button"
>
<svg
width="34"
height="34"
class="text-gray-200 dark:text-gray-600"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 15.75C12.4142 15.75 12.75 15.4142 12.75 15V4.02744L14.4306 5.98809C14.7001 6.30259 15.1736 6.33901 15.4881 6.06944C15.8026 5.79988 15.839 5.3264 15.5694 5.01191L12.5694 1.51191C12.427 1.34567 12.2189 1.25 12 1.25C11.7811 1.25 11.573 1.34567 11.4306 1.51191L8.43056 5.01191C8.16099 5.3264 8.19741 5.79988 8.51191 6.06944C8.8264 6.33901 9.29988 6.30259 9.56944 5.98809L11.25 4.02744L11.25 15C11.25 15.4142 11.5858 15.75 12 15.75Z"
/>
<path
d="M16 9C15.2978 9 14.9467 9 14.6945 9.16851C14.5853 9.24148 14.4915 9.33525 14.4186 9.44446C14.25 9.69667 14.25 10.0478 14.25 10.75L14.25 15C14.25 16.2426 13.2427 17.25 12 17.25C10.7574 17.25 9.75004 16.2426 9.75004 15L9.75004 10.75C9.75004 10.0478 9.75004 9.69664 9.58149 9.4444C9.50854 9.33523 9.41481 9.2415 9.30564 9.16855C9.05341 9 8.70227 9 8 9C5.17157 9 3.75736 9 2.87868 9.87868C2 10.7574 2 12.1714 2 14.9998V15.9998C2 18.8282 2 20.2424 2.87868 21.1211C3.75736 21.9998 5.17157 21.9998 8 21.9998H16C18.8284 21.9998 20.2426 21.9998 21.1213 21.1211C22 20.2424 22 18.8282 22 15.9998V14.9998C22 12.1714 22 10.7574 21.1213 9.87868C20.2426 9 18.8284 9 16 9Z"
/>
</svg>
</label>
</div>
</div>
</main>
<!-- Template HTML Elements -->
<div class="hidden">
<svg id="local-svg-template" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 22H10C6.22876 22 4.34315 22 3.17157 20.8284C2 19.6569 2 17.7712 2 14V10C2 6.22876 2 4.34315 3.17157 3.17157C4.34315 2 6.23869 2 10.0298 2C10.6358 2 11.1214 2 11.53 2.01666C11.5166 2.09659 11.5095 2.17813 11.5092 2.26057L11.5 5.09497C11.4999 6.19207 11.4998 7.16164 11.6049 7.94316C11.7188 8.79028 11.9803 9.63726 12.6716 10.3285C13.3628 11.0198 14.2098 11.2813 15.0569 11.3952C15.8385 11.5003 16.808 11.5002 17.9051 11.5001L18 11.5001H21.9574C22 12.0344 22 12.6901 22 13.5629V14C22 17.7712 22 19.6569 20.8284 20.8284C19.6569 22 17.7712 22 14 22Z" />
<path d="M19.3517 7.61665L15.3929 4.05375C14.2651 3.03868 13.7012 2.53114 13.0092 2.26562L13 5.00011C13 7.35713 13 8.53564 13.7322 9.26787C14.4645 10.0001 15.643 10.0001 18 10.0001H21.5801C21.2175 9.29588 20.5684 8.71164 19.3517 7.61665Z" />
</svg>
<svg id="remote-svg-template" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M19.3517 7.61665L15.3929 4.05375C14.2651 3.03868 13.7012 2.53114 13.0092 2.26562L13 5.00011C13 7.35713 13 8.53564 13.7322 9.26787C14.4645 10.0001 15.643 10.0001 18 10.0001H21.5801C21.2175 9.29588 20.5684 8.71164 19.3517 7.61665Z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 22H14C17.7712 22 19.6569 22 20.8284 20.8284C22 19.6569 22 17.7712 22 14V13.5629C22 12.6901 22 12.0344 21.9574 11.5001H18L17.9051 11.5001C16.808 11.5002 15.8385 11.5003 15.0569 11.3952C14.2098 11.2813 13.3628 11.0198 12.6716 10.3285C11.9803 9.63726 11.7188 8.79028 11.6049 7.94316C11.4998 7.16164 11.4999 6.19207 11.5 5.09497L11.5092 2.26057C11.5095 2.17813 11.5166 2.09659 11.53 2.01666C11.1214 2 10.6358 2 10.0298 2C6.23869 2 4.34315 2 3.17157 3.17157C2 4.34315 2 6.22876 2 10V14C2 17.7712 2 19.6569 3.17157 20.8284C4.34315 22 6.22876 22 10 22ZM11 18C12.1046 18 13 17.2099 13 16.2353C13 15.4629 12.4375 14.8063 11.6543 14.5672C11.543 13.6855 10.6956 13 9.66667 13C8.5621 13 7.66667 13.7901 7.66667 14.7647C7.66667 14.9803 7.71047 15.1868 7.79066 15.3778C7.69662 15.3615 7.59944 15.3529 7.5 15.3529C6.67157 15.3529 6 15.9455 6 16.6765C6 17.4074 6.67157 18 7.5 18H11Z"/>
</svg>
<div id="item-template" class="w-full relative">
<div class="flex gap-4 w-full h-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
<div class="min-w-fit my-auto h-48 relative">
<a href="#">
<img class="rounded object-cover h-full" src="/assets/images/no-cover.jpg"></img>
</a>
</div>
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Title</p>
<p class="font-medium">
N/A
</p>
</div>
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Author</p>
<p class="font-medium">
N/A
</p>
</div>
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Progress</p>
<p class="font-medium">
0%
</p>
</div>
</div>
</div>
<div class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400">
<div class="relative">
<label for="delete-button">
<svg
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 6.52381C3 6.12932 3.32671 5.80952 3.72973 5.80952H8.51787C8.52437 4.9683 8.61554 3.81504 9.45037 3.01668C10.1074 2.38839 11.0081 2 12 2C12.9919 2 13.8926 2.38839 14.5496 3.01668C15.3844 3.81504 15.4756 4.9683 15.4821 5.80952H20.2703C20.6733 5.80952 21 6.12932 21 6.52381C21 6.9183 20.6733 7.2381 20.2703 7.2381H3.72973C3.32671 7.2381 3 6.9183 3 6.52381Z"
/>
<path
d="M11.6066 22H12.3935C15.101 22 16.4547 22 17.3349 21.1368C18.2151 20.2736 18.3052 18.8576 18.4853 16.0257L18.7448 11.9452C18.8425 10.4086 18.8913 9.64037 18.4498 9.15352C18.0082 8.66667 17.2625 8.66667 15.7712 8.66667H8.22884C6.7375 8.66667 5.99183 8.66667 5.55026 9.15352C5.1087 9.64037 5.15756 10.4086 5.25528 11.9452L5.51479 16.0257C5.69489 18.8576 5.78494 20.2736 6.66513 21.1368C7.54532 22 8.89906 22 11.6066 22Z"
/>
</svg>
</label>
<input type="checkbox" id="delete-button" class="hidden css-button"/>
<div class="absolute z-30 bottom-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<span
class="block cursor-pointer font-medium text-sm text-center w-32 px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
>Delete</span>
</div>
</div>
<a href="#">
<svg
width="24"
height="24"
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM12 6.25C12.4142 6.25 12.75 6.58579 12.75 7V12.1893L14.4697 10.4697C14.7626 10.1768 15.2374 10.1768 15.5303 10.4697C15.8232 10.7626 15.8232 11.2374 15.5303 11.5303L12.5303 14.5303C12.3897 14.671 12.1989 14.75 12 14.75C11.8011 14.75 11.6103 14.671 11.4697 14.5303L8.46967 11.5303C8.17678 11.2374 8.17678 10.7626 8.46967 10.4697C8.76256 10.1768 9.23744 10.1768 9.53033 10.4697L11.25 12.1893V7C11.25 6.58579 11.5858 6.25 12 6.25ZM8 16.25C7.58579 16.25 7.25 16.5858 7.25 17C7.25 17.4142 7.58579 17.75 8 17.75H16C16.4142 17.75 16.75 17.4142 16.75 17C16.75 16.5858 16.4142 16.25 16 16.25H8Z"
/>
</svg>
</a>
</div>
</div>
<div class="absolute top-0 right-0">
<strong class="bg-blue-100 text-blue-700 inline-flex items-center gap-1 rounded-tr rounded-bl p-1">
<div class="w-4 h-4"></div>
<span class="text-xs font-medium">REMOTE</span>
</strong>
</div>
</div>
</div>
</body>
</html>

319
assets/local/index.js Normal file
View File

@@ -0,0 +1,319 @@
const GET_SW_CACHE = "GET_SW_CACHE";
const DEL_SW_CACHE = "DEL_SW_CACHE";
// ----------------------------------------------------------------------- //
// --------------------------- Event Listeners --------------------------- //
// ----------------------------------------------------------------------- //
/**
* Initial load handler. Gets called on DOMContentLoaded.
**/
async function handleLoad() {
handleOnlineChange();
// If SW Redirected
if (document.location.pathname !== "/local")
window.history.replaceState(null, null, "/local");
// Create Upload Listener
let uploadButton = document.querySelector("button");
uploadButton.addEventListener("click", handleFileAdd);
// Ensure Installed -> Get Cached Items
let swCache = await SW.install()
// Get Service Worker Cache Books
.then(async () => {
let swResponse = await SW.send({ type: GET_SW_CACHE });
return Promise.all(
// Normalize Cached Results
swResponse.map(async (item) => {
let localCache = await IDB.get("PROGRESS-" + item.id);
if (localCache) {
item.progress = localCache.progress;
item.percentage = Math.round(localCache.percentage * 10000) / 100;
}
// Additional Values
item.fileURL = "/documents/" + item.id + "/file";
item.coverURL = "/documents/" + item.id + "/cover";
item.type = "REMOTE";
return item;
})
);
})
// Fail Nicely -> Allows Local Feature
.catch((e) => {
console.log("[loadContent] Service Worker Cache Error:", e);
return [];
});
// Get & Normalize Local Books
let localResponse = await IDB.find(/^FILE-.{32}$/, false);
let localCache = await Promise.all(localResponse.map(getLocalProgress));
// Populate DOM with Cache & Local Books
populateDOMBooks([...swCache, ...localCache]);
}
/**
* Update DOM to indicate online status. If no argument is passed, we attempt
* to determine online status via `navigator.onLine`.
**/
function handleOnlineChange(isOnline) {
let onlineEl = document.querySelector("#online");
isOnline = isOnline == undefined ? navigator.onLine : isOnline;
onlineEl.hidden = !isOnline;
}
/**
* Allow deleting local or remote cached files. Deleting remotely cached files
* does not remove progress. Progress will still be flushed once online.
**/
async function handleFileDelete(event, item) {
let mainEl =
event.target.parentElement.parentElement.parentElement.parentElement
.parentElement;
if (item.type == "LOCAL") {
await IDB.del("FILE-" + item.id);
await IDB.del("FILE-METADATA-" + item.id);
} else if (item.type == "REMOTE") {
let swResp = await SW.send({ type: DEL_SW_CACHE, id: item.id });
if (swResp != "SUCCESS")
throw new Error("[handleFileDelete] Service Worker Error");
}
console.log("[handleFileDelete] Item Deleted");
mainEl.remove();
updateMessage();
}
/**
* Allow adding file to offline reader. Add to IndexedDB,
* and later upload? Add style indicating external file?
**/
async function handleFileAdd() {
const fileInput = document.getElementById("document_file");
const file = fileInput.files[0];
if (!file) return console.log("[handleFileAdd] No File");
function readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => resolve(event.target.result);
reader.onerror = (error) => reject(error);
reader.readAsArrayBuffer(file);
});
}
function randomID() {
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16)
);
}
let newID = randomID();
readFile(file)
// Store Blob in IDB
.then((fileData) => {
if (!isEpubFile(fileData)) throw new Error("Invalid File Type");
return IDB.set(
"FILE-" + newID,
new Blob([fileData], { type: "application/octet-binary" })
);
})
// Process File
.then(() => getLocalProgress("FILE-" + newID))
// Populate in DOM
.then((item) => populateDOMBooks([item]))
// Hide Add File Button
.then(() => {
let addButtonEl = document.querySelector("#add-file-button");
addButtonEl.checked = false;
})
// Logging
.then(() => console.log("[handleFileAdd] File Add Successfully"))
.catch((e) => console.log("[handleFileAdd] File Add Failed:", e));
}
// Add Event Listeners
window.addEventListener("DOMContentLoaded", handleLoad);
window.addEventListener("online", () => handleOnlineChange(true));
window.addEventListener("offline", () => handleOnlineChange(false));
// ----------------------------------------------------------------------- //
// ------------------------------- Helpers ------------------------------- //
// ----------------------------------------------------------------------- //
/**
* Update the message element. Called after initial load, on item add or on
* item delete.
**/
function updateMessage() {
// Update Loader / No Results Indicator
let itemsEl = document.querySelector("#items");
let messageEl = document.querySelector("#message");
if (itemsEl.children.length == 0) {
messageEl.innerText = "No Results";
messageEl.hidden = false;
} else messageEl.hidden = true;
}
/**
* Populate DOM with cached documents.
**/
function populateDOMBooks(data) {
let allDocuments = document.querySelector("#items");
// Create Document Items
data.forEach((item) => {
// Create Main Element
let baseEl = document.querySelector("#item-template").cloneNode(true);
baseEl.removeAttribute("id");
// Get Elements
let [titleEl, authorEl, percentageEl] = baseEl.querySelectorAll("p + p");
let [svgDivEl, textEl] = baseEl.querySelector("strong").children;
let coverEl = baseEl.querySelector("a img");
let downloadEl = baseEl.querySelector("svg").parentElement;
let deleteInputEl = baseEl.querySelector("#delete-button");
let deleteLabelEl = deleteInputEl.previousElementSibling;
let deleteTextEl = baseEl.querySelector("input + div span");
// Set Download Attributes
downloadEl.setAttribute("href", item.fileURL);
downloadEl.setAttribute(
"download",
item.title + " - " + item.author + ".epub"
);
// Set Cover Attributes
coverEl.setAttribute("src", item.coverURL);
coverEl.parentElement.setAttribute(
"href",
"/reader#id=" + item.id + "&type=" + item.type
);
// Set Additional Metadata Attributes
titleEl.textContent = item.title;
authorEl.textContent = item.author;
percentageEl.textContent = item.percentage + "%";
// Set Remote / Local Indicator
let newSvgEl =
item.type == "LOCAL"
? document.querySelector("#local-svg-template").cloneNode(true)
: document.querySelector("#remote-svg-template").cloneNode(true);
svgDivEl.append(newSvgEl);
textEl.textContent = item.type;
// Delete Item
deleteInputEl.setAttribute("id", "delete-button-" + item.id);
deleteLabelEl.setAttribute("for", "delete-button-" + item.id);
deleteTextEl.addEventListener("click", (e) => handleFileDelete(e, item));
deleteTextEl.textContent =
item.type == "LOCAL" ? "Delete Local" : "Delete Cache";
allDocuments.append(baseEl);
});
updateMessage();
}
/**
* Given an item id, generate expected item format from IDB data store.
**/
async function getLocalProgress(id) {
// Get Metadata (Cover Always Needed)
let fileBlob = await IDB.get(id);
let fileURL = URL.createObjectURL(fileBlob);
let metadata = await getMetadata(fileURL);
// Attempt Cache
let documentID = id.replace("FILE-", "");
let documentData = await IDB.get("FILE-METADATA-" + documentID);
if (documentData)
return { ...documentData, fileURL, coverURL: metadata.coverURL };
// Create Starting Progress
let newProgress = {
id: documentID,
title: metadata.title,
author: metadata.author,
type: "LOCAL",
percentage: 0,
progress: "",
words: 0,
};
// Update Cache
await IDB.set("FILE-METADATA-" + documentID, newProgress);
// Return Cache + coverURL
return { ...newProgress, fileURL, coverURL: metadata.coverURL };
}
/**
* Retrieve the Title, Author, and CoverURL (blob) for a given file.
**/
async function getMetadata(fileURL) {
let book = ePub(fileURL, { openAs: "epub" });
console.log({ book });
let coverURL = (await book.coverUrl()) || "/assets/images/no-cover.jpg";
let metadata = await book.loaded.metadata;
let title =
metadata.title && metadata.title != "" ? metadata.title : "Unknown";
let author =
metadata.creator && metadata.creator != "" ? metadata.creator : "Unknown";
book.destroy();
return { title, author, coverURL };
}
/**
* Validate filetype. We check the headers and validate that they are ZIP.
* After which we validate contents. This isn't 100% effective, but unless
* someone is trying to trick it, it should be fine.
**/
function isEpubFile(arrayBuffer) {
const view = new DataView(arrayBuffer);
// Too Small
if (view.byteLength < 4) {
return false;
}
// Check for the ZIP file signature (PK)
const littleEndianSignature = view.getUint16(0, true);
const bigEndianSignature = view.getUint16(0, false);
if (littleEndianSignature !== 0x504b && bigEndianSignature !== 0x504b) {
return false;
}
// Additional Checks (No FP on ZIP)
const textDecoder = new TextDecoder();
const zipContent = textDecoder.decode(new Uint8Array(arrayBuffer));
if (
zipContent.includes("mimetype") &&
zipContent.includes("META-INF/container.xml")
) {
return true;
}
return false;
}

View File

@@ -1,8 +1,17 @@
{
"short_name": "Book Manager",
"name": "Book Manager",
"name": "AnthoLume",
"short_name": "AnthoLume",
"lang": "en-US",
"theme_color": "#1F2937",
"display": "standalone",
"scope": "/",
"start_url": "/"
"start_url": "/",
"icons": [
{
"purpose": "any",
"sizes": "512x512",
"src": "/assets/icons/icon512.png",
"type": "image/png"
}
]
}

File diff suppressed because one or more lines are too long

119
assets/reader/fonts.css Normal file
View File

@@ -0,0 +1,119 @@
/**
* Lato
* - Charsets: [latin,latin-ext]
* - Styles: [100,700,100italic,regular,italic,700italic]
**/
/* lato-100 - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Lato";
font-style: normal;
font-weight: 100;
src: url("./fonts/lato-v24-latin_latin-ext-100.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* lato-100italic - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Lato";
font-style: italic;
font-weight: 100;
src: url("./fonts/lato-v24-latin_latin-ext-100italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* lato-regular - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Lato";
font-style: normal;
font-weight: 400;
src: url("./fonts/lato-v24-latin_latin-ext-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* lato-italic - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Lato";
font-style: italic;
font-weight: 400;
src: url("./fonts/lato-v24-latin_latin-ext-italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* lato-700 - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Lato";
font-style: normal;
font-weight: 700;
src: url("./fonts/lato-v24-latin_latin-ext-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* lato-700italic - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Lato";
font-style: italic;
font-weight: 700;
src: url("./fonts/lato-v24-latin_latin-ext-700italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/**
* Open Sans
* - Charsets: [latin,latin-ext]
* - Styles: [700,regular,italic,700italic]
**/
/* open-sans-regular - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Open Sans";
font-style: normal;
font-weight: 400;
src: url("./fonts/open-sans-v36-latin_latin-ext-regular.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* open-sans-italic - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Open Sans";
font-style: italic;
font-weight: 400;
src: url("./fonts/open-sans-v36-latin_latin-ext-italic.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* open-sans-700 - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Open Sans";
font-style: normal;
font-weight: 700;
src: url("./fonts/open-sans-v36-latin_latin-ext-700.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* open-sans-700italic - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Open Sans";
font-style: italic;
font-weight: 700;
src: url("./fonts/open-sans-v36-latin_latin-ext-700italic.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/**
* Arbutus Slab
* - Charsets: [latin,latin-ext]
* - Styles: [regular]
**/
/* arbutus-slab-regular - latin_latin-ext */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: "Arbutus Slab";
font-style: normal;
font-weight: 400;
src: url("./fonts/arbutus-slab-v16-latin_latin-ext-regular.woff2")
format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

Binary file not shown.

Binary file not shown.

385
assets/reader/index.htm Normal file
View File

@@ -0,0 +1,385 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
id="viewport"
name="viewport"
content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="theme-color" content="#D2B48C" />
<title>AnthoLume - Reader</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" />
<!-- Libraries -->
<script src="/assets/lib/jszip.min.js"></script>
<script src="/assets/lib/epub.min.js"></script>
<script src="/assets/lib/no-sleep.min.js"></script>
<script src="/assets/lib/idb-keyval.min.js"></script>
<!-- Reader -->
<script src="/assets/common.js"></script>
<script src="/assets/index.js"></script>
<script src="/assets/reader/index.js"></script>
<style>
/* ----------------------------- */
/* -------- PWA Styling -------- */
/* ----------------------------- */
html,
body {
overscroll-behavior-y: none;
margin: 0px;
}
html {
min-height: calc(100% + env(safe-area-inset-top));
}
#viewer {
padding-top: env(safe-area-inset-top);
}
/* For Webkit-based browsers (Chrome, Safari and Opera) */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* For IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
#bottom-bar {
padding-bottom: env(safe-area-inset-bottom);
}
#top-bar {
padding-top: env(safe-area-inset-top);
}
#top-bar:not(.top-0) {
top: calc((8em + env(safe-area-inset-top)) * -1);
}
select:invalid {
color: gray;
}
</style>
</head>
<body class="bg-gray-100 dark:bg-gray-800">
<main class="relative overflow-hidden h-[100dvh]">
<div
id="top-bar"
class="transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 w-full px-2"
>
<div class="max-h-[75vh] w-full flex flex-col items-center justify-around relative dark:text-white">
<div class="h-32">
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4">
<a href="#">
<svg
width="32"
height="32"
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M20.5355 3.46447C19.0711 2 16.714 2 12 2C7.28595 2 4.92893 2 3.46447 3.46447C2 4.92893 2 7.28595 2 12C2 16.714 2 19.0711 3.46447 20.5355C4.92893 22 7.28595 22 12 22C16.714 22 19.0711 22 20.5355 20.5355C22 19.0711 22 16.714 22 12C22 7.28595 22 4.92893 20.5355 3.46447ZM14.0303 8.46967C14.3232 8.76256 14.3232 9.23744 14.0303 9.53033L11.5607 12L14.0303 14.4697C14.3232 14.7626 14.3232 15.2374 14.0303 15.5303C13.7374 15.8232 13.2626 15.8232 12.9697 15.5303L9.96967 12.5303C9.82902 12.3897 9.75 12.1989 9.75 12C9.75 11.8011 9.82902 11.6103 9.96967 11.4697L12.9697 8.46967C13.2626 8.17678 13.7374 8.17678 14.0303 8.46967Z"
/>
</svg>
</a>
<svg
width="32"
height="32"
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100 close-top-bar"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22ZM8.96965 8.96967C9.26254 8.67678 9.73742 8.67678 10.0303 8.96967L12 10.9394L13.9696 8.96969C14.2625 8.6768 14.7374 8.6768 15.0303 8.96969C15.3232 9.26258 15.3232 9.73746 15.0303 10.0303L13.0606 12L15.0303 13.9697C15.3232 14.2625 15.3232 14.7374 15.0303 15.0303C14.7374 15.3232 14.2625 15.3232 13.9696 15.0303L12 13.0607L10.0303 15.0303C9.73744 15.3232 9.26256 15.3232 8.96967 15.0303C8.67678 14.7374 8.67678 14.2626 8.96967 13.9697L10.9393 12L8.96965 10.0303C8.67676 9.73744 8.67676 9.26256 8.96965 8.96967Z"
/>
</svg>
</div>
<div class="flex gap-10 h-full p-4 pl-14 rounded">
<div class="h-full my-auto relative">
<a href="#">
<img
class="rounded object-cover h-full"
src="/assets/images/no-cover.jpg"
/>
</a>
</div>
<div class="flex gap-7 justify-around dark:text-white text-sm">
<div class="flex flex-col gap-4">
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Title</p>
<p
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
>
"N/A"
</p>
</div>
</div>
<div class="inline-flex shrink-0 items-center">
<div>
<p class="text-gray-400">Author</p>
<p
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
>
"N/A"
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="toc" class="w-full text-center max-h-[50%] overflow-scroll no-scrollbar"></div>
</div>
</div>
<div
id="bottom-bar"
class="-bottom-28 transition-all duration-200 absolute z-10 bg-gray-100 dark:bg-gray-800 items-center flex w-full overflow-y-scroll snap-x snap-mandatory no-scrollbar"
>
<div
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
>
<div
class="flex flex-wrap gap-2 justify-around w-full dark:text-white pb-2"
>
<div class="flex justify-center gap-2 w-full md:w-fit">
<p class="text-gray-400 text-xs">Chapter:</p>
<p id="chapter-name-status" class="text-xs">N/A</p>
</div>
<div class="inline-flex gap-2">
<p class="text-gray-400 text-xs">Chapter Pages:</p>
<p id="chapter-status" class="text-xs">N/A</p>
</div>
<div class="inline-flex gap-2">
<p class="text-gray-400 text-xs">Progress:</p>
<p id="progress-status" class="text-xs">N/A</p>
</div>
</div>
<div class="w-[90%] h-2 rounded border border-gray-500">
<div
id="progress-bar-status"
class="w-0 bg-green-200 h-full rounded-l"
></div>
</div>
</div>
<div
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
>
<p class="text-gray-400">Theme</p>
<div class="flex justify-around w-full gap-4 p-2 text-sm">
<div
class="color-scheme cursor-pointer rounded border border-white bg-[#fff] text-[#000] grow text-center"
>
light
</div>
<div
class="color-scheme cursor-pointer rounded border border-white bg-[#d2b48c] text-[#333] grow text-center"
>
tan
</div>
<div
class="color-scheme cursor-pointer rounded border border-white bg-[#1f2937] text-[#fff] grow text-center"
>
blue
</div>
<div
class="color-scheme cursor-pointer rounded border border-white bg-[#232323] text-[#fff] grow text-center"
>
gray
</div>
<div
class="color-scheme cursor-pointer rounded border border-white bg-[#000] text-[#ccc] grow text-center"
>
black
</div>
</div>
</div>
<div
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
>
<p class="text-gray-400">Font</p>
<div class="flex justify-around w-full gap-4 p-2 text-sm">
<div
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
>
Serif
</div>
<div
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
>
Open Sans
</div>
<div
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
>
Arbutus Slab
</div>
<div
class="font-family cursor-pointer rounded border border-white grow text-center dark:text-white"
>
Lato
</div>
</div>
</div>
<div
class="items-center flex flex-col w-screen h-full flex-none snap-center p-2"
>
<p class="text-gray-400">Font Size</p>
<div class="flex justify-around w-full gap-4 p-2 text-sm">
<div
class="font-size cursor-pointer rounded border border-white grow text-center dark:text-white"
>
-
</div>
<div
class="font-size cursor-pointer rounded border border-white grow text-center dark:text-white"
>
+
</div>
</div>
</div>
</div>
<div id="viewer" class="w-full h-full"></div>
</main>
<!-- Device Selector -->
<div
id="device-selector"
class="hidden absolute top-0 left-0 w-full h-full z-50"
>
<div
class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"
></div>
<div
class="relative flex flex-col gap-4 p-4 max-h-[95%] w-5/6 md:w-1/2 bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 overflow-hidden shadow rounded"
>
<div class="text-center flex flex-col gap-2">
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">
Select Device
</h3>
<p class="text-xs text-gray-500 text-center">
This device appears to be new! Please either assume an existing
device, or create a new one.
</p>
</div>
<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"
>
<div class="flex gap-4 flex-col">
<div class="flex relative min-w-[12em]">
<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"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.65517 2.22732C5.2225 2.34037 4.9438 2.50021 4.72718 2.71244C4.42179 3.01165 4.22268 3.43172 4.11382 4.225C4.00176 5.04159 4 6.12387 4 7.67568V16.2442C4.38867 15.9781 4.82674 15.7756 5.29899 15.6517C5.41296 15.6217 5.53103 15.5983 5.65517 15.5799V2.22732Z"
/>
<path
d="M7.31034 15.5135C7.32206 15.5135 7.33382 15.5135 7.34563 15.5135L20 15.5135V7.67568C20 6.12387 19.9982 5.04159 19.8862 4.22499C19.7773 3.43172 19.5782 3.01165 19.2728 2.71244C18.9674 2.41324 18.5387 2.21816 17.729 2.11151C16.8955 2.00172 15.7908 2 14.2069 2H9.7931C8.79138 2 7.98133 2.00069 7.31034 2.02897V15.5135Z"
/>
<path
d="M7.47341 17.1351C6.39395 17.1351 6.01657 17.1421 5.72738 17.218C4.93365 17.4264 4.30088 18.0044 4.02952 18.7558C4.0463 19.1382 4.07259 19.4746 4.11382 19.775C4.22268 20.5683 4.42179 20.9884 4.72718 21.2876C5.03258 21.5868 5.46135 21.7818 6.27103 21.8885C7.10452 21.9983 8.2092 22 9.7931 22H14.2069C15.7908 22 16.8955 21.9983 17.729 21.8885C18.5387 21.7818 18.9674 21.5868 19.2728 21.2876C19.5782 20.9884 19.7773 20.5683 19.8862 19.775C19.9776 19.1088 19.9956 18.2657 19.9991 17.1351H7.47341Z"
/>
</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="source"
name="source"
required
>
<option value="" disabled selected hidden>
Select Existing Device
</option>
</select>
</div>
<button
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">Assume Device</span>
</button>
</div>
</div>
<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"
>
<div class="flex gap-4 flex-col">
<div class="flex flex-col w-full 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"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.65517 2.22732C5.2225 2.34037 4.9438 2.50021 4.72718 2.71244C4.42179 3.01165 4.22268 3.43172 4.11382 4.225C4.00176 5.04159 4 6.12387 4 7.67568V16.2442C4.38867 15.9781 4.82674 15.7756 5.29899 15.6517C5.41296 15.6217 5.53103 15.5983 5.65517 15.5799V2.22732Z"
/>
<path
d="M7.31034 15.5135C7.32206 15.5135 7.33382 15.5135 7.34563 15.5135L20 15.5135V7.67568C20 6.12387 19.9982 5.04159 19.8862 4.22499C19.7773 3.43172 19.5782 3.01165 19.2728 2.71244C18.9674 2.41324 18.5387 2.21816 17.729 2.11151C16.8955 2.00172 15.7908 2 14.2069 2H9.7931C8.79138 2 7.98133 2.00069 7.31034 2.02897V15.5135Z"
/>
<path
d="M7.47341 17.1351C6.39395 17.1351 6.01657 17.1421 5.72738 17.218C4.93365 17.4264 4.30088 18.0044 4.02952 18.7558C4.0463 19.1382 4.07259 19.4746 4.11382 19.775C4.22268 20.5683 4.42179 20.9884 4.72718 21.2876C5.03258 21.5868 5.46135 21.7818 6.27103 21.8885C7.10452 21.9983 8.2092 22 9.7931 22H14.2069C15.7908 22 16.8955 21.9983 17.729 21.8885C18.5387 21.7818 18.9674 21.5868 19.2728 21.2876C19.5782 20.9884 19.7773 20.5683 19.8862 19.775C19.9776 19.1088 19.9956 18.2657 19.9991 17.1351H7.47341Z"
/>
</svg>
</span>
<input
type="text"
id="name"
name="name"
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 Device Name"
/>
</div>
</div>
<button
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">Create Device</span>
</button>
</div>
</div>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

265
assets/sw.js Normal file
View File

@@ -0,0 +1,265 @@
// Misc Consts
const SW_VERSION = 1;
const SW_CACHE_NAME = "OFFLINE_V1";
// Message Types
const PURGE_SW_CACHE = "PURGE_SW_CACHE";
const DEL_SW_CACHE = "DEL_SW_CACHE";
const GET_SW_CACHE = "GET_SW_CACHE";
const GET_SW_VERSION = "GET_SW_VERSION";
// Cache Types
const CACHE_ONLY = "CACHE_ONLY";
const CACHE_NEVER = "CACHE_NEVER";
const CACHE_UPDATE_SYNC = "CACHE_UPDATE_SYNC";
const CACHE_UPDATE_ASYNC = "CACHE_UPDATE_ASYNC";
/**
* Define routes and their directives. Takes `routes`, `type`, and `fallback`.
*
* Routes (Required):
* Either a string of the exact request, or a RegExp. Order precedence.
*
* Fallback (Optional):
* A fallback function. If the request fails, this function is executed and
* its return value is returned as the result.
*
* Types (Required):
* - CACHE_ONLY
* Cache once & never refresh.
* - CACHE_NEVER
* Never cache & always perform a request.
* - CACHE_UPDATE_SYNC
* Update cache & return result.
* - CACHE_UPDATE_ASYNC
* Return cache if exists & update cache in background.
**/
const ROUTES = [
{ route: "/local", type: CACHE_UPDATE_ASYNC },
{ route: "/reader", type: CACHE_UPDATE_ASYNC },
{ route: "/manifest.json", type: CACHE_UPDATE_ASYNC },
{ route: /^\/assets\/reader\/fonts\//, type: CACHE_ONLY },
{ route: /^\/assets\//, type: CACHE_UPDATE_ASYNC },
{
route: /^\/documents\/[a-zA-Z0-9]{32}\/(cover|file)$/,
type: CACHE_UPDATE_ASYNC,
},
{
route: /^\/reader\/progress\/[a-zA-Z0-9]{32}$/,
type: CACHE_UPDATE_SYNC,
},
{
route: /.*/,
type: CACHE_NEVER,
fallback: (event) => caches.match("/local"),
},
];
/**
* These are assets that are cached on initial service worker installation.
**/
const PRECACHE_ASSETS = [
// Offline & Reader Assets
"/local",
"/reader",
"/assets/local/index.js",
"/assets/reader/index.js",
"/assets/reader/fonts.css",
"/assets/reader/themes.css",
"/assets/icons/icon512.png",
"/assets/images/no-cover.jpg",
// Main App Assets
"/manifest.json",
"/assets/index.js",
"/assets/style.css",
"/assets/common.js",
// Library Assets
"/assets/lib/jszip.min.js",
"/assets/lib/epub.min.js",
"/assets/lib/no-sleep.min.js",
"/assets/lib/idb-keyval.min.js",
// Fonts
"/assets/reader/fonts/arbutus-slab-v16-latin_latin-ext-regular.woff2",
"/assets/reader/fonts/lato-v24-latin_latin-ext-100.woff2",
"/assets/reader/fonts/lato-v24-latin_latin-ext-100italic.woff2",
"/assets/reader/fonts/lato-v24-latin_latin-ext-700.woff2",
"/assets/reader/fonts/lato-v24-latin_latin-ext-700italic.woff2",
"/assets/reader/fonts/lato-v24-latin_latin-ext-italic.woff2",
"/assets/reader/fonts/lato-v24-latin_latin-ext-regular.woff2",
"/assets/reader/fonts/open-sans-v36-latin_latin-ext-700.woff2",
"/assets/reader/fonts/open-sans-v36-latin_latin-ext-700italic.woff2",
"/assets/reader/fonts/open-sans-v36-latin_latin-ext-italic.woff2",
"/assets/reader/fonts/open-sans-v36-latin_latin-ext-regular.woff2",
];
// ------------------------------------------------------- //
// ----------------------- Helpers ----------------------- //
// ------------------------------------------------------- //
async function purgeCache() {
console.log("[purgeCache] Purging Cache");
return caches.keys().then(function (names) {
for (let name of names) caches.delete(name);
});
}
async function updateCache(request) {
let url = request.url ? new URL(request.url).pathname : request;
console.log("[updateCache] Updating Cache:", url);
let cache = await caches.open(SW_CACHE_NAME);
return fetch(request)
.then((response) => {
const resClone = response.clone();
if (response.status < 400) cache.put(request, resClone);
return response;
})
.catch((e) => {
console.log("[updateCache] Updating Cache Failed:", url);
throw e;
});
}
// ------------------------------------------------------- //
// ------------------- Event Listeners ------------------- //
// ------------------------------------------------------- //
async function handleFetch(event) {
// Get Path
let url = new URL(event.request.url).pathname;
// Find Directive
const directive = ROUTES.find(
(item) =>
(item.route instanceof RegExp && url.match(item.route)) ||
url == item.route,
) || { type: CACHE_NEVER };
// Get Fallback
const fallbackFunc = (event) => {
console.log("[handleFetch] Fallback:", { url, directive });
if (directive.fallback) return directive.fallback(event);
};
console.log("[handleFetch] Processing:", { url, directive });
// Get Current Cache
let currentCache = await caches.match(event.request);
// Perform Caching Method
switch (directive.type) {
case CACHE_NEVER:
return fetch(event.request).catch((e) => fallbackFunc(event));
case CACHE_ONLY:
return (
currentCache ||
updateCache(event.request).catch((e) => fallbackFunc(event))
);
case CACHE_UPDATE_SYNC:
return updateCache(event.request).catch(
(e) => currentCache || fallbackFunc(event),
);
case CACHE_UPDATE_ASYNC:
let newResponse = updateCache(event.request).catch((e) =>
fallbackFunc(event),
);
return currentCache || newResponse;
}
}
function handleMessage(event) {
console.log("[handleMessage] Received Message:", event.data);
let { id, data } = event.data;
if (data.type === GET_SW_VERSION) {
event.source.postMessage({ id, data: SW_VERSION });
} else if (data.type === PURGE_SW_CACHE) {
purgeCache()
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
.catch(() => event.source.postMessage({ id, data: "FAILURE" }));
} else if (data.type === GET_SW_CACHE) {
caches.open(SW_CACHE_NAME).then(async (cache) => {
let allKeys = await cache.keys();
// Get Cached Resources
let docResources = allKeys
.map((item) => new URL(item.url).pathname)
.filter(
(item) =>
item.startsWith("/documents/") ||
item.startsWith("/reader/progress/"),
);
// Derive Unique IDs
let documentIDs = Array.from(
new Set(
docResources
.filter((item) => item.startsWith("/documents/"))
.map((item) => item.split("/")[2]),
),
);
/**
* Filter for cached items only. Attempt to fetch updated result. If
* failure, return cached version. This ensures we return the most up to
* date version possible.
**/
let cachedDocuments = await Promise.all(
documentIDs
.filter(
(id) =>
docResources.includes("/documents/" + id + "/file") &&
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 });
});
} else if (data.type === DEL_SW_CACHE) {
caches
.open(SW_CACHE_NAME)
.then((cache) =>
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" }));
} else {
event.source.postMessage({ id, data: { pong: 1 } });
}
}
async function handleInstall(event) {
let cache = await caches.open(SW_CACHE_NAME);
return cache.addAll(PRECACHE_ASSETS);
}
self.addEventListener("message", handleMessage);
self.addEventListener("install", function (event) {
event.waitUntil(handleInstall(event));
});
self.addEventListener("fetch", (event) => {
/**
* Weird things happen when a service worker attempts to handle a request
* when the server responds with chunked transfer encoding. Right now we only
* use chunked encoding on POSTs. So this is to avoid processing those.
**/
if (event.request.method != "GET") return;
return event.respondWith(handleFetch(event));
});

BIN
banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
banner.xcf Normal file

Binary file not shown.

View File

@@ -1,6 +1,6 @@
# Book Manager - SyncNinja KOReader Plugin
# AnthoLume - SyncNinja KOReader Plugin
This is BookManagers KOReader Plugin called `syncninja.koplugin`. Features include:
This is AnthoLume's KOReader Plugin called `syncninja.koplugin`. Features include:
- Syncing read activity
- Uploading documents
@@ -12,10 +12,10 @@ Copy the `syncninja.koplugin` directory to the `plugins` directory for your KORe
## 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.
You must configure the AnthoLume 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
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.
AnthoLume 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.

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,14 @@
package config
import (
"fmt"
"os"
"path"
"path/filepath"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
)
type Config struct {
@@ -21,27 +27,101 @@ type Config struct {
// Miscellaneous Settings
RegistrationEnabled bool
SearchEnabled bool
DemoMode bool
LogLevel string
// Cookie Settings
CookieSessionKey string
CookieSecure bool
CookieHTTPOnly bool
CookieAuthKey string
CookieEncKey string
CookieSecure bool
CookieHTTPOnly bool
}
type customFormatter struct {
log.Formatter
}
// Force UTC & Set type (app)
func (cf customFormatter) Format(e *log.Entry) ([]byte, error) {
if e.Data["type"] == nil {
e.Data["type"] = "app"
}
e.Time = e.Time.UTC()
return cf.Formatter.Format(e)
}
// Set at runtime
var version string = "develop"
func Load() *Config {
return &Config{
Version: "0.0.2",
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
DBName: trimLowerString(getEnv("DATABASE_NAME", "book_manager")),
c := &Config{
Version: version,
ConfigPath: getEnv("CONFIG_PATH", "/config"),
DataPath: getEnv("DATA_PATH", "/data"),
ListenPort: getEnv("LISTEN_PORT", "8585"),
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")),
RegistrationEnabled: trimLowerString(getEnv("REGISTRATION_ENABLED", "false")) == "true",
DemoMode: trimLowerString(getEnv("DEMO_MODE", "false")) == "true",
SearchEnabled: trimLowerString(getEnv("SEARCH_ENABLED", "false")) == "true",
CookieSessionKey: trimLowerString(getEnv("COOKIE_SESSION_KEY", "")),
CookieAuthKey: trimLowerString(getEnv("COOKIE_AUTH_KEY", "")),
CookieEncKey: trimLowerString(getEnv("COOKIE_ENC_KEY", "")),
LogLevel: trimLowerString(getEnv("LOG_LEVEL", "info")),
CookieSecure: trimLowerString(getEnv("COOKIE_SECURE", "true")) == "true",
CookieHTTPOnly: trimLowerString(getEnv("COOKIE_HTTP_ONLY", "true")) == "true",
}
// Parse log level
logLevel, err := log.ParseLevel(c.LogLevel)
if err != nil {
logLevel = log.InfoLevel
}
// Create custom formatter
logFormatter := &customFormatter{&log.JSONFormatter{
CallerPrettyfier: prettyCaller,
}}
// Create log rotator
rotateFileHook, err := NewRotateFileHook(RotateFileConfig{
Filename: path.Join(c.ConfigPath, "logs/antholume.log"),
MaxSize: 50,
MaxBackups: 3,
MaxAge: 30,
Level: logLevel,
Formatter: logFormatter,
})
if err != nil {
log.Fatal("Unable to initialize file rotate hook")
}
// Rotate now
rotateFileHook.Rotate()
// Set logger settings
log.SetLevel(logLevel)
log.SetFormatter(logFormatter)
log.SetReportCaller(true)
log.AddHook(rotateFileHook)
// Ensure directories exist
c.EnsureDirectories()
return c
}
// Ensures needed directories exist
func (c *Config) EnsureDirectories() {
os.Mkdir(c.ConfigPath, 0755)
os.Mkdir(c.DataPath, 0755)
docDir := filepath.Join(c.DataPath, "documents")
coversDir := filepath.Join(c.DataPath, "covers")
backupDir := filepath.Join(c.DataPath, "backups")
os.Mkdir(docDir, 0755)
os.Mkdir(coversDir, 0755)
os.Mkdir(backupDir, 0755)
}
func getEnv(key, fallback string) string {
@@ -54,3 +134,24 @@ func getEnv(key, fallback string) string {
func trimLowerString(val string) string {
return strings.ToLower(strings.TrimSpace(val))
}
func prettyCaller(f *runtime.Frame) (function string, file string) {
purgePrefix := "reichard.io/antholume/"
pathName := strings.Replace(f.Func.Name(), purgePrefix, "", 1)
parts := strings.Split(pathName, ".")
filepath, line := f.Func.FileLine(f.PC)
splitFilePath := strings.Split(filepath, "/")
fileName := fmt.Sprintf("%s/%s@%d", parts[0], splitFilePath[len(splitFilePath)-1], line)
functionName := strings.Replace(pathName, parts[0]+".", "", 1)
// Exclude GIN Logger
if functionName == "NewApi.apiLogger.func1" {
fileName = ""
functionName = ""
}
return functionName, fileName
}

View File

@@ -1,35 +1,37 @@
package config
import "testing"
import (
"runtime"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoadConfig(t *testing.T) {
conf := Load()
want := "sqlite"
if conf.DBType != want {
t.Fatalf(`Load().DBType = %q, want match for %#q, nil`, conf.DBType, want)
}
assert.Equal(t, "sqlite", conf.DBType)
}
func TestGetEnvDefault(t *testing.T) {
want := "def_val"
envDefault := getEnv("DEFAULT_TEST", want)
if envDefault != want {
t.Fatalf(`getEnv("DEFAULT_TEST", "def_val") = %q, want match for %#q, nil`, envDefault, want)
}
}
desiredValue := "def_val"
envDefault := getEnv("DEFAULT_TEST", desiredValue)
func TestGetEnvSet(t *testing.T) {
envDefault := getEnv("SET_TEST", "not_this")
want := "set_val"
if envDefault != want {
t.Fatalf(`getEnv("SET_TEST", "not_this") = %q, want match for %#q, nil`, envDefault, want)
}
assert.Equal(t, desiredValue, envDefault)
}
func TestTrimLowerString(t *testing.T) {
want := "trimtest"
output := trimLowerString(" trimTest ")
if output != want {
t.Fatalf(`trimLowerString(" trimTest ") = %q, want match for %#q, nil`, output, want)
}
desiredValue := "trimtest"
outputValue := trimLowerString(" trimTest ")
assert.Equal(t, desiredValue, outputValue)
}
func TestPrettyCaller(t *testing.T) {
p, _, _, _ := runtime.Caller(0)
result := runtime.CallersFrames([]uintptr{p})
f, _ := result.Next()
functionName, fileName := prettyCaller(&f)
assert.Equal(t, "TestPrettyCaller", functionName, "should have current function name")
assert.Equal(t, "config/config_test.go@30", fileName, "should have current file path and line number")
}

54
config/logger.go Normal file
View File

@@ -0,0 +1,54 @@
package config
import (
"github.com/sirupsen/logrus"
"gopkg.in/natefinch/lumberjack.v2"
)
// Modified "snowzach/rotatefilehook" to support manual rotation
type RotateFileConfig struct {
Filename string
MaxSize int
MaxBackups int
MaxAge int
Compress bool
Level logrus.Level
Formatter logrus.Formatter
}
type RotateFileHook struct {
Config RotateFileConfig
logWriter *lumberjack.Logger
}
func NewRotateFileHook(config RotateFileConfig) (*RotateFileHook, error) {
hook := RotateFileHook{
Config: config,
}
hook.logWriter = &lumberjack.Logger{
Filename: config.Filename,
MaxSize: config.MaxSize,
MaxBackups: config.MaxBackups,
MaxAge: config.MaxAge,
Compress: config.Compress,
}
return &hook, nil
}
func (hook *RotateFileHook) Rotate() error {
return hook.logWriter.Rotate()
}
func (hook *RotateFileHook) Levels() []logrus.Level {
return logrus.AllLevels[:hook.Config.Level+1]
}
func (hook *RotateFileHook) Fire(entry *logrus.Entry) (err error) {
b, err := hook.Config.Formatter.Format(entry)
if err != nil {
return err
}
hook.logWriter.Write(b)
return nil
}

View File

@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.21.0
// sqlc v1.29.0
package database

View File

@@ -0,0 +1,151 @@
WITH grouped_activity AS (
SELECT
ga.user_id,
ga.document_id,
MAX(ga.created_at) AS created_at,
MAX(ga.start_time) AS start_time,
MIN(ga.start_percentage) AS start_percentage,
MAX(ga.end_percentage) AS end_percentage,
-- Total Duration & Percentage
SUM(ga.duration) AS total_time_seconds,
SUM(ga.end_percentage - ga.start_percentage) AS total_read_percentage,
-- Yearly Duration
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-1 year')
THEN ga.duration
ELSE 0
END
)
AS yearly_time_seconds,
-- Yearly Percentage
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-1 year')
THEN ga.end_percentage - ga.start_percentage
ELSE 0
END
)
AS yearly_read_percentage,
-- Monthly Duration
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-1 month')
THEN ga.duration
ELSE 0
END
)
AS monthly_time_seconds,
-- Monthly Percentage
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-1 month')
THEN ga.end_percentage - ga.start_percentage
ELSE 0
END
)
AS monthly_read_percentage,
-- Weekly Duration
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-7 days')
THEN ga.duration
ELSE 0
END
)
AS weekly_time_seconds,
-- Weekly Percentage
SUM(
CASE
WHEN
ga.start_time >= DATE('now', '-7 days')
THEN ga.end_percentage - ga.start_percentage
ELSE 0
END
)
AS weekly_read_percentage
FROM activity AS ga
GROUP BY ga.user_id, ga.document_id
),
current_progress AS (
SELECT
user_id,
document_id,
COALESCE((
SELECT dp.percentage
FROM document_progress AS dp
WHERE
dp.user_id = iga.user_id
AND dp.document_id = iga.document_id
ORDER BY dp.created_at DESC
LIMIT 1
), end_percentage) AS percentage
FROM grouped_activity AS iga
)
INSERT INTO document_user_statistics
SELECT
ga.document_id,
ga.user_id,
cp.percentage,
MAX(ga.start_time) AS last_read,
MAX(ga.created_at) AS last_seen,
SUM(ga.total_read_percentage) AS read_percentage,
-- All Time WPM
SUM(ga.total_time_seconds) AS total_time_seconds,
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(ga.total_read_percentage))
AS total_words_read,
(CAST(COALESCE(d.words, 0.0) AS REAL) * SUM(ga.total_read_percentage))
/ (SUM(ga.total_time_seconds) / 60.0) AS total_wpm,
-- Yearly WPM
ga.yearly_time_seconds,
CAST(COALESCE(d.words, 0.0) AS REAL) * ga.yearly_read_percentage
AS yearly_words_read,
COALESCE(
(CAST(COALESCE(d.words, 0.0) AS REAL) * ga.yearly_read_percentage)
/ (ga.yearly_time_seconds / 60), 0.0)
AS yearly_wpm,
-- Monthly WPM
ga.monthly_time_seconds,
CAST(COALESCE(d.words, 0.0) AS REAL) * ga.monthly_read_percentage
AS monthly_words_read,
COALESCE(
(CAST(COALESCE(d.words, 0.0) AS REAL) * ga.monthly_read_percentage)
/ (ga.monthly_time_seconds / 60), 0.0)
AS monthly_wpm,
-- Weekly WPM
ga.weekly_time_seconds,
CAST(COALESCE(d.words, 0.0) AS REAL) * ga.weekly_read_percentage
AS weekly_words_read,
COALESCE(
(CAST(COALESCE(d.words, 0.0) AS REAL) * ga.weekly_read_percentage)
/ (ga.weekly_time_seconds / 60), 0.0)
AS weekly_wpm
FROM grouped_activity AS ga
INNER JOIN
current_progress AS cp
ON ga.user_id = cp.user_id AND ga.document_id = cp.document_id
INNER JOIN
documents AS d
ON ga.document_id = d.id
GROUP BY ga.document_id, ga.user_id
ORDER BY total_wpm DESC;

27
database/documents.go Normal file
View File

@@ -0,0 +1,27 @@
package database
import (
"context"
"fmt"
"reichard.io/antholume/pkg/ptr"
"reichard.io/antholume/pkg/sliceutils"
)
func (d *DBManager) GetDocument(ctx context.Context, docID, userID string) (*GetDocumentsWithStatsRow, error) {
documents, err := d.Queries.GetDocumentsWithStats(ctx, GetDocumentsWithStatsParams{
ID: ptr.Of(docID),
UserID: userID,
Limit: 1,
})
if err != nil {
return nil, err
}
document, found := sliceutils.First(documents)
if !found {
return nil, fmt.Errorf("document not found: %s", docID)
}
return &document, nil
}

115
database/documents_test.go Normal file
View File

@@ -0,0 +1,115 @@
package database
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/suite"
"reichard.io/antholume/config"
)
type DocumentsTestSuite struct {
suite.Suite
dbm *DBManager
}
func TestDocuments(t *testing.T) {
suite.Run(t, new(DocumentsTestSuite))
}
func (suite *DocumentsTestSuite) SetupTest() {
cfg := config.Config{
DBType: "memory",
}
suite.dbm = NewMgr(&cfg)
// Create Document
_, err := suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: documentID,
Title: &documentTitle,
Author: &documentAuthor,
Words: &documentWords,
})
suite.NoError(err)
}
// DOCUMENT - TODO:
// - 󰊕 (q *Queries) GetDocumentProgress
// - 󰊕 (q *Queries) GetDocumentWithStats
// - 󰊕 (q *Queries) GetDocumentsSize
// - 󰊕 (q *Queries) GetDocumentsWithStats
// - 󰊕 (q *Queries) GetMissingDocuments
func (suite *DocumentsTestSuite) TestGetDocument() {
doc, err := suite.dbm.Queries.GetDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err")
suite.Equal(documentID, doc.ID, "should have changed the document")
}
func (suite *DocumentsTestSuite) TestUpsertDocument() {
testDocID := "docid1"
doc, err := suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: testDocID,
Title: &documentTitle,
Author: &documentAuthor,
})
suite.Nil(err, "should have nil err")
suite.Equal(testDocID, doc.ID, "should have document id")
suite.Equal(documentTitle, *doc.Title, "should have document title")
suite.Equal(documentAuthor, *doc.Author, "should have document author")
}
func (suite *DocumentsTestSuite) TestDeleteDocument() {
changed, err := suite.dbm.Queries.DeleteDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed, "should have changed the document")
doc, err := suite.dbm.Queries.GetDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err")
suite.True(doc.Deleted, "should have deleted the document")
}
func (suite *DocumentsTestSuite) TestGetDeletedDocuments() {
changed, err := suite.dbm.Queries.DeleteDocument(context.Background(), documentID)
suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed, "should have changed the document")
deletedDocs, err := suite.dbm.Queries.GetDeletedDocuments(context.Background(), []string{documentID})
suite.Nil(err, "should have nil err")
suite.Len(deletedDocs, 1, "should have one deleted document")
}
// TODO - Convert GetWantedDocuments -> (sqlc.slice('document_ids'));
func (suite *DocumentsTestSuite) TestGetWantedDocuments() {
wantedDocs, err := suite.dbm.Queries.GetWantedDocuments(context.Background(), fmt.Sprintf("[\"%s\"]", documentID))
suite.Nil(err, "should have nil err")
suite.Len(wantedDocs, 1, "should have one wanted document")
}
func (suite *DocumentsTestSuite) TestGetMissingDocuments() {
// Create Document
_, err := suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: documentID,
Filepath: &documentFilepath,
})
suite.NoError(err)
missingDocs, err := suite.dbm.Queries.GetMissingDocuments(context.Background(), []string{documentID})
suite.Nil(err, "should have nil err")
suite.Len(missingDocs, 0, "should have no wanted document")
missingDocs, err = suite.dbm.Queries.GetMissingDocuments(context.Background(), []string{"other"})
suite.Nil(err, "should have nil err")
suite.Len(missingDocs, 1, "should have one missing document")
suite.Equal(documentID, missingDocs[0].ID, "should have missing doc")
// TODO - https://github.com/sqlc-dev/sqlc/issues/3451
// missingDocs, err = suite.dbm.Queries.GetMissingDocuments(context.Background(), []string{})
// suite.Nil(err, "should have nil err")
// suite.Len(missingDocs, 1, "should have one missing document")
// suite.Equal(documentID, missingDocs[0].ID, "should have missing doc")
}

View File

@@ -3,62 +3,256 @@ package database
import (
"context"
"database/sql"
_ "embed"
"database/sql/driver"
"embed"
"errors"
"fmt"
"path/filepath"
"time"
"github.com/pressly/goose/v3"
log "github.com/sirupsen/logrus"
_ "modernc.org/sqlite"
"path"
"reichard.io/bbank/config"
sqlite "modernc.org/sqlite"
"reichard.io/antholume/config"
_ "reichard.io/antholume/database/migrations"
)
type DBManager struct {
DB *sql.DB
Ctx context.Context
Queries *Queries
cfg *config.Config
}
//go:embed schema.sql
var ddl string
//go:embed update_temp_tables.sql
var tsql string
//go:embed user_streaks.sql
var user_streaks string
//go:embed document_user_statistics.sql
var document_user_statistics string
//go:embed migrations/*
var migrations embed.FS
// Register scalar sqlite function on init
func init() {
sqlite.MustRegisterFunction("LOCAL_TIME", &sqlite.FunctionImpl{
NArgs: 2,
Deterministic: true,
Scalar: localTime,
})
sqlite.MustRegisterFunction("LOCAL_DATE", &sqlite.FunctionImpl{
NArgs: 2,
Deterministic: true,
Scalar: localDate,
})
}
// NewMgr Returns an initialized manager
func NewMgr(c *config.Config) *DBManager {
// Create Manager
dbm := &DBManager{
Ctx: context.Background(),
dbm := &DBManager{cfg: c}
if err := dbm.init(context.Background()); err != nil {
log.Panic("Unable to init DB")
}
// Create Database
if c.DBType == "sqlite" || c.DBType == "memory" {
var dbLocation string = ":memory:"
if c.DBType == "sqlite" {
dbLocation = path.Join(c.ConfigPath, fmt.Sprintf("%s.db", c.DBName))
}
var err error
dbm.DB, err = sql.Open("sqlite", dbLocation)
if err != nil {
log.Fatal(err)
}
// Single Open Connection
dbm.DB.SetMaxOpenConns(1)
if _, err := dbm.DB.Exec(ddl, nil); err != nil {
log.Info("Exec Error:", err)
}
} else {
log.Fatal("Unsupported Database")
}
dbm.Queries = New(dbm.DB)
return dbm
}
func (dbm *DBManager) CacheTempTables() error {
if _, err := dbm.DB.ExecContext(dbm.Ctx, tsql); err != nil {
// init loads the DB manager
func (dbm *DBManager) init(ctx context.Context) error {
// Build DB Location
var dbLocation string
switch dbm.cfg.DBType {
case "sqlite":
dbLocation = filepath.Join(dbm.cfg.ConfigPath, fmt.Sprintf("%s.db", dbm.cfg.DBName))
case "memory":
dbLocation = ":memory:"
default:
return fmt.Errorf("unsupported database")
}
var err error
dbm.DB, err = sql.Open("sqlite", dbLocation)
if err != nil {
log.Panicf("Unable to open DB: %v", err)
return err
}
// Single open connection
dbm.DB.SetMaxOpenConns(1)
// Check if DB is new
isNew, err := isEmpty(dbm.DB)
if err != nil {
log.Panicf("Unable to determine db info: %v", err)
return err
}
// Init SQLc
dbm.Queries = New(dbm.DB)
// Execute schema
if _, err := dbm.DB.Exec(ddl, nil); err != nil {
log.Panicf("Error executing schema: %v", err)
return err
}
// Perform migrations
err = dbm.performMigrations(isNew)
if err != nil && err != goose.ErrNoMigrationFiles {
log.Panicf("Error running DB migrations: %v", err)
return err
}
// Update settings
err = dbm.updateSettings(ctx)
if err != nil {
log.Panicf("Error running DB settings update: %v", err)
return err
}
// Cache tables
if err := dbm.CacheTempTables(ctx); err != nil {
log.Warn("Refreshing temp table cache failed: ", err)
}
return nil
}
// Reload closes the DB & reinits
func (dbm *DBManager) Reload(ctx context.Context) error {
// Close handle
err := dbm.DB.Close()
if err != nil {
return err
}
// Reinit DB
if err := dbm.init(ctx); err != nil {
return err
}
return nil
}
// CacheTempTables clears existing statistics and recalculates
func (dbm *DBManager) CacheTempTables(ctx context.Context) error {
start := time.Now()
if _, err := dbm.DB.ExecContext(ctx, user_streaks); err != nil {
return err
}
log.Debug("Cached 'user_streaks' in: ", time.Since(start))
start = time.Now()
if _, err := dbm.DB.ExecContext(ctx, document_user_statistics); err != nil {
return err
}
log.Debug("Cached 'document_user_statistics' in: ", time.Since(start))
return nil
}
// updateSettings ensures that we're enforcing foreign keys and enable journal
// mode.
func (dbm *DBManager) updateSettings(ctx context.Context) error {
// Set SQLite PRAGMA Settings
pragmaQuery := `
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
`
if _, err := dbm.DB.Exec(pragmaQuery, nil); err != nil {
log.Errorf("Error executing pragma: %v", err)
return err
}
// Update Antholume Version in DB
if _, err := dbm.Queries.UpdateSettings(ctx, UpdateSettingsParams{
Name: "version",
Value: dbm.cfg.Version,
}); err != nil {
log.Errorf("Error updating DB settings: %v", err)
return err
}
return nil
}
// performMigrations runs all migrations
func (dbm *DBManager) performMigrations(isNew bool) error {
// Create context
ctx := context.WithValue(context.Background(), "isNew", isNew) // nolint
// Set DB migration
goose.SetBaseFS(migrations)
// Run migrations
goose.SetLogger(log.StandardLogger())
if err := goose.SetDialect("sqlite"); err != nil {
return err
}
return goose.UpContext(ctx, dbm.DB, "migrations")
}
// isEmpty determines whether the database is empty
func isEmpty(db *sql.DB) (bool, error) {
var tableCount int
err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table';").Scan(&tableCount)
if err != nil {
return false, err
}
return tableCount == 0, nil
}
// localTime is a custom SQL function that is registered as LOCAL_TIME in the init function
func localTime(ctx *sqlite.FunctionContext, args []driver.Value) (driver.Value, error) {
timeStr, ok := args[0].(string)
if !ok {
return nil, errors.New("both arguments to TZTime must be strings")
}
timeZoneStr, ok := args[1].(string)
if !ok {
return nil, errors.New("both arguments to TZTime must be strings")
}
timeZone, err := time.LoadLocation(timeZoneStr)
if err != nil {
return nil, errors.New("unable to parse timezone")
}
formattedTime, err := time.ParseInLocation(time.RFC3339, timeStr, time.UTC)
if err != nil {
return nil, errors.New("unable to parse time")
}
return formattedTime.In(timeZone).Format(time.RFC3339), nil
}
// localDate is a custom SQL function that is registered as LOCAL_DATE in the init function
func localDate(ctx *sqlite.FunctionContext, args []driver.Value) (driver.Value, error) {
timeStr, ok := args[0].(string)
if !ok {
return nil, errors.New("both arguments to TZTime must be strings")
}
timeZoneStr, ok := args[1].(string)
if !ok {
return nil, errors.New("both arguments to TZTime must be strings")
}
timeZone, err := time.LoadLocation(timeZoneStr)
if err != nil {
return nil, errors.New("unable to parse timezone")
}
formattedTime, err := time.ParseInLocation(time.RFC3339, timeStr, time.UTC)
if err != nil {
return nil, errors.New("unable to parse time")
}
return formattedTime.In(timeZone).Format("2006-01-02"), nil
}

View File

@@ -1,213 +1,171 @@
package database
import (
"context"
"fmt"
"testing"
"time"
"reichard.io/bbank/config"
"github.com/stretchr/testify/suite"
"reichard.io/antholume/config"
"reichard.io/antholume/utils"
)
type databaseTest struct {
*testing.T
var (
userID string = "testUser"
userPass string = "testPass"
deviceID string = "testDevice"
deviceName string = "testDeviceName"
documentID string = "testDocument"
documentTitle string = "testTitle"
documentAuthor string = "testAuthor"
documentFilepath string = "./testPath.epub"
documentWords int64 = 5000
)
type DatabaseTestSuite struct {
suite.Suite
dbm *DBManager
}
var userID string = "testUser"
var userPass string = "testPass"
var deviceID string = "testDevice"
var deviceName string = "testDeviceName"
var documentID string = "testDocument"
var documentTitle string = "testTitle"
var documentAuthor string = "testAuthor"
func TestDatabase(t *testing.T) {
suite.Run(t, new(DatabaseTestSuite))
}
func TestNewMgr(t *testing.T) {
// PROGRESS - TODO:
// - 󰊕 (q *Queries) GetProgress
// - 󰊕 (q *Queries) UpdateProgress
func (suite *DatabaseTestSuite) SetupTest() {
cfg := config.Config{
DBType: "memory",
}
dbm := NewMgr(&cfg)
if dbm == nil {
t.Fatalf(`Expected: *DBManager, Got: nil`)
suite.dbm = NewMgr(&cfg)
// Create User
rawAuthHash, _ := utils.GenerateToken(64)
authHash := fmt.Sprintf("%x", rawAuthHash)
_, err := suite.dbm.Queries.CreateUser(context.Background(), CreateUserParams{
ID: userID,
Pass: &userPass,
AuthHash: &authHash,
})
suite.NoError(err)
// Create Document
_, err = suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: documentID,
Title: &documentTitle,
Author: &documentAuthor,
Filepath: &documentFilepath,
Words: &documentWords,
})
suite.NoError(err)
// Create Device
_, err = suite.dbm.Queries.UpsertDevice(context.Background(), UpsertDeviceParams{
ID: deviceID,
UserID: userID,
DeviceName: deviceName,
})
suite.NoError(err)
// Create Activity
end := time.Now()
start := end.AddDate(0, 0, -9)
var counter int64 = 0
for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) {
counter += 1
// Add Item
activity, err := suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{
DocumentID: documentID,
DeviceID: deviceID,
UserID: userID,
StartTime: d.UTC().Format(time.RFC3339),
Duration: 60,
StartPercentage: float64(counter) / 100.0,
EndPercentage: float64(counter+1) / 100.0,
})
suite.Nil(err, fmt.Sprintf("[%d] should have nil err for add activity", counter))
suite.Equal(counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter))
}
t.Run("Database", func(t *testing.T) {
dt := databaseTest{t, dbm}
dt.TestUser()
dt.TestDocument()
dt.TestDevice()
dt.TestActivity()
dt.TestDailyReadStats()
})
// Initiate Cache
err = suite.dbm.CacheTempTables(context.Background())
suite.NoError(err)
}
func (dt *databaseTest) TestUser() {
dt.Run("User", func(t *testing.T) {
changed, err := dt.dbm.Queries.CreateUser(dt.dbm.Ctx, CreateUserParams{
ID: userID,
Pass: &userPass,
})
if err != nil || changed != 1 {
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, 1, changed, err)
}
user, err := dt.dbm.Queries.GetUser(dt.dbm.Ctx, userID)
if err != nil || *user.Pass != userPass {
t.Fatalf(`Expected: %v, Got: %v, Error: %v`, userPass, *user.Pass, err)
}
// DEVICES - TODO:
// - 󰊕 (q *Queries) GetDevice
// - 󰊕 (q *Queries) GetDevices
// - 󰊕 (q *Queries) UpsertDevice
func (suite *DatabaseTestSuite) TestDevice() {
testDevice := "dev123"
device, err := suite.dbm.Queries.UpsertDevice(context.Background(), UpsertDeviceParams{
ID: testDevice,
UserID: userID,
DeviceName: deviceName,
})
suite.Nil(err, "should have nil err")
suite.Equal(testDevice, device.ID, "should have device id")
suite.Equal(userID, device.UserID, "should have user id")
suite.Equal(deviceName, device.DeviceName, "should have device name")
}
func (dt *databaseTest) TestDocument() {
dt.Run("Document", func(t *testing.T) {
doc, err := dt.dbm.Queries.UpsertDocument(dt.dbm.Ctx, UpsertDocumentParams{
ID: documentID,
Title: &documentTitle,
Author: &documentAuthor,
})
if err != nil {
t.Fatalf(`Expected: Document, Got: %v, Error: %v`, doc, err)
}
if doc.ID != documentID {
t.Fatalf(`Expected: %v, Got: %v`, documentID, doc.ID)
}
if *doc.Title != documentTitle {
t.Fatalf(`Expected: %v, Got: %v`, documentTitle, *doc.Title)
}
if *doc.Author != documentAuthor {
t.Fatalf(`Expected: %v, Got: %v`, documentAuthor, *doc.Author)
}
// ACTIVITY - TODO:
// - 󰊕 (q *Queries) AddActivity
// - 󰊕 (q *Queries) GetActivity
// - 󰊕 (q *Queries) GetLastActivity
func (suite *DatabaseTestSuite) TestActivity() {
// Validate Exists
existsRows, err := suite.dbm.Queries.GetActivity(context.Background(), GetActivityParams{
UserID: userID,
Offset: 0,
Limit: 50,
})
suite.Nil(err, "should have nil err for get activity")
suite.Len(existsRows, 10, "should have correct number of rows get activity")
// Validate Doesn't Exist
doesntExistsRows, err := suite.dbm.Queries.GetActivity(context.Background(), GetActivityParams{
UserID: userID,
DocumentID: "unknownDoc",
DocFilter: true,
Offset: 0,
Limit: 50,
})
suite.Nil(err, "should have nil err for get activity")
suite.Len(doesntExistsRows, 0, "should have no rows")
}
func (dt *databaseTest) TestDevice() {
dt.Run("Device", func(t *testing.T) {
device, err := dt.dbm.Queries.UpsertDevice(dt.dbm.Ctx, UpsertDeviceParams{
ID: deviceID,
UserID: userID,
DeviceName: deviceName,
})
// MISC - TODO:
// - 󰊕 (q *Queries) AddMetadata
// - 󰊕 (q *Queries) GetDailyReadStats
// - 󰊕 (q *Queries) GetDatabaseInfo
// - 󰊕 (q *Queries) UpdateSettings
func (suite *DatabaseTestSuite) TestGetDailyReadStats() {
readStats, err := suite.dbm.Queries.GetDailyReadStats(context.Background(), userID)
if err != nil {
t.Fatalf(`Expected: Device, Got: %v, Error: %v`, device, err)
}
suite.Nil(err, "should have nil err")
suite.Len(readStats, 30, "should have length of 30")
if device.ID != deviceID {
t.Fatalf(`Expected: %v, Got: %v`, deviceID, device.ID)
}
// Validate 1 Minute / Day - Last 10 Days
for i := 0; i < 10; i++ {
stat := readStats[i]
suite.Equal(int64(1), stat.MinutesRead, "should have one minute read")
}
if device.UserID != userID {
t.Fatalf(`Expected: %v, Got: %v`, userID, device.UserID)
}
if device.DeviceName != deviceName {
t.Fatalf(`Expected: %v, Got: %v`, deviceName, device.DeviceName)
}
})
}
func (dt *databaseTest) TestActivity() {
dt.Run("Progress", func(t *testing.T) {
// 10 Activities, 10 Days
end := time.Now()
start := end.AddDate(0, 0, -9)
var counter int64 = 0
for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) {
counter += 1
// Add Item
activity, err := dt.dbm.Queries.AddActivity(dt.dbm.Ctx, AddActivityParams{
DocumentID: documentID,
DeviceID: deviceID,
UserID: userID,
StartTime: d.UTC().Format(time.RFC3339),
Duration: 60,
Page: counter,
Pages: 100,
})
// Validate No Error
if err != nil {
t.Fatalf(`expected: rawactivity, got: %v, error: %v`, activity, err)
}
// Validate Auto Increment Working
if activity.ID != counter {
t.Fatalf(`Expected: %v, Got: %v`, counter, activity.ID)
}
}
// Initiate Cache
if err := dt.dbm.CacheTempTables(); err != nil {
t.Fatalf(`Error: %v`, err)
}
// Validate Exists
existsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{
UserID: userID,
Offset: 0,
Limit: 50,
})
if err != nil {
t.Fatalf(`Expected: []GetActivityRow, Got: %v, Error: %v`, existsRows, err)
}
if len(existsRows) != 10 {
t.Fatalf(`Expected: %v, Got: %v`, 10, len(existsRows))
}
// Validate Doesn't Exist
doesntExistsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{
UserID: userID,
DocumentID: "unknownDoc",
DocFilter: true,
Offset: 0,
Limit: 50,
})
if err != nil {
t.Fatalf(`Expected: []GetActivityRow, Got: %v, Error: %v`, doesntExistsRows, err)
}
if len(doesntExistsRows) != 0 {
t.Fatalf(`Expected: %v, Got: %v`, 0, len(doesntExistsRows))
}
})
}
func (dt *databaseTest) TestDailyReadStats() {
dt.Run("DailyReadStats", func(t *testing.T) {
readStats, err := dt.dbm.Queries.GetDailyReadStats(dt.dbm.Ctx, userID)
if err != nil {
t.Fatalf(`Expected: []GetDailyReadStatsRow, Got: %v, Error: %v`, readStats, err)
}
// Validate 30 Days Stats
if len(readStats) != 30 {
t.Fatalf(`Expected: %v, Got: %v`, 30, len(readStats))
}
// Validate 1 Minute / Day - Last 10 Days
for i := 0; i < 10; i++ {
stat := readStats[i]
if stat.MinutesRead != 1 {
t.Fatalf(`Day: %v, Expected: %v, Got: %v`, stat.Date, 1, stat.MinutesRead)
}
}
// Validate 0 Minute / Day - Remaining 20 Days
for i := 10; i < 30; i++ {
stat := readStats[i]
if stat.MinutesRead != 0 {
t.Fatalf(`Day: %v, Expected: %v, Got: %v`, stat.Date, 0, stat.MinutesRead)
}
}
})
// Validate 0 Minute / Day - Remaining 20 Days
for i := 10; i < 30; i++ {
stat := readStats[i]
suite.Equal(int64(0), stat.MinutesRead, "should have zero minutes read")
}
}

View File

@@ -0,0 +1,89 @@
package migrations
import (
"context"
"database/sql"
"fmt"
"github.com/pressly/goose/v3"
"reichard.io/antholume/utils"
)
func init() {
goose.AddMigrationContext(upUserAuthHash, downUserAuthHash)
}
func upUserAuthHash(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
}
// Copy table & create column
_, err := tx.Exec(`
-- Create Copy Table
CREATE TABLE temp_users AS SELECT * FROM users;
ALTER TABLE temp_users ADD COLUMN auth_hash TEXT;
-- Update Schema
DELETE FROM users;
ALTER TABLE users ADD COLUMN auth_hash TEXT NOT NULL;
`)
if err != nil {
return err
}
// Get current users
rows, err := tx.Query("SELECT id FROM temp_users")
if err != nil {
return err
}
// Query existing users
var users []string
for rows.Next() {
var user string
if err := rows.Scan(&user); err != nil {
return err
}
users = append(users, user)
}
// Create auth hash per user
for _, user := range users {
rawAuthHash, err := utils.GenerateToken(64)
if err != nil {
return err
}
authHash := fmt.Sprintf("%x", rawAuthHash)
_, err = tx.Exec("UPDATE temp_users SET auth_hash = ? WHERE id = ?", authHash, user)
if err != nil {
return err
}
}
// Copy from temp to true table
_, err = tx.Exec(`
-- Copy Into New
INSERT INTO users SELECT * FROM temp_users;
-- Drop Temp Table
DROP TABLE temp_users;
`)
if err != nil {
return err
}
return nil
}
func downUserAuthHash(ctx context.Context, tx *sql.Tx) error {
// Drop column
_, err := tx.Exec("ALTER users DROP COLUMN auth_hash")
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,58 @@
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigrationContext(upUserTimezone, downUserTimezone)
}
func upUserTimezone(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
}
// Copy table & create column
_, err := tx.Exec(`
-- Copy Table
CREATE TABLE temp_users AS SELECT * FROM users;
ALTER TABLE temp_users DROP COLUMN time_offset;
ALTER TABLE temp_users ADD COLUMN timezone TEXT;
UPDATE temp_users SET timezone = 'Europe/London';
-- Clean Table
DELETE FROM users;
ALTER TABLE users DROP COLUMN time_offset;
ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'Europe/London';
-- Copy Temp Table -> Clean Table
INSERT INTO users SELECT * FROM temp_users;
-- Drop Temp Table
DROP TABLE temp_users;
`)
if err != nil {
return err
}
return nil
}
func downUserTimezone(ctx context.Context, tx *sql.Tx) error {
// Update column name & value
_, err := tx.Exec(`
ALTER TABLE users RENAME COLUMN timezone TO time_offset;
UPDATE users SET time_offset = '0 hours';
`)
if err != nil {
return err
}
return nil
}

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

@@ -0,0 +1,9 @@
# DB Migrations
```bash
goose create migration_name
```
## Note
Since we update both the `schema.sql`, as well as the migration files, when we create a new DB it will inherently be up-to-date. We don't want to run the migrations if it's already up-to-date. Instead each migration checks if we have a new DB (via a value passed into the context), and if we do we simply return.

View File

@@ -1,22 +1,19 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.21.0
// sqlc v1.29.0
package database
import (
"database/sql"
)
type Activity struct {
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`
CreatedAt string `json:"created_at"`
StartTime string `json:"start_time"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
Duration int64 `json:"duration"`
ID int64 `json:"id"`
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`
StartTime string `json:"start_time"`
StartPercentage float64 `json:"start_percentage"`
EndPercentage float64 `json:"end_percentage"`
Duration int64 `json:"duration"`
CreatedAt string `json:"created_at"`
}
type Device struct {
@@ -31,6 +28,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"`
@@ -60,19 +58,27 @@ type DocumentProgress struct {
}
type DocumentUserStatistic struct {
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
LastRead string `json:"last_read"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
TotalTimeSeconds int64 `json:"total_time_seconds"`
ReadPages int64 `json:"read_pages"`
Percentage float64 `json:"percentage"`
WordsRead int64 `json:"words_read"`
Wpm float64 `json:"wpm"`
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
Percentage float64 `json:"percentage"`
LastRead string `json:"last_read"`
LastSeen string `json:"last_seen"`
ReadPercentage float64 `json:"read_percentage"`
TotalTimeSeconds int64 `json:"total_time_seconds"`
TotalWordsRead int64 `json:"total_words_read"`
TotalWpm float64 `json:"total_wpm"`
YearlyTimeSeconds int64 `json:"yearly_time_seconds"`
YearlyWordsRead int64 `json:"yearly_words_read"`
YearlyWpm float64 `json:"yearly_wpm"`
MonthlyTimeSeconds int64 `json:"monthly_time_seconds"`
MonthlyWordsRead int64 `json:"monthly_words_read"`
MonthlyWpm float64 `json:"monthly_wpm"`
WeeklyTimeSeconds int64 `json:"weekly_time_seconds"`
WeeklyWordsRead int64 `json:"weekly_words_read"`
WeeklyWpm float64 `json:"weekly_wpm"`
}
type Metadatum struct {
type Metadata struct {
ID int64 `json:"id"`
DocumentID string `json:"document_id"`
Title *string `json:"title"`
@@ -85,24 +91,20 @@ type Metadatum struct {
CreatedAt string `json:"created_at"`
}
type RawActivity struct {
ID int64 `json:"id"`
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`
StartTime string `json:"start_time"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
Duration int64 `json:"duration"`
CreatedAt string `json:"created_at"`
type Setting struct {
ID int64 `json:"id"`
Name string `json:"name"`
Value string `json:"value"`
CreatedAt string `json:"created_at"`
}
type User struct {
ID string `json:"id"`
Pass *string `json:"-"`
Admin bool `json:"-"`
TimeOffset *string `json:"time_offset"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
Pass *string `json:"-"`
AuthHash *string `json:"auth_hash"`
Admin bool `json:"-"`
Timezone *string `json:"timezone"`
CreatedAt string `json:"created_at"`
}
type UserStreak struct {
@@ -114,39 +116,8 @@ type UserStreak struct {
CurrentStreak int64 `json:"current_streak"`
CurrentStreakStartDate string `json:"current_streak_start_date"`
CurrentStreakEndDate string `json:"current_streak_end_date"`
}
type ViewDocumentUserStatistic struct {
DocumentID string `json:"document_id"`
UserID string `json:"user_id"`
LastRead string `json:"last_read"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
TotalTimeSeconds sql.NullFloat64 `json:"total_time_seconds"`
ReadPages int64 `json:"read_pages"`
Percentage float64 `json:"percentage"`
WordsRead interface{} `json:"words_read"`
Wpm int64 `json:"wpm"`
}
type ViewRescaledActivity struct {
UserID string `json:"user_id"`
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`
CreatedAt string `json:"created_at"`
StartTime string `json:"start_time"`
Page int64 `json:"page"`
Pages int64 `json:"pages"`
Duration int64 `json:"duration"`
}
type ViewUserStreak struct {
UserID string `json:"user_id"`
Window string `json:"window"`
MaxStreak interface{} `json:"max_streak"`
MaxStreakStartDate interface{} `json:"max_streak_start_date"`
MaxStreakEndDate interface{} `json:"max_streak_end_date"`
CurrentStreak interface{} `json:"current_streak"`
CurrentStreakStartDate interface{} `json:"current_streak_start_date"`
CurrentStreakEndDate interface{} `json:"current_streak_end_date"`
LastTimezone string `json:"last_timezone"`
LastSeen string `json:"last_seen"`
LastRecord string `json:"last_record"`
LastCalculated string `json:"last_calculated"`
}

View File

@@ -1,12 +1,12 @@
-- name: AddActivity :one
INSERT INTO raw_activity (
INSERT INTO activity (
user_id,
document_id,
device_id,
start_time,
duration,
page,
pages
start_percentage,
end_percentage
)
VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING *;
@@ -26,10 +26,13 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *;
-- name: CreateUser :execrows
INSERT INTO users (id, pass)
VALUES (?, ?)
INSERT INTO users (id, pass, auth_hash, admin)
VALUES (?, ?, ?, ?)
ON CONFLICT DO NOTHING;
-- name: DeleteUser :execrows
DELETE FROM users WHERE id = $id;
-- name: DeleteDocument :execrows
UPDATE documents
SET
@@ -40,11 +43,13 @@ WHERE id = $id;
WITH filtered_activity AS (
SELECT
document_id,
device_id,
user_id,
start_time,
duration,
page,
pages
ROUND(CAST(start_percentage AS REAL) * 100, 2) AS start_percentage,
ROUND(CAST(end_percentage AS REAL) * 100, 2) AS end_percentage,
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
FROM activity
WHERE
activity.user_id = $user_id
@@ -61,19 +66,21 @@ WITH filtered_activity AS (
SELECT
document_id,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time,
device_id,
LOCAL_TIME(activity.start_time, users.timezone) AS start_time,
title,
author,
duration,
page,
pages
start_percentage,
end_percentage,
read_percentage
FROM filtered_activity AS activity
LEFT JOIN documents ON documents.id = activity.document_id
LEFT JOIN users ON users.id = activity.user_id;
-- name: GetDailyReadStats :many
WITH RECURSIVE last_30_days AS (
SELECT DATE('now', time_offset) AS date
SELECT LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone) AS date
FROM users WHERE users.id = $user_id
UNION ALL
SELECT DATE(date, '-1 days')
@@ -82,9 +89,9 @@ WITH RECURSIVE last_30_days AS (
),
filtered_activity AS (
SELECT
user_id,
user_id,
start_time,
duration
duration
FROM activity
WHERE start_time > DATE('now', '-31 days')
AND activity.user_id = $user_id
@@ -92,11 +99,10 @@ filtered_activity AS (
activity_days AS (
SELECT
SUM(duration) AS seconds_read,
DATE(start_time, time_offset) AS day
LOCAL_DATE(start_time, timezone) AS day
FROM filtered_activity AS activity
LEFT JOIN users ON users.id = activity.user_id
GROUP BY day
LIMIT 30
)
SELECT
CAST(date AS TEXT),
@@ -130,9 +136,10 @@ WHERE id = $device_id LIMIT 1;
-- name: GetDevices :many
SELECT
devices.id,
devices.device_name,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.created_at, users.time_offset) AS TEXT) AS created_at,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced
LOCAL_TIME(devices.created_at, users.timezone) AS created_at,
LOCAL_TIME(devices.last_synced, users.timezone) AS last_synced
FROM devices
JOIN users ON users.id = devices.user_id
WHERE users.id = $user_id
@@ -142,77 +149,18 @@ ORDER BY devices.last_synced DESC;
SELECT * FROM documents
WHERE id = $document_id LIMIT 1;
-- name: GetDocumentDaysRead :one
WITH document_days AS (
SELECT DATE(start_time, time_offset) AS dates
FROM activity
JOIN users ON users.id = activity.user_id
WHERE document_id = $document_id
AND user_id = $user_id
GROUP BY dates
)
SELECT CAST(COUNT(*) AS INTEGER) AS days_read
FROM document_days;
-- name: GetDocumentReadStats :one
-- name: GetDocumentProgress :one
SELECT
COUNT(DISTINCT page) AS pages_read,
SUM(duration) AS total_time
FROM activity
WHERE document_id = $document_id
AND user_id = $user_id
AND start_time >= $start_time;
-- name: GetDocumentReadStatsCapped :one
WITH capped_stats AS (
SELECT MIN(SUM(duration), CAST($page_duration_cap AS INTEGER)) AS durations
FROM activity
WHERE document_id = $document_id
AND user_id = $user_id
AND start_time >= $start_time
GROUP BY page
)
SELECT
CAST(COUNT(*) AS INTEGER) AS pages_read,
CAST(SUM(durations) AS INTEGER) AS total_time
FROM capped_stats;
-- name: GetDocumentWithStats :one
SELECT
docs.id,
docs.title,
docs.author,
docs.description,
docs.isbn10,
docs.isbn13,
docs.filepath,
docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.page, 0) AS page,
COALESCE(dus.pages, 0) AS pages,
COALESCE(dus.read_pages, 0) AS read_pages,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
AS last_read,
CASE
WHEN dus.percentage > 97.0 THEN 100.0
WHEN dus.percentage IS NULL THEN 0.0
ELSE dus.percentage
END AS percentage,
CAST(CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE
CAST(dus.total_time_seconds AS REAL)
/ CAST(dus.read_pages AS REAL)
END AS INTEGER) AS seconds_per_page
FROM documents AS docs
LEFT JOIN users ON users.id = $user_id
LEFT JOIN
document_user_statistics AS dus
ON dus.document_id = docs.id AND dus.user_id = $user_id
WHERE users.id = $user_id
AND docs.id = $document_id
document_progress.*,
devices.device_name
FROM document_progress
JOIN devices ON document_progress.device_id = devices.id
WHERE
document_progress.user_id = $user_id
AND document_progress.document_id = $document_id
ORDER BY
document_progress.created_at
DESC
LIMIT 1;
-- name: GetDocuments :many
@@ -221,6 +169,16 @@ ORDER BY created_at DESC
LIMIT $limit
OFFSET $offset;
-- name: GetDocumentsSize :one
SELECT
COUNT(rowid) AS length
FROM documents AS docs
WHERE $query IS NULL OR (
docs.title LIKE $query OR
docs.author LIKE $query
)
LIMIT 1;
-- name: GetDocumentsWithStats :many
SELECT
docs.id,
@@ -232,32 +190,36 @@ SELECT
docs.filepath,
docs.words,
CAST(COALESCE(dus.wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.page, 0) AS page,
COALESCE(dus.pages, 0) AS pages,
COALESCE(dus.read_pages, 0) AS read_pages,
CAST(COALESCE(dus.total_wpm, 0.0) AS INTEGER) AS wpm,
COALESCE(dus.read_percentage, 0) AS read_percentage,
COALESCE(dus.total_time_seconds, 0) AS total_time_seconds,
STRFTIME('%Y-%m-%d %H:%M:%S', COALESCE(dus.last_read, "1970-01-01"), users.time_offset)
STRFTIME('%Y-%m-%d %H:%M:%S', LOCAL_TIME(COALESCE(dus.last_read, STRFTIME('%Y-%m-%dT%H:%M:%SZ', 0, 'unixepoch')), users.timezone))
AS last_read,
CASE
WHEN dus.percentage > 97.0 THEN 100.0
ROUND(CAST(CASE
WHEN dus.percentage IS NULL THEN 0.0
ELSE dus.percentage
END AS percentage,
CASE
WHEN (dus.percentage * 100.0) > 97.0 THEN 100.0
ELSE dus.percentage * 100.0
END AS REAL), 2) AS percentage,
CAST(CASE
WHEN dus.total_time_seconds IS NULL THEN 0.0
ELSE
ROUND(
CAST(dus.total_time_seconds AS REAL)
/ CAST(dus.read_pages AS REAL)
)
END AS seconds_per_page
CAST(dus.total_time_seconds AS REAL)
/ (dus.read_percentage * 100.0)
END AS INTEGER) AS seconds_per_percent
FROM documents AS docs
LEFT JOIN users ON users.id = $user_id
LEFT JOIN
document_user_statistics AS dus
ON dus.document_id = docs.id AND dus.user_id = $user_id
WHERE docs.deleted = false
WHERE
(docs.id = sqlc.narg('id') OR $id IS NULL)
AND (docs.deleted = sqlc.narg(deleted) OR $deleted IS NULL)
AND (
(
docs.title LIKE sqlc.narg('query') OR
docs.author LIKE $query
) OR $query IS NULL
)
ORDER BY dus.last_read DESC, docs.created_at DESC
LIMIT $limit
OFFSET $offset;
@@ -276,19 +238,30 @@ WHERE
AND documents.deleted = false
AND documents.id NOT IN (sqlc.slice('document_ids'));
-- name: GetProgress :one
-- name: GetProgress :many
SELECT
document_progress.*,
devices.device_name
FROM document_progress
JOIN devices ON document_progress.device_id = devices.id
documents.title,
documents.author,
devices.device_name,
ROUND(CAST(progress.percentage AS REAL) * 100, 2) AS percentage,
progress.document_id,
progress.user_id,
LOCAL_TIME(progress.created_at, users.timezone) AS created_at
FROM document_progress AS progress
LEFT JOIN users ON progress.user_id = users.id
LEFT JOIN devices ON progress.device_id = devices.id
LEFT JOIN documents ON progress.document_id = documents.id
WHERE
document_progress.user_id = $user_id
AND document_progress.document_id = $document_id
ORDER BY
document_progress.created_at
DESC
LIMIT 1;
progress.user_id = $user_id
AND (
(
CAST($doc_filter AS BOOLEAN) = TRUE
AND document_id = $document_id
) OR $doc_filter = FALSE
)
ORDER BY created_at DESC
LIMIT $limit
OFFSET $offset;
-- name: GetUser :one
SELECT * FROM users
@@ -299,64 +272,53 @@ SELECT * FROM user_streaks
WHERE user_id = $user_id;
-- name: GetUsers :many
SELECT * FROM users
WHERE
users.id = $user
OR ?1 IN (
SELECT id
FROM users
WHERE id = $user
AND admin = 1
)
ORDER BY created_at DESC
LIMIT $limit
OFFSET $offset;
SELECT * FROM users;
-- name: GetWPMLeaderboard :many
-- name: GetUserStatistics :many
SELECT
user_id,
CAST(SUM(words_read) AS INTEGER) AS total_words_read,
CAST(SUM(total_words_read) AS INTEGER) AS total_words_read,
CAST(SUM(total_time_seconds) AS INTEGER) AS total_seconds,
ROUND(CAST(SUM(words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 2)
AS wpm
ROUND(COALESCE(CAST(SUM(total_words_read) AS REAL) / (SUM(total_time_seconds) / 60.0), 0.0), 2)
AS total_wpm,
CAST(SUM(yearly_words_read) AS INTEGER) AS yearly_words_read,
CAST(SUM(yearly_time_seconds) AS INTEGER) AS yearly_seconds,
ROUND(COALESCE(CAST(SUM(yearly_words_read) AS REAL) / (SUM(yearly_time_seconds) / 60.0), 0.0), 2)
AS yearly_wpm,
CAST(SUM(monthly_words_read) AS INTEGER) AS monthly_words_read,
CAST(SUM(monthly_time_seconds) AS INTEGER) AS monthly_seconds,
ROUND(COALESCE(CAST(SUM(monthly_words_read) AS REAL) / (SUM(monthly_time_seconds) / 60.0), 0.0), 2)
AS monthly_wpm,
CAST(SUM(weekly_words_read) AS INTEGER) AS weekly_words_read,
CAST(SUM(weekly_time_seconds) AS INTEGER) AS weekly_seconds,
ROUND(COALESCE(CAST(SUM(weekly_words_read) AS REAL) / (SUM(weekly_time_seconds) / 60.0), 0.0), 2)
AS weekly_wpm
FROM document_user_statistics
WHERE words_read > 0
WHERE total_words_read > 0
GROUP BY user_id
ORDER BY wpm DESC;
ORDER BY total_wpm DESC;
-- name: GetWantedDocuments :many
SELECT
CAST(value AS TEXT) AS id,
CAST((documents.filepath IS NULL) AS BOOLEAN) AS want_file,
CAST((IFNULL(documents.synced, false) != true) AS BOOLEAN) AS want_metadata
CAST((documents.id IS NULL) AS BOOLEAN) AS want_metadata
FROM json_each(?1)
LEFT JOIN documents
ON value = documents.id
WHERE (
documents.id IS NOT NULL
AND documents.deleted = false
AND (
documents.synced = false
OR documents.filepath IS NULL
)
AND documents.filepath IS NULL
)
OR (documents.id IS NULL)
OR CAST($document_ids AS TEXT) != CAST($document_ids AS TEXT);
-- name: UpdateDocumentDeleted :one
UPDATE documents
SET
deleted = $deleted
WHERE id = $id
RETURNING *;
-- name: UpdateDocumentSync :one
UPDATE documents
SET
synced = $synced
WHERE id = $id
RETURNING *;
-- name: UpdateProgress :one
INSERT OR REPLACE INTO document_progress (
user_id,
@@ -372,10 +334,21 @@ RETURNING *;
UPDATE users
SET
pass = COALESCE($password, pass),
time_offset = COALESCE($time_offset, time_offset)
auth_hash = COALESCE($auth_hash, auth_hash),
timezone = COALESCE($timezone, timezone),
admin = COALESCE($admin, admin)
WHERE id = $user_id
RETURNING *;
-- name: UpdateSettings :one
INSERT INTO settings (name, value)
VALUES (?, ?)
ON CONFLICT DO UPDATE
SET
name = COALESCE(excluded.name, name),
value = COALESCE(excluded.value, value)
RETURNING *;
-- name: UpsertDevice :one
INSERT INTO devices (id, user_id, last_synced, device_name)
VALUES (?, ?, ?, ?)
@@ -389,6 +362,7 @@ RETURNING *;
INSERT INTO documents (
id,
md5,
basepath,
filepath,
coverfile,
title,
@@ -403,10 +377,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),

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,3 @@
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
---------------------------------------------------------------
------------------------ Normal Tables ------------------------
---------------------------------------------------------------
@@ -10,8 +7,9 @@ CREATE TABLE IF NOT EXISTS users (
id TEXT NOT NULL PRIMARY KEY,
pass TEXT NOT NULL,
auth_hash TEXT NOT NULL,
admin BOOLEAN NOT NULL DEFAULT 0 CHECK (admin IN (0, 1)),
time_offset TEXT NOT NULL DEFAULT '0 hours',
timezone TEXT NOT NULL DEFAULT 'Europe/London',
created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
@@ -21,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,
@@ -46,7 +45,6 @@ CREATE TABLE IF NOT EXISTS documents (
-- Metadata
CREATE TABLE IF NOT EXISTS metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id TEXT NOT NULL,
title TEXT,
@@ -91,16 +89,17 @@ CREATE TABLE IF NOT EXISTS document_progress (
PRIMARY KEY (user_id, document_id, device_id)
);
-- Raw Read Activity
CREATE TABLE IF NOT EXISTS raw_activity (
-- Read Activity
CREATE TABLE IF NOT EXISTS activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
document_id TEXT NOT NULL,
device_id TEXT NOT NULL,
start_time DATETIME NOT NULL,
page INTEGER NOT NULL,
pages INTEGER NOT NULL,
start_percentage REAL NOT NULL,
end_percentage REAL NOT NULL,
duration INTEGER NOT NULL,
created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now')),
@@ -109,25 +108,46 @@ CREATE TABLE IF NOT EXISTS raw_activity (
FOREIGN KEY (device_id) REFERENCES devices (id)
);
---------------------------------------------------------------
----------------------- Temporary Tables ----------------------
---------------------------------------------------------------
-- Settings
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Temporary Activity Table (Cached from View)
CREATE TEMPORARY TABLE IF NOT EXISTS activity (
user_id TEXT NOT NULL,
document_id TEXT NOT NULL,
device_id TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
created_at DATETIME NOT NULL,
start_time DATETIME NOT NULL,
page INTEGER NOT NULL,
pages INTEGER NOT NULL,
duration INTEGER NOT NULL
created_at DATETIME NOT NULL DEFAULT (STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
-- Temporary User Streaks Table (Cached from View)
CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks (
-- Document User Statistics Table
CREATE TABLE IF NOT EXISTS document_user_statistics (
document_id TEXT NOT NULL,
user_id TEXT NOT NULL,
percentage REAL NOT NULL,
last_read DATETIME NOT NULL,
last_seen DATETIME NOT NULL,
read_percentage REAL NOT NULL,
total_time_seconds INTEGER NOT NULL,
total_words_read INTEGER NOT NULL,
total_wpm REAL NOT NULL,
yearly_time_seconds INTEGER NOT NULL,
yearly_words_read INTEGER NOT NULL,
yearly_wpm REAL NOT NULL,
monthly_time_seconds INTEGER NOT NULL,
monthly_words_read INTEGER NOT NULL,
monthly_wpm REAL NOT NULL,
weekly_time_seconds INTEGER NOT NULL,
weekly_words_read INTEGER NOT NULL,
weekly_wpm REAL NOT NULL,
UNIQUE(document_id, user_id) ON CONFLICT REPLACE
);
-- User Streaks Table
CREATE TABLE IF NOT EXISTS user_streaks (
user_id TEXT NOT NULL,
window TEXT NOT NULL,
@@ -137,291 +157,28 @@ CREATE TEMPORARY TABLE IF NOT EXISTS user_streaks (
current_streak INTEGER NOT NULL,
current_streak_start_date TEXT NOT NULL,
current_streak_end_date TEXT NOT NULL
);
current_streak_end_date TEXT NOT NULL,
CREATE TEMPORARY TABLE IF NOT EXISTS document_user_statistics (
document_id TEXT NOT NULL,
user_id TEXT NOT NULL,
last_read TEXT NOT NULL,
page INTEGER NOT NULL,
pages INTEGER NOT NULL,
total_time_seconds INTEGER NOT NULL,
read_pages INTEGER NOT NULL,
percentage REAL NOT NULL,
words_read INTEGER NOT NULL,
wpm REAL NOT NULL
);
last_timezone TEXT NOT NULL,
last_seen TEXT NOT NULL,
last_record TEXT NOT NULL,
last_calculated TEXT NOT NULL,
UNIQUE(user_id, window) ON CONFLICT REPLACE
);
---------------------------------------------------------------
--------------------------- Indexes ---------------------------
---------------------------------------------------------------
CREATE INDEX IF NOT EXISTS temp.activity_start_time ON activity (start_time);
CREATE INDEX IF NOT EXISTS temp.activity_user_id ON activity (user_id);
CREATE INDEX IF NOT EXISTS temp.activity_user_id_document_id ON activity (
CREATE INDEX IF NOT EXISTS activity_start_time ON activity (start_time);
CREATE INDEX IF NOT EXISTS activity_created_at ON activity (created_at);
CREATE INDEX IF NOT EXISTS activity_user_id ON activity (user_id);
CREATE INDEX IF NOT EXISTS activity_user_id_document_id ON activity (
user_id,
document_id
);
---------------------------------------------------------------
---------------------------- Views ----------------------------
---------------------------------------------------------------
--------------------------------
------- Rescaled Activity ------
--------------------------------
CREATE VIEW IF NOT EXISTS view_rescaled_activity AS
WITH RECURSIVE nums (idx) AS (
SELECT 1 AS idx
UNION ALL
SELECT idx + 1
FROM nums
LIMIT 1000
),
current_pages AS (
SELECT
document_id,
user_id,
pages
FROM raw_activity
GROUP BY document_id, user_id
HAVING MAX(start_time)
ORDER BY start_time DESC
),
intermediate AS (
SELECT
raw_activity.document_id,
raw_activity.device_id,
raw_activity.user_id,
raw_activity.created_at,
raw_activity.start_time,
raw_activity.duration,
raw_activity.page,
current_pages.pages,
-- Derive first page
((raw_activity.page - 1) * current_pages.pages) / raw_activity.pages
+ 1 AS first_page,
-- Derive last page
MAX(
((raw_activity.page - 1) * current_pages.pages)
/ raw_activity.pages
+ 1,
(raw_activity.page * current_pages.pages) / raw_activity.pages
) AS last_page
FROM raw_activity
INNER JOIN current_pages ON
current_pages.document_id = raw_activity.document_id
AND current_pages.user_id = raw_activity.user_id
),
num_limit AS (
SELECT * FROM nums
LIMIT (SELECT MAX(last_page - first_page + 1) FROM intermediate)
),
rescaled_raw AS (
SELECT
intermediate.document_id,
intermediate.device_id,
intermediate.user_id,
intermediate.created_at,
intermediate.start_time,
intermediate.last_page,
intermediate.pages,
intermediate.first_page + num_limit.idx - 1 AS page,
intermediate.duration / (
intermediate.last_page - intermediate.first_page + 1.0
) AS duration
FROM intermediate
LEFT JOIN num_limit ON
num_limit.idx <= (intermediate.last_page - intermediate.first_page + 1)
)
SELECT
user_id,
document_id,
device_id,
created_at,
start_time,
page,
pages,
-- Round up if last page (maintains total duration)
CAST(CASE
WHEN page = last_page AND duration != CAST(duration AS INTEGER)
THEN duration + 1
ELSE duration
END AS INTEGER) AS duration
FROM rescaled_raw;
--------------------------------
--------- User Streaks ---------
--------------------------------
CREATE VIEW IF NOT EXISTS view_user_streaks AS
WITH document_windows AS (
SELECT
activity.user_id,
users.time_offset,
DATE(
activity.start_time,
users.time_offset,
'weekday 0', '-7 day'
) AS weekly_read,
DATE(activity.start_time, users.time_offset) AS daily_read
FROM raw_activity AS activity
LEFT JOIN users ON users.id = activity.user_id
GROUP BY activity.user_id, weekly_read, daily_read
),
weekly_partitions AS (
SELECT
user_id,
time_offset,
'WEEK' AS "window",
weekly_read AS read_window,
row_number() OVER (
PARTITION BY user_id ORDER BY weekly_read DESC
) AS seqnum
FROM document_windows
GROUP BY user_id, weekly_read
),
daily_partitions AS (
SELECT
user_id,
time_offset,
'DAY' AS "window",
daily_read AS read_window,
row_number() OVER (
PARTITION BY user_id ORDER BY daily_read DESC
) AS seqnum
FROM document_windows
GROUP BY user_id, daily_read
),
streaks AS (
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
window,
user_id,
time_offset
FROM daily_partitions
GROUP BY
time_offset,
user_id,
DATE(read_window, '+' || seqnum || ' day')
UNION ALL
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
window,
user_id,
time_offset
FROM weekly_partitions
GROUP BY
time_offset,
user_id,
DATE(read_window, '+' || (seqnum * 7) || ' day')
),
max_streak AS (
SELECT
MAX(streak) AS max_streak,
start_date AS max_streak_start_date,
end_date AS max_streak_end_date,
window,
user_id
FROM streaks
GROUP BY user_id, window
),
current_streak AS (
SELECT
streak AS current_streak,
start_date AS current_streak_start_date,
end_date AS current_streak_end_date,
window,
user_id
FROM streaks
WHERE CASE
WHEN window = "WEEK" THEN
DATE('now', time_offset, 'weekday 0', '-14 day') = current_streak_end_date
OR DATE('now', time_offset, 'weekday 0', '-7 day') = current_streak_end_date
WHEN window = "DAY" THEN
DATE('now', time_offset, '-1 day') = current_streak_end_date
OR DATE('now', time_offset) = current_streak_end_date
END
GROUP BY user_id, window
)
SELECT
max_streak.user_id,
max_streak.window,
IFNULL(max_streak, 0) AS max_streak,
IFNULL(max_streak_start_date, "N/A") AS max_streak_start_date,
IFNULL(max_streak_end_date, "N/A") AS max_streak_end_date,
IFNULL(current_streak, 0) AS current_streak,
IFNULL(current_streak_start_date, "N/A") AS current_streak_start_date,
IFNULL(current_streak_end_date, "N/A") AS current_streak_end_date
FROM max_streak
LEFT JOIN current_streak ON
current_streak.user_id = max_streak.user_id
AND current_streak.window = max_streak.window;
--------------------------------
------- Document Stats ---------
--------------------------------
CREATE VIEW IF NOT EXISTS view_document_user_statistics AS
WITH true_progress AS (
SELECT
document_id,
user_id,
start_time AS last_read,
page,
pages,
SUM(duration) AS total_time_seconds,
-- Determine Read Pages
COUNT(DISTINCT page) AS read_pages,
-- Derive Percentage of Book
ROUND(CAST(page AS REAL) / CAST(pages AS REAL) * 100, 2) AS percentage
FROM view_rescaled_activity
GROUP BY document_id, user_id
HAVING MAX(start_time)
)
SELECT
true_progress.*,
(CAST(COALESCE(documents.words, 0.0) AS REAL) / pages * read_pages)
AS words_read,
(CAST(COALESCE(documents.words, 0.0) AS REAL) / pages * read_pages)
/ (total_time_seconds / 60.0) AS wpm
FROM true_progress
INNER JOIN documents ON documents.id = true_progress.document_id
ORDER BY wpm DESC;
---------------------------------------------------------------
------------------ Populate Temporary Tables ------------------
---------------------------------------------------------------
INSERT INTO activity SELECT * FROM view_rescaled_activity;
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
INSERT INTO document_user_statistics SELECT * FROM view_document_user_statistics;
---------------------------------------------------------------
--------------------------- Triggers --------------------------
---------------------------------------------------------------
@@ -433,3 +190,11 @@ UPDATE documents
SET updated_at = STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = old.id;
END;
-- Delete User
CREATE TRIGGER IF NOT EXISTS user_deleted
BEFORE DELETE ON users BEGIN
DELETE FROM activity WHERE activity.user_id=OLD.id;
DELETE FROM devices WHERE devices.user_id=OLD.id;
DELETE FROM document_progress WHERE document_progress.user_id=OLD.id;
END;

View File

@@ -1,8 +0,0 @@
DELETE FROM activity;
INSERT INTO activity SELECT * FROM view_rescaled_activity;
DELETE FROM user_streaks;
INSERT INTO user_streaks SELECT * FROM view_user_streaks;
DELETE FROM document_user_statistics;
INSERT INTO document_user_statistics
SELECT *
FROM view_document_user_statistics;

154
database/user_streaks.sql Normal file
View File

@@ -0,0 +1,154 @@
WITH updated_users AS (
SELECT a.user_id
FROM activity AS a
LEFT JOIN users AS u ON u.id = a.user_id
LEFT JOIN user_streaks AS s ON a.user_id = s.user_id AND s.window = 'DAY'
WHERE
a.created_at > COALESCE(s.last_seen, '1970-01-01')
AND LOCAL_DATE(s.last_record, u.timezone) != LOCAL_DATE(a.start_time, u.timezone)
GROUP BY a.user_id
),
outdated_users AS (
SELECT
a.user_id,
u.timezone AS last_timezone,
MAX(a.created_at) AS last_seen,
MAX(a.start_time) AS last_record,
STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now') AS last_calculated
FROM activity AS a
LEFT JOIN users AS u ON u.id = a.user_id
LEFT JOIN user_streaks AS s ON a.user_id = s.user_id AND s.window = 'DAY'
GROUP BY a.user_id
HAVING
-- User Changed Timezones
s.last_timezone != u.timezone
-- Users Date Changed
OR LOCAL_DATE(COALESCE(s.last_calculated, '1970-01-01T00:00:00Z'), u.timezone) !=
LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), u.timezone)
-- User Added New Data
OR a.user_id IN updated_users
),
document_windows AS (
SELECT
activity.user_id,
users.timezone,
DATE(
LOCAL_DATE(activity.start_time, users.timezone),
'weekday 0', '-7 day'
) AS weekly_read,
LOCAL_DATE(activity.start_time, users.timezone) AS daily_read
FROM activity
INNER JOIN outdated_users ON outdated_users.user_id = activity.user_id
LEFT JOIN users ON users.id = activity.user_id
GROUP BY activity.user_id, weekly_read, daily_read
),
weekly_partitions AS (
SELECT
user_id,
timezone,
'WEEK' AS "window",
weekly_read AS read_window,
ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY weekly_read DESC
) AS seqnum
FROM document_windows
GROUP BY user_id, weekly_read
),
daily_partitions AS (
SELECT
user_id,
timezone,
'DAY' AS "window",
daily_read AS read_window,
ROW_NUMBER() OVER (
PARTITION BY user_id ORDER BY daily_read DESC
) AS seqnum
FROM document_windows
GROUP BY user_id, daily_read
),
streaks AS (
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
window,
user_id,
timezone
FROM daily_partitions
GROUP BY
timezone,
user_id,
DATE(read_window, '+' || seqnum || ' day')
UNION ALL
SELECT
COUNT(*) AS streak,
MIN(read_window) AS start_date,
MAX(read_window) AS end_date,
window,
user_id,
timezone
FROM weekly_partitions
GROUP BY
timezone,
user_id,
DATE(read_window, '+' || (seqnum * 7) || ' day')
),
max_streak AS (
SELECT
MAX(streak) AS max_streak,
start_date AS max_streak_start_date,
end_date AS max_streak_end_date,
window,
user_id
FROM streaks
GROUP BY user_id, window
),
current_streak AS (
SELECT
streak AS current_streak,
start_date AS current_streak_start_date,
end_date AS current_streak_end_date,
window,
user_id
FROM streaks
WHERE CASE
WHEN window = "WEEK" THEN
DATE(LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone), 'weekday 0', '-14 day') = current_streak_end_date
OR DATE(LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone), 'weekday 0', '-7 day') = current_streak_end_date
WHEN window = "DAY" THEN
DATE(LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone), '-1 day') = current_streak_end_date
OR DATE(LOCAL_DATE(STRFTIME('%Y-%m-%dT%H:%M:%SZ', 'now'), timezone)) = current_streak_end_date
END
GROUP BY user_id, window
)
INSERT INTO user_streaks
SELECT
max_streak.user_id,
max_streak.window,
IFNULL(max_streak, 0) AS max_streak,
IFNULL(max_streak_start_date, "N/A") AS max_streak_start_date,
IFNULL(max_streak_end_date, "N/A") AS max_streak_end_date,
IFNULL(current_streak.current_streak, 0) AS current_streak,
IFNULL(current_streak.current_streak_start_date, "N/A") AS current_streak_start_date,
IFNULL(current_streak.current_streak_end_date, "N/A") AS current_streak_end_date,
outdated_users.last_timezone AS last_timezone,
outdated_users.last_seen AS last_seen,
outdated_users.last_record AS last_record,
outdated_users.last_calculated AS last_calculated
FROM max_streak
JOIN outdated_users ON max_streak.user_id = outdated_users.user_id
LEFT JOIN current_streak ON
current_streak.user_id = max_streak.user_id
AND current_streak.window = max_streak.window;

205
database/users_test.go Normal file
View File

@@ -0,0 +1,205 @@
package database
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/suite"
"reichard.io/antholume/config"
"reichard.io/antholume/utils"
)
var (
testUserID string = "testUser"
testUserPass string = "testPass"
)
type UsersTestSuite struct {
suite.Suite
dbm *DBManager
}
func TestUsers(t *testing.T) {
suite.Run(t, new(UsersTestSuite))
}
func (suite *UsersTestSuite) SetupTest() {
cfg := config.Config{
DBType: "memory",
}
suite.dbm = NewMgr(&cfg)
// Create User
rawAuthHash, _ := utils.GenerateToken(64)
authHash := fmt.Sprintf("%x", rawAuthHash)
_, err := suite.dbm.Queries.CreateUser(context.Background(), CreateUserParams{
ID: testUserID,
Pass: &testUserPass,
AuthHash: &authHash,
})
suite.NoError(err)
// Create Document
_, err = suite.dbm.Queries.UpsertDocument(context.Background(), UpsertDocumentParams{
ID: documentID,
Title: &documentTitle,
Author: &documentAuthor,
Words: &documentWords,
})
suite.NoError(err)
// Create Device
_, err = suite.dbm.Queries.UpsertDevice(context.Background(), UpsertDeviceParams{
ID: deviceID,
UserID: testUserID,
DeviceName: deviceName,
})
suite.NoError(err)
}
func (suite *UsersTestSuite) TestGetUser() {
user, err := suite.dbm.Queries.GetUser(context.Background(), testUserID)
suite.Nil(err, "should have nil err")
suite.Equal(testUserPass, *user.Pass)
}
func (suite *UsersTestSuite) TestCreateUser() {
testUser := "user1"
testPass := "pass1"
// Generate Auth Hash
rawAuthHash, err := utils.GenerateToken(64)
suite.Nil(err, "should have nil err")
authHash := fmt.Sprintf("%x", rawAuthHash)
changed, err := suite.dbm.Queries.CreateUser(context.Background(), CreateUserParams{
ID: testUser,
Pass: &testPass,
AuthHash: &authHash,
})
suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed)
user, err := suite.dbm.Queries.GetUser(context.Background(), testUser)
suite.Nil(err, "should have nil err")
suite.Equal(testPass, *user.Pass)
}
func (suite *UsersTestSuite) TestDeleteUser() {
changed, err := suite.dbm.Queries.DeleteUser(context.Background(), testUserID)
suite.Nil(err, "should have nil err")
suite.Equal(int64(1), changed, "should have one changed row")
_, err = suite.dbm.Queries.GetUser(context.Background(), testUserID)
suite.ErrorIs(err, sql.ErrNoRows, "should have no rows error")
}
func (suite *UsersTestSuite) TestGetUsers() {
users, err := suite.dbm.Queries.GetUsers(context.Background())
suite.Nil(err, "should have nil err")
suite.Len(users, 1, "should have single user")
}
func (suite *UsersTestSuite) TestUpdateUser() {
newPassword := "newPass123"
user, err := suite.dbm.Queries.UpdateUser(context.Background(), UpdateUserParams{
UserID: testUserID,
Password: &newPassword,
})
suite.Nil(err, "should have nil err")
suite.Equal(newPassword, *user.Pass, "should have new password")
}
func (suite *UsersTestSuite) TestGetUserStatistics() {
err := suite.dbm.CacheTempTables(context.Background())
suite.NoError(err)
// Ensure Zero Items
userStats, err := suite.dbm.Queries.GetUserStatistics(context.Background())
suite.Nil(err, "should have nil err")
suite.Empty(userStats, "should be empty")
// Create Activity
end := time.Now()
start := end.AddDate(0, 0, -9)
var counter int64 = 0
for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) {
counter += 1
// Add Item
activity, err := suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{
DocumentID: documentID,
DeviceID: deviceID,
UserID: testUserID,
StartTime: d.UTC().Format(time.RFC3339),
Duration: 60,
StartPercentage: float64(counter) / 100.0,
EndPercentage: float64(counter+1) / 100.0,
})
suite.Nil(err, fmt.Sprintf("[%d] should have nil err for add activity", counter))
suite.Equal(counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter))
}
err = suite.dbm.CacheTempTables(context.Background())
suite.NoError(err)
// Ensure One Item
userStats, err = suite.dbm.Queries.GetUserStatistics(context.Background())
suite.Nil(err, "should have nil err")
suite.Len(userStats, 1, "should have length of one")
}
func (suite *UsersTestSuite) TestGetUsersStreaks() {
err := suite.dbm.CacheTempTables(context.Background())
suite.NoError(err)
// Ensure Zero Items
userStats, err := suite.dbm.Queries.GetUserStreaks(context.Background(), testUserID)
suite.Nil(err, "should have nil err")
suite.Empty(userStats, "should be empty")
// Create Activity
end := time.Now()
start := end.AddDate(0, 0, -9)
var counter int64 = 0
for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) {
counter += 1
// Add Item
activity, err := suite.dbm.Queries.AddActivity(context.Background(), AddActivityParams{
DocumentID: documentID,
DeviceID: deviceID,
UserID: testUserID,
StartTime: d.UTC().Format(time.RFC3339),
Duration: 60,
StartPercentage: float64(counter) / 100.0,
EndPercentage: float64(counter+1) / 100.0,
})
suite.Nil(err, fmt.Sprintf("[%d] should have nil err for add activity", counter))
suite.Equal(counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter))
}
err = suite.dbm.CacheTempTables(context.Background())
suite.NoError(err)
// Ensure Two Item
userStats, err = suite.dbm.Queries.GetUserStreaks(context.Background(), testUserID)
suite.Nil(err, "should have nil err")
suite.Len(userStats, 2, "should have length of two")
// Ensure Streak Stats
dayStats := userStats[0]
weekStats := userStats[1]
suite.Equal(int64(10), dayStats.CurrentStreak, "should be 10 days")
suite.Greater(weekStats.CurrentStreak, int64(1), "should be 2 or 3")
}

Some files were not shown because too many files have changed in this diff Show More