From 7ff317756fc9a140f895c8e2d61f27ffbb4650be Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Sat, 14 Jun 2025 19:14:30 -0400 Subject: [PATCH] feat: version v0.0.2 --- .github/workflows/docker.yml | 14 +++- CHANGELOG.md | 14 ++++ Dockerfile | 13 ++- client/app/app.css | 14 +++- client/app/components/ActivityGrid.tsx | 83 +++++++++++-------- client/app/components/Footer.tsx | 2 +- client/app/components/LastPlays.tsx | 6 +- client/app/components/modals/Account.tsx | 73 +++++++++------- .../app/components/modals/SettingsModal.tsx | 17 +++- client/app/components/sidebar/Sidebar.tsx | 36 +++++--- .../components/themeSwitcher/ThemeOption.tsx | 4 +- client/app/root.tsx | 14 ++-- client/app/routes/Home.tsx | 2 +- client/app/routes/MediaItems/Album.tsx | 2 +- client/app/routes/MediaItems/MediaLayout.tsx | 23 +++-- client/app/routes/MediaItems/Track.tsx | 6 +- client/package.json | 2 +- client/vite.config.ts | 2 +- docs/src/content/docs/guides/importing.md | 5 ++ docs/src/content/docs/guides/installation.md | 4 +- .../content/docs/reference/configuration.md | 5 +- engine/engine.go | 9 +- engine/engine_test.go | 2 +- engine/handlers/alias.go | 16 ++-- engine/middleware/middleware.go | 33 +++++--- engine/middleware/validate.go | 29 +++++-- engine/routes.go | 8 +- internal/catalog/catalog_test.go | 2 +- internal/cfg/cfg.go | 24 ++++-- internal/db/psql/album_test.go | 7 ++ internal/db/psql/artist_test.go | 7 ++ internal/db/psql/psql_test.go | 2 +- internal/db/psql/track_test.go | 7 ++ internal/importer/lastfm.go | 1 + internal/importer/maloja.go | 1 + internal/importer/spotify.go | 1 + 36 files changed, 333 insertions(+), 157 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c1881fe..0df4d53 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -9,9 +9,11 @@ name: Publish Docker image -on: +on: push: branches: [main] + tags: + - 'v*' jobs: test: @@ -64,6 +66,10 @@ jobs: with: images: gabehf/koito + - name: Extract tag version + id: extract_version + run: echo "KOITO_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + - name: Build and push Docker image id: push uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 @@ -71,7 +77,11 @@ jobs: context: . file: ./Dockerfile push: true - tags: gabehf/koito:latest + tags: | + gabehf/koito:latest + gabehf/koito:${{ env.KOITO_VERSION }} + build-args: | + KOITO_VERSION=${{ env.KOITO_VERSION }} - name: Generate artifact attestation uses: actions/attest-build-provenance@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6eee375 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# v0.0.2 +## Features +- Configurable CORS policy via KOITO_CORS_ALLOWED_ORIGINS +- A baseline mobile UI + +## Enhancements +- The import source is now saved as the client for the imported listen. + +## Fixes +- Account update form now works on enter key + +## Updates +- Non-sensitive query parameters are logged with requests +- Koito version number is embedded through tags \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0ae78bb..3c95c9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,22 @@ FROM node AS frontend +ARG KOITO_VERSION +ENV VITE_KOITO_VERSION=$KOITO_VERSION +ENV BUILD_TARGET=docker + WORKDIR /client COPY ./client/package.json ./client/yarn.lock ./ RUN yarn install COPY ./client . -ENV BUILD_TARGET=docker -RUN yarn run build +RUN yarn run build FROM golang:1.23 AS backend +ARG KOITO_VERSION +ENV CGO_ENABLED=1 +ENV GOOS=linux + WORKDIR /app RUN apt-get update && \ @@ -21,7 +28,7 @@ RUN go mod download COPY . . -RUN CGO_ENABLED=1 GOOS=linux go build -o app ./cmd/api +RUN go build -ldflags "-X main.Version=$KOITO_VERSION" -o app ./cmd/api FROM debian:bookworm-slim AS final diff --git a/client/app/app.css b/client/app/app.css index bbc1200..f0b786e 100644 --- a/client/app/app.css +++ b/client/app/app.css @@ -55,7 +55,7 @@ :root { - --header-xl: 78px; + --header-xl: 36px; --header-lg: 28px; --header-md: 22px; --header-sm: 16px; @@ -63,6 +63,18 @@ --header-weight: 600; } +@media (min-width: 60rem) { + :root { + --header-xl: 78px; + --header-lg: 28px; + --header-md: 22px; + --header-sm: 16px; + --header-xl-weight: 600; + --header-weight: 600; + } +} + + html, body { background-color: var(--color-bg); diff --git a/client/app/components/ActivityGrid.tsx b/client/app/components/ActivityGrid.tsx index e9a3cec..a410c39 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query" -import { getActivity, type getActivityArgs } from "api/api" +import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api" import Popup from "./Popup" import { useEffect, useState } from "react" import { useTheme } from "~/hooks/useTheme" @@ -142,44 +142,55 @@ export default function ActivityGrid({ } } - const dotSize = 12; - return ( -
-

