Changed the DB schema AGAIN

This commit is contained in:
Eclypsed
2024-02-01 18:10:15 -05:00
parent dda5b7f6d2
commit 044b3616f9
9 changed files with 133 additions and 182 deletions

15
src/app.d.ts vendored
View File

@@ -29,6 +29,21 @@ declare global {
type ServiceType = 'jellyfin' | 'youtube-music' type ServiceType = 'jellyfin' | 'youtube-music'
interface Service {
type: ServiceType
userId: string
urlOrigin: string
username?: string
serverName?: string
}
interface Connection {
id: string
user: User
service: Service
accessToken: string
}
interface MediaItem { interface MediaItem {
connectionId: string connectionId: string
serviceType: string serviceType: string

Binary file not shown.

View File

@@ -1,50 +1,22 @@
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import { generateUUID } from '$lib/utils' import { generateUUID } from '$lib/utils'
import { isValidURL } from '$lib/utils'
const db = new Database('./src/lib/server/users.db', { verbose: console.info }) const db = new Database('./src/lib/server/users.db', { verbose: console.info })
db.pragma('foreign_keys = ON') db.pragma('foreign_keys = ON')
const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id VARCHAR(36) PRIMARY KEY, username VARCHAR(30) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)' const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id VARCHAR(36) PRIMARY KEY, username VARCHAR(30) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)'
const initServicesTable = 'CREATE TABLE IF NOT EXISTS Services(id VARCHAR(36) PRIMARY KEY, type VARCHAR(64) NOT NULL, serviceUserId TEXT NOT NULL, url TEXT NOT NULL)' const initConnectionsTable = 'CREATE TABLE IF NOT EXISTS Connections(id VARCHAR(36) PRIMARY KEY, userId VARCHAR(36) NOT NULL, service TEXT NOT NULL, accessToken TEXT NOT NULL, FOREIGN KEY(userId) REFERENCES Users(id))'
const initConnectionsTable = db.exec(initUsersTable), db.exec(initConnectionsTable)
'CREATE TABLE IF NOT EXISTS Connections(id VARCHAR(36) PRIMARY KEY, userId VARCHAR(36) NOT NULL, serviceId VARCHAR(36), accessToken TEXT NOT NULL, refreshToken TEXT, expiry INTEGER, FOREIGN KEY(userId) REFERENCES Users(id), FOREIGN KEY(serviceId) REFERENCES Services(id))'
db.exec(initUsersTable)
db.exec(initServicesTable)
db.exec(initConnectionsTable)
type UserQueryParams = { type UserQueryParams = {
includePassword?: boolean includePassword?: boolean
} }
export interface DBServiceData { interface ConnectionsTableSchema {
id: string
type: ServiceType
serviceUserId: string
url: string
}
interface DBServiceRow {
id: string
type: string
serviceUserId: string
url: string
}
export interface DBConnectionData {
id: string
user: User
service: DBServiceData
accessToken: string
refreshToken: string | null
expiry: number | null
}
interface DBConnectionRow {
id: string id: string
userId: string userId: string
serviceId: string service: string
accessToken: string accessToken: string
refreshToken: string | null
expiry: number | null
} }
export class Users { export class Users {
@@ -78,46 +50,28 @@ export class Users {
} }
} }
export class Services {
static getService = (id: string): DBServiceData => {
const { type, serviceUserId, url } = db.prepare('SELECT * FROM Users WHERE id = ?').get(id) as DBServiceRow
const service: DBServiceData = { id, type: type as ServiceType, serviceUserId, url }
return service
}
static addService = (type: ServiceType, serviceUserId: string, url: URL): DBServiceData => {
const serviceId = generateUUID()
db.prepare('INSERT INTO Services(id, type, serviceUserId, url) VALUES(?, ?, ?, ?)').run(serviceId, type, serviceUserId, url.origin)
return this.getService(serviceId)
}
static deleteService = (id: string): void => {
const commandInfo = db.prepare('DELETE FROM Services WHERE id = ?').run(id)
if (commandInfo.changes === 0) throw new Error(`Serivce with id ${id} does not exist`)
}
}
export class Connections { export class Connections {
static getConnection = (id: string): DBConnectionData => { static getConnection = (id: string): Connection => {
const { userId, serviceId, accessToken, refreshToken, expiry } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as DBConnectionRow const { userId, service, accessToken } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as ConnectionsTableSchema
const connection: DBConnectionData = { id, user: Users.getUser(userId)!, service: Services.getService(serviceId), accessToken, refreshToken, expiry } const connection: Connection = { id, user: Users.getUser(userId)!, service: JSON.parse(service), accessToken }
return connection return connection
} }
static getUserConnections = (userId: string): DBConnectionData[] => { static getUserConnections = (userId: string): Connection[] => {
const connectionRows = db.prepare('SELECT * FROM Connections WHERE userId = ?').all(userId) as DBConnectionRow[] const connectionRows = db.prepare('SELECT * FROM Connections WHERE userId = ?').all(userId) as ConnectionsTableSchema[]
const connections: DBConnectionData[] = [] const connections: Connection[] = []
const user = Users.getUser(userId)! const user = Users.getUser(userId)!
connectionRows.forEach((row) => { connectionRows.forEach((row) => {
const { id, serviceId, accessToken, refreshToken, expiry } = row const { id, service, accessToken } = row
connections.push({ id, user, service: Services.getService(serviceId), accessToken, refreshToken, expiry }) connections.push({ id, user, service: JSON.parse(service), accessToken })
}) })
return connections return connections
} }
static addConnection = (userId: string, serviceId: string, accessToken: string, refreshToken: string | null, expiry: number | null): DBConnectionData => { static addConnection = (userId: string, service: Service, accessToken: string): Connection => {
const connectionId = generateUUID() const connectionId = generateUUID()
db.prepare('INSERT INTO Connections(id, userId, serviceId, accessToken, refreshToken, expiry) VALUES(?, ?, ?, ?, ?, ?)').run(connectionId, userId, serviceId, accessToken, refreshToken, expiry) if (!isValidURL(service.urlOrigin)) throw new Error('Service does not have valid url')
db.prepare('INSERT INTO Connections(id, userId, service, accessToken) VALUES(?, ?, ?, ?)').run(connectionId, userId, JSON.stringify(service), accessToken)
return this.getConnection(connectionId) return this.getConnection(connectionId)
} }

View File

@@ -1,4 +1,4 @@
import { Services, Connections } from '$lib/server/users' import { Connections } from '$lib/server/users'
import { isValidURL } from '$lib/utils' import { isValidURL } from '$lib/utils'
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { z } from 'zod' import { z } from 'zod'
@@ -13,10 +13,8 @@ export const GET: RequestHandler = async ({ params }) => {
const connectionSchema = z.object({ const connectionSchema = z.object({
serviceType: z.enum(['jellyfin', 'youtube-music']), serviceType: z.enum(['jellyfin', 'youtube-music']),
serviceUserId: z.string(), serviceUserId: z.string(),
url: z.string().refine((val) => isValidURL(val)), urlOrigin: z.string().refine((val) => isValidURL(val)),
accessToken: z.string(), accessToken: z.string(),
refreshToken: z.string().nullable().optional(),
expiry: z.number().nullable().optional(),
}) })
export type NewConnection = z.infer<typeof connectionSchema> export type NewConnection = z.infer<typeof connectionSchema>
@@ -28,9 +26,13 @@ export const POST: RequestHandler = async ({ params, request }) => {
const connectionValidation = connectionSchema.safeParse(connection) const connectionValidation = connectionSchema.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, url, accessToken, refreshToken, expiry } = connectionValidation.data const { serviceType, serviceUserId, urlOrigin, accessToken } = connectionValidation.data
const newService = Services.addService(serviceType as ServiceType, serviceUserId, new URL(url)) const service: Service = {
const newConnection = Connections.addConnection(userId, newService.id, accessToken, refreshToken as string | null, expiry as number | null) type: serviceType,
userId: serviceUserId,
urlOrigin: new URL(urlOrigin).origin,
}
const newConnection = Connections.addConnection(userId, service, accessToken)
return new Response(JSON.stringify(newConnection)) return new Response(JSON.stringify(newConnection))
} }

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"> <div class="w-full flex-shrink">
<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="overflow-hidden transition-[width] duration-300" style="width: {formMode === 'newUser' ? '100%' : 0};" aria-hidden={formMode !== 'newUser'}> <div class="flex-shrink 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,22 +1,56 @@
<script lang="ts"> <script lang="ts">
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import type { LayoutServerData } from '../$types'
export let data: LayoutServerData
interface SettingRoute {
pathname: string
displayName: string
icon: string
}
const accountRoutes: SettingRoute[] = [
{
pathname: '/settings/connections',
displayName: 'Connections',
icon: 'fa-solid fa-circle-nodes',
},
{
pathname: '/settings/devices',
displayName: 'Devices',
icon: 'fa-solid fa-mobile-screen',
},
]
</script> </script>
<main class="h-full"> <main class="grid h-full grid-rows-[min-content_auto] pb-12">
<h1 class="sticky top-0 grid grid-cols-[1fr_auto_1fr] grid-rows-1 items-center text-2xl"> <h1 class="sticky top-0 grid grid-cols-[1fr_auto_1fr] grid-rows-1 items-center p-6 text-2xl">
<span class="h-12">
<IconButton on:click={() => history.back()}> <IconButton on:click={() => history.back()}>
<i slot="icon" class="fa-solid fa-arrow-left" /> <i slot="icon" class="fa-solid fa-arrow-left" />
</IconButton> </IconButton>
<span>Account</span> </span>
<span>Settings</span>
</h1> </h1>
<section class="px-[5vw]"> <section class="grid grid-cols-[min-content_auto] grid-rows-1 gap-8 px-[5vw]">
<nav class="h-full">
<a class="whitespace-nowrap text-lg {data.url.pathname === '/settings' ? 'text-lazuli-primary' : 'text-neutral-400'}" href="/settings">
<i class="fa-solid fa-user mr-1 w-4 text-center" />
Account
</a>
<ol class="ml-2 mt-4 flex flex-col gap-3 border-2 border-transparent border-l-neutral-500 px-2">
{#each accountRoutes as route}
{@const isActive = route.pathname === data.url.pathname}
<li class="w-60 px-3 py-1">
<a class="whitespace-nowrap {isActive ? 'text-lazuli-primary' : 'text-neutral-400'}" href={route.pathname}>
<i class="{route.icon} mr-1 w-4 text-center" />
{route.displayName}
</a>
</li>
{/each}
</ol>
</nav>
<slot /> <slot />
</section> </section>
</main> </main>
<style>
h1 {
height: 80px;
padding: 16px 5vw;
}
</style>

View File

@@ -1,57 +1 @@
<script lang="ts"> <h1>Main Settings Page</h1>
import IconButton from '$lib/components/util/iconButton.svelte'
import { goto } from '$app/navigation'
import type { LayoutServerData } from '../$types.js'
export let data: LayoutServerData
interface SettingRoute {
pathname: string
displayName: string
icon: string
}
const settingRoutes: SettingRoute[] = [
{
pathname: '/settings/connections',
displayName: 'Connections',
icon: 'fa-solid fa-circle-nodes',
},
{
pathname: '/settings/devices',
displayName: 'Devices',
icon: 'fa-solid fa-mobile-screen',
},
]
</script>
<nav class="h-full rounded-lg bg-neutral-950 p-6">
<h1 class="flex h-6 justify-between text-neutral-400">
<span>
<i class="fa-solid fa-gear" />
Settings
</span>
{#if data.url.pathname.split('/').at(-1) !== 'settings'}
<IconButton on:click={() => goto('/settings')}>
<i slot="icon" class="fa-solid fa-caret-left" />
</IconButton>
{/if}
</h1>
<ol class="ml-2 mt-4 flex flex-col gap-3 border-2 border-transparent border-l-neutral-500 px-2">
{#each settingRoutes as route}
<li>
{#if data.url.pathname === route.pathname}
<div class="rounded-lg bg-neutral-500 px-3 py-1">
<i class={route.icon} />
{route.displayName}
</div>
{:else}
<a href={route.pathname} class="block rounded-lg px-3 py-1 opacity-50 hover:bg-neutral-700">
<i class={route.icon} />
{route.displayName}
</a>
{/if}
</li>
{/each}
</ol>
</nav>

View File

@@ -1,16 +1,15 @@
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 { DBConnectionData } from '$lib/server/users'
import type { NewConnection } from '../../api/users/[userId]/connections/+server' 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 }) => {
const connectionsResponse = await fetch(`/api/user/${locals.user.id}/connections`, { const connectionsResponse = await fetch(`/api/users/${locals.user.id}/connections`, {
method: 'GET', method: 'GET',
headers: { apikey: SECRET_INTERNAL_API_KEY }, headers: { apikey: SECRET_INTERNAL_API_KEY },
}) })
const userConnections: DBConnectionData[] = await connectionsResponse.json() const userConnections: Connection[] = await connectionsResponse.json()
return { userConnections } return { userConnections }
} }
@@ -32,7 +31,7 @@ export const actions: Actions = {
const authData: Jellyfin.AuthData = await jellyfinAuthResponse.json() const authData: Jellyfin.AuthData = await jellyfinAuthResponse.json()
const newConnectionPayload: NewConnection = { const newConnectionPayload: NewConnection = {
url: serverUrl.toString(), urlOrigin: serverUrl.toString(),
serviceType: 'jellyfin', serviceType: 'jellyfin',
serviceUserId: authData.User.Id, serviceUserId: authData.User.Id,
accessToken: authData.AccessToken, accessToken: authData.AccessToken,
@@ -45,7 +44,21 @@ export const actions: Actions = {
if (!newConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' }) if (!newConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
const newConnection: DBConnectionData = await newConnectionResponse.json() const newConnection: Connection = await newConnectionResponse.json()
return { newConnection } return { newConnection }
}, },
deleteConnection: async ({ request, fetch, locals }) => {
const formData = await request.formData()
const connectionId = formData.get('connectionId')
const deleteConnectionResponse = await fetch(`/api/users/${locals.user.id}/connections`, {
method: 'DELETE',
headers: { apikey: SECRET_INTERNAL_API_KEY },
body: JSON.stringify({ connectionId }),
})
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
return { deletedConnectionId: connectionId }
},
} }

View File

@@ -3,13 +3,14 @@
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' import { newestAlert } from '$lib/stores.js'
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import Toggle from '$lib/components/util/toggle.svelte' import Toggle from '$lib/components/util/toggle.svelte'
import type { PageServerData } from './$types.js'
import type { SubmitFunction } from '@sveltejs/kit' import type { SubmitFunction } from '@sveltejs/kit'
export let data export let data: PageServerData
let connectionProfiles = data.connectionProfiles let connections = data.userConnections
const submitCredentials: SubmitFunction = ({ formData, action, cancel }) => { const submitCredentials: SubmitFunction = ({ formData, action, cancel }) => {
switch (action.search) { switch (action.search) {
@@ -39,32 +40,26 @@
return async ({ result }) => { return async ({ result }) => {
switch (result.type) { switch (result.type) {
case 'failure': case 'failure':
$newestAlert = ['warning', result.data.message] return ($newestAlert = ['warning', result.data?.message])
return
case 'success': case 'success':
modal = null
if (result.data?.newConnection) { if (result.data?.newConnection) {
const newConnection = result.data.newConnection const newConnection: Connection = result.data.newConnection
connectionProfiles = [newConnection, ...connectionProfiles] connections = [newConnection, ...connections]
$newestAlert = ['success', `Added ${Services[newConnection.serviceType].displayName}`] return ($newestAlert = ['success', `Added ${Services[newConnection.service.type].displayName}`])
return
} else if (result.data?.deletedConnectionId) { } else if (result.data?.deletedConnectionId) {
const id = result.data.deletedConnectionId const id = result.data.deletedConnectionId
const indexToDelete = connectionProfiles.findIndex((profile) => profile.connectionId === id) const indexToDelete = connections.findIndex((connection) => connection.id === id)
const serviceType = connectionProfiles[indexToDelete].serviceType const serviceType = connections[indexToDelete].service.type
connectionProfiles.splice(indexToDelete, 1) connections.splice(indexToDelete, 1)
connectionProfiles = connectionProfiles connections = connections
$newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`] return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
return
} }
} }
} }
} }
let modal
</script> </script>
<main> <main>
@@ -72,35 +67,29 @@
<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">
{#each Object.entries(Services) as [serviceType, serviceData]} {#each Object.entries(Services) as [serviceType, serviceData]}
<button <button class="bg-ne h-14 rounded-md" style="background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));">
class="bg-ne h-14 rounded-md"
style="background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));"
on:click={() => {
if (serviceType === 'jellyfin') modal = JellyfinAuthBox
}}
>
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-2" /> <img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-2" />
</button> </button>
{/each} {/each}
</div> </div>
</section> </section>
<div class="grid gap-8"> <div class="grid gap-8">
{#each connectionProfiles as connectionProfile} {#each connections as connection}
{@const serviceData = Services[connectionProfile.serviceType]} {@const serviceData = Services[connection.service.type]}
<section class="overflow-hidden rounded-lg" style="background-color: rgba(82, 82, 82, 0.25);" transition:fly={{ x: 50 }}> <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"> <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" /> <img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-1" />
<div> <div>
<div>{connectionProfile?.username ? connectionProfile.username : 'Placeholder Account Name'}</div> <div>{connection.service?.username ? connection.service.username : 'Placeholder Account Name'}</div>
<div class="text-sm text-neutral-500"> <div class="text-sm text-neutral-500">
{serviceData.displayName} {serviceData.displayName}
{#if connectionProfile.serviceType === 'jellyfin' && connectionProfile?.serverName} {#if connection.service.type === 'jellyfin' && connection.service?.serverName}
- {connectionProfile.serverName} - {connection.service.serverName}
{/if} {/if}
</div> </div>
</div> </div>
<div class="ml-auto h-8"> <div class="ml-auto h-8">
<IconButton on:click={() => (modal = `delete-${connectionProfile.connectionId}`)}> <IconButton>
<i slot="icon" class="fa-solid fa-link-slash" /> <i slot="icon" class="fa-solid fa-link-slash" />
</IconButton> </IconButton>
</div> </div>
@@ -115,12 +104,12 @@
</section> </section>
{/each} {/each}
</div> </div>
{#if modal} <!-- {#if modal}
<form method="post" use:enhance={submitCredentials} transition:fly={{ y: -15 }} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"> <form method="post" use:enhance={submitCredentials} transition:fly={{ y: -15 }} class="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
{#if typeof modal === 'string'} {#if typeof modal === 'string'}
{@const connectionId = modal.replace('delete-', '')} {@const connectionId = modal.replace('delete-', '')}
{@const connection = connectionProfiles.find((profile) => profile.connectionId === connectionId)} {@const connection = connections.find((connection) => connection.id === connectionId)}
{@const serviceData = Services[connection.serviceType]} {@const serviceData = Services[connection.service.type]}
<div class="rounded-lg bg-neutral-900 p-5"> <div class="rounded-lg bg-neutral-900 p-5">
<h1 class="pb-4 text-center">Delete {serviceData.displayName} connection?</h1> <h1 class="pb-4 text-center">Delete {serviceData.displayName} connection?</h1>
<div class="flex w-60 justify-around"> <div class="flex w-60 justify-around">
@@ -133,5 +122,5 @@
<svelte:component this={modal} on:close={() => (modal = null)} /> <svelte:component this={modal} on:close={() => (modal = null)} />
{/if} {/if}
</form> </form>
{/if} {/if} -->
</main> </main>