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 (
-
+
@@ -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)}
- />
-
-
+
+
{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.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)