mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 13:38:15 -08:00
feat: version v0.0.2
This commit is contained in:
parent
0dceaf017a
commit
7ff317756f
36 changed files with 336 additions and 160 deletions
14
.github/workflows/docker.yml
vendored
14
.github/workflows/docker.yml
vendored
|
|
@ -9,9 +9,11 @@
|
||||||
|
|
||||||
name: Publish Docker image
|
name: Publish Docker image
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|
@ -64,6 +66,10 @@ jobs:
|
||||||
with:
|
with:
|
||||||
images: gabehf/koito
|
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
|
- name: Build and push Docker image
|
||||||
id: push
|
id: push
|
||||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||||
|
|
@ -71,7 +77,11 @@ jobs:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
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
|
- name: Generate artifact attestation
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
|
|
|
||||||
14
CHANGELOG.md
Normal file
14
CHANGELOG.md
Normal file
|
|
@ -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
|
||||||
13
Dockerfile
13
Dockerfile
|
|
@ -1,15 +1,22 @@
|
||||||
FROM node AS frontend
|
FROM node AS frontend
|
||||||
|
|
||||||
|
ARG KOITO_VERSION
|
||||||
|
ENV VITE_KOITO_VERSION=$KOITO_VERSION
|
||||||
|
ENV BUILD_TARGET=docker
|
||||||
|
|
||||||
WORKDIR /client
|
WORKDIR /client
|
||||||
COPY ./client/package.json ./client/yarn.lock ./
|
COPY ./client/package.json ./client/yarn.lock ./
|
||||||
RUN yarn install
|
RUN yarn install
|
||||||
COPY ./client .
|
COPY ./client .
|
||||||
ENV BUILD_TARGET=docker
|
|
||||||
RUN yarn run build
|
RUN yarn run build
|
||||||
|
|
||||||
|
|
||||||
FROM golang:1.23 AS backend
|
FROM golang:1.23 AS backend
|
||||||
|
|
||||||
|
ARG KOITO_VERSION
|
||||||
|
ENV CGO_ENABLED=1
|
||||||
|
ENV GOOS=linux
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
|
|
@ -21,7 +28,7 @@ RUN go mod download
|
||||||
|
|
||||||
COPY . .
|
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
|
FROM debian:bookworm-slim AS final
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--header-xl: 78px;
|
--header-xl: 36px;
|
||||||
--header-lg: 28px;
|
--header-lg: 28px;
|
||||||
--header-md: 22px;
|
--header-md: 22px;
|
||||||
--header-sm: 16px;
|
--header-sm: 16px;
|
||||||
|
|
@ -63,6 +63,18 @@
|
||||||
--header-weight: 600;
|
--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,
|
html,
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useQuery } from "@tanstack/react-query"
|
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 Popup from "./Popup"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useTheme } from "~/hooks/useTheme"
|
import { useTheme } from "~/hooks/useTheme"
|
||||||
|
|
@ -142,44 +142,55 @@ export default function ActivityGrid({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dotSize = 12;
|
|
||||||
|
|
||||||
return (
|
const mobileDotSize = 10
|
||||||
<div className="flex flex-col items-start">
|
const normalDotSize = 12
|
||||||
<h2>Activity</h2>
|
|
||||||
{configurable ?
|
let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
|
||||||
<ActivityOptsSelector rangeSetter={setRange} currentRange={rangeState} stepSetter={setStep} currentStep={stepState} />
|
|
||||||
:
|
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]">
|
<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) => (
|
{data.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={new Date(item.start_time).toString()}
|
key={new Date(item.start_time).toString()}
|
||||||
style={{ width: dotSize, height: dotSize }}
|
style={{ width: dotSize, height: dotSize }}
|
||||||
|
>
|
||||||
|
<Popup
|
||||||
|
position="top"
|
||||||
|
space={dotSize}
|
||||||
|
extraClasses="left-2"
|
||||||
|
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
|
||||||
>
|
>
|
||||||
<Popup
|
<div
|
||||||
position="top"
|
style={{
|
||||||
space={dotSize}
|
display: 'inline-block',
|
||||||
extraClasses="left-2"
|
width: dotSize,
|
||||||
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
|
height: dotSize,
|
||||||
>
|
background:
|
||||||
<div
|
item.listens > 0
|
||||||
style={{
|
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
|
||||||
width: dotSize,
|
: 'var(--color-bg-secondary)',
|
||||||
height: dotSize,
|
}}
|
||||||
display: 'inline-block',
|
className={`rounded-[2px] md:rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
|
||||||
background:
|
></div>
|
||||||
item.listens > 0
|
</Popup>
|
||||||
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
|
</div>
|
||||||
: 'var(--color-bg-secondary)',
|
))}
|
||||||
}}
|
|
||||||
className={`rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
|
|
||||||
></div>
|
|
||||||
</Popup>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto py-10 pt-20 color-fg-tertiary text-sm">
|
<div className="mx-auto py-10 pt-20 color-fg-tertiary text-sm">
|
||||||
<ul className="flex flex-col items-center w-sm justify-around">
|
<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>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export default function LastPlays(props: Props) {
|
||||||
|
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<div className="w-[500px]">
|
<div className="w-[400px] sm:w-[500px]">
|
||||||
<h2>Last Played</h2>
|
<h2>Last Played</h2>
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,8 +43,8 @@ export default function LastPlays(props: Props) {
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.items.map((item) => (
|
{data.items.map((item) => (
|
||||||
<tr key={`last_listen_${item.time}`}>
|
<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="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-[600px]">
|
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
||||||
{props.hideArtists ? <></> : <><ArtistLinks artists={item.track.artists} /> - </>}
|
{props.hideArtists ? <></> : <><ArtistLinks artists={item.track.artists} /> - </>}
|
||||||
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
|
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ export default function Account() {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
const updateHandler = () => {
|
const updateHandler = () => {
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
if (password != "" && confirmPw === "") {
|
if (password != "" && confirmPw === "") {
|
||||||
setError("confirm your password before submitting")
|
setError("confirm your new password before submitting")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
|
|
@ -58,37 +60,44 @@ export default function Account() {
|
||||||
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
|
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
|
||||||
</div>
|
</div>
|
||||||
<h2>Update User</h2>
|
<h2>Update User</h2>
|
||||||
<div className="flex flex gap-4">
|
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
|
||||||
<input
|
<div className="flex flex gap-4">
|
||||||
name="koito-update-username"
|
<input
|
||||||
type="text"
|
name="koito-update-username"
|
||||||
placeholder="Update username"
|
type="text"
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
placeholder="Update username"
|
||||||
value={username}
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
value={username}
|
||||||
/>
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
</div>
|
/>
|
||||||
<div className="flex flex gap-4">
|
</div>
|
||||||
<input
|
<div className="w-sm">
|
||||||
name="koito-update-password"
|
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
|
||||||
type="password"
|
</div>
|
||||||
placeholder="Update password"
|
</form>
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
|
||||||
value={password}
|
<div className="flex flex gap-4">
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
<input
|
||||||
/>
|
name="koito-update-password"
|
||||||
<input
|
type="password"
|
||||||
name="koito-confirm-password"
|
placeholder="Update password"
|
||||||
type="password"
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
placeholder="Confirm new password"
|
value={password}
|
||||||
className="w-full mx-auto fg bg rounded p-2"
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
value={confirmPw}
|
/>
|
||||||
onChange={(e) => setConfirmPw(e.target.value)}
|
<input
|
||||||
/>
|
name="koito-confirm-password"
|
||||||
</div>
|
type="password"
|
||||||
<div className="w-sm">
|
placeholder="Confirm new password"
|
||||||
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
|
className="w-full mx-auto fg bg rounded p-2"
|
||||||
</div>
|
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>}
|
{success != "" && <p className="success">{success}</p>}
|
||||||
{error != "" && <p className="error">{error}</p>}
|
{error != "" && <p className="error">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,25 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
||||||
const { user } = useAppContext()
|
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 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 (
|
return (
|
||||||
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
|
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
|
||||||
<Tabs defaultValue="Appearance" orientation="vertical" className="flex justify-between h-full">
|
<Tabs
|
||||||
<TabsList className="w-full flex flex-col gap-1 items-start max-w-1/4 rounded-md bg p-2 grow-0">
|
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="Appearance">Appearance</TabsTrigger>
|
||||||
<TabsTrigger className={triggerClasses} value="Account">Account</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>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="Account" className={contentClasses}>
|
<TabsContent value="Account" className={contentClasses}>
|
||||||
<AccountPage />
|
<AccountPage />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,36 @@
|
||||||
import { ExternalLink, Home, Info } from "lucide-react";
|
import { ExternalLink, Home, Info } from "lucide-react";
|
||||||
import SidebarSearch from "./SidebarSearch";
|
import SidebarSearch from "./SidebarSearch";
|
||||||
import SidebarItem from "./SidebarItem";
|
import SidebarItem from "./SidebarItem";
|
||||||
import SidebarSettings from "./SidebarSettings";
|
import SidebarSettings from "./SidebarSettings";
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
|
|
||||||
const iconSize = 20;
|
const iconSize = 20;
|
||||||
|
|
||||||
return (
|
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="overflow-x-hidden w-full sm:w-auto">
|
||||||
<div className="flex flex-col gap-4">
|
<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)">
|
||||||
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}><Home size={iconSize} /></SidebarItem>
|
<div className="flex gap-4 sm:flex-col">
|
||||||
<SidebarSearch size={iconSize} />
|
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}>
|
||||||
</div>
|
<Home size={iconSize} />
|
||||||
<div className="flex flex-col gap-4">
|
</SidebarItem>
|
||||||
<SidebarItem icon keyHint={<ExternalLink size={14} />} space={22} externalLink to="https://koito.io" name="About" onClick={() => {}} modal={<></>}><Info size={iconSize} /></SidebarItem>
|
<SidebarSearch size={iconSize} />
|
||||||
<SidebarSettings 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ export default function ThemeOption({ theme, setTheme }: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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}}>
|
<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}}>
|
||||||
{capitalizeFirstLetter(theme.name)}
|
<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.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.fgSecondary}}></div>
|
||||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>
|
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>
|
||||||
|
|
|
||||||
|
|
@ -65,12 +65,12 @@ export default function App() {
|
||||||
<AppProvider>
|
<AppProvider>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<div className="flex">
|
<div className="flex-col flex sm:flex-row">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex flex-col items-center mx-auto w-full">
|
<div className="flex flex-col items-center mx-auto w-full">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
@ -117,7 +117,7 @@ export function ErrorBoundary() {
|
||||||
<div className="w-full flex flex-col">
|
<div className="w-full flex flex-col">
|
||||||
<main className="pt-16 p-4 container mx-auto flex-grow">
|
<main className="pt-16 p-4 container mx-auto flex-grow">
|
||||||
<div className="flex gap-4 items-end">
|
<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>
|
<div>
|
||||||
<h1>{message}</h1>
|
<h1>{message}</h1>
|
||||||
<p>{details}</p>
|
<p>{details}</p>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export default function Home() {
|
||||||
<ActivityGrid />
|
<ActivityGrid />
|
||||||
</div>
|
</div>
|
||||||
<PeriodSelector setter={setPeriod} current={period} />
|
<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} />
|
<TopArtists period={period} limit={homeItems} />
|
||||||
<TopAlbums period={period} limit={homeItems} />
|
<TopAlbums period={period} limit={homeItems} />
|
||||||
<TopTracks period={period} limit={homeItems} />
|
<TopTracks period={period} limit={homeItems} />
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export default function Album() {
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<PeriodSelector setter={setPeriod} current={period} />
|
<PeriodSelector setter={setPeriod} current={period} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-20 mt-10">
|
<div className="flex flex-wrap gap-20 mt-10">
|
||||||
<LastPlays limit={30} albumId={album.id} />
|
<LastPlays limit={30} albumId={album.id} />
|
||||||
<TopTracks limit={12} period={period} albumId={album.id} />
|
<TopTracks limit={12} period={period} albumId={album.id} />
|
||||||
<ActivityGrid autoAdjust configurable 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 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 (
|
return (
|
||||||
<main
|
<main
|
||||||
className="w-full flex flex-col flex-grow"
|
className="w-full flex flex-col flex-grow"
|
||||||
|
|
@ -61,19 +68,21 @@ export default function MediaLayout(props: Props) {
|
||||||
content={title}
|
content={title}
|
||||||
/>
|
/>
|
||||||
<div className="w-19/20 mx-auto pt-12">
|
<div className="w-19/20 mx-auto pt-12">
|
||||||
<div className="flex gap-8 relative">
|
<div className="flex gap-8 flex-wrap relative">
|
||||||
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="w-sm shadow-(--color-shadow) shadow-lg" />
|
<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">
|
<div className="flex flex-col items-start">
|
||||||
<h3>{props.type}</h3>
|
<h3>{props.type}</h3>
|
||||||
<h1>{props.title}</h1>
|
<h1>{props.title}</h1>
|
||||||
{props.subContent}
|
{props.subContent}
|
||||||
</div>
|
</div>
|
||||||
{ user &&
|
{ user &&
|
||||||
<div className="absolute right-1 flex gap-3 items-center">
|
<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={30} /></button>
|
<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={30} /></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={30} /></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={30} /></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}/>
|
<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} />
|
<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} />
|
<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">
|
<div className="mt-10">
|
||||||
<PeriodSelector setter={setPeriod} current={period} />
|
<PeriodSelector setter={setPeriod} current={period} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-20 mt-10">
|
<div className="flex flex-wrap gap-20 mt-10">
|
||||||
<LastPlays limit={20} trackId={track.id}/>
|
<LastPlays limit={20} trackId={track.id}/>
|
||||||
<ActivityGrid trackId={track.id} configurable autoAdjust />
|
<ActivityGrid trackId={track.id} configurable autoAdjust />
|
||||||
</div>
|
</div>
|
||||||
</MediaLayout>
|
</MediaLayout>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "koito",
|
"name": "koito",
|
||||||
"version": "v0.0.1",
|
"version": "dev",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export default defineConfig({
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/images': {
|
'/images': {
|
||||||
target: 'http://192.168.0.153:4110',
|
target: 'http://localhost:4110',
|
||||||
changeOrigin: true,
|
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.
|
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.
|
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
|
## 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`
|
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
|
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 when using either of the Docker methods described above.
|
from.
|
||||||
|
|
||||||
Those are the two required environment variables. You can find a full list of configuration options in the [configuration reference](/reference/configuration).
|
Those are the two required environment variables. You can find a full list of configuration options in the [configuration reference](/reference/configuration).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,4 +69,7 @@ Koito is configured using **environment variables**. This is the full list of co
|
||||||
##### KOITO_IMPORT_BEFORE_UNIX
|
##### KOITO_IMPORT_BEFORE_UNIX
|
||||||
- Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded.
|
- Description: A unix timestamp. If an imported listen has a timestamp after this, it will be discarded.
|
||||||
##### KOITO_IMPORT_AFTER_UNIX
|
##### KOITO_IMPORT_AFTER_UNIX
|
||||||
- Description: A unix timestamp. If an imported listen has a timestamp before this, it will be discarded.
|
- 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,
|
w io.Writer,
|
||||||
version string,
|
version string,
|
||||||
) error {
|
) error {
|
||||||
err := cfg.Load(getenv)
|
err := cfg.Load(getenv, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("Engine: Failed to load configuration")
|
panic("Engine: Failed to load configuration")
|
||||||
}
|
}
|
||||||
|
|
@ -150,6 +150,12 @@ func Run(
|
||||||
l.Info().Msgf("Engine: Allowing hosts: %v", cfg.AllowedHosts())
|
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")
|
l.Debug().Msg("Engine: Setting up HTTP server")
|
||||||
var ready atomic.Bool
|
var ready atomic.Bool
|
||||||
mux := chi.NewRouter()
|
mux := chi.NewRouter()
|
||||||
|
|
@ -157,6 +163,7 @@ func Run(
|
||||||
mux.Use(middleware.Logger(l))
|
mux.Use(middleware.Logger(l))
|
||||||
mux.Use(chimiddleware.Recoverer)
|
mux.Use(chimiddleware.Recoverer)
|
||||||
mux.Use(chimiddleware.RealIP)
|
mux.Use(chimiddleware.RealIP)
|
||||||
|
mux.Use(middleware.AllowedHosts)
|
||||||
bindRoutes(mux, &ready, store, mbzC)
|
bindRoutes(mux, &ready, store, mbzC)
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ func TestMain(m *testing.M) {
|
||||||
}
|
}
|
||||||
|
|
||||||
getenv := getTestGetenv(resource)
|
getenv := getTestGetenv(resource)
|
||||||
err = cfg.Load(getenv)
|
err = cfg.Load(getenv, "test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Could not load cfg: %s", err)
|
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")
|
trackIDStr := r.URL.Query().Get("track_id")
|
||||||
|
|
||||||
if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" {
|
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)
|
utils.WriteError(w, "artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
|
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)
|
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -97,12 +97,12 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc {
|
||||||
alias := r.URL.Query().Get("alias")
|
alias := r.URL.Query().Get("alias")
|
||||||
|
|
||||||
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
|
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)
|
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
|
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)
|
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -177,12 +177,12 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc {
|
||||||
trackIDStr := r.URL.Query().Get("track_id")
|
trackIDStr := r.URL.Query().Get("track_id")
|
||||||
|
|
||||||
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
|
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)
|
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
|
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)
|
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -247,12 +247,12 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc {
|
||||||
alias := r.URL.Query().Get("alias")
|
alias := r.URL.Query().Get("alias")
|
||||||
|
|
||||||
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
|
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)
|
utils.WriteError(w, "alias and artist_id, album_id, or track_id must be provided", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if utils.MoreThanOneString(artistIDStr, albumIDStr, trackIDStr) {
|
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)
|
utils.WriteError(w, "only one of artist_id, album_id, or track_id can be provided at a time", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package middleware
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
|
@ -63,9 +64,21 @@ func Logger(baseLogger *zerolog.Logger) func(next http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||||
reqID := GetRequestID(r.Context())
|
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)
|
r = logger.Inject(r, &l)
|
||||||
|
|
||||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
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)
|
utils.WriteError(ww, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pathS := strings.Split(r.URL.Path, "/")
|
|
||||||
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)
|
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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().Msg(msg)
|
||||||
|
} else {
|
||||||
|
l.Debug().Str("type", "access").Timestamp().Msg(msg)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
next.ServeHTTP(ww, r)
|
next.ServeHTTP(ww, r)
|
||||||
}
|
}
|
||||||
return http.HandlerFunc(fn)
|
return http.HandlerFunc(fn)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -24,25 +25,34 @@ func ValidateSession(store db.DB) func(next http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
l := logger.FromContext(r.Context())
|
l := logger.FromContext(r.Context())
|
||||||
|
|
||||||
|
l.Debug().Msgf("ValidateSession: Checking user authentication via session cookie")
|
||||||
|
|
||||||
cookie, err := r.Cookie("koito_session")
|
cookie, err := r.Cookie("koito_session")
|
||||||
var sid uuid.UUID
|
var sid uuid.UUID
|
||||||
if err == nil {
|
if err == nil {
|
||||||
sid, err = uuid.Parse(cookie.Value)
|
sid, err = uuid.Parse(cookie.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
l.Err(err).Msg("ValidateSession: Could not parse UUID from session cookie")
|
||||||
utils.WriteError(w, "session cookie is invalid", http.StatusUnauthorized)
|
utils.WriteError(w, "session cookie is invalid", http.StatusUnauthorized)
|
||||||
return
|
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)
|
u, err := store.GetUserBySession(r.Context(), sid)
|
||||||
if err != nil {
|
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)
|
utils.WriteError(w, "internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if u == nil {
|
if u == nil {
|
||||||
|
l.Debug().Msg("ValidateSession: No user with session id found")
|
||||||
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -50,11 +60,11 @@ func ValidateSession(store db.DB) func(next http.Handler) http.Handler {
|
||||||
ctx := context.WithValue(r.Context(), UserContextKey, u)
|
ctx := context.WithValue(r.Context(), UserContextKey, u)
|
||||||
r = r.WithContext(ctx)
|
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))
|
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)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|
@ -67,10 +77,19 @@ func ValidateApiKey(store db.DB) func(next http.Handler) http.Handler {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
l := logger.FromContext(ctx)
|
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")
|
authh := r.Header.Get("Authorization")
|
||||||
s := strings.Split(authh, "Token ")
|
s := strings.Split(authh, "Token ")
|
||||||
if len(s) < 2 {
|
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)
|
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,16 @@ func bindRoutes(
|
||||||
db db.DB,
|
db db.DB,
|
||||||
mbz mbz.MusicBrainzCaller,
|
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)).
|
r.With(chimiddleware.RequestSize(5<<20)).
|
||||||
With(middleware.AllowedHosts).
|
|
||||||
Get("/images/{size}/{filename}", handlers.ImageHandler(db))
|
Get("/images/{size}/{filename}", handlers.ImageHandler(db))
|
||||||
|
|
||||||
r.Route("/apis/web/v1", func(r chi.Router) {
|
r.Route("/apis/web/v1", func(r chi.Router) {
|
||||||
r.Use(middleware.AllowedHosts)
|
|
||||||
r.Get("/artist", handlers.GetArtistHandler(db))
|
r.Get("/artist", handlers.GetArtistHandler(db))
|
||||||
r.Get("/album", handlers.GetAlbumHandler(db))
|
r.Get("/album", handlers.GetAlbumHandler(db))
|
||||||
r.Get("/track", handlers.GetTrackHandler(db))
|
r.Get("/track", handlers.GetTrackHandler(db))
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@ func TestMain(m *testing.M) {
|
||||||
log.Fatalf("Could not start resource: %s", err)
|
log.Fatalf("Could not start resource: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = cfg.Load(getTestGetenv(resource))
|
err = cfg.Load(getTestGetenv(resource), "test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Could not load cfg: %s", err)
|
log.Fatalf("Could not load cfg: %s", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ const (
|
||||||
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
|
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
|
||||||
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
|
SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT"
|
||||||
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
|
ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS"
|
||||||
|
CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS"
|
||||||
DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT"
|
DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT"
|
||||||
THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS"
|
THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS"
|
||||||
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
|
IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX"
|
||||||
|
|
@ -64,6 +65,7 @@ type config struct {
|
||||||
skipImport bool
|
skipImport bool
|
||||||
allowedHosts []string
|
allowedHosts []string
|
||||||
allowAllHosts bool
|
allowAllHosts bool
|
||||||
|
allowedOrigins []string
|
||||||
disableRateLimit bool
|
disableRateLimit bool
|
||||||
importThrottleMs int
|
importThrottleMs int
|
||||||
userAgent string
|
userAgent string
|
||||||
|
|
@ -78,21 +80,18 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initialize initializes the global configuration using the provided getenv function.
|
// 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
|
var err error
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
globalConfig, err = loadConfig(getenv)
|
globalConfig, err = loadConfig(getenv, version)
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadConfig loads the configuration from environment variables.
|
// 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 := new(config)
|
||||||
// cfg.baseUrl = getenv(BASE_URL_ENV)
|
|
||||||
// if cfg.baseUrl == "" {
|
|
||||||
// cfg.baseUrl = defaultBaseUrl
|
|
||||||
// }
|
|
||||||
cfg.databaseUrl = getenv(DATABASE_URL_ENV)
|
cfg.databaseUrl = getenv(DATABASE_URL_ENV)
|
||||||
if cfg.databaseUrl == "" {
|
if cfg.databaseUrl == "" {
|
||||||
return nil, errors.New("required parameter " + DATABASE_URL_ENV + " not provided")
|
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.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV))
|
||||||
cfg.skipImport = parseBool(getenv(SKIP_IMPORT_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) == "" {
|
if getenv(DEFAULT_USERNAME_ENV) == "" {
|
||||||
cfg.defaultUsername = "admin"
|
cfg.defaultUsername = "admin"
|
||||||
|
|
@ -161,6 +160,9 @@ func loadConfig(getenv func(string) string) (*config, error) {
|
||||||
cfg.allowedHosts = strings.Split(rawHosts, ",")
|
cfg.allowedHosts = strings.Split(rawHosts, ",")
|
||||||
cfg.allowAllHosts = cfg.allowedHosts[0] == "*"
|
cfg.allowAllHosts = cfg.allowedHosts[0] == "*"
|
||||||
|
|
||||||
|
rawCors := getenv(CORS_ORIGINS_ENV)
|
||||||
|
cfg.allowedOrigins = strings.Split(rawCors, ",")
|
||||||
|
|
||||||
switch strings.ToLower(getenv(LOG_LEVEL_ENV)) {
|
switch strings.ToLower(getenv(LOG_LEVEL_ENV)) {
|
||||||
case "debug":
|
case "debug":
|
||||||
cfg.logLevel = 0
|
cfg.logLevel = 0
|
||||||
|
|
@ -312,6 +314,12 @@ func AllowAllHosts() bool {
|
||||||
return globalConfig.allowAllHosts
|
return globalConfig.allowAllHosts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func AllowedOrigins() []string {
|
||||||
|
lock.RLock()
|
||||||
|
defer lock.RUnlock()
|
||||||
|
return globalConfig.allowedOrigins
|
||||||
|
}
|
||||||
|
|
||||||
func RateLimitDisabled() bool {
|
func RateLimitDisabled() bool {
|
||||||
lock.RLock()
|
lock.RLock()
|
||||||
defer lock.RUnlock()
|
defer lock.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,13 @@ func TestDeleteAlbumAlias(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, exists, "expected alias to still exist")
|
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)
|
truncateTestData(t)
|
||||||
}
|
}
|
||||||
func TestGetAllAlbumAliases(t *testing.T) {
|
func TestGetAllAlbumAliases(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,13 @@ func TestDeleteArtistAlias(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, exists, "expected alias to still exist")
|
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)
|
truncateTestData(t)
|
||||||
}
|
}
|
||||||
func TestDeleteArtist(t *testing.T) {
|
func TestDeleteArtist(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ func TestMain(m *testing.M) {
|
||||||
log.Fatalf("Could not start resource: %s", err)
|
log.Fatalf("Could not start resource: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = cfg.Load(getTestGetenv(resource))
|
err = cfg.Load(getTestGetenv(resource), "test")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Could not load cfg: %s", err)
|
log.Fatalf("Could not load cfg: %s", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,13 @@ func TestTrackAliases(t *testing.T) {
|
||||||
err = store.SetPrimaryTrackAlias(ctx, 1, "Fake Alias")
|
err = store.SetPrimaryTrackAlias(ctx, 1, "Fake Alias")
|
||||||
require.Error(t, err)
|
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")
|
store.SetPrimaryTrackAlias(ctx, 1, "Track One")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ func ImportLastFMFile(ctx context.Context, store db.DB, mbzc mbz.MusicBrainzCall
|
||||||
RecordingMbzID: trackMbzID,
|
RecordingMbzID: trackMbzID,
|
||||||
ReleaseTitle: album,
|
ReleaseTitle: album,
|
||||||
ReleaseMbzID: albumMbzID,
|
ReleaseMbzID: albumMbzID,
|
||||||
|
Client: "lastfm",
|
||||||
Time: ts,
|
Time: ts,
|
||||||
UserID: 1,
|
UserID: 1,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ func ImportMalojaFile(ctx context.Context, store db.DB, filename string) error {
|
||||||
TrackTitle: item.Track.Title,
|
TrackTitle: item.Track.Title,
|
||||||
ReleaseTitle: item.Track.Album.Title,
|
ReleaseTitle: item.Track.Album.Title,
|
||||||
Time: ts.Local(),
|
Time: ts.Local(),
|
||||||
|
Client: "maloja",
|
||||||
UserID: 1,
|
UserID: 1,
|
||||||
}
|
}
|
||||||
err = catalog.SubmitListen(ctx, store, opts)
|
err = catalog.SubmitListen(ctx, store, opts)
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ func ImportSpotifyFile(ctx context.Context, store db.DB, filename string) error
|
||||||
ReleaseTitle: item.AlbumName,
|
ReleaseTitle: item.AlbumName,
|
||||||
Duration: dur / 1000,
|
Duration: dur / 1000,
|
||||||
Time: item.Timestamp,
|
Time: item.Timestamp,
|
||||||
|
Client: "spotify",
|
||||||
UserID: 1,
|
UserID: 1,
|
||||||
}
|
}
|
||||||
err = catalog.SubmitListen(ctx, store, opts)
|
err = catalog.SubmitListen(ctx, store, opts)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue