From daa1bb2456ecf64a5b143cfa216bb0504ab265e6 Mon Sep 17 00:00:00 2001
From: Gabe Farrell <90876006+gabehf@users.noreply.github.com>
Date: Thu, 20 Nov 2025 22:50:15 -0500
Subject: [PATCH] feat: config to gate all statistics behind login (#99)
* feat: gate all stats behind login
* docs: add config reference for login gate
---
client/app/components/LastPlays.tsx | 2 +
client/app/components/TopAlbums.tsx | 93 +++++++++++-------
client/app/components/TopArtists.tsx | 81 ++++++++--------
client/app/components/TopTracks.tsx | 95 +++++++++++--------
.../content/docs/reference/configuration.md | 3 +
engine/routes.go | 32 ++++---
internal/cfg/cfg.go | 12 +++
7 files changed, 191 insertions(+), 127 deletions(-)
diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx
index c6687b4..af95bf0 100644
--- a/client/app/components/LastPlays.tsx
+++ b/client/app/components/LastPlays.tsx
@@ -72,6 +72,8 @@ export default function LastPlays(props: Props) {
return
Error: {error.message}
;
}
+ if (!data.items) return;
+
const listens = items ?? data.items;
let params = "";
diff --git a/client/app/components/TopAlbums.tsx b/client/app/components/TopAlbums.tsx
index 4ae87bd..6f034c5 100644
--- a/client/app/components/TopAlbums.tsx
+++ b/client/app/components/TopAlbums.tsx
@@ -1,42 +1,63 @@
-import { useQuery } from "@tanstack/react-query"
-import ArtistLinks from "./ArtistLinks"
-import { getTopAlbums, getTopTracks, imageUrl, type getItemsArgs } from "api/api"
-import { Link } from "react-router"
-import TopListSkeleton from "./skeletons/TopListSkeleton"
-import TopItemList from "./TopItemList"
+import { useQuery } from "@tanstack/react-query";
+import ArtistLinks from "./ArtistLinks";
+import {
+ getTopAlbums,
+ getTopTracks,
+ imageUrl,
+ type getItemsArgs,
+} from "api/api";
+import { Link } from "react-router";
+import TopListSkeleton from "./skeletons/TopListSkeleton";
+import TopItemList from "./TopItemList";
interface Props {
- limit: number,
- period: string,
- artistId?: Number
+ limit: number;
+ period: string;
+ artistId?: Number;
}
-export default function TopAlbums (props: Props) {
-
- const { isPending, isError, data, error } = useQuery({
- queryKey: ['top-albums', {limit: props.limit, period: props.period, artistId: props.artistId, page: 0 }],
- queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
- })
-
- if (isPending) {
- return (
-
-
Top Albums
-
Loading...
-
- )
- }
- if (isError) {
- return Error:{error.message}
- }
+export default function TopAlbums(props: Props) {
+ const { isPending, isError, data, error } = useQuery({
+ queryKey: [
+ "top-albums",
+ {
+ limit: props.limit,
+ period: props.period,
+ artistId: props.artistId,
+ page: 0,
+ },
+ ],
+ queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
+ });
+ if (isPending) {
return (
-
-
Top Albums
-
-
- {data.items.length < 1 ? 'Nothing to show' : ''}
-
-
- )
-}
\ No newline at end of file
+
+
Top Albums
+
Loading...
+
+ );
+ }
+ if (isError) {
+ return Error:{error.message}
;
+ }
+ if (!data.items) return;
+
+ return (
+
+
+
+ Top Albums
+
+
+
+
+ {data.items.length < 1 ? "Nothing to show" : ""}
+
+
+ );
+}
diff --git a/client/app/components/TopArtists.tsx b/client/app/components/TopArtists.tsx
index 1c7b719..fbe83ee 100644
--- a/client/app/components/TopArtists.tsx
+++ b/client/app/components/TopArtists.tsx
@@ -1,43 +1,50 @@
-import { useQuery } from "@tanstack/react-query"
-import ArtistLinks from "./ArtistLinks"
-import { getTopArtists, imageUrl, type getItemsArgs } from "api/api"
-import { Link } from "react-router"
-import TopListSkeleton from "./skeletons/TopListSkeleton"
-import TopItemList from "./TopItemList"
+import { useQuery } from "@tanstack/react-query";
+import ArtistLinks from "./ArtistLinks";
+import { getTopArtists, imageUrl, type getItemsArgs } from "api/api";
+import { Link } from "react-router";
+import TopListSkeleton from "./skeletons/TopListSkeleton";
+import TopItemList from "./TopItemList";
interface Props {
- limit: number,
- period: string,
- artistId?: Number
- albumId?: Number
+ limit: number;
+ period: string;
+ artistId?: Number;
+ albumId?: Number;
}
-export default function TopArtists (props: Props) {
-
- const { isPending, isError, data, error } = useQuery({
- queryKey: ['top-artists', {limit: props.limit, period: props.period, page: 0 }],
- queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs),
- })
-
- if (isPending) {
- return (
-
-
Top Artists
-
Loading...
-
- )
- }
- if (isError) {
- return Error:{error.message}
- }
+export default function TopArtists(props: Props) {
+ const { isPending, isError, data, error } = useQuery({
+ queryKey: [
+ "top-artists",
+ { limit: props.limit, period: props.period, page: 0 },
+ ],
+ queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs),
+ });
+ if (isPending) {
return (
-
-
Top Artists
-
-
- {data.items.length < 1 ? 'Nothing to show' : ''}
-
-
- )
-}
\ No newline at end of file
+
+
Top Artists
+
Loading...
+
+ );
+ }
+ if (isError) {
+ return Error:{error.message}
;
+ }
+ if (!data.items) return;
+
+ return (
+
+
+
+ Top Artists
+
+
+
+
+ {data.items.length < 1 ? "Nothing to show" : ""}
+
+
+ );
+}
diff --git a/client/app/components/TopTracks.tsx b/client/app/components/TopTracks.tsx
index b1d14c7..5dc3950 100644
--- a/client/app/components/TopTracks.tsx
+++ b/client/app/components/TopTracks.tsx
@@ -1,50 +1,63 @@
-import { useQuery } from "@tanstack/react-query"
-import ArtistLinks from "./ArtistLinks"
-import { getTopTracks, imageUrl, type getItemsArgs } from "api/api"
-import { Link } from "react-router"
-import TopListSkeleton from "./skeletons/TopListSkeleton"
-import { useEffect } from "react"
-import TopItemList from "./TopItemList"
+import { useQuery } from "@tanstack/react-query";
+import ArtistLinks from "./ArtistLinks";
+import { getTopTracks, imageUrl, type getItemsArgs } from "api/api";
+import { Link } from "react-router";
+import TopListSkeleton from "./skeletons/TopListSkeleton";
+import { useEffect } from "react";
+import TopItemList from "./TopItemList";
interface Props {
- limit: number,
- period: string,
- artistId?: Number
- albumId?: Number
+ limit: number;
+ period: string;
+ artistId?: Number;
+ albumId?: Number;
}
const TopTracks = (props: Props) => {
+ const { isPending, isError, data, error } = useQuery({
+ queryKey: [
+ "top-tracks",
+ {
+ limit: props.limit,
+ period: props.period,
+ artist_id: props.artistId,
+ album_id: props.albumId,
+ page: 0,
+ },
+ ],
+ queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
+ });
- const { isPending, isError, data, error } = useQuery({
- queryKey: ['top-tracks', {limit: props.limit, period: props.period, artist_id: props.artistId, album_id: props.albumId, page: 0}],
- queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
- })
+ if (isPending) {
+ return (
+
+
Top Tracks
+
Loading...
+
+ );
+ }
+ if (isError) {
+ return Error:{error.message}
;
+ }
+ if (!data.items) return;
- if (isPending) {
- return (
-
-
Top Tracks
-
Loading...
-
- )
- }
- if (isError) {
- return Error:{error.message}
- }
-
- let params = ''
- params += props.artistId ? `&artist_id=${props.artistId}` : ''
- params += props.albumId ? `&album_id=${props.albumId}` : ''
+ let params = "";
+ params += props.artistId ? `&artist_id=${props.artistId}` : "";
+ params += props.albumId ? `&album_id=${props.albumId}` : "";
- return (
-
-
Top Tracks
-
-
- {data.items.length < 1 ? 'Nothing to show' : ''}
-
-
- )
-}
+ return (
+
+
+
+ Top Tracks
+
+
+
+
+ {data.items.length < 1 ? "Nothing to show" : ""}
+
+
+ );
+};
-export default TopTracks
\ No newline at end of file
+export default TopTracks;
diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md
index e22398f..4e806a0 100644
--- a/docs/src/content/docs/reference/configuration.md
+++ b/docs/src/content/docs/reference/configuration.md
@@ -26,6 +26,9 @@ If the environment variable is defined without **and** with the suffix at the sa
##### KOITO_DEFAULT_THEME
- Default: `yuu`
- Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher.
+##### KOITO_LOGIN_GATE
+- Default: `false`
+- Description: When `true`, Koito will not show any statistics unless the user is logged in.
##### KOITO_BIND_ADDR
- Description: The address to bind to. The default blank value is equivalent to `0.0.0.0`.
##### KOITO_LISTEN_PORT
diff --git a/engine/routes.go b/engine/routes.go
index e218752..e792e25 100644
--- a/engine/routes.go
+++ b/engine/routes.go
@@ -36,19 +36,25 @@ func bindRoutes(
r.Route("/apis/web/v1", func(r chi.Router) {
r.Get("/config", handlers.GetCfgHandler())
- r.Get("/artist", handlers.GetArtistHandler(db))
- r.Get("/artists", handlers.GetArtistsForItemHandler(db))
- r.Get("/album", handlers.GetAlbumHandler(db))
- r.Get("/track", handlers.GetTrackHandler(db))
- r.Get("/top-tracks", handlers.GetTopTracksHandler(db))
- r.Get("/top-albums", handlers.GetTopAlbumsHandler(db))
- r.Get("/top-artists", handlers.GetTopArtistsHandler(db))
- r.Get("/listens", handlers.GetListensHandler(db))
- r.Get("/listen-activity", handlers.GetListenActivityHandler(db))
- r.Get("/now-playing", handlers.NowPlayingHandler(db))
- r.Get("/stats", handlers.StatsHandler(db))
- r.Get("/search", handlers.SearchHandler(db))
- r.Get("/aliases", handlers.GetAliasesHandler(db))
+
+ r.Group(func(r chi.Router) {
+ if cfg.LoginGate() {
+ r.Use(middleware.ValidateSession(db))
+ }
+ r.Get("/artist", handlers.GetArtistHandler(db))
+ r.Get("/artists", handlers.GetArtistsForItemHandler(db))
+ r.Get("/album", handlers.GetAlbumHandler(db))
+ r.Get("/track", handlers.GetTrackHandler(db))
+ r.Get("/top-tracks", handlers.GetTopTracksHandler(db))
+ r.Get("/top-albums", handlers.GetTopAlbumsHandler(db))
+ r.Get("/top-artists", handlers.GetTopArtistsHandler(db))
+ r.Get("/listens", handlers.GetListensHandler(db))
+ r.Get("/listen-activity", handlers.GetListenActivityHandler(db))
+ r.Get("/now-playing", handlers.NowPlayingHandler(db))
+ r.Get("/stats", handlers.StatsHandler(db))
+ r.Get("/search", handlers.SearchHandler(db))
+ r.Get("/aliases", handlers.GetAliasesHandler(db))
+ })
r.Post("/logout", handlers.LogoutHandler(db))
if !cfg.RateLimitDisabled() {
r.With(httprate.Limit(
diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go
index 8f40a36..9e537eb 100644
--- a/internal/cfg/cfg.go
+++ b/internal/cfg/cfg.go
@@ -47,6 +47,7 @@ const (
IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX"
FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT"
ARTIST_SEPARATORS_ENV = "KOITO_ARTIST_SEPARATORS_REGEX"
+ LOGIN_GATE_ENV = "KOITO_LOGIN_GATE"
)
type config struct {
@@ -83,6 +84,7 @@ type config struct {
importBefore time.Time
importAfter time.Time
artistSeparators []*regexp.Regexp
+ loginGate bool
}
var (
@@ -204,6 +206,10 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
cfg.artistSeparators = []*regexp.Regexp{regexp.MustCompile(`\s+ยท\s+`)}
}
+ if strings.ToLower(getenv(LOGIN_GATE_ENV)) == "true" {
+ cfg.loginGate = true
+ }
+
switch strings.ToLower(getenv(LOG_LEVEL_ENV)) {
case "debug":
cfg.logLevel = 0
@@ -409,3 +415,9 @@ func ArtistSeparators() []*regexp.Regexp {
defer lock.RUnlock()
return globalConfig.artistSeparators
}
+
+func LoginGate() bool {
+ lock.RLock()
+ defer lock.RUnlock()
+ return globalConfig.loginGate
+}