diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 36680c1..bf88687 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,6 +38,7 @@ jobs: uses: docker/build-push-action@v4 with: context: . + platforms: linux/amd64,linux/arm64 build-args: | KOMPANION_VERSION=${{ steps.get_tag.outputs.TAG }} push: true diff --git a/Dockerfile b/Dockerfile index 94813f7..415f1bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,9 +14,11 @@ WORKDIR /app ARG KOMPANION_VERSION=local ENV KOMPANION_VERSION=$KOMPANION_VERSION -RUN GOOS=linux GOARCH=amd64 \ +ARG TARGETARCH +RUN GOOS=linux GOARCH=${TARGETARCH:-amd64} \ go build -ldflags "-X main.Version=$KOMPANION_VERSION" -tags migrate -o /bin/app ./cmd/app + # Step 3: Final FROM golang:1.22.5-alpine ENV GIN_MODE=release diff --git a/Makefile b/Makefile index f7bf8ea..abeaaaf 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,8 @@ endif LOCAL_BIN:=$(CURDIR)/bin PATH:=$(LOCAL_BIN):$(PATH) +COMPOSE_CMD := $(shell command -v docker-compose >/dev/null 2>&1 && echo "docker-compose" || echo "docker compose") + # HELP ================================================================================================================= # This will output the help for each task # thanks to https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html @@ -18,19 +20,19 @@ help: ## Display this help screen @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) compose-up: ### Run docker-compose - docker-compose up --build -d postgres && docker-compose logs -f + $(COMPOSE_CMD) up --build -d postgres && $(COMPOSE_CMD) logs -f .PHONY: compose-up compose-all: ### Run all containers - UID=$(id -u) GID=$(id -g) docker-compose up --build + UID=$(id -u) GID=$(id -g) $(COMPOSE_CMD) up --build .PHONY: compose-all compose-up-integration-test: ### Run docker-compose with integration test - docker-compose -f docker-compose.yml -f docker-compose-integration.yml up --build --abort-on-container-exit --exit-code-from integration + $(COMPOSE_CMD) -f docker-compose.yml -f docker-compose-integration.yml up --build --abort-on-container-exit --exit-code-from integration .PHONY: compose-up-integration-test compose-down: ### Down docker-compose - docker-compose down --remove-orphans + $(COMPOSE_CMD) down --remove-orphans .PHONY: compose-down run: ### swag run diff --git a/README.md b/README.md index d422b72..e34d830 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,14 @@ KOmpanion is a minimalistic library web application, that tightly coupled to KOReader features. Main features are: + - upload and view your bookshelf - OPDS to download books - KOReader sync progress API - KOReader book stats via WebDAV What KOmpanion is NOT about: + - web interface for book reading (just install KOReader) - converter between formats (I don't want to do another calibre) @@ -15,6 +17,7 @@ What KOmpanion is NOT about: KOReader is the best available reader on the market (personal opinion). Features, that can buy you in: + - sync progress between tablet, phone and ebook - extensive stats for book reading: total time, time per page, estimates @@ -40,6 +43,7 @@ Features, that can buy you in: - `KOMPANION_AUTH_PASSWORD` - required for setup - `KOMPANION_AUTH_STORAGE` - postgres or memory (default: postgres) - `KOMPANION_HTTP_PORT` - port for service (default: 8080) +- `KOMPANION_URL_PREFIX` - base url for service (default: "/") - `KOMPANION_LOG_LEVEL` - debug, info, error (default: info) - `KOMPANION_PG_POOL_MAX` - integer number for pooling connections (default: 2) - `KOMPANION_PG_URL` - postgresql link @@ -53,6 +57,7 @@ Features, that can buy you in: ### Web interface First of all, you need to add your devices: + 1. Go to service 2. Login 3. Click devices @@ -63,23 +68,25 @@ First of all, you need to add your devices: ### KOReader Go to following plugins: + 1. Cloud storage - 1. Add new WebDAV: URL - `https://your-kompanion.org/webdav/`, username - device name, password - password + 1. Add new WebDAV: URL - `https://your-kompanion.org/webdav/`, username - device name, password - password 2. Statistics - Settings - Cloud sync - 1. It's OKAY to have empty list, just press on **Long press to choose current folder**. + 1. It's OKAY to have empty list, just press on **Long press to choose current folder**. 3. Open book - tools - Progress sync - 1. Custom sync server: `https://your-kompanion.org/` - 1. Login: username - device name, password - password + 1. Custom sync server: `https://your-kompanion.org/` + 2. Login: username - device name, password - password 4. To setup OPDS catalog: - 1. Toolbar -> Search -> OPDS Catalog - 2. Hit plus - 3. Catalog URL: `https://your-kompanion.org/opds/`, username - device name, password - password + 1. Toolbar -> Search -> OPDS Catalog + 2. Hit plus + 3. Catalog URL: `https://your-kompanion.org/opds/`, username - device name, password - password ## Development Project was started with [go-clean-template](https://github.com/evrone/go-clean-template), but then heavily modified. Local development: + ```sh # Postgres $ make compose-up @@ -88,6 +95,7 @@ $ make run ``` Integration tests (can be run in CI): + ```sh # DB, app + migrations, integration tests $ make compose-up-integration-test diff --git a/config/config.go b/config/config.go index 008200b..9ef7217 100644 --- a/config/config.go +++ b/config/config.go @@ -33,7 +33,8 @@ type ( // HTTP -. HTTP struct { - Port string + Port string + UrlPrefix string } // Log -. @@ -117,9 +118,11 @@ func readHTTPConfig() (HTTP, error) { if port == "" { port = "8080" } + url_prefix := readPrefixedEnv("URL_PREFIX") return HTTP{ - Port: port, + Port: port, + UrlPrefix: strings.TrimSuffix(url_prefix, "/"), }, nil } diff --git a/internal/app/app.go b/internal/app/app.go index 764e546..b10f747 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -60,12 +60,13 @@ func Run(cfg *config.Config) { rs := stats.NewKOReaderPGStats(pg) // HTTP Server - handler := gin.New() - web.NewRouter(handler, l, authService, progress, shelf, rs, cfg.Version) + router := gin.New() + handler := router.Group(cfg.UrlPrefix) + web.NewRouter(handler, router, l, authService, progress, shelf, rs, cfg.Version) v1.NewRouter(handler, l, authService, progress, shelf) opds.NewRouter(handler, l, authService, progress, shelf) webdav.NewRouter(handler, authService, l, rs) - httpServer := httpserver.New(handler, httpserver.Port(cfg.HTTP.Port)) + httpServer := httpserver.New(router, httpserver.Port(cfg.HTTP.Port)) // Waiting signal interrupt := make(chan os.Signal, 1) diff --git a/internal/controller/http/opds/opds.go b/internal/controller/http/opds/opds.go index 143802c..0d75270 100644 --- a/internal/controller/http/opds/opds.go +++ b/internal/controller/http/opds/opds.go @@ -54,10 +54,10 @@ type Summary struct { Text string `xml:",chardata"` } -func BuildFeed(id, title, href string, entries []Entry, additionalLinks []Link) *Feed { +func BuildFeed(id, title, href string, entries []Entry, additionalLinks []Link, urlPrefix string) *Feed { finalLinks := []Link{ { - Href: "/opds/", + Href: urlPrefix + "/opds/", Type: DirMime, Rel: "start", }, @@ -67,7 +67,7 @@ func BuildFeed(id, title, href string, entries []Entry, additionalLinks []Link) Rel: "self", }, { - Href: "/opds/search/{searchTerms}/", + Href: urlPrefix + "/opds/search/{searchTerms}/", Type: "application/atom+xml", Rel: "search", }, @@ -83,7 +83,7 @@ func BuildFeed(id, title, href string, entries []Entry, additionalLinks []Link) } } -func translateBooksToEntries(books []entity.Book) []Entry { +func translateBooksToEntries(books []entity.Book, urlPrefix string) []Entry { entries := make([]Entry, 0, len(books)) for _, book := range books { entries = append(entries, Entry{ @@ -95,7 +95,7 @@ func translateBooksToEntries(books []entity.Book) []Entry { }, Link: []Link{ { - Href: fmt.Sprintf("/opds/book/%s/download", book.ID), + Href: fmt.Sprintf(urlPrefix+"/opds/book/%s/download", book.ID), Type: book.MimeType(), Rel: FileRel, // Mtime: book.UpdatedAt.Format(AtomTime), diff --git a/internal/controller/http/opds/router.go b/internal/controller/http/opds/router.go index 3d1d3a2..70c8f2e 100644 --- a/internal/controller/http/opds/router.go +++ b/internal/controller/http/opds/router.go @@ -3,6 +3,7 @@ package opds import ( "net/http" "strconv" + "strings" "time" "github.com/gin-gonic/gin" @@ -13,17 +14,19 @@ import ( ) type OPDSRouter struct { - books library.Shelf - logger logger.Interface + urlPrefix string + books library.Shelf + logger logger.Interface } func NewRouter( - handler *gin.Engine, + handler *gin.RouterGroup, l logger.Interface, a auth.AuthInterface, p sync.Progress, shelf library.Shelf) { - sh := &OPDSRouter{shelf, l} + urlPrefix := strings.TrimSuffix(handler.BasePath(), "/") + sh := &OPDSRouter{urlPrefix, shelf, l} h := handler.Group("/opds") h.Use(basicAuth(a)) @@ -43,14 +46,14 @@ func (r *OPDSRouter) listShelves(c *gin.Context) { Title: "By Newest", Link: []Link{ { - Href: "/opds/newest/", + Href: r.urlPrefix + "/opds/newest/", Type: "application/atom+xml;type=feed;profile=opds-catalog", }, }, }, } links := []Link{} - feed := BuildFeed("urn:kompanion:main", "KOmpanion library", "/opds", shelves, links) + feed := BuildFeed("urn:kompanion:main", "KOmpanion library", r.urlPrefix+"/opds", shelves, links, r.urlPrefix) c.XML(http.StatusOK, feed) } @@ -66,10 +69,10 @@ func (r *OPDSRouter) listNewest(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"message": "Internal server error", "code": 1001}) return } - baseUrl := "/opds/newest/" - entries := translateBooksToEntries(books.Books) + baseUrl := r.urlPrefix + "/opds/newest/" + entries := translateBooksToEntries(books.Books, r.urlPrefix) navLinks := formNavLinks(baseUrl, books) - feed := BuildFeed("urn:kompanion:newest", "KOmpanion library", baseUrl, entries, navLinks) + feed := BuildFeed("urn:kompanion:newest", "KOmpanion library", baseUrl, entries, navLinks, r.urlPrefix) c.XML(http.StatusOK, feed) } diff --git a/internal/controller/http/v1/router.go b/internal/controller/http/v1/router.go index 02ea227..7668a72 100644 --- a/internal/controller/http/v1/router.go +++ b/internal/controller/http/v1/router.go @@ -14,7 +14,7 @@ import ( ) // NewRouter -. -func NewRouter(handler *gin.Engine, l logger.Interface, a auth.AuthInterface, p sync.Progress, shelf library.Shelf) { +func NewRouter(handler *gin.RouterGroup, l logger.Interface, a auth.AuthInterface, p sync.Progress, shelf library.Shelf) { // Options handler.Use(gin.Logger()) handler.Use(gin.Recovery()) diff --git a/internal/controller/http/web/auth.go b/internal/controller/http/web/auth.go index 14c7bb4..a881e1e 100644 --- a/internal/controller/http/web/auth.go +++ b/internal/controller/http/web/auth.go @@ -7,12 +7,13 @@ import ( ) type authRoutes struct { - auth auth.AuthInterface - l logger.Interface + auth auth.AuthInterface + urlPrefix string + l logger.Interface } -func newAuthRoutes(handler *gin.RouterGroup, a auth.AuthInterface, l logger.Interface) { - r := &authRoutes{a, l} +func newAuthRoutes(handler *gin.RouterGroup, urlPrefix string, a auth.AuthInterface, l logger.Interface) { + r := &authRoutes{a, urlPrefix, l} handler.GET("/login", r.loginForm) handler.POST("/login", r.loginAction) @@ -20,18 +21,18 @@ func newAuthRoutes(handler *gin.RouterGroup, a auth.AuthInterface, l logger.Inte } func (r *authRoutes) loginForm(c *gin.Context) { - c.HTML(200, "login", passStandartContext(c, gin.H{})) + c.HTML(200, "login", passStandartContext(c, gin.H{"urlPrefix": r.urlPrefix})) } func (r *authRoutes) logoutAction(c *gin.Context) { sessionKey, err := c.Cookie("session") if err != nil { - c.Redirect(302, "/auth/login") + c.Redirect(302, r.urlPrefix+"/auth/login") return } r.auth.Logout(c.Request.Context(), sessionKey) c.SetCookie("session", "", 0, "/", "", false, true) - c.Redirect(302, "/auth/login") + c.Redirect(302, r.urlPrefix+"/auth/login") } func (r *authRoutes) loginAction(c *gin.Context) { @@ -49,20 +50,20 @@ func (r *authRoutes) loginAction(c *gin.Context) { return } c.SetCookie("session", sessionKey, 0, "/", "", false, true) - c.Redirect(302, "/books") + c.Redirect(302, r.urlPrefix+"/books") } -func authMiddleware(a auth.AuthInterface) gin.HandlerFunc { +func authMiddleware(a auth.AuthInterface, urlPrefix string) gin.HandlerFunc { return func(c *gin.Context) { sessionKey, err := c.Cookie("session") if err != nil { - c.Redirect(302, "/auth/login") + c.Redirect(302, urlPrefix+"/auth/login") c.Abort() return } if !a.IsAuthenticated(c.Request.Context(), sessionKey) { - c.Redirect(302, "/auth/login") + c.Redirect(302, urlPrefix+"/auth/login") c.Abort() return } diff --git a/internal/controller/http/web/books.go b/internal/controller/http/web/books.go index e9aae9a..9a68a28 100644 --- a/internal/controller/http/web/books.go +++ b/internal/controller/http/web/books.go @@ -14,14 +14,15 @@ import ( ) type booksRoutes struct { - shelf library.Shelf - stats stats.ReadingStats - progress syncpkg.Progress - logger logger.Interface + urlPrefix string + shelf library.Shelf + stats stats.ReadingStats + progress syncpkg.Progress + logger logger.Interface } -func newBooksRoutes(handler *gin.RouterGroup, shelf library.Shelf, stats stats.ReadingStats, progress syncpkg.Progress, l logger.Interface) { - r := &booksRoutes{shelf: shelf, stats: stats, progress: progress, logger: l} +func newBooksRoutes(handler *gin.RouterGroup, urlPrefix string, shelf library.Shelf, stats stats.ReadingStats, progress syncpkg.Progress, l logger.Interface) { + r := &booksRoutes{urlPrefix: urlPrefix, shelf: shelf, stats: stats, progress: progress, logger: l} handler.GET("/", r.listBooks) handler.POST("/upload", r.uploadBook) @@ -65,7 +66,8 @@ func (r *booksRoutes) listBooks(c *gin.Context) { } c.HTML(200, "books", passStandartContext(c, gin.H{ - "books": booksWithProgress, + "urlPrefix": r.urlPrefix, + "books": booksWithProgress, "pagination": gin.H{ "currentPage": page, "perPage": perPage, @@ -107,7 +109,7 @@ func (r *booksRoutes) uploadBook(c *gin.Context) { c.JSON(500, passStandartContext(c, gin.H{"message": "internal server error"})) return } - c.Redirect(302, "/books/"+book.ID) + c.Redirect(302, r.urlPrefix+"/books/"+book.ID) } func (r *booksRoutes) downloadBook(c *gin.Context) { @@ -141,8 +143,9 @@ func (r *booksRoutes) viewBook(c *gin.Context) { } c.HTML(200, "book", passStandartContext(c, gin.H{ - "book": book, - "stats": bookStats, + "urlPrefix": r.urlPrefix, + "book": book, + "stats": bookStats, })) } @@ -166,7 +169,7 @@ func (r *booksRoutes) updateBookMetadata(c *gin.Context) { } // TODO: why not redirect? - c.HTML(200, "book", passStandartContext(c, gin.H{"book": book})) + c.HTML(200, "book", passStandartContext(c, gin.H{"urlPrefix": r.urlPrefix, "book": book})) } func (r *booksRoutes) viewBookCover(c *gin.Context) { diff --git a/internal/controller/http/web/devices.go b/internal/controller/http/web/devices.go index 35dceec..3e5f9ed 100644 --- a/internal/controller/http/web/devices.go +++ b/internal/controller/http/web/devices.go @@ -7,12 +7,13 @@ import ( ) type deviceRoutes struct { - auth auth.AuthInterface - l logger.Interface + auth auth.AuthInterface + urlPrefix string + l logger.Interface } -func newDeviceRoutes(handler *gin.RouterGroup, a auth.AuthInterface, l logger.Interface) { - r := &deviceRoutes{a, l} +func newDeviceRoutes(handler *gin.RouterGroup, urlPrefix string, a auth.AuthInterface, l logger.Interface) { + r := &deviceRoutes{a, urlPrefix, l} handler.GET("/", r.listDevices) handler.POST("/add", r.addDeviceAction) @@ -23,13 +24,15 @@ func (r *deviceRoutes) listDevices(c *gin.Context) { devices, err := r.auth.ListDevices(c.Request.Context()) if err != nil { c.HTML(500, "devices", passStandartContext(c, gin.H{ - "error": "Failed to load devices", + "urlPrefix": r.urlPrefix, + "error": "Failed to load devices", })) return } c.HTML(200, "devices", passStandartContext(c, gin.H{ - "devices": devices, + "urlPrefix": r.urlPrefix, + "devices": devices, })) } @@ -39,7 +42,8 @@ func (r *deviceRoutes) addDeviceAction(c *gin.Context) { if deviceName == "" || password == "" { c.HTML(400, "devices", passStandartContext(c, gin.H{ - "error": "Device name and password are required", + "error": "Device name and password are required", + "urlPrefix": r.urlPrefix, })) return } @@ -47,12 +51,13 @@ func (r *deviceRoutes) addDeviceAction(c *gin.Context) { err := r.auth.AddUserDevice(c.Request.Context(), deviceName, password) if err != nil { c.HTML(400, "devices", passStandartContext(c, gin.H{ - "error": err.Error(), + "error": err.Error(), + "urlPrefix": r.urlPrefix, })) return } - c.Redirect(302, "/devices") + c.Redirect(302, r.urlPrefix+"/devices") } func (r *deviceRoutes) deactivateDeviceAction(c *gin.Context) { @@ -60,10 +65,11 @@ func (r *deviceRoutes) deactivateDeviceAction(c *gin.Context) { err := r.auth.DeactivateUserDevice(c.Request.Context(), deviceName) if err != nil { c.HTML(400, "devices", passStandartContext(c, gin.H{ - "error": err.Error(), + "urlPrefix": r.urlPrefix, + "error": err.Error(), })) return } - c.Redirect(302, "/devices") + c.Redirect(302, r.urlPrefix+"/devices") } diff --git a/internal/controller/http/web/router.go b/internal/controller/http/web/router.go index a8df3de..90bd4a6 100644 --- a/internal/controller/http/web/router.go +++ b/internal/controller/http/web/router.go @@ -23,7 +23,8 @@ import ( ) func NewRouter( - handler *gin.Engine, + handler *gin.RouterGroup, + router *gin.Engine, l logger.Interface, a auth.AuthInterface, p sync.Progress, @@ -37,6 +38,9 @@ func NewRouter( handler.Use(func(c *gin.Context) { c.Set("startTime", time.Now()) }) + + // hold origin prefix as urlPrefix + urlPrefix := strings.TrimSuffix(handler.BasePath(), "/") // static files staticFs, err := fs.Sub(kompanion.WebAssets, "web/static") if err != nil { @@ -70,31 +74,31 @@ func NewRouter( } gv := ginview.New(config) gv.SetFileHandler(embeddedFH) - handler.HTMLRender = gv + router.HTMLRender = gv // Home handler.GET("/", func(c *gin.Context) { - c.Redirect(302, "/books") + c.Redirect(302, urlPrefix+"/books") }) // Login authGroup := handler.Group("/auth") - newAuthRoutes(authGroup, a, l) + newAuthRoutes(authGroup, urlPrefix, a, l) // Product pages bookGroup := handler.Group("/books") - bookGroup.Use(authMiddleware(a)) - newBooksRoutes(bookGroup, shelf, stats, p, l) + bookGroup.Use(authMiddleware(a, urlPrefix)) + newBooksRoutes(bookGroup, urlPrefix, shelf, stats, p, l) // Stats pages statsGroup := handler.Group("/stats") - statsGroup.Use(authMiddleware(a)) - newStatsRoutes(statsGroup, stats, l) + statsGroup.Use(authMiddleware(a, urlPrefix)) + newStatsRoutes(statsGroup, urlPrefix, stats, l) // Device management deviceGroup := handler.Group("/devices") - deviceGroup.Use(authMiddleware(a)) - newDeviceRoutes(deviceGroup, a, l) + deviceGroup.Use(authMiddleware(a, urlPrefix)) + newDeviceRoutes(deviceGroup, urlPrefix, a, l) } func passStandartContext(c *gin.Context, data gin.H) gin.H { diff --git a/internal/controller/http/web/stats.go b/internal/controller/http/web/stats.go index 7d89167..69f736a 100644 --- a/internal/controller/http/web/stats.go +++ b/internal/controller/http/web/stats.go @@ -117,7 +117,7 @@ func generateDailyStatsChart(stats []stats.DailyStats) ([]byte, error) { return buffer.Bytes(), nil } -func newStatsRoutes(handler *gin.RouterGroup, stats stats.ReadingStats, l logger.Interface) { +func newStatsRoutes(handler *gin.RouterGroup, urlPrefix string, stats stats.ReadingStats, l logger.Interface) { handler.GET("/", func(c *gin.Context) { // Get date range from query params, default to current month now := time.Now() @@ -146,9 +146,10 @@ func newStatsRoutes(handler *gin.RouterGroup, stats stats.ReadingStats, l logger } c.HTML(200, "stats", passStandartContext(c, gin.H{ - "from": from.Format("2006-01-02"), - "to": to.Format("2006-01-02"), - "stats": generalStats, + "urlPrefix": urlPrefix, + "from": from.Format("2006-01-02"), + "to": to.Format("2006-01-02"), + "stats": generalStats, })) }) diff --git a/internal/controller/http/webdav/router.go b/internal/controller/http/webdav/router.go index 5430de0..acf1d1e 100644 --- a/internal/controller/http/webdav/router.go +++ b/internal/controller/http/webdav/router.go @@ -10,7 +10,7 @@ import ( ) func NewRouter( - handler *gin.Engine, + handler *gin.RouterGroup, a auth.AuthInterface, l logger.Interface, rs stats.ReadingStats, diff --git a/web/templates/book.html b/web/templates/book.html index 629d06e..ba4d478 100644 --- a/web/templates/book.html +++ b/web/templates/book.html @@ -5,7 +5,7 @@
- {{.Title}} - {{.Author}} + {{.Title}} - {{.Author}}
@@ -40,7 +40,7 @@
-
diff --git a/web/templates/books.html b/web/templates/books.html index 3eb6322..7b97a6d 100644 --- a/web/templates/books.html +++ b/web/templates/books.html @@ -2,9 +2,9 @@ {{ define "content" }}
-
+
- +
@@ -14,13 +14,13 @@

- + {{.Title}}

diff --git a/web/templates/devices.html b/web/templates/devices.html index e3c6c83..7fbf427 100644 --- a/web/templates/devices.html +++ b/web/templates/devices.html @@ -12,7 +12,7 @@

Device Management

Add New Device

-
+ @@ -34,7 +34,7 @@

Registered Devices

{{.Name}} - + @@ -45,7 +45,7 @@

Reading Statistics

Daily Reading Progress

- Daily Reading Progress