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'
interface Service {
type: ServiceType
userId: string
urlOrigin: string
username?: string
serverName?: string
}
interface Connection {
id: string
user: User
service: Service
accessToken: string
}
interface MediaItem {
connectionId: string
serviceType: string

Binary file not shown.

View File

@@ -1,50 +1,22 @@
import Database from 'better-sqlite3'
import { generateUUID } from '$lib/utils'
import { isValidURL } from '$lib/utils'
const db = new Database('./src/lib/server/users.db', { verbose: console.info })
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 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, 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)
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))'
db.exec(initUsersTable), db.exec(initConnectionsTable)
type UserQueryParams = {
includePassword?: boolean
}
export interface DBServiceData {
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 {
interface ConnectionsTableSchema {
id: string
userId: string
serviceId: string
service: string
accessToken: string
refreshToken: string | null
expiry: number | null
}
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 {
static getConnection = (id: string): DBConnectionData => {
const { userId, serviceId, accessToken, refreshToken, expiry } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as DBConnectionRow
const connection: DBConnectionData = { id, user: Users.getUser(userId)!, service: Services.getService(serviceId), accessToken, refreshToken, expiry }
static getConnection = (id: string): Connection => {
const { userId, service, accessToken } = db.prepare('SELECT * FROM Connections WHERE id = ?').get(id) as ConnectionsTableSchema
const connection: Connection = { id, user: Users.getUser(userId)!, service: JSON.parse(service), accessToken }
return connection
}
static getUserConnections = (userId: string): DBConnectionData[] => {
const connectionRows = db.prepare('SELECT * FROM Connections WHERE userId = ?').all(userId) as DBConnectionRow[]
const connections: DBConnectionData[] = []
static getUserConnections = (userId: string): Connection[] => {
const connectionRows = db.prepare('SELECT * FROM Connections WHERE userId = ?').all(userId) as ConnectionsTableSchema[]
const connections: Connection[] = []
const user = Users.getUser(userId)!
connectionRows.forEach((row) => {
const { id, serviceId, accessToken, refreshToken, expiry } = row
connections.push({ id, user, service: Services.getService(serviceId), accessToken, refreshToken, expiry })
const { id, service, accessToken } = row
connections.push({ id, user, service: JSON.parse(service), accessToken })
})
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()
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)
}

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 type { RequestHandler } from '@sveltejs/kit'
import { z } from 'zod'
@@ -13,10 +13,8 @@ export const GET: RequestHandler = async ({ params }) => {
const connectionSchema = z.object({
serviceType: z.enum(['jellyfin', 'youtube-music']),
serviceUserId: z.string(),
url: z.string().refine((val) => isValidURL(val)),
urlOrigin: z.string().refine((val) => isValidURL(val)),
accessToken: z.string(),
refreshToken: z.string().nullable().optional(),
expiry: z.number().nullable().optional(),
})
export type NewConnection = z.infer<typeof connectionSchema>
@@ -28,9 +26,13 @@ export const POST: RequestHandler = async ({ params, request }) => {
const connectionValidation = connectionSchema.safeParse(connection)
if (!connectionValidation.success) return new Response(connectionValidation.error.message, { status: 400 })
const { serviceType, serviceUserId, url, accessToken, refreshToken, expiry } = connectionValidation.data
const newService = Services.addService(serviceType as ServiceType, serviceUserId, new URL(url))
const newConnection = Connections.addConnection(userId, newService.id, accessToken, refreshToken as string | null, expiry as number | null)
const { serviceType, serviceUserId, urlOrigin, accessToken } = connectionValidation.data
const service: Service = {
type: serviceType,
userId: serviceUserId,
urlOrigin: new URL(urlOrigin).origin,
}
const newConnection = Connections.addConnection(userId, service, accessToken)
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" />
</div>
<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" />
</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
name="confirmPassword"
type={passwordVisible ? 'text' : 'password'}

View File

@@ -1,22 +1,56 @@
<script lang="ts">
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>
<main class="h-full">
<h1 class="sticky top-0 grid grid-cols-[1fr_auto_1fr] grid-rows-1 items-center text-2xl">
<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 p-6 text-2xl">
<span class="h-12">
<IconButton on:click={() => history.back()}>
<i slot="icon" class="fa-solid fa-arrow-left" />
</IconButton>
<span>Account</span>
</span>
<span>Settings</span>
</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 />
</section>
</main>
<style>
h1 {
height: 80px;
padding: 16px 5vw;
}
</style>

View File

@@ -1,57 +1 @@
<script lang="ts">
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>
<h1>Main Settings Page</h1>

View File

@@ -1,16 +1,15 @@
import { fail } from '@sveltejs/kit'
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 { PageServerLoad, Actions } from './$types'
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',
headers: { apikey: SECRET_INTERNAL_API_KEY },
})
const userConnections: DBConnectionData[] = await connectionsResponse.json()
const userConnections: Connection[] = await connectionsResponse.json()
return { userConnections }
}
@@ -32,7 +31,7 @@ export const actions: Actions = {
const authData: Jellyfin.AuthData = await jellyfinAuthResponse.json()
const newConnectionPayload: NewConnection = {
url: serverUrl.toString(),
urlOrigin: serverUrl.toString(),
serviceType: 'jellyfin',
serviceUserId: authData.User.Id,
accessToken: authData.AccessToken,
@@ -45,7 +44,21 @@ export const actions: Actions = {
if (!newConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
const newConnection: DBConnectionData = await newConnectionResponse.json()
const newConnection: Connection = await newConnectionResponse.json()
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 Services from '$lib/services.json'
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 Toggle from '$lib/components/util/toggle.svelte'
import type { PageServerData } from './$types.js'
import type { SubmitFunction } from '@sveltejs/kit'
export let data
let connectionProfiles = data.connectionProfiles
export let data: PageServerData
let connections = data.userConnections
const submitCredentials: SubmitFunction = ({ formData, action, cancel }) => {
switch (action.search) {
@@ -39,32 +40,26 @@
return async ({ result }) => {
switch (result.type) {
case 'failure':
$newestAlert = ['warning', result.data.message]
return
return ($newestAlert = ['warning', result.data?.message])
case 'success':
modal = null
if (result.data?.newConnection) {
const newConnection = result.data.newConnection
connectionProfiles = [newConnection, ...connectionProfiles]
const newConnection: Connection = result.data.newConnection
connections = [newConnection, ...connections]
$newestAlert = ['success', `Added ${Services[newConnection.serviceType].displayName}`]
return
return ($newestAlert = ['success', `Added ${Services[newConnection.service.type].displayName}`])
} else if (result.data?.deletedConnectionId) {
const id = result.data.deletedConnectionId
const indexToDelete = connectionProfiles.findIndex((profile) => profile.connectionId === id)
const serviceType = connectionProfiles[indexToDelete].serviceType
const indexToDelete = connections.findIndex((connection) => connection.id === id)
const serviceType = connections[indexToDelete].service.type
connectionProfiles.splice(indexToDelete, 1)
connectionProfiles = connectionProfiles
connections.splice(indexToDelete, 1)
connections = connections
$newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`]
return
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
}
}
}
}
let modal
</script>
<main>
@@ -72,35 +67,29 @@
<h1 class="py-2 text-xl">Add Connection</h1>
<div class="flex flex-wrap gap-2 pb-4">
{#each Object.entries(Services) as [serviceType, serviceData]}
<button
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
}}
>
<button class="bg-ne h-14 rounded-md" style="background-image: linear-gradient(to bottom, rgb(30, 30, 30), rgb(10, 10, 10));">
<img src={serviceData.icon} alt="{serviceData.displayName} icon" class="aspect-square h-full p-2" />
</button>
{/each}
</div>
</section>
<div class="grid gap-8">
{#each connectionProfiles as connectionProfile}
{@const serviceData = Services[connectionProfile.serviceType]}
{#each connections as connection}
{@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 }}>
<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>{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">
{serviceData.displayName}
{#if connectionProfile.serviceType === 'jellyfin' && connectionProfile?.serverName}
- {connectionProfile.serverName}
{#if connection.service.type === 'jellyfin' && connection.service?.serverName}
- {connection.service.serverName}
{/if}
</div>
</div>
<div class="ml-auto h-8">
<IconButton on:click={() => (modal = `delete-${connectionProfile.connectionId}`)}>
<IconButton>
<i slot="icon" class="fa-solid fa-link-slash" />
</IconButton>
</div>
@@ -115,12 +104,12 @@
</section>
{/each}
</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">
{#if typeof modal === 'string'}
{@const connectionId = modal.replace('delete-', '')}
{@const connection = connectionProfiles.find((profile) => profile.connectionId === connectionId)}
{@const serviceData = Services[connection.serviceType]}
{@const connection = connections.find((connection) => connection.id === connectionId)}
{@const serviceData = Services[connection.service.type]}
<div class="rounded-lg bg-neutral-900 p-5">
<h1 class="pb-4 text-center">Delete {serviceData.displayName} connection?</h1>
<div class="flex w-60 justify-around">
@@ -133,5 +122,5 @@
<svelte:component this={modal} on:close={() => (modal = null)} />
{/if}
</form>
{/if}
{/if} -->
</main>