Activity

- {configurable ? - - : + const mobileDotSize = 10 + const normalDotSize = 12 + + let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) + + let dotSize = vw > 768 ? normalDotSize : mobileDotSize + + return (
+

Activity

+ {configurable ? ( + + ) : ( '' - } -
- {data.map((item) => ( -
+ {data.map((item) => ( +
+ - -
0 - ? LightenDarkenColor(color, getDarkenAmount(item.listens, 100)) - : 'var(--color-bg-secondary)', - }} - className={`rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`} - >
-
-
- ))} -
+
0 + ? LightenDarkenColor(color, getDarkenAmount(item.listens, 100)) + : 'var(--color-bg-secondary)', + }} + className={`rounded-[2px] md:rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`} + >
+ +
+ ))}
+
+ ); } diff --git a/client/app/components/Footer.tsx b/client/app/components/Footer.tsx index 16d2c94..5bc8150 100644 --- a/client/app/components/Footer.tsx +++ b/client/app/components/Footer.tsx @@ -5,7 +5,7 @@ export default function Footer() { return (
diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx index 056042b..b1eda5e 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -21,7 +21,7 @@ export default function LastPlays(props: Props) { if (isPending) { return ( -
+

Last Played

Loading...

@@ -43,8 +43,8 @@ export default function LastPlays(props: Props) { {data.items.map((item) => ( - {timeSince(new Date(item.time))} - + {timeSince(new Date(item.time))} + {props.hideArtists ? <> : <> - } {item.track.title} diff --git a/client/app/components/modals/Account.tsx b/client/app/components/modals/Account.tsx index 318bc56..06d540e 100644 --- a/client/app/components/modals/Account.tsx +++ b/client/app/components/modals/Account.tsx @@ -25,8 +25,10 @@ export default function Account() { setLoading(false) } const updateHandler = () => { + setError('') + setSuccess('') if (password != "" && confirmPw === "") { - setError("confirm your password before submitting") + setError("confirm your new password before submitting") return } setError('') @@ -58,37 +60,44 @@ export default function Account() { Logout

Update User

-
- setUsername(e.target.value)} - /> -
-
- setPassword(e.target.value)} - /> - setConfirmPw(e.target.value)} - /> -
-
- Submit -
+
e.preventDefault()} className="flex flex-col gap-4"> +
+ setUsername(e.target.value)} + /> +
+
+ Submit +
+
+
e.preventDefault()} className="flex flex-col gap-4"> +
+ setPassword(e.target.value)} + /> + setConfirmPw(e.target.value)} + /> +
+
+ Submit +
+
{success != "" &&

{success}

} {error != "" &&

{error}

} diff --git a/client/app/components/modals/SettingsModal.tsx b/client/app/components/modals/SettingsModal.tsx index bd8a4ce..4ae62d6 100644 --- a/client/app/components/modals/SettingsModal.tsx +++ b/client/app/components/modals/SettingsModal.tsx @@ -16,16 +16,25 @@ export default function SettingsModal({ open, setOpen } : Props) { const { user } = useAppContext() const triggerClasses = "px-4 py-2 w-full hover-bg-secondary rounded-md text-start data-[state=active]:bg-[var(--color-bg-secondary)]" - const contentClasses = "w-full px-10 overflow-y-auto" + const contentClasses = "w-full px-2 mt-8 sm:mt-0 sm:px-10 overflow-y-auto" return ( setOpen(false)} maxW={900}> - - + + Appearance Account - { user && API Keys} + {user && ( + + API Keys + + )} + diff --git a/client/app/components/sidebar/Sidebar.tsx b/client/app/components/sidebar/Sidebar.tsx index f1927e5..11ff824 100644 --- a/client/app/components/sidebar/Sidebar.tsx +++ b/client/app/components/sidebar/Sidebar.tsx @@ -1,22 +1,36 @@ -import { ExternalLink, Home, Info } from "lucide-react"; +import { ExternalLink, Home, Info } from "lucide-react"; import SidebarSearch from "./SidebarSearch"; import SidebarItem from "./SidebarItem"; import SidebarSettings from "./SidebarSettings"; export default function Sidebar() { - const iconSize = 20; return ( -
-
- {}} modal={<>}> - -
-
- } space={22} externalLink to="https://koito.io" name="About" onClick={() => {}} modal={<>}> - +
+
+
+ {}} modal={<>}> + + + +
+
+ } + space={22} + externalLink + to="https://koito.io" + name="About" + onClick={() => {}} + modal={<>} + > + + + +
); -} \ No newline at end of file +} diff --git a/client/app/components/themeSwitcher/ThemeOption.tsx b/client/app/components/themeSwitcher/ThemeOption.tsx index 7cecf76..224fcce 100644 --- a/client/app/components/themeSwitcher/ThemeOption.tsx +++ b/client/app/components/themeSwitcher/ThemeOption.tsx @@ -12,8 +12,8 @@ export default function ThemeOption({ theme, setTheme }: Props) { } return ( -
setTheme(theme.name)} className="rounded-md p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}> - {capitalizeFirstLetter(theme.name)} +
setTheme(theme.name)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}> +
{capitalizeFirstLetter(theme.name)}
diff --git a/client/app/root.tsx b/client/app/root.tsx index 94d7132..e7e2415 100644 --- a/client/app/root.tsx +++ b/client/app/root.tsx @@ -65,12 +65,12 @@ export default function App() { -
- -
- -
-
+
+ +
+ +
+
@@ -117,7 +117,7 @@ export function ErrorBoundary() {
- +

{message}

{details}

diff --git a/client/app/routes/Home.tsx b/client/app/routes/Home.tsx index b340caa..04359a2 100644 --- a/client/app/routes/Home.tsx +++ b/client/app/routes/Home.tsx @@ -29,7 +29,7 @@ export default function Home() {
-
+
diff --git a/client/app/routes/MediaItems/Album.tsx b/client/app/routes/MediaItems/Album.tsx index 77b52df..654fc9e 100644 --- a/client/app/routes/MediaItems/Album.tsx +++ b/client/app/routes/MediaItems/Album.tsx @@ -47,7 +47,7 @@ export default function Album() {
-
+
diff --git a/client/app/routes/MediaItems/MediaLayout.tsx b/client/app/routes/MediaItems/MediaLayout.tsx index a0bf2fb..18a8b78 100644 --- a/client/app/routes/MediaItems/MediaLayout.tsx +++ b/client/app/routes/MediaItems/MediaLayout.tsx @@ -45,6 +45,13 @@ export default function MediaLayout(props: Props) { const title = `${props.title} - Koito` + const mobileIconSize = 22 + const normalIconSize = 30 + + let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0) + + let iconSize = vw > 768 ? normalIconSize : mobileIconSize + return (
-
- {props.title} +
+
+ {props.title} +

{props.type}

{props.title}

{props.subContent}
{ user && -
- - - - +
+ + + + diff --git a/client/app/routes/MediaItems/Track.tsx b/client/app/routes/MediaItems/Track.tsx index c74c7ef..bd08a8f 100644 --- a/client/app/routes/MediaItems/Track.tsx +++ b/client/app/routes/MediaItems/Track.tsx @@ -50,9 +50,9 @@ export default function Track() {
-
- - +
+ +
) diff --git a/client/package.json b/client/package.json index 8bea239..78cfbea 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "koito", - "version": "v0.0.1", + "version": "dev", "private": true, "type": "module", "scripts": { diff --git a/client/vite.config.ts b/client/vite.config.ts index de218ca..7feebd6 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ changeOrigin: true, }, '/images': { - target: 'http://192.168.0.153:4110', + target: 'http://localhost:4110', changeOrigin: true, } } diff --git a/docs/src/content/docs/guides/importing.md b/docs/src/content/docs/guides/importing.md index 26cfb3f..c7c1845 100644 --- a/docs/src/content/docs/guides/importing.md +++ b/docs/src/content/docs/guides/importing.md @@ -45,6 +45,11 @@ durations will be filled in as you submit listens using the API. First, create an export file using [this tool from ghan.nl](https://lastfm.ghan.nl/export/) in JSON format. Then, place the resulting file into the `import` folder in your config directory. Once you restart Koito, it will automatically detect the file as a Last FM import, and begin adding your listen activity immediately. +:::note +LastFM exports do not include track duration information, which means that the 'Hours Listened' statistic may be incorrect after importing. However, track +durations will be filled in as you submit listens using the API. +::: + ## ListenBrainz Create a ListenBrainz export file using [the export tool on the ListenBrainz website](https://listenbrainz.org/settings/export/). Then, place the resulting `.zip` file into the `import` diff --git a/docs/src/content/docs/guides/installation.md b/docs/src/content/docs/guides/installation.md index 36b7022..7242134 100644 --- a/docs/src/content/docs/guides/installation.md +++ b/docs/src/content/docs/guides/installation.md @@ -34,8 +34,8 @@ services: ``` -Be sure to replace `secret_password` with a random password of your choice, and set `KOITO_ALLOWED_HOSTS` to include the domain name or IP address + port you will be accessing Koito -from when using either of the Docker methods described above. +Be sure to replace `secret_password` with a random password of your choice, and set `KOITO_ALLOWED_HOSTS` to include the domain name or IP address you will be accessing Koito +from. Those are the two required environment variables. You can find a full list of configuration options in the [configuration reference](/reference/configuration). diff --git a/docs/src/content/docs/reference/configuration.md b/docs/src/content/docs/reference/configuration.md index 196c006..6524e43 100644 --- a/docs/src/content/docs/reference/configuration.md +++ b/docs/src/content/docs/reference/configuration.md @@ -69,4 +69,7 @@ Koito is configured using **environment variables**. This is the full list of co ##### KOITO_IMPORT_BEFORE_UNIX - Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded. ##### KOITO_IMPORT_AFTER_UNIX -- Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded. \ No newline at end of file +- Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded. +##### KOITO_CORS_ALLOWED_ORIGINS +- Default: No CORS policy +- Description: A comma separated list of origins to allow CORS requests from. The special value `*` allows CORS requests from all origins. \ No newline at end of file diff --git a/engine/engine.go b/engine/engine.go index 264cad0..fdaab5c 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -34,7 +34,7 @@ func Run( w io.Writer, version string, ) error { - err := cfg.Load(getenv) + err := cfg.Load(getenv, version) if err != nil { panic("Engine: Failed to load configuration") } @@ -150,6 +150,12 @@ func Run( l.Info().Msgf("Engine: Allowing hosts: %v", cfg.AllowedHosts()) } + if len(cfg.AllowedOrigins()) == 0 || cfg.AllowedOrigins()[0] == "" { + l.Info().Msgf("Engine: Using default CORS policy") + } else { + l.Info().Msgf("Engine: CORS policy: Allowing origins: %v", cfg.AllowedOrigins()) + } + l.Debug().Msg("Engine: Setting up HTTP server") var ready atomic.Bool mux := chi.NewRouter() @@ -157,6 +163,7 @@ func Run( mux.Use(middleware.Logger(l)) mux.Use(chimiddleware.Recoverer) mux.Use(chimiddleware.RealIP) + mux.Use(middleware.AllowedHosts) bindRoutes(mux, &ready, store, mbzC) httpServer := &http.Server{ diff --git a/engine/engine_test.go b/engine/engine_test.go index d93977e..1c3e197 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -80,7 +80,7 @@ func TestMain(m *testing.M) { } getenv := getTestGetenv(resource) - err = cfg.Load(getenv) + err = cfg.Load(getenv, "test") if err != nil { log.Fatalf("Could not load cfg: %s", err) } diff --git a/engine/handlers/alias.go b/engine/handlers/alias.go index e84377b..add1b09 100644 --- a/engine/handlers/alias.go +++ b/engine/handlers/alias.go @@ -25,12 +25,12 @@ func GetAliasesHandler(store db.DB) http.HandlerFunc { trackIDStr := r.URL.Query().Get("track_id") if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" { - l.Debug().Msgf("Request is missing required parameters") + l.Debug().Msgf("GetAliasesHandler: Request is missing required parameters") utils.WriteError(w, "artist_id, album_id, or track_id must be provided", http.StatusBadRequest) return } if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) { - l.Debug().Msgf("Request is has more than one of artist_id, album_id, and track_id") + l.Debug().Msgf("GetAliasesHandler: Request is has more than one of artist_id, album_id, and track_id") utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest) return } @@ -97,12 +97,12 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc { alias := r.URL.Query().Get("alias") if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") { - l.Debug().Msgf("Request is missing required parameters") + l.Debug().Msgf("DeleteAliasHandler: Request is missing required parameters") utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest) return } if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) { - l.Debug().Msgf("Request is has more than one of artist_id, album_id, and track_id") + l.Debug().Msgf("DeleteAliasHandler: Request is has more than one of artist_id, album_id, and track_id") utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest) return } @@ -177,12 +177,12 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc { trackIDStr := r.URL.Query().Get("track_id") if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") { - l.Debug().Msgf("Request is missing required parameters") + l.Debug().Msgf("CreateAliasHandler: Request is missing required parameters") utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest) return } if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) { - l.Debug().Msgf("Request is has more than one of artist_id, album_id, and track_id") + l.Debug().Msgf("CreateAliasHandler: Request is has more than one of artist_id, album_id, and track_id") utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest) return } @@ -247,12 +247,12 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc { alias := r.URL.Query().Get("alias") if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") { - l.Debug().Msgf("Request is missing required parameters") + l.Debug().Msgf("SetPrimaryAliasHandler: Request is missing required parameters") utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest) return } if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) { - l.Debug().Msgf("Request is has more than one of artist_id, album_id, and track_id") + l.Debug().Msgf("SetPrimaryAliasHandler: Request is has more than one of artist_id, album_id, and track_id") utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest) return } diff --git a/engine/middleware/middleware.go b/engine/middleware/middleware.go index b1b5f48..886a290 100644 --- a/engine/middleware/middleware.go +++ b/engine/middleware/middleware.go @@ -3,6 +3,7 @@ package middleware import ( "context" "crypto/rand" + "fmt" "math/big" "net/http" "runtime/debug" @@ -63,9 +64,21 @@ func Logger(baseLogger *zerolog.Logger) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { reqID := GetRequestID(r.Context()) - l := baseLogger.With().Str("request_id", reqID).Logger() - // Inject logger with request_id into the context + loggerCtx := baseLogger.With().Str("request_id", reqID) + + for key, values := range r.URL.Query() { + if strings.Contains(strings.ToLower(key), "password") { + continue + } + if len(values) > 0 { + loggerCtx = loggerCtx.Str(fmt.Sprintf("query.%s", key), values[0]) + } + } + + l := loggerCtx.Logger() + + // Inject logger into context r = logger.Inject(r, &l) ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) @@ -82,20 +95,18 @@ func Logger(baseLogger *zerolog.Logger) func(next http.Handler) http.Handler { utils.WriteError(ww, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + pathS := strings.Split(r.URL.Path, "/") + msg := fmt.Sprintf("Received %s %s - Responded with %d in %.2fms", + r.Method, r.URL.Path, ww.Status(), float64(t2.Sub(t1).Nanoseconds())/1_000_000.0) + if len(pathS) > 1 && pathS[1] == "apis" { - l.Info(). - Str("type", "access"). - Timestamp(). - Msgf("Received %s %s - Responded with %d in %.2fms", r.Method, r.URL.Path, ww.Status(), float64(t2.Sub(t1).Nanoseconds())/1_000_000.0) + l.Info().Str("type", "access").Timestamp().Msg(msg) } else { - l.Debug(). - Str("type", "access"). - Timestamp(). - Msgf("Received %s %s - Responded with %d in %.2fms", r.Method, r.URL.Path, ww.Status(), float64(t2.Sub(t1).Nanoseconds())/1_000_000.0) + l.Debug().Str("type", "access").Timestamp().Msg(msg) } - }() + next.ServeHTTP(ww, r) } return http.HandlerFunc(fn) diff --git a/engine/middleware/validate.go b/engine/middleware/validate.go index 42b7068..fc08a4f 100644 --- a/engine/middleware/validate.go +++ b/engine/middleware/validate.go @@ -2,6 +2,7 @@ package middleware import ( "context" + "fmt" "net/http" "strings" "time" @@ -24,25 +25,34 @@ func ValidateSession(store db.DB) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { l := logger.FromContext(r.Context()) + + l.Debug().Msgf("ValidateSession: Checking user authentication via session cookie") + cookie, err := r.Cookie("koito_session") var sid uuid.UUID if err == nil { sid, err = uuid.Parse(cookie.Value) if err != nil { + l.Err(err).Msg("ValidateSession: Could not parse UUID from session cookie") utils.WriteError(w, "session cookie is invalid", http.StatusUnauthorized) return } + } else { + l.Debug().Msgf("ValidateSession: No session cookie found; attempting API key authentication") + utils.WriteError(w, "session cookie is missing", http.StatusUnauthorized) + return } - l.Debug().Msg("Retrieved login cookie from request") + l.Debug().Msg("ValidateSession: Retrieved login cookie from request") u, err := store.GetUserBySession(r.Context(), sid) if err != nil { - l.Err(err).Msg("Failed to get user from session") + l.Err(fmt.Errorf("ValidateSession: %w", err)).Msg("Error accessing database") utils.WriteError(w, "internal server error", http.StatusInternalServerError) return } if u == nil { + l.Debug().Msg("ValidateSession: No user with session id found") utils.WriteError(w, "unauthorized", http.StatusUnauthorized) return } @@ -50,11 +60,11 @@ func ValidateSession(store db.DB) func(next http.Handler) http.Handler { ctx := context.WithValue(r.Context(), UserContextKey, u) r = r.WithContext(ctx) - l.Debug().Msgf("Refreshing session for user '%s'", u.Username) + l.Debug().Msgf("ValidateSession: Refreshing session for user '%s'", u.Username) store.RefreshSession(r.Context(), sid, time.Now().Add(30*24*time.Hour)) - l.Debug().Msgf("Refreshed session for user '%s'", u.Username) + l.Debug().Msgf("ValidateSession: Refreshed session for user '%s'", u.Username) next.ServeHTTP(w, r) }) @@ -67,10 +77,19 @@ func ValidateApiKey(store db.DB) func(next http.Handler) http.Handler { ctx := r.Context() l := logger.FromContext(ctx) + l.Debug().Msg("ValidateApiKey: Checking if user is already authenticated") + + u := GetUserFromContext(ctx) + if u != nil { + l.Debug().Msg("ValidateApiKey: User is already authenticated; skipping API key authentication") + next.ServeHTTP(w, r) + return + } + authh := r.Header.Get("Authorization") s := strings.Split(authh, "Token ") if len(s) < 2 { - l.Debug().Msg("Authorization header must be formatted 'Token {token}'") + l.Debug().Msg("ValidateApiKey: Authorization header must be formatted 'Token {token}'") utils.WriteError(w, "unauthorized", http.StatusUnauthorized) return } diff --git a/engine/routes.go b/engine/routes.go index 732fadd..4b7d302 100644 --- a/engine/routes.go +++ b/engine/routes.go @@ -25,12 +25,16 @@ func bindRoutes( db db.DB, mbz mbz.MusicBrainzCaller, ) { + if !(len(cfg.AllowedOrigins()) == 0) && !(cfg.AllowedOrigins()[0] == "") { + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: cfg.AllowedOrigins(), + AllowedMethods: []string{"GET", "OPTIONS", "HEAD"}, + })) + } r.With(chimiddleware.RequestSize(5<<20)). - With(middleware.AllowedHosts). Get("/images/{size}/{filename}", handlers.ImageHandler(db)) r.Route("/apis/web/v1", func(r chi.Router) { - r.Use(middleware.AllowedHosts) r.Get("/artist", handlers.GetArtistHandler(db)) r.Get("/album", handlers.GetAlbumHandler(db)) r.Get("/track", handlers.GetTrackHandler(db)) diff --git a/internal/catalog/catalog_test.go b/internal/catalog/catalog_test.go index 6148466..039fe1c 100644 --- a/internal/catalog/catalog_test.go +++ b/internal/catalog/catalog_test.go @@ -268,7 +268,7 @@ func TestMain(m *testing.M) { log.Fatalf("Could not start resource: %s", err) } - err = cfg.Load(getTestGetenv(resource)) + err = cfg.Load(getTestGetenv(resource), "test") if err != nil { log.Fatalf("Could not load cfg: %s", err) } diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index b976d24..0d65f7a 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -36,6 +36,7 @@ const ( DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT" ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS" + CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS" DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT" THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS" IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX" @@ -64,6 +65,7 @@ type config struct { skipImport bool allowedHosts []string allowAllHosts bool + allowedOrigins []string disableRateLimit bool importThrottleMs int userAgent string @@ -78,21 +80,18 @@ var ( ) // Initialize initializes the global configuration using the provided getenv function. -func Load(getenv func(string) string) error { +func Load(getenv func(string) string, version string) error { var err error once.Do(func() { - globalConfig, err = loadConfig(getenv) + globalConfig, err = loadConfig(getenv, version) }) return err } // loadConfig loads the configuration from environment variables. -func loadConfig(getenv func(string) string) (*config, error) { +func loadConfig(getenv func(string) string, version string) (*config, error) { cfg := new(config) - // cfg.baseUrl = getenv(BASE_URL_ENV) - // if cfg.baseUrl == "" { - // cfg.baseUrl = defaultBaseUrl - // } + cfg.databaseUrl = getenv(DATABASE_URL_ENV) if cfg.databaseUrl == "" { return nil, errors.New("required parameter " + DATABASE_URL_ENV + " not provided") @@ -139,7 +138,7 @@ func loadConfig(getenv func(string) string) (*config, error) { cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV)) cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV)) - cfg.userAgent = "Koito v0.0.1 (contact@koito.io)" + cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version) if getenv(DEFAULT_USERNAME_ENV) == "" { cfg.defaultUsername = "admin" @@ -161,6 +160,9 @@ func loadConfig(getenv func(string) string) (*config, error) { cfg.allowedHosts = strings.Split(rawHosts, ",") cfg.allowAllHosts = cfg.allowedHosts[0] == "*" + rawCors := getenv(CORS_ORIGINS_ENV) + cfg.allowedOrigins = strings.Split(rawCors, ",") + switch strings.ToLower(getenv(LOG_LEVEL_ENV)) { case "debug": cfg.logLevel = 0 @@ -312,6 +314,12 @@ func AllowAllHosts() bool { return globalConfig.allowAllHosts } +func AllowedOrigins() []string { + lock.RLock() + defer lock.RUnlock() + return globalConfig.allowedOrigins +} + func RateLimitDisabled() bool { lock.RLock() defer lock.RUnlock() diff --git a/internal/db/psql/album_test.go b/internal/db/psql/album_test.go index 4b30203..373abdb 100644 --- a/internal/db/psql/album_test.go +++ b/internal/db/psql/album_test.go @@ -281,6 +281,13 @@ func TestDeleteAlbumAlias(t *testing.T) { require.NoError(t, err) assert.True(t, exists, "expected alias to still exist") + // Ensure primary alias cannot be deleted + err = store.DeleteAlbumAlias(ctx, rg.ID, "Test Album") + require.NoError(t, err) // shouldn't error when nothing is deleted + rg, err = store.GetAlbum(ctx, db.GetAlbumOpts{ID: rg.ID}) + require.NoError(t, err) + assert.Equal(t, "Test Album", rg.Title) + truncateTestData(t) } func TestGetAllAlbumAliases(t *testing.T) { diff --git a/internal/db/psql/artist_test.go b/internal/db/psql/artist_test.go index 6ed5c4f..4928988 100644 --- a/internal/db/psql/artist_test.go +++ b/internal/db/psql/artist_test.go @@ -195,6 +195,13 @@ func TestDeleteArtistAlias(t *testing.T) { require.NoError(t, err) assert.True(t, exists, "expected alias to still exist") + // Ensure primary alias cannot be deleted + err = store.DeleteArtistAlias(ctx, artist.ID, "Alias Artist") + require.NoError(t, err) // shouldn't error when nothing is deleted + artist, err = store.GetArtist(ctx, db.GetArtistOpts{ID: 1}) + require.NoError(t, err) + assert.Equal(t, "Alias Artist", artist.Name) + truncateTestData(t) } func TestDeleteArtist(t *testing.T) { diff --git a/internal/db/psql/psql_test.go b/internal/db/psql/psql_test.go index 7da1ce4..46303aa 100644 --- a/internal/db/psql/psql_test.go +++ b/internal/db/psql/psql_test.go @@ -45,7 +45,7 @@ func TestMain(m *testing.M) { log.Fatalf("Could not start resource: %s", err) } - err = cfg.Load(getTestGetenv(resource)) + err = cfg.Load(getTestGetenv(resource), "test") if err != nil { log.Fatalf("Could not load cfg: %s", err) } diff --git a/internal/db/psql/track_test.go b/internal/db/psql/track_test.go index 73bf4e0..ac79423 100644 --- a/internal/db/psql/track_test.go +++ b/internal/db/psql/track_test.go @@ -198,6 +198,13 @@ func TestTrackAliases(t *testing.T) { err = store.SetPrimaryTrackAlias(ctx, 1, "Fake Alias") require.Error(t, err) + // Ensure primary alias cannot be deleted + err = store.DeleteTrackAlias(ctx, track.ID, "Alias One") + require.NoError(t, err) // shouldn't error when nothing is deleted + track, err = store.GetTrack(ctx, db.GetTrackOpts{ID: 1}) + require.NoError(t, err) + assert.Equal(t, "Alias One", track.Title) + store.SetPrimaryTrackAlias(ctx, 1, "Track One") } diff --git a/internal/importer/lastfm.go b/internal/importer/lastfm.go index 1fd6d7c..f01e4b1 100644 --- a/internal/importer/lastfm.go +++ b/internal/importer/lastfm.go @@ -105,6 +105,7 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall RecordingMbzID: trackMbzID, ReleaseTitle: album, ReleaseMbzID: albumMbzID, + Client: "lastfm", Time: ts, UserID: 1, } diff --git a/internal/importer/maloja.go b/internal/importer/maloja.go index 3343af7..4265b98 100644 --- a/internal/importer/maloja.go +++ b/internal/importer/maloja.go @@ -77,6 +77,7 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error { TrackTitle: item.Track.Title, ReleaseTitle: item.Track.Album.Title, Time: ts.Local(), + Client: "maloja", UserID: 1, } err = catalog.SubmitListen(ctx, store, opts) diff --git a/internal/importer/spotify.go b/internal/importer/spotify.go index 9b2cd81..9e9073c 100644 --- a/internal/importer/spotify.go +++ b/internal/importer/spotify.go @@ -64,6 +64,7 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error ReleaseTitle: item.AlbumName, Duration: dur / 1000, Time: item.Timestamp, + Client: "spotify", UserID: 1, } err = catalog.SubmitListen(ctx, store, opts)