feat: version v0.0.2

pull/20/head v0.0.2
Gabe Farrell 6 months ago
parent 0dceaf017a
commit 7ff317756f

@ -12,6 +12,8 @@ name: Publish Docker image
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

@ -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

@ -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

@ -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);

@ -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 (
<div className="flex flex-col items-start">
<h2>Activity</h2>
{configurable ?
<ActivityOptsSelector rangeSetter={setRange} currentRange={rangeState} stepSetter={setStep} currentStep={stepState} />
:
const mobileDotSize = 10
const normalDotSize = 12
let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
let dotSize = vw > 768 ? normalDotSize : mobileDotSize
return (<div className="flex flex-col items-start">
<h2>Activity</h2>
{configurable ? (
<ActivityOptsSelector
rangeSetter={setRange}
currentRange={rangeState}
stepSetter={setStep}
currentStep={stepState}
/>
) : (
''
}
<div className="grid grid-flow-col grid-rows-7 gap-[5px]">
{data.map((item) => (
<div
key={new Date(item.start_time).toString()}
style={{ width: dotSize, height: dotSize }}
)}
<div className="flex flex-row flex-wrap w-[94px] md:w-auto md:grid md:grid-flow-col md:grid-cols-7 md:grid-rows-7 gap-[4px] md:gap-[5px]">
{data.map((item) => (
<div
key={new Date(item.start_time).toString()}
style={{ width: dotSize, height: dotSize }}
>
<Popup
position="top"
space={dotSize}
extraClasses="left-2"
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
>
<Popup
position="top"
space={dotSize}
extraClasses="left-2"
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
>
<div
style={{
width: dotSize,
height: dotSize,
display: 'inline-block',
background:
item.listens > 0
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
: 'var(--color-bg-secondary)',
}}
className={`rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
></div>
</Popup>
</div>
))}
</div>
<div
style={{
display: 'inline-block',
width: dotSize,
height: dotSize,
background:
item.listens > 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)'}`}
></div>
</Popup>
</div>
))}
</div>
</div>
);
}

@ -5,7 +5,7 @@ export default function Footer() {
return (
<div className="mx-auto py-10 pt-20 color-fg-tertiary text-sm">
<ul className="flex flex-col items-center w-sm justify-around">
<li>Koito {pkg.version}</li>
<li>Koito {import.meta.env.VITE_KOITO_VERSION || pkg.version}</li>
<li><a href="https://github.com/gabehf/koito" target="_blank" className="link-underline">View the source on GitHub <ExternalLinkIcon className='inline mb-1' size={14}/></a></li>
</ul>
</div>

@ -21,7 +21,7 @@ export default function LastPlays(props: Props) {
if (isPending) {
return (
<div className="w-[500px]">
<div className="w-[400px] sm:w-[500px]">
<h2>Last Played</h2>
<p>Loading...</p>
</div>
@ -43,8 +43,8 @@ export default function LastPlays(props: Props) {
<tbody>
{data.items.map((item) => (
<tr key={`last_listen_${item.time}`}>
<td className="color-fg-tertiary pr-4 text-sm" title={new Date(item.time).toString()}>{timeSince(new Date(item.time))}</td>
<td className="text-ellipsis overflow-hidden max-w-[600px]">
<td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0" title={new Date(item.time).toString()}>{timeSince(new Date(item.time))}</td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{props.hideArtists ? <></> : <><ArtistLinks artists={item.track.artists} /> - </>}
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
</td>

@ -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() {
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
</div>
<h2>Update User</h2>
<div className="flex flex gap-4">
<input
name="koito-update-username"
type="text"
placeholder="Update username"
className="w-full mx-auto fg bg rounded p-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="flex flex gap-4">
<input
name="koito-update-password"
type="password"
placeholder="Update password"
className="w-full mx-auto fg bg rounded p-2"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<input
name="koito-confirm-password"
type="password"
placeholder="Confirm new password"
className="w-full mx-auto fg bg rounded p-2"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
</div>
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
<div className="flex flex gap-4">
<input
name="koito-update-username"
type="text"
placeholder="Update username"
className="w-full mx-auto fg bg rounded p-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
</div>
</form>
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
<div className="flex flex gap-4">
<input
name="koito-update-password"
type="password"
placeholder="Update password"
className="w-full mx-auto fg bg rounded p-2"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<input
name="koito-confirm-password"
type="password"
placeholder="Confirm new password"
className="w-full mx-auto fg bg rounded p-2"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
</div>
</form>
{success != "" && <p className="success">{success}</p>}
{error != "" && <p className="error">{error}</p>}
</div>

@ -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 (
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
<Tabs defaultValue="Appearance" orientation="vertical" className="flex justify-between h-full">
<TabsList className="w-full flex flex-col gap-1 items-start max-w-1/4 rounded-md bg p-2 grow-0">
<Tabs
defaultValue="Appearance"
orientation="vertical" // still vertical, but layout is responsive via Tailwind
className="flex flex-col sm:flex-row h-full"
>
<TabsList className="flex flex-row sm:flex-col gap-1 w-full sm:max-w-1/4 rounded-md bg p-2">
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
{ user && <TabsTrigger className={triggerClasses} value="API Keys">API Keys</TabsTrigger>}
{user && (
<TabsTrigger className={triggerClasses} value="API Keys">
API Keys
</TabsTrigger>
)}
</TabsList>
<TabsContent value="Account" className={contentClasses}>
<AccountPage />
</TabsContent>

@ -1,21 +1,35 @@
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 (
<div className="z-50 flex flex-col justify-between h-screen border-r-1 border-(--color-bg-tertiary) p-1 py-10 sticky left-0 top-0 bg-(--color-bg)">
<div className="flex flex-col gap-4">
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}><Home size={iconSize} /></SidebarItem>
<SidebarSearch size={iconSize} />
</div>
<div className="flex flex-col gap-4">
<SidebarItem icon keyHint={<ExternalLink size={14} />} space={22} externalLink to="https://koito.io" name="About" onClick={() => {}} modal={<></>}><Info size={iconSize} /></SidebarItem>
<SidebarSettings size={iconSize} />
<div className="overflow-x-hidden w-full sm:w-auto">
<div className="z-50 flex sm:flex-col justify-between sm:h-screen h-auto sm:w-auto w-full border-b sm:border-b-0 sm:border-r border-(--color-bg-tertiary) pt-2 sm:py-10 sm:px-1 px-4 sticky top-0 sm:left-0 bg-(--color-bg)">
<div className="flex gap-4 sm:flex-col">
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}>
<Home size={iconSize} />
</SidebarItem>
<SidebarSearch size={iconSize} />
</div>
<div className="flex gap-4 sm:flex-col">
<SidebarItem
icon
keyHint={<ExternalLink size={14} />}
space={22}
externalLink
to="https://koito.io"
name="About"
onClick={() => {}}
modal={<></>}
>
<Info size={iconSize} />
</SidebarItem>
<SidebarSettings size={iconSize} />
</div>
</div>
</div>
);

@ -12,8 +12,8 @@ export default function ThemeOption({ theme, setTheme }: Props) {
}
return (
<div onClick={() => 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)}
<div onClick={() => 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}}>
<div className="text-xs sm:text-sm">{capitalizeFirstLetter(theme.name)}</div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>

@ -65,12 +65,12 @@ export default function App() {
<AppProvider>
<ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<div className="flex">
<Sidebar />
<div className="flex flex-col items-center mx-auto w-full">
<Outlet />
<Footer />
</div>
<div className="flex-col flex sm:flex-row">
<Sidebar />
<div className="flex flex-col items-center mx-auto w-full">
<Outlet />
<Footer />
</div>
</div>
</QueryClientProvider>
</ThemeProvider>
@ -117,7 +117,7 @@ export function ErrorBoundary() {
<div className="w-full flex flex-col">
<main className="pt-16 p-4 container mx-auto flex-grow">
<div className="flex gap-4 items-end">
<img className="w-[200px] rounded" src="../public/yuu.jpg" />
<img className="w-[200px] rounded" src="../yuu.jpg" />
<div>
<h1>{message}</h1>
<p>{details}</p>

@ -29,7 +29,7 @@ export default function Home() {
<ActivityGrid />
</div>
<PeriodSelector setter={setPeriod} current={period} />
<div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-around gap-5">
<div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-between mx-5 gap-5">
<TopArtists period={period} limit={homeItems} />
<TopAlbums period={period} limit={homeItems} />
<TopTracks period={period} limit={homeItems} />

@ -47,7 +47,7 @@ export default function Album() {
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex gap-20 mt-10">
<div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={30} albumId={album.id} />
<TopTracks limit={12} period={period} albumId={album.id} />
<ActivityGrid autoAdjust configurable albumId={album.id} />

@ -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 (
<main
className="w-full flex flex-col flex-grow"
@ -61,19 +68,21 @@ export default function MediaLayout(props: Props) {
content={title}
/>
<div className="w-19/20 mx-auto pt-12">
<div className="flex gap-8 relative">
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="w-sm shadow-(--color-shadow) shadow-lg" />
<div className="flex gap-8 flex-wrap relative">
<div className="flex flex-col justify-around">
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="md:w-sm w-[220px] h-auto shadow-(--color-shadow) shadow-lg" />
</div>
<div className="flex flex-col items-start">
<h3>{props.type}</h3>
<h1>{props.title}</h1>
{props.subContent}
</div>
{ user &&
<div className="absolute right-1 flex gap-3 items-center">
<button title="Rename Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={30} /></button>
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={30} /></button>
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={30} /></button>
<button title="Delete Item" className="hover:cursor-pointer" onClick={() => setDeleteModalOpen(true)}><Trash size={30} /></button>
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
<button title="Rename Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button>
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={iconSize} /></button>
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button>
<button title="Delete Item" className="hover:cursor-pointer" onClick={() => setDeleteModalOpen(true)}><Trash size={iconSize} /></button>
<RenameModal open={renameModalOpen} setOpen={setRenameModalOpen} type={props.type.toLowerCase()} id={props.id}/>
<ImageReplaceModal open={imageModalOpen} setOpen={setImageModalOpen} id={props.imgItemId} musicbrainzId={props.musicbrainzId} type={props.type === "Track" ? "Album" : props.type} />
<MergeModal currentTitle={props.title} mergeFunc={props.mergeFunc} mergeCleanerFunc={props.mergeCleanerFunc} type={props.type} currentId={props.id} open={mergeModalOpen} setOpen={setMergeModalOpen} />

@ -50,9 +50,9 @@ export default function Track() {
<div className="mt-10">
<PeriodSelector setter={setPeriod} current={period} />
</div>
<div className="flex gap-20 mt-10">
<LastPlays limit={20} trackId={track.id}/>
<ActivityGrid trackId={track.id} configurable autoAdjust />
<div className="flex flex-wrap gap-20 mt-10">
<LastPlays limit={20} trackId={track.id}/>
<ActivityGrid trackId={track.id} configurable autoAdjust />
</div>
</MediaLayout>
)

@ -1,6 +1,6 @@
{
"name": "koito",
"version": "v0.0.1",
"version": "dev",
"private": true,
"type": "module",
"scripts": {

@ -14,7 +14,7 @@ export default defineConfig({
changeOrigin: true,
},
'/images': {
target: 'http://192.168.0.153:4110',
target: 'http://localhost:4110',
changeOrigin: true,
}
}

@ -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`

@ -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).

@ -70,3 +70,6 @@ Koito is configured using **environment variables**. This is the full list of co
- 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.
##### 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.

@ -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{

@ -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)
}

@ -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
}

@ -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)

@ -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
}

@ -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))

@ -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)
}

@ -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()

@ -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) {

@ -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) {

@ -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)
}

@ -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")
}

@ -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,
}

@ -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)

@ -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)

Loading…
Cancel
Save