Major improvements to how connections are managed, also interfaces, interfaces EVERYWHERE

This commit is contained in:
Eclypsed
2024-02-02 03:05:42 -05:00
parent 909b78807f
commit 20454e22d1
12 changed files with 183 additions and 140 deletions

View File

@@ -4,10 +4,6 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
* {
flex-shrink: 0;
}
img { img {
max-width: 100%; max-width: 100%;
} }

54
src/app.d.ts vendored
View File

@@ -11,16 +11,6 @@ declare global {
// interface Platform {} // interface Platform {}
} }
namespace Jellyfin {
interface AuthData {
User: {
Name: string
Id: string
}
AccessToken: string
}
}
interface User { interface User {
id: string id: string
username: string username: string
@@ -33,8 +23,6 @@ declare global {
type: ServiceType type: ServiceType
userId: string userId: string
urlOrigin: string urlOrigin: string
username?: string
serverName?: string
} }
interface Connection { interface Connection {
@@ -44,6 +32,48 @@ declare global {
accessToken: string accessToken: string
} }
namespace Jellyfin {
// The jellyfin API will not always return the data it says it will, for example /Users/AuthenticateByName says it will
// retrun the ServerName, it wont. This must be fetched from /System/Info.
// So, ONLY DEFINE THE INTERFACES FOR DATA THAT IS GARUNTEED TO BE RETURNED (unless the data value itself is inherently optional)
interface JFService extends Service {
type: 'jellyfin'
username: string
serverName: string
}
interface JFConnection extends Connection {
service: JFService
}
interface AuthData {
User: {
Id: string
}
AccessToken: string
}
interface User {
Name: string
Id: string
}
interface System {
ServerName: string
}
}
namespace YouTubeMusic {
interface YTService extends Service {
type: 'youtube-music'
username: string
}
interface YTConnection extends Connection {
service: YTService
}
}
interface MediaItem { interface MediaItem {
connectionId: string connectionId: string
serviceType: string serviceType: string

View File

@@ -7,7 +7,7 @@ export const handle: Handle = async ({ event, resolve }) => {
const urlpath = event.url.pathname const urlpath = event.url.pathname
if (urlpath.startsWith('/api') && event.request.headers.get('apikey') !== SECRET_INTERNAL_API_KEY && event.url.searchParams.get('apikey') !== SECRET_INTERNAL_API_KEY) { if (urlpath.startsWith('/api') && event.request.headers.get('apikey') !== SECRET_INTERNAL_API_KEY && event.url.searchParams.get('apikey') !== SECRET_INTERNAL_API_KEY) {
return new Response('Unauthorized', { status: 400 }) return new Response('Unauthorized', { status: 401 })
} }
if (!nonJwtProtectedRoutes.some((route) => urlpath.startsWith(route))) { if (!nonJwtProtectedRoutes.some((route) => urlpath.startsWith(route))) {

Binary file not shown.

View File

@@ -12,10 +12,11 @@ export const POST: RequestHandler = async ({ request, fetch }) => {
const jellyfinAuthData = await request.json() const jellyfinAuthData = await request.json()
const jellyfinAuthValidation = jellyfinAuthSchema.safeParse(jellyfinAuthData) const jellyfinAuthValidation = jellyfinAuthSchema.safeParse(jellyfinAuthData)
if (!jellyfinAuthValidation.success) return new Response(jellyfinAuthValidation.error.message, { status: 400 }) if (!jellyfinAuthValidation.success) return new Response('Invalid data in request body', { status: 400 })
const { serverUrl, username, password, deviceId } = jellyfinAuthValidation.data const { serverUrl, username, password, deviceId } = jellyfinAuthValidation.data
const authUrl = new URL('/Users/AuthenticateByName', serverUrl).href const authUrl = new URL('/Users/AuthenticateByName', serverUrl).href
try {
const authResponse = await fetch(authUrl, { const authResponse = await fetch(authUrl, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@@ -27,9 +28,11 @@ export const POST: RequestHandler = async ({ request, fetch }) => {
'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="1.0.0.0"`, 'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="1.0.0.0"`,
}, },
}) })
if (!authResponse.ok) return new Response('Failed to authenticate', { status: 401 })
if (!authResponse.ok) return new Response('Failed to authenticate', { status: 400 })
const authData: Jellyfin.AuthData = await authResponse.json() const authData: Jellyfin.AuthData = await authResponse.json()
return new Response(JSON.stringify(authData)) return Response.json(authData)
} catch {
return new Response('Fetch request failed', { status: 404 })
}
} }

View File

@@ -10,38 +10,35 @@ export const GET: RequestHandler = async ({ params }) => {
return new Response(JSON.stringify(connections)) return new Response(JSON.stringify(connections))
} }
const connectionSchema = z.object({ // This schema should be identical to the Connection Data Type but without the id and userId
serviceType: z.enum(['jellyfin', 'youtube-music']), const newConnectionSchema = z.object({
serviceUserId: z.string(), service: z.object({
type: z.enum(['jellyfin', 'youtube-music']),
userId: z.string(),
urlOrigin: z.string().refine((val) => isValidURL(val)), urlOrigin: z.string().refine((val) => isValidURL(val)),
}),
accessToken: z.string(), accessToken: z.string(),
}) })
export type NewConnection = z.infer<typeof connectionSchema>
export const POST: RequestHandler = async ({ params, request }) => { export const POST: RequestHandler = async ({ params, request }) => {
const userId = params.userId as string const userId = params.userId as string
const connection = await request.json() const connection: Connection = await request.json()
const connectionValidation = connectionSchema.safeParse(connection) const connectionValidation = newConnectionSchema.safeParse(connection)
if (!connectionValidation.success) return new Response(connectionValidation.error.message, { status: 400 }) if (!connectionValidation.success) return new Response(connectionValidation.error.message, { status: 400 })
const { serviceType, serviceUserId, urlOrigin, accessToken } = connectionValidation.data const { service, accessToken } = connection
const service: Service = {
type: serviceType,
userId: serviceUserId,
urlOrigin: new URL(urlOrigin).origin,
}
const newConnection = Connections.addConnection(userId, service, accessToken) const newConnection = Connections.addConnection(userId, service, accessToken)
return new Response(JSON.stringify(newConnection)) return new Response(JSON.stringify(newConnection))
} }
export const DELETE: RequestHandler = async ({ request }) => { export const DELETE: RequestHandler = async ({ request }) => {
const connectionId: string = await request.json() const requestData = await request.json()
try { try {
Connections.deleteConnection(connectionId) Connections.deleteConnection(requestData.connectionId)
return new Response('Connection Deleted') return new Response('Connection Deleted')
} catch { } catch (error) {
return new Response('Connection does not exist', { status: 400 }) return new Response('Connection does not exist', { status: 400 })
} }
} }

View File

@@ -64,10 +64,10 @@
<input name="username" type="text" autocomplete="off" placeholder="Username" class="h-10 w-full border-b-2 border-lazuli-primary bg-transparent px-1 outline-none" /> <input name="username" type="text" autocomplete="off" placeholder="Username" class="h-10 w-full border-b-2 border-lazuli-primary bg-transparent px-1 outline-none" />
</div> </div>
<div class="flex items-center gap-4 p-4"> <div class="flex items-center gap-4 p-4">
<div class="w-full flex-shrink"> <div class="w-full">
<input name="password" type={passwordVisible ? 'text' : 'password'} placeholder="Password" class="h-10 w-full border-b-2 border-lazuli-primary bg-transparent px-1 outline-none" /> <input name="password" type={passwordVisible ? 'text' : 'password'} placeholder="Password" class="h-10 w-full border-b-2 border-lazuli-primary bg-transparent px-1 outline-none" />
</div> </div>
<div class="flex-shrink overflow-hidden transition-[width] duration-300" style="width: {formMode === 'newUser' ? '100%' : 0};" aria-hidden={formMode !== 'newUser'}> <div class="overflow-hidden transition-[width] duration-300" style="width: {formMode === 'newUser' ? '100%' : 0};" aria-hidden={formMode !== 'newUser'}>
<input <input
name="confirmPassword" name="confirmPassword"
type={passwordVisible ? 'text' : 'password'} type={passwordVisible ? 'text' : 'password'}

View File

@@ -1,6 +1,5 @@
import { fail } from '@sveltejs/kit' import { fail } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY } from '$env/static/private' import { SECRET_INTERNAL_API_KEY } from '$env/static/private'
import type { NewConnection } from '../../api/users/[userId]/connections/+server'
import type { PageServerLoad, Actions } from './$types' import type { PageServerLoad, Actions } from './$types'
export const load: PageServerLoad = async ({ fetch, locals }) => { export const load: PageServerLoad = async ({ fetch, locals }) => {
@@ -25,26 +24,44 @@ export const actions: Actions = {
}) })
if (!jellyfinAuthResponse.ok) { if (!jellyfinAuthResponse.ok) {
const authError = await jellyfinAuthResponse.text() if (jellyfinAuthResponse.status === 404) {
return fail(jellyfinAuthResponse.status, { message: authError }) return fail(404, { message: 'Request failed, check Server URL' })
} else if (jellyfinAuthResponse.status === 401) {
return fail(401, { message: 'Invalid Credentials' })
}
return fail(500, { message: 'Internal Server Error' })
} }
const authData: Jellyfin.AuthData = await jellyfinAuthResponse.json() const authData: Jellyfin.AuthData = await jellyfinAuthResponse.json()
const newConnectionPayload: NewConnection = {
const userUrl = new URL(`Users/${authData.User.Id}`, serverUrl.toString()).href
const systemUrl = new URL('System/Info', serverUrl.toString()).href
const reqHeaders = new Headers({ Authorization: `MediaBrowser Token="${authData.AccessToken}"` })
const userResponse = await fetch(userUrl, { headers: reqHeaders })
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
const userData: Jellyfin.User = await userResponse.json()
const systemData: Jellyfin.System = await systemResponse.json()
const serviceData: Jellyfin.JFService = {
type: 'jellyfin',
userId: authData.User.Id,
urlOrigin: serverUrl.toString(), urlOrigin: serverUrl.toString(),
serviceType: 'jellyfin', username: userData.Name,
serviceUserId: authData.User.Id, serverName: systemData.ServerName,
accessToken: authData.AccessToken,
} }
const newConnectionResponse = await fetch(`/api/users/${locals.user.id}/connections`, { const newConnectionResponse = await fetch(`/api/users/${locals.user.id}/connections`, {
method: 'POST', method: 'POST',
headers: { apikey: SECRET_INTERNAL_API_KEY }, headers: { apikey: SECRET_INTERNAL_API_KEY },
body: JSON.stringify(newConnectionPayload), body: JSON.stringify({ service: serviceData, accessToken: authData.AccessToken }),
}) })
if (!newConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' }) if (!newConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
const newConnection: Connection = await newConnectionResponse.json() const newConnection: Jellyfin.JFConnection = await newConnectionResponse.json()
return { newConnection } return { newConnection }
}, },
deleteConnection: async ({ request, fetch, locals }) => { deleteConnection: async ({ request, fetch, locals }) => {

View File

@@ -1,15 +1,13 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'
import { fly } from 'svelte/transition' import { fly } from 'svelte/transition'
import Services from '$lib/services.json' import Services from '$lib/services.json'
import JellyfinAuthBox from './jellyfinAuthBox.svelte' import JellyfinAuthBox from './jellyfinAuthBox.svelte'
import { newestAlert } from '$lib/stores.js' import { newestAlert } from '$lib/stores.js'
import IconButton from '$lib/components/util/iconButton.svelte'
import Toggle from '$lib/components/util/toggle.svelte'
import type { PageServerData } from './$types.js' import type { PageServerData } from './$types.js'
import type { SubmitFunction } from '@sveltejs/kit' import type { SubmitFunction } from '@sveltejs/kit'
import { getDeviceUUID } from '$lib/utils' import { getDeviceUUID } from '$lib/utils'
import DeleteConnectionModal from './deleteConnectionModal.svelte' import { SvelteComponent, type ComponentType } from 'svelte'
import ConnectionProfile from './connectionProfile.svelte'
export let data: PageServerData export let data: PageServerData
let connections = data.userConnections let connections = data.userConnections
@@ -24,7 +22,7 @@
return cancel() return cancel()
} }
try { try {
new URL(serverUrl.toString()) formData.set('serverUrl', new URL(serverUrl.toString()).origin)
} catch { } catch {
$newestAlert = ['caution', 'Server URL is invalid'] $newestAlert = ['caution', 'Server URL is invalid']
return cancel() return cancel()
@@ -48,6 +46,7 @@
const newConnection: Connection = result.data.newConnection const newConnection: Connection = result.data.newConnection
connections = [newConnection, ...connections] connections = [newConnection, ...connections]
newConnectionModal = null
return ($newestAlert = ['success', `Added ${Services[newConnection.service.type].displayName}`]) return ($newestAlert = ['success', `Added ${Services[newConnection.service.type].displayName}`])
} else if (result.data?.deletedConnectionId) { } else if (result.data?.deletedConnectionId) {
const id = result.data.deletedConnectionId const id = result.data.deletedConnectionId
@@ -63,70 +62,27 @@
} }
} }
const openModal = (type: 'jellyfin' | 'delete', connection?: Connection) => { let newConnectionModal: ComponentType<SvelteComponent<{ submitFunction: SubmitFunction }>> | null = null
switch (type) {
case 'jellyfin':
const jellyfinModal = new JellyfinAuthBox({
target: modalForm
})
jellyfinModal.$on('close', () => jellyfinModal.$destroy())
case 'delete':
if (!connection) throw new Error('Connection required for delete modal')
const deleteConnectionModal = new DeleteConnectionModal({
target: modalForm,
props: { connection }
})
}
}
let modalForm: HTMLFormElement
</script> </script>
<main> <main>
<section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);"> <section class="mb-8 rounded-lg px-4" style="background-color: rgba(82, 82, 82, 0.25);">
<h1 class="py-2 text-xl">Add Connection</h1> <h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4"> <div class="flex flex-wrap gap-2 pb-4">
<button class="h-14 rounded-md add-connection-button" on:click={() => modalForm}> <button class="add-connection-button h-14 rounded-md" on:click={() => (newConnectionModal = JellyfinAuthBox)}>
<img src={Services.jellyfin.icon} alt="{Services.jellyfin.displayName} icon" class="aspect-square h-full p-2" /> <img src={Services.jellyfin.icon} alt="{Services.jellyfin.displayName} icon" class="aspect-square h-full p-2" />
</button> </button>
<button class="h-14 rounded-md add-connection-button"> <button class="add-connection-button h-14 rounded-md">
<img src={Services['youtube-music'].icon} alt="{Services['youtube-music'].displayName} icon" class="aspect-square h-full p-2" /> <img src={Services['youtube-music'].icon} alt="{Services['youtube-music'].displayName} icon" class="aspect-square h-full p-2" />
</button> </button>
</div> </div>
</section> </section>
<div class="grid gap-8"> <div class="grid gap-8">
{#each connections as connection} {#each connections as connection}
{@const serviceData = Services[connection.service.type]} <ConnectionProfile {connection} submitFunction={submitCredentials} />
<section class="overflow-hidden rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
<header class="flex h-20 items-center gap-4 p-4">
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-1" />
<div>
<div>{connection.service?.username ? connection.service.username : 'Placeholder Account Name'}</div>
<div class="text-sm text-neutral-500">
{serviceData.displayName}
{#if connection.service.type === 'jellyfin' && connection.service?.serverName}
- {connection.service.serverName}
{/if}
</div>
</div>
<div class="ml-auto h-8">
<IconButton>
<i slot="icon" class="fa-solid fa-link-slash" />
</IconButton>
</div>
</header>
<hr class="mx-2 border-t-2 border-neutral-600" />
<div class="p-4 text-sm text-neutral-400">
<div class="grid grid-cols-[3rem_auto] gap-4">
<Toggle on:toggled={(event) => console.log(event.detail.toggled)} />
<span>Enable Connection</span>
</div>
</div>
</section>
{/each} {/each}
</div> </div>
<form bind:this={modalForm} method="post" use:enhance={submitCredentials} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"></form> <svelte:component this={newConnectionModal} submitFunction={submitCredentials} on:close={() => (newConnectionModal = null)} />
</main> </main>
<style> <style>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import Services from '$lib/services.json'
import IconButton from '$lib/components/util/iconButton.svelte'
import Toggle from '$lib/components/util/toggle.svelte'
import type { SubmitFunction } from '@sveltejs/kit'
import { fly } from 'svelte/transition'
import { enhance } from '$app/forms'
export let connection: Connection
export let submitFunction: SubmitFunction
const serviceData = Services[connection.service.type]
let showUnlinkModal = false
</script>
<section class="overflow-hidden rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}>
<header class="flex h-20 items-center gap-4 p-4">
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-1" />
<div>
<div>{'username' in connection.service ? connection.service.username : 'Placeholder Account Name'}</div>
<div class="text-sm text-neutral-500">
{serviceData.displayName}
{#if 'serverName' in connection.service}
- {connection.service.serverName}
{/if}
</div>
</div>
<div class="relative ml-auto h-8">
<IconButton halo={true} on:click={() => (showUnlinkModal = !showUnlinkModal)}>
<i slot="icon" class="fa-solid fa-link-slash" />
</IconButton>
{#if showUnlinkModal}
<form use:enhance={submitFunction} action="?/deleteConnection" method="post" class="absolute right-full top-0 flex -translate-x-3 flex-col items-center justify-center gap-3 rounded-md bg-neutral-925 p-4">
<span class="whitespace-nowrap">Delete Connection</span>
<div class="flex gap-4">
<button class="w-20 rounded-md bg-red-500 px-2 py-1">Confirm</button>
<button class="w-20 rounded-md bg-neutral-600 px-2 py-1" on:click|preventDefault={() => (showUnlinkModal = false)}>Cancel</button>
</div>
<input type="hidden" value={connection.id} name="connectionId" />
</form>
{/if}
</div>
</header>
<hr class="mx-2 border-t-2 border-neutral-600" />
<div class="p-4 text-sm text-neutral-400">
<div class="grid grid-cols-[3rem_auto] gap-4">
<Toggle on:toggled={(event) => console.log(event.detail.toggled)} />
<span>Enable Connection</span>
</div>
</div>
</section>

View File

@@ -1,14 +0,0 @@
<script lang="ts">
import Services from '$lib/services.json'
export let connection: Connection
</script>
<div class="rounded-lg bg-neutral-900 p-5">
<h1 class="pb-4 text-center">Delete {Services[connection.service.type].displayName} connection?</h1>
<div class="flex w-60 justify-around">
<input type="hidden" name="connectionId" value={connection.id} />
<button class="rounded bg-neutral-800 px-4 py-2 text-center" on:click|preventDefault>Cancel</button>
<button class="rounded bg-red-500 px-4 py-2 text-center" formaction="?/deleteConnection">Delete</button>
</div>
</div>

View File

@@ -1,10 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { SubmitFunction } from '@sveltejs/kit'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { enhance } from '$app/forms'
export let submitFunction: SubmitFunction
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
</script> </script>
<div id="main-box" class="relative flex aspect-video w-screen max-w-2xl flex-col justify-center gap-9 rounded-xl bg-neutral-900 px-8"> <form method="post" use:enhance={submitFunction} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div id="main-box" class="relative flex aspect-video w-screen max-w-2xl flex-col justify-center gap-9 rounded-xl bg-neutral-925 px-8">
<h1 class="text-center text-4xl">Jellyfin Sign In</h1> <h1 class="text-center text-4xl">Jellyfin Sign In</h1>
<div class="flex w-full flex-col gap-5"> <div class="flex w-full flex-col gap-5">
<input type="text" name="serverUrl" autocomplete="off" placeholder="Server Url" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" /> <input type="text" name="serverUrl" autocomplete="off" placeholder="Server Url" class="h-10 w-full border-b-2 border-jellyfin-blue bg-transparent px-1 outline-none" />
@@ -14,10 +19,11 @@
</div> </div>
</div> </div>
<div class="flex items-center justify-around text-lg"> <div class="flex items-center justify-around text-lg">
<button id="cancel-button" type="button" class="w-1/3 rounded bg-neutral-800 py-2 transition-all active:scale-[97%]" on:click|preventDefault={() => dispatch('close')}>Cancel</button> <button id="cancel-button" type="button" class="w-1/3 rounded bg-neutral-800 py-2 transition-all active:scale-95" on:click|preventDefault={() => dispatch('close')}>Cancel</button>
<button id="submit-button" type="submit" class="w-1/3 rounded bg-jellyfin-blue py-2 transition-all active:scale-[97%]" formaction="?/authenticateJellyfin">Submit</button> <button id="submit-button" type="submit" class="w-1/3 rounded bg-jellyfin-blue py-2 transition-all active:scale-95" formaction="?/authenticateJellyfin">Submit</button>
</div> </div>
</div> </div>
</form>
<style> <style>
@property --gradient-angle { @property --gradient-angle {