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
12
.github/workflows/docker.yml
vendored
12
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -54,6 +54,16 @@
|
|||
|
||||
|
||||
|
||||
:root {
|
||||
--header-xl: 36px;
|
||||
--header-lg: 28px;
|
||||
--header-md: 22px;
|
||||
--header-sm: 16px;
|
||||
--header-xl-weight: 600;
|
||||
--header-weight: 600;
|
||||
}
|
||||
|
||||
@media (min-width: 60rem) {
|
||||
:root {
|
||||
--header-xl: 78px;
|
||||
--header-lg: 28px;
|
||||
|
|
@ -62,6 +72,8 @@
|
|||
--header-xl-weight: 600;
|
||||
--header-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
html,
|
||||
body {
|
||||
|
|
|
|||
|
|
@ -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,17 +142,27 @@ export default function ActivityGrid({
|
|||
}
|
||||
}
|
||||
|
||||
const dotSize = 12;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
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} />
|
||||
:
|
||||
{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) => (
|
||||
<div
|
||||
key={new Date(item.start_time).toString()}
|
||||
|
|
@ -166,20 +176,21 @@ export default function ActivityGrid({
|
|||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
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)'}`}
|
||||
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,6 +60,7 @@ export default function Account() {
|
|||
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
|
||||
</div>
|
||||
<h2>Update User</h2>
|
||||
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-username"
|
||||
|
|
@ -68,6 +71,11 @@ export default function Account() {
|
|||
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"
|
||||
|
|
@ -89,6 +97,7 @@ export default function Account() {
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -4,19 +4,33 @@ 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>
|
||||
<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 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>
|
||||
<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,7 +65,7 @@ export default function App() {
|
|||
<AppProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="flex">
|
||||
<div className="flex-col flex sm:flex-row">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col items-center mx-auto w-full">
|
||||
<Outlet />
|
||||
|
|
@ -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,7 +50,7 @@ export default function Track() {
|
|||
<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={20} trackId={track.id}/>
|
||||
<ActivityGrid trackId={track.id} configurable autoAdjust />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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, "/")
|
||||
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)
|
||||
}
|
||||
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…
Add table
Add a link
Reference in a new issue