Created ConnectionProfile class to encapsulate connection data for display on connection page
This commit is contained in:
104
package-lock.json
generated
104
package-lock.json
generated
@@ -13,7 +13,6 @@
|
|||||||
"better-sqlite3": "^9.1.1",
|
"better-sqlite3": "^9.1.1",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"youtubei.js": "^7.0.0",
|
|
||||||
"ytdl-core": "^4.11.5"
|
"ytdl-core": "^4.11.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -410,6 +409,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.0.0.tgz",
|
||||||
"integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==",
|
"integrity": "sha512-JUFJad5lv7jxj926GPgymrWQxxjPYuJNiNjNMzqT+HiuP6Vl3dk5xzG+8sTX96np0ZAluvaMzPsjhHZ5rNuNQQ==",
|
||||||
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
@@ -468,6 +468,18 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/source-map": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.0",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.4.15",
|
"version": "1.4.15",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
|
||||||
@@ -666,6 +678,7 @@
|
|||||||
"version": "8.10.0",
|
"version": "8.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz",
|
||||||
"integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==",
|
"integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==",
|
||||||
|
"dev": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -944,6 +957,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/camelcase-css": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
@@ -1608,17 +1629,6 @@
|
|||||||
"@types/estree": "*"
|
"@types/estree": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jintr": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/jintr/-/jintr-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-Tu9wk3BpN2v+kb8yT6YBtue+/nbjeLFv4vvVC4PJ7oCidHKbifWhvORrAbQfxVIQZG+67am/mDagpiGSVtvrZg==",
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/LuanRT"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"acorn": "^8.8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "1.19.3",
|
"version": "1.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.19.3.tgz",
|
||||||
@@ -2681,6 +2691,29 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/source-map-support": {
|
||||||
|
"version": "0.5.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||||
|
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"source-map": "^0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map-support/node_modules/source-map": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -2878,6 +2911,34 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/terser": {
|
||||||
|
"version": "5.26.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.26.0.tgz",
|
||||||
|
"integrity": "sha512-dytTGoE2oHgbNV9nTzgBEPaqAWvcJNl66VZ0BkJqlvp71IjO8CxdBx/ykCNb47cLnCmCvRZ6ZR0tLkqvZCdVBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
|
"acorn": "^8.8.2",
|
||||||
|
"commander": "^2.20.0",
|
||||||
|
"source-map-support": "~0.5.20"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"terser": "bin/terser"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/terser/node_modules/commander": {
|
||||||
|
"version": "2.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
@@ -2941,11 +3002,6 @@
|
|||||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
|
||||||
"version": "2.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
|
||||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
|
|
||||||
},
|
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
@@ -2961,6 +3017,7 @@
|
|||||||
"version": "5.26.5",
|
"version": "5.26.5",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-5.26.5.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-5.26.5.tgz",
|
||||||
"integrity": "sha512-cSb4bPFd5qgR7qr2jYAi0hlX9n5YKK2ONKkLFkxl+v/9BvC0sOpZjBHDBSXc5lWAf5ty9oZdRXytBIHzgUcerw==",
|
"integrity": "sha512-cSb4bPFd5qgR7qr2jYAi0hlX9n5YKK2ONKkLFkxl+v/9BvC0sOpZjBHDBSXc5lWAf5ty9oZdRXytBIHzgUcerw==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/busboy": "^2.0.0"
|
"@fastify/busboy": "^2.0.0"
|
||||||
},
|
},
|
||||||
@@ -3113,19 +3170,6 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/youtubei.js": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/youtubei.js/-/youtubei.js-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-z87cv6AAjj0c98BkD0qTJvBDTF2DdT+FntJUjmi+vHY2EV+CepeYQAE/eLsdhGvCb6LrNBgGVwVUzXpHYi8NoA==",
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/LuanRT"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"jintr": "^1.1.0",
|
|
||||||
"tslib": "^2.5.0",
|
|
||||||
"undici": "^5.19.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ytdl-core": {
|
"node_modules/ytdl-core": {
|
||||||
"version": "4.11.5",
|
"version": "4.11.5",
|
||||||
"resolved": "https://registry.npmjs.org/ytdl-core/-/ytdl-core-4.11.5.tgz",
|
"resolved": "https://registry.npmjs.org/ytdl-core/-/ytdl-core-4.11.5.tgz",
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
"better-sqlite3": "^9.1.1",
|
"better-sqlite3": "^9.1.1",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"youtubei.js": "^7.0.0",
|
|
||||||
"ytdl-core": "^4.11.5"
|
"ytdl-core": "^4.11.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -5,7 +5,7 @@ const db = new Database('./src/lib/server/db/users.db', { verbose: console.info
|
|||||||
db.pragma('foreign_keys = ON')
|
db.pragma('foreign_keys = ON')
|
||||||
const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(64) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)'
|
const initUsersTable = 'CREATE TABLE IF NOT EXISTS Users(id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(64) UNIQUE NOT NULL, password VARCHAR(72) NOT NULL)'
|
||||||
const initUserConnectionsTable =
|
const initUserConnectionsTable =
|
||||||
'CREATE TABLE IF NOT EXISTS UserConnections(id INTEGER PRIMARY KEY AUTOINCREMENT, userId INTEGER NOT NULL, serviceType VARCHAR(64) NOT NULL, accessToken TEXT, refreshToken TEXT, expiry INTEGER, connectionInfo TEXT, FOREIGN KEY(userId) REFERENCES Users(id))'
|
'CREATE TABLE IF NOT EXISTS UserConnections(id VARCHAR(36) PRIMARY KEY, userId INTEGER NOT NULL, serviceType VARCHAR(64) NOT NULL, serviceUserId TEXT NOT NULL, serviceUrl TEXT NOT NULL, accessToken TEXT NOT NULL, refreshToken TEXT, expiry INTEGER, FOREIGN KEY(userId) REFERENCES Users(id))'
|
||||||
db.exec(initUsersTable)
|
db.exec(initUsersTable)
|
||||||
db.exec(initUserConnectionsTable)
|
db.exec(initUserConnectionsTable)
|
||||||
|
|
||||||
@@ -27,29 +27,38 @@ export class Users {
|
|||||||
export class UserConnections {
|
export class UserConnections {
|
||||||
static validServices = Object.keys(Services)
|
static validServices = Object.keys(Services)
|
||||||
|
|
||||||
static getConnections = (userId) => {
|
static getConnection = (id) => {
|
||||||
|
return db.prepare(`SELECT * FROM UserConnections WHERE id = ?`).get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
static getUserConnections = (userId) => {
|
||||||
const connections = db.prepare(`SELECT * FROM UserConnections WHERE userId = ?`).all(userId)
|
const connections = db.prepare(`SELECT * FROM UserConnections WHERE userId = ?`).all(userId)
|
||||||
if (connections.length === 0) return null
|
if (connections.length === 0) return null
|
||||||
return connections
|
return connections
|
||||||
}
|
}
|
||||||
|
|
||||||
// May want to give accessToken a default of null in the future if one of the services does not use access tokens
|
// May want to give accessToken a default of null in the future if one of the services does not use access tokens
|
||||||
static addConnection = (userId, serviceType, accessToken, options = {}) => {
|
static addConnection = (userId, serviceType, serviceUserId, serviceUrl, accessToken, additionalApiData = {}) => {
|
||||||
const { refreshToken = null, expiry = null, connectionInfo = null } = options
|
const { refreshToken = null, expiry = null } = additionalApiData
|
||||||
|
|
||||||
if (!this.validServices.includes(serviceType)) throw new Error(`Service name ${serviceType} is invalid`)
|
if (!this.validServices.includes(serviceType)) throw new Error(`Service name ${serviceType} is invalid`)
|
||||||
|
|
||||||
if (connectionInfo) JSON.parse(connectionInfo) // Aditional validation, if connectionInfo is not stringified valid json it will throw an error
|
const connectionId = '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))
|
||||||
|
db.prepare('INSERT INTO UserConnections(id, userId, serviceType, serviceUserId, serviceUrl, accessToken, refreshToken, expiry) VALUES(?, ?, ?, ?, ?, ?, ?, ?)').run(
|
||||||
const commandInfo = db
|
connectionId,
|
||||||
.prepare('INSERT INTO UserConnections(userId, serviceType, accessToken, refreshToken, expiry, connectionInfo) VALUES(?, ?, ?, ?, ?, ?)')
|
userId,
|
||||||
.run(userId, serviceType, accessToken, refreshToken, expiry, connectionInfo)
|
serviceType,
|
||||||
return commandInfo.lastInsertRowid
|
serviceUserId,
|
||||||
|
serviceUrl,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
expiry,
|
||||||
|
)
|
||||||
|
return connectionId
|
||||||
}
|
}
|
||||||
|
|
||||||
static deleteConnection = (userId, serviceId) => {
|
static deleteConnection = (userId, serviceId) => {
|
||||||
const commandInfo = db.prepare('DELETE FROM UserConnections WHERE userId = ? AND id = ?').run(userId, serviceId)
|
const commandInfo = db.prepare('DELETE FROM UserConnections WHERE userId = ? AND id = ?').run(userId, serviceId)
|
||||||
if (!commandInfo.changes === 0) throw new Error(`User does not have connection with id: ${serviceId}`)
|
if (!commandInfo.changes === 0) throw new Error(`User does not have connection with id: ${serviceId}`)
|
||||||
return serviceId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,8 @@ export async function GET({ url, fetch }) {
|
|||||||
return authResponse
|
return authResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authResponse.headers.get('content-type').includes('application/json')) return new Response('Jellyfin server returned invalid data', { status: 500 })
|
|
||||||
|
|
||||||
const data = await authResponse.json()
|
const data = await authResponse.json()
|
||||||
const requiredData = ['User', 'AccessToken', 'ServerId']
|
if (!('AccessToken' in data && 'User' in data)) return new Response('Jellyfin server response has missing data', { status: 500 })
|
||||||
|
|
||||||
if (!requiredData.every((key) => Object.keys(data).includes(key))) return new Response('Data missing from Jellyfin server response', { status: 500 })
|
|
||||||
|
|
||||||
const responseData = JSON.stringify(data)
|
const responseData = JSON.stringify(data)
|
||||||
const responseHeaders = new Headers({
|
const responseHeaders = new Headers({
|
||||||
|
|||||||
@@ -2,70 +2,67 @@ import { UserConnections } from '$lib/server/db/users'
|
|||||||
import Joi from 'joi'
|
import Joi from 'joi'
|
||||||
|
|
||||||
/** @type {import('./$types').RequestHandler} */
|
/** @type {import('./$types').RequestHandler} */
|
||||||
export async function GET({ request, url }) {
|
export async function GET({ url }) {
|
||||||
const schema = Joi.number().required()
|
const { userId, filter } = Object.fromEntries(url.searchParams)
|
||||||
const userId = request.headers.get('userId')
|
if (!userId) return new Response('Requires User Id', { status: 400 })
|
||||||
|
|
||||||
const validation = schema.validate(userId)
|
|
||||||
if (validation.error) return new Response(validation.error.message, { status: 400 })
|
|
||||||
|
|
||||||
const responseHeaders = new Headers({
|
const responseHeaders = new Headers({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
})
|
})
|
||||||
|
|
||||||
const filter = url.searchParams.get('filter')
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const requestedConnections = filter.split(',').map((item) => item.toLowerCase())
|
const requestedConnections = filter.split(',').map((item) => item.toLowerCase())
|
||||||
const userConnections = UserConnections.getConnections(userId, requestedConnections)
|
const userConnections = UserConnections.getUserConnections(userId, requestedConnections)
|
||||||
return new Response(JSON.stringify(userConnections), { headers: responseHeaders })
|
return new Response(JSON.stringify(userConnections), { headers: responseHeaders })
|
||||||
}
|
}
|
||||||
|
|
||||||
const userConnections = UserConnections.getConnections(userId)
|
const userConnections = UserConnections.getUserConnections(userId)
|
||||||
return new Response(JSON.stringify(userConnections), { headers: responseHeaders })
|
return new Response(JSON.stringify(userConnections), { headers: responseHeaders })
|
||||||
}
|
}
|
||||||
|
|
||||||
// May need to add support for refresh token and expiry in the future
|
// May need to add support for refresh token and expiry in the future
|
||||||
/** @type {import('./$types').RequestHandler} */
|
/** @type {import('./$types').RequestHandler} */
|
||||||
export async function PATCH({ request }) {
|
export async function PATCH({ request, url }) {
|
||||||
const schema = Joi.object({
|
const schema = Joi.object({
|
||||||
userId: Joi.number().required(),
|
userId: Joi.required(),
|
||||||
connection: Joi.object({
|
connection: Joi.object({
|
||||||
serviceType: Joi.string().required(),
|
serviceType: Joi.string().required(),
|
||||||
|
serviceUserId: Joi.string().required(),
|
||||||
|
serviceUrl: Joi.string().required(),
|
||||||
accessToken: Joi.string().required(),
|
accessToken: Joi.string().required(),
|
||||||
refreshToken: Joi.string(),
|
refreshToken: Joi.string(),
|
||||||
expiry: Joi.number(),
|
expiry: Joi.number(),
|
||||||
connectionInfo: Joi.string(),
|
|
||||||
}).required(),
|
}).required(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const userId = request.headers.get('userId')
|
const userId = url.searchParams.get('userId')
|
||||||
const connection = await request.json()
|
const connection = await request.json()
|
||||||
|
|
||||||
const validation = schema.validate({ userId, connection })
|
const validation = schema.validate({ userId, connection })
|
||||||
if (validation.error) return new Response(validation.error.message, { status: 400 })
|
if (validation.error) return new Response(validation.error.message, { status: 400 })
|
||||||
|
|
||||||
const { serviceType, accessToken, refreshToken, expiry, connectionInfo } = connection
|
const { serviceType, serviceUserId, serviceUrl, accessToken, refreshToken, expiry } = connection
|
||||||
const newConnectionId = UserConnections.addConnection(userId, serviceType, accessToken, { refreshToken, expiry, connectionInfo })
|
const newConnectionId = UserConnections.addConnection(userId, serviceType, serviceUserId, serviceUrl, accessToken, { refreshToken, expiry })
|
||||||
|
|
||||||
return new Response(JSON.stringify({ id: newConnectionId }))
|
return new Response(JSON.stringify({ id: newConnectionId }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {import('./$types').RequestHandler} */
|
/** @type {import('./$types').RequestHandler} */
|
||||||
export async function DELETE({ request }) {
|
export async function DELETE({ request, url }) {
|
||||||
const schema = Joi.object({
|
const schema = Joi.object({
|
||||||
userId: Joi.number().required(),
|
userId: Joi.required(),
|
||||||
connection: Joi.object({
|
connection: Joi.object({
|
||||||
serviceId: Joi.string().required(),
|
connectionId: Joi.string().required(),
|
||||||
}).required(),
|
}).required(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const userId = request.headers.get('userId')
|
const userId = url.searchParams.get('userId')
|
||||||
const connection = await request.json()
|
const connection = await request.json()
|
||||||
|
|
||||||
const validation = schema.validate({ userId, connection })
|
const validation = schema.validate({ userId, connection })
|
||||||
if (validation.error) return new Response(validation.error.message, { status: 400 })
|
if (validation.error) return new Response(validation.error.message, { status: 400 })
|
||||||
|
|
||||||
const deletedConnectionId = UserConnections.deleteConnection(userId, connection.serviceId)
|
UserConnections.deleteConnection(userId, connection.connectionId)
|
||||||
|
|
||||||
return new Response(JSON.stringify({ id: deletedConnectionId }))
|
return new Response('Connection deleted', { status: 200 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,62 @@
|
|||||||
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 { UserConnections } from '$lib/server/db/users'
|
||||||
|
|
||||||
|
class ConnectionProfile {
|
||||||
|
static async createProfile(connectionId) {
|
||||||
|
const connectionData = await this.getUserData(connectionId)
|
||||||
|
return { connectionId, ...connectionData }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getUserData = async (connectionId) => {
|
||||||
|
const connectionData = UserConnections.getConnection(connectionId)
|
||||||
|
const { serviceType, serviceUserId, serviceUrl, accessToken } = connectionData
|
||||||
|
|
||||||
|
switch (serviceType) {
|
||||||
|
case 'jellyfin':
|
||||||
|
const userUrl = new URL(`Users/${serviceUserId}`, serviceUrl).href
|
||||||
|
const systemUrl = new URL('System/Info', serviceUrl).href
|
||||||
|
|
||||||
|
const reqHeaders = new Headers()
|
||||||
|
reqHeaders.append('Authorization', `MediaBrowser Token="${accessToken}"`)
|
||||||
|
|
||||||
|
const userResponse = await fetch(userUrl, { headers: reqHeaders })
|
||||||
|
const systemResponse = await fetch(systemUrl, { headers: reqHeaders })
|
||||||
|
|
||||||
|
const userData = await userResponse.json()
|
||||||
|
const systemData = await systemResponse.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
serviceType,
|
||||||
|
userId: serviceUserId,
|
||||||
|
username: userData?.Name,
|
||||||
|
serviceUrl: serviceUrl,
|
||||||
|
serverName: systemData?.ServerName,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {import('./$types').PageServerLoad} */
|
/** @type {import('./$types').PageServerLoad} */
|
||||||
export const load = async ({ fetch, locals }) => {
|
export const load = async ({ fetch, locals }) => {
|
||||||
const response = await fetch('/api/user/connections', {
|
const response = await fetch(`/api/user/connections?userId=${locals.userId}`, {
|
||||||
headers: {
|
headers: {
|
||||||
apikey: SECRET_INTERNAL_API_KEY,
|
apikey: SECRET_INTERNAL_API_KEY,
|
||||||
userId: locals.userId,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (response.ok) {
|
|
||||||
const connectionData = await response.json()
|
const allConnections = await response.json()
|
||||||
const clientConnectionData = {}
|
const connectionProfiles = []
|
||||||
connectionData?.forEach((connection) => {
|
if (allConnections) {
|
||||||
const { id, serviceType, connectionInfo } = connection
|
for (const connection of allConnections) {
|
||||||
clientConnectionData[id] = {
|
const connectionProfile = await ConnectionProfile.createProfile(connection.id)
|
||||||
serviceType,
|
connectionProfiles.push(connectionProfile)
|
||||||
connectionInfo: JSON.parse(connectionInfo),
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
return { existingConnections: clientConnectionData }
|
|
||||||
} else {
|
|
||||||
const error = await response.text()
|
|
||||||
console.log(error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { connectionProfiles }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {import('./$types').Actions}} */
|
/** @type {import('./$types').Actions}} */
|
||||||
@@ -35,6 +68,7 @@ export const actions = {
|
|||||||
const [key, value] = field
|
const [key, value] = field
|
||||||
queryParams.append(key, value)
|
queryParams.append(key, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const jellyfinAuthResponse = await fetch(`/api/jellyfin/auth?${queryParams.toString()}`, {
|
const jellyfinAuthResponse = await fetch(`/api/jellyfin/auth?${queryParams.toString()}`, {
|
||||||
headers: {
|
headers: {
|
||||||
apikey: SECRET_INTERNAL_API_KEY,
|
apikey: SECRET_INTERNAL_API_KEY,
|
||||||
@@ -47,40 +81,38 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const jellyfinAuthData = await jellyfinAuthResponse.json()
|
const jellyfinAuthData = await jellyfinAuthResponse.json()
|
||||||
const { User, AccessToken, ServerId } = jellyfinAuthData
|
const accessToken = jellyfinAuthData.AccessToken
|
||||||
const connectionInfo = JSON.stringify({ User, ServerId })
|
const jellyfinUserId = jellyfinAuthData.User.Id
|
||||||
const updateConnectionsResponse = await fetch('/api/user/connections', {
|
const updateConnectionsResponse = await fetch(`/api/user/connections?userId=${locals.userId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
apikey: SECRET_INTERNAL_API_KEY,
|
apikey: SECRET_INTERNAL_API_KEY,
|
||||||
userId: locals.userId,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ serviceType: 'jellyfin', accessToken: AccessToken, connectionInfo }),
|
body: JSON.stringify({ serviceType: 'jellyfin', serviceUserId: jellyfinUserId, serviceUrl: formData.get('serverUrl'), accessToken }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' })
|
if (!updateConnectionsResponse.ok) return fail(500, { message: 'Internal Server Error' })
|
||||||
|
|
||||||
const newConnectionData = await updateConnectionsResponse.json()
|
const newConnectionData = await updateConnectionsResponse.json()
|
||||||
|
|
||||||
return { message: 'Added Jellyfin connection', newConnection: { id: newConnectionData.id, serviceType: 'jellyfin', connectionInfo: JSON.parse(connectionInfo) } }
|
const jellyfinProfile = await ConnectionProfile.createProfile(newConnectionData.id)
|
||||||
|
|
||||||
|
return { newConnection: jellyfinProfile }
|
||||||
},
|
},
|
||||||
deleteConnection: async ({ request, fetch, locals }) => {
|
deleteConnection: async ({ request, fetch, locals }) => {
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
const serviceId = formData.get('serviceId')
|
const connectionId = formData.get('connectionId')
|
||||||
|
|
||||||
const deleteConnectionResponse = await fetch('/api/user/connections', {
|
const deleteConnectionResponse = await fetch(`/api/user/connections?userId=${locals.userId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
apikey: SECRET_INTERNAL_API_KEY,
|
apikey: SECRET_INTERNAL_API_KEY,
|
||||||
userId: locals.userId,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ serviceId }),
|
body: JSON.stringify({ connectionId }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
|
if (!deleteConnectionResponse.ok) return fail(500, { message: 'Internal Server Error' })
|
||||||
|
|
||||||
const deletedConnectionData = await deleteConnectionResponse.json()
|
return { deletedConnectionId: connectionId }
|
||||||
|
|
||||||
return { message: 'Connection deleted', deletedConnection: { id: deletedConnectionData.id } }
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import Toggle from '$lib/components/utility/toggle.svelte'
|
import Toggle from '$lib/components/utility/toggle.svelte'
|
||||||
|
|
||||||
export let data
|
export let data
|
||||||
let existingConnections = data.existingConnections
|
let connectionProfiles = data.connectionProfiles
|
||||||
|
|
||||||
const submitCredentials = ({ formData, action, cancel }) => {
|
const submitCredentials = ({ formData, action, cancel }) => {
|
||||||
switch (action.search) {
|
switch (action.search) {
|
||||||
@@ -43,18 +43,20 @@
|
|||||||
case 'success':
|
case 'success':
|
||||||
modal = null
|
modal = null
|
||||||
if (result.data?.newConnection) {
|
if (result.data?.newConnection) {
|
||||||
const { id, serviceType, connectionInfo } = result.data.newConnection
|
const newConnection = result.data.newConnection
|
||||||
existingConnections[id] = {
|
connectionProfiles = [newConnection, ...connectionProfiles]
|
||||||
serviceType,
|
|
||||||
connectionInfo,
|
return ($newestAlert = ['success', `Added ${Services[newConnection.serviceType].displayName}`])
|
||||||
}
|
} else if (result.data?.deletedConnectionId) {
|
||||||
existingConnections = existingConnections
|
const id = result.data.deletedConnectionId
|
||||||
} else if (result.data?.deletedConnection) {
|
const indexToDelete = connectionProfiles.findIndex((profile) => profile.connectionId === id)
|
||||||
const id = result.data.deletedConnection.id
|
const serviceType = connectionProfiles[indexToDelete].serviceType
|
||||||
delete existingConnections[id]
|
|
||||||
existingConnections = existingConnections
|
connectionProfiles.splice(indexToDelete, 1)
|
||||||
|
connectionProfiles = connectionProfiles
|
||||||
|
|
||||||
|
return ($newestAlert = ['success', `Deleted ${Services[serviceType].displayName}`])
|
||||||
}
|
}
|
||||||
return ($newestAlert = ['success', result.data.message])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,21 +82,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="grid gap-8">
|
<div class="grid gap-8">
|
||||||
{#each Object.entries(existingConnections) as [connectionId, connectionData]}
|
{#each connectionProfiles as connectionProfile}
|
||||||
{@const serviceData = Services[connectionData.serviceType]}
|
{@const serviceData = Services[connectionProfile.serviceType]}
|
||||||
<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>
|
||||||
{#if connectionData.serviceType === 'jellyfin'}
|
<div>{connectionProfile?.username ? connectionProfile.username : 'Placeholder Account Name'}</div>
|
||||||
<div>{connectionData.connectionInfo.User.Name}</div>
|
<div class="text-sm text-neutral-500">
|
||||||
{:else}
|
{serviceData.displayName}
|
||||||
<div>Account Name</div>
|
{#if connectionProfile.serviceType === 'jellyfin'}
|
||||||
{/if}
|
- {connectionProfile.serverName}
|
||||||
<div class="text-sm text-neutral-500">{serviceData.displayName}</div>
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-auto h-8">
|
<div class="ml-auto h-8">
|
||||||
<IconButton on:click={() => (modal = `delete-${connectionId}`)}>
|
<IconButton on:click={() => (modal = `delete-${connectionProfile.connectionId}`)}>
|
||||||
<i slot="icon" class="fa-solid fa-link-slash" />
|
<i slot="icon" class="fa-solid fa-link-slash" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,11 +116,12 @@
|
|||||||
<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 service = Services[existingConnections[connectionId].serviceType]}
|
{@const connection = connectionProfiles.find((profile) => profile.connectionId === connectionId)}
|
||||||
|
{@const serviceData = Services[connection.serviceType]}
|
||||||
<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 {service.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">
|
||||||
<input type="hidden" name="serviceId" value={connectionId} />
|
<input type="hidden" name="connectionId" value={connectionId} />
|
||||||
<button class="rounded bg-neutral-800 px-4 py-2 text-center" on:click|preventDefault={() => (modal = null)}>Cancel</button>
|
<button class="rounded bg-neutral-800 px-4 py-2 text-center" on:click|preventDefault={() => (modal = null)}>Cancel</button>
|
||||||
<button class="rounded bg-red-500 px-4 py-2 text-center" formaction="?/deleteConnection">Delete</button>
|
<button class="rounded bg-red-500 px-4 py-2 text-center" formaction="?/deleteConnection">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
12
src/routes/settings/connections/userDataSchemaRef.json
Normal file
12
src/routes/settings/connections/userDataSchemaRef.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"connectionId": "Database id of the respective connection",
|
||||||
|
"serviceType": "Type of service of the respective connection",
|
||||||
|
"userId": "The UUID of the account [req]",
|
||||||
|
"username": "The username of the account [req]",
|
||||||
|
"email": "Email asscociated w/ account [opt]",
|
||||||
|
"serviceUrl": "Id of source server [req]",
|
||||||
|
"serverName": "Name of source server [jellyfin]",
|
||||||
|
|
||||||
|
"V POTENTIAL": "TBD V",
|
||||||
|
"connectionEnabled": "[Toggle] boolean; enables/disables pulling data from the respective connection"
|
||||||
|
}
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
/** @type {import('./$types').PageServerLoad} */
|
export const prerender = false
|
||||||
export async function load({ url, fetch }) {
|
export const ssr = false
|
||||||
const videoId = url.searchParams.get('videoId')
|
|
||||||
const response = await fetch(`/api/yt/media?videoId=${videoId}`)
|
/** @type {import('./$types').PageLoad} */
|
||||||
const responseData = await response.json()
|
export async function load() {}
|
||||||
return {
|
|
||||||
videoId: videoId,
|
|
||||||
videoUrl: responseData.video,
|
|
||||||
audioUrl: responseData.audio,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte'
|
|
||||||
|
|
||||||
export let data
|
|
||||||
|
|
||||||
let videoElement
|
|
||||||
let audioElement
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
audioElement.volume = 0.3
|
|
||||||
})
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<video controls bind:this={videoElement} preload="auto">
|
<section class="grid h-full place-items-center">
|
||||||
<source src={data.videoUrl} type="video/mp4" />
|
<div class="aspect-video h-2/3 bg-slate-900">
|
||||||
<track kind="captions" />
|
<video id="video-player" controls></video>
|
||||||
</video>
|
<!-- <iframe
|
||||||
<audio controls bind:this={audioElement}
|
src="https://www.youtube.com/embed/uhx-u5peyeY?controls=0&autoplay=0&showinfo=0&rel=0"
|
||||||
on:play={videoElement.play()}
|
title="Video"
|
||||||
on:pause={videoElement.pause()}
|
frameBorder="0"
|
||||||
on:seeked={(videoElement.currentTime = audioElement.currentTime)} preload="auto">
|
allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||||
<source src={data.audioUrl} type="audio/webm" />
|
allowFullScreen
|
||||||
</audio>
|
class="h-full w-full"
|
||||||
|
/> -->
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user