Implemented replay

This commit is contained in:
kontiko 2024-06-27 22:22:13 +02:00
parent 6a3a37758c
commit 207b7f290f
12 changed files with 441 additions and 227 deletions

View file

@ -15,11 +15,8 @@
// Features to add to the dev container. More info: https://containers.dev/features. // Features to add to the dev container. More info: https://containers.dev/features.
// "features": {}, // "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally. // Use 'forwardPorts' to make a list of ports inside the container available locally.
//"forwardPorts": [ "forwardPorts": [3000, 3333],
// 3000, "runArgs": ["--network=host", "-p 3000:3000", "-p 3333:3333"],
// 3333
//],
"runArgs": ["--network=host"],
// Use 'postCreateCommand' to run commands after the container is created. // Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "sh .devcontainer/post-create.sh", "postCreateCommand": "sh .devcontainer/post-create.sh",
// Configure tool-specific properties. // Configure tool-specific properties.

28
.vscode/launch.json vendored
View file

@ -9,36 +9,24 @@
"request": "launch", "request": "launch",
"name": "Debug server", "name": "Debug server",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"args": [ "args": ["run", "dev"],
"run", "skipFiles": ["<node_internals>/**"]
"dev"
],
"skipFiles": [
"<node_internals>/**"
]
}, },
{ {
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"name": "Debug client (nuxt)", "name": "Debug client (nuxt)",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"args": [ "args": ["run", "dev"],
"run",
"dev"
],
"cwd": "${workspaceFolder}/client", "cwd": "${workspaceFolder}/client",
"skipFiles": [ "skipFiles": ["${workspaceFolder}/<node_internals>/**"]
"${workspaceFolder}/<node_internals>/**"
]
} }
], ],
"compounds": [ "compounds": [
{ {
"name": "Debug server and client (nuxt)", "name": "Debug server and client (nuxt)",
"configurations": [ "configurations": ["Debug server", "Debug client (nuxt)"]
"Debug server",
"Debug client (nuxt)"
]
} }
] ],
} "outputCapture": "std"
}

View file

@ -23,5 +23,6 @@
}, },
"[vue]": { "[vue]": {
"editor.defaultFormatter": "octref.vetur" "editor.defaultFormatter": "octref.vetur"
} },
} "console": "integratedTerminal"
}

View file

@ -11,7 +11,7 @@
<transition name="menu"> <transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm DlnaDropdownMenu" tabindex="-1" role="listbox"> <ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm DlnaDropdownMenu" tabindex="-1" role="listbox">
<template v-for="device in get_devices"> <template v-for="device in get_devices">
<li :key="device.host" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" v-bind:class="{ 'bg-black-600': get_device == device.host }" role="option" tabindex="0" @keydown.enter="selectDevice(device.host)" @click="selectDevice(device.host)"> <li :key="device.host" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" v-bind:class="{ 'bg-black-600': get_device == device.UDN }" role="option" tabindex="0" @keydown.enter="selectDevice(device.host)" @click="selectDevice(device.UDN)">
<div class="flex items-center px-2"> <div class="flex items-center px-2">
<span class="font-normal block truncate font-sans text-sm">{{ device.name }}</span> <span class="font-normal block truncate font-sans text-sm">{{ device.name }}</span>
</div> </div>
@ -92,4 +92,4 @@ export default {
.icon-blue { .icon-blue {
color: blue; color: blue;
} }
</style> </style>

View file

@ -7,7 +7,7 @@ export default class DLNAPlayer extends EventEmitter {
this.ctx = ctx this.ctx = ctx
this.player = null this.player = null
this.playerController = null this.playerController = null
this.state = null
this.libraryItem = null this.libraryItem = null
this.audioTracks = [] this.audioTracks = []
this.currentTrackIndex = 0 this.currentTrackIndex = 0
@ -15,7 +15,8 @@ export default class DLNAPlayer extends EventEmitter {
this.currentTime = 0 this.currentTime = 0
this.playWhenReady = false this.playWhenReady = false
this.defaultPlaybackRate = 1 this.defaultPlaybackRate = 1
this.paused
this.currentTime = null
// TODO: Use canDisplayType on receiver to check mime types // TODO: Use canDisplayType on receiver to check mime types
this.playableMimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac', 'audio/x-ms-wma', 'audio/x-aiff', 'audio/webm'] this.playableMimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac', 'audio/x-ms-wma', 'audio/x-aiff', 'audio/webm']
@ -34,13 +35,15 @@ export default class DLNAPlayer extends EventEmitter {
} }
initialize() { initialize() {
this.ctx.$root.socket.on('test', this.destroy) this.ctx.$root.socket.on('dlna_status', (data) => this.setStatus(data))
//this.player = this.ctx.$root.castPlayer //this.player = this.ctx.$root.castPlayer
} }
evtMediaInfoChanged() {} evtMediaInfoChanged() {}
destroy() {} destroy() {
this.ctx.$root.socket.emit('dlna_exit')
}
async set(libraryItem, tracks, isHlsTranscode, startTime, playWhenReady = false) { async set(libraryItem, tracks, isHlsTranscode, startTime, playWhenReady = false) {
this.libraryItem = libraryItem this.libraryItem = libraryItem
@ -56,16 +59,28 @@ export default class DLNAPlayer extends EventEmitter {
} else { } else {
this.coverUrl = `${window.location.origin}${coverImg}` this.coverUrl = `${window.location.origin}${coverImg}`
} }
this.ctx.$root.socket.emit('dlna_start', this.audioTracks) var serverAddress = window.origin
if (this.$isDev) serverAddress = `http://localhost:3333${this.$config.routerBasePath}`
this.ctx.$root.socket.emit('dlna_start', this.ctx.$store.state.globals.dlnaDevice, this.audioTracks, startTime, serverAddress)
} }
resetStream(startTime) {} resetStream(startTime) {}
playPause() {} playPause() {
if (this.state == 'PLAYING') {
this.pause()
} else {
this.play()
}
}
play() {} play() {
this.ctx.$root.socket.emit('dlna_play')
}
pause() {} pause() {
this.ctx.$root.socket.emit('dlna_pause')
}
getCurrentTime() { getCurrentTime() {
//var currentTrackOffset = this.currentTrack.startOffset || 0 //var currentTrackOffset = this.currentTrack.startOffset || 0
@ -83,6 +98,22 @@ export default class DLNAPlayer extends EventEmitter {
} }
async seek(time, playWhenReady) {} async seek(time, playWhenReady) {}
setStatus(data) {
console.log(data)
if (this.state != data.status) {
this.state = data.status
this.emit('stateChange', this.state)
}
this.currentTrackIndex = data.track_idx
this.currentTime = data.pos
console.log(this.currentTrackIndex, this.currentTime)
}
getCurrentTime() {
return this.currentTime + this.audioTracks[this.currentTrackIndex].startOffset
}
seek(time) {
this.ctx.$root.socket.emit('dlna_seek', time)
}
setVolume(volume) {} setVolume(volume) {}
} }

View file

@ -13,7 +13,6 @@ const Logger = require('./Logger')
* @class Class for handling all the authentication related functionality. * @class Class for handling all the authentication related functionality.
*/ */
class Auth { class Auth {
constructor() { constructor() {
// Map of openId sessions indexed by oauth2 state-variable // Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map() this.openIdAuthSession = new Map()
@ -24,40 +23,52 @@ class Auth {
*/ */
async initPassportJs() { async initPassportJs() {
// Check if we should load the local strategy (username + password login) // Check if we should load the local strategy (username + password login)
if (global.ServerSettings.authActiveAuthMethods.includes("local")) { if (global.ServerSettings.authActiveAuthMethods.includes('local')) {
this.initAuthStrategyPassword() this.initAuthStrategyPassword()
} }
// Check if we should load the openid strategy // Check if we should load the openid strategy
if (global.ServerSettings.authActiveAuthMethods.includes("openid")) { if (global.ServerSettings.authActiveAuthMethods.includes('openid')) {
this.initAuthStrategyOpenID() this.initAuthStrategyOpenID()
} }
// Load the JwtStrategy (always) -> for bearer token auth // Load the JwtStrategy (always) -> for bearer token auth
passport.use(new JwtStrategy({ passport.use(
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), new JwtStrategy(
secretOrKey: Database.serverSettings.tokenSecret {
}, this.jwtAuthCheck.bind(this))) jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
secretOrKey: Database.serverSettings.tokenSecret
},
this.jwtAuthCheck.bind(this)
)
)
// define how to seralize a user (to be put into the session) // define how to seralize a user (to be put into the session)
passport.serializeUser(function (user, cb) { passport.serializeUser(function (user, cb) {
process.nextTick(function () { process.nextTick(function () {
// only store id to session // only store id to session
return cb(null, JSON.stringify({ return cb(
id: user.id, null,
})) JSON.stringify({
id: user.id
})
)
}) })
}) })
// define how to deseralize a user (use the ID to get it from the database) // define how to deseralize a user (use the ID to get it from the database)
passport.deserializeUser((function (user, cb) { passport.deserializeUser(
process.nextTick((async function () { function (user, cb) {
const parsedUserInfo = JSON.parse(user) process.nextTick(
// load the user by ID that is stored in the session async function () {
const dbUser = await Database.userModel.getUserById(parsedUserInfo.id) const parsedUserInfo = JSON.parse(user)
return cb(null, dbUser) // load the user by ID that is stored in the session
}).bind(this)) const dbUser = await Database.userModel.getUserById(parsedUserInfo.id)
}).bind(this)) return cb(null, dbUser)
}.bind(this)
)
}.bind(this)
)
} }
/** /**
@ -92,43 +103,49 @@ class Auth {
client_secret: global.ServerSettings.authOpenIDClientSecret, client_secret: global.ServerSettings.authOpenIDClientSecret,
id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
}) })
passport.use('openid-client', new OpenIDClient.Strategy({ passport.use(
client: openIdClient, 'openid-client',
params: { new OpenIDClient.Strategy(
redirect_uri: '/auth/openid/callback', {
scope: 'openid profile email' client: openIdClient,
} params: {
}, async (tokenset, userinfo, done) => { redirect_uri: '/auth/openid/callback',
try { scope: 'openid profile email'
Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) }
},
async (tokenset, userinfo, done) => {
try {
Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
if (!userinfo.sub) { if (!userinfo.sub) {
throw new Error('Invalid userinfo, no sub') throw new Error('Invalid userinfo, no sub')
}
if (!this.validateGroupClaim(userinfo)) {
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
}
let user = await this.findOrCreateUser(userinfo)
if (!user?.isActive) {
throw new Error('User not active or not found')
}
await this.setUserGroup(user, userinfo)
await this.updateUserPermissions(user, userinfo)
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
user.openid_id_token = tokenset.id_token
return done(null, user)
} catch (error) {
Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`)
return done(null, null, 'Unauthorized')
}
} }
)
if (!this.validateGroupClaim(userinfo)) { )
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
}
let user = await this.findOrCreateUser(userinfo)
if (!user?.isActive) {
throw new Error('User not active or not found')
}
await this.setUserGroup(user, userinfo)
await this.updateUserPermissions(user, userinfo)
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
user.openid_id_token = tokenset.id_token
return done(null, user)
} catch (error) {
Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`)
return done(null, null, 'Unauthorized')
}
}))
} }
/** /**
@ -181,7 +198,6 @@ class Auth {
return null return null
} }
user = await Database.userModel.getUserByUsername(username) user = await Database.userModel.getUserByUsername(username)
if (user?.authOpenIDSub) { if (user?.authOpenIDSub) {
@ -220,7 +236,8 @@ class Auth {
*/ */
validateGroupClaim(userinfo) { validateGroupClaim(userinfo) {
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
if (!groupClaimName) // Allow no group claim when configured like this if (!groupClaimName)
// Allow no group claim when configured like this
return true return true
// If configured it must exist in userinfo // If configured it must exist in userinfo
@ -232,22 +249,22 @@ class Auth {
/** /**
* Sets the user group based on group claim in userinfo. * Sets the user group based on group claim in userinfo.
* *
* @param {import('./objects/user/User')} user * @param {import('./objects/user/User')} user
* @param {Object} userinfo * @param {Object} userinfo
*/ */
async setUserGroup(user, userinfo) { async setUserGroup(user, userinfo) {
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
if (!groupClaimName) // No group claim configured, don't set anything if (!groupClaimName)
// No group claim configured, don't set anything
return return
if (!userinfo[groupClaimName]) if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
const groupsList = userinfo[groupClaimName].map(group => group.toLowerCase()) const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
const rolesInOrderOfPriority = ['admin', 'user', 'guest'] const rolesInOrderOfPriority = ['admin', 'user', 'guest']
let userType = rolesInOrderOfPriority.find(role => groupsList.includes(role)) let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
if (userType) { if (userType) {
if (user.type === 'root') { if (user.type === 'root') {
// Check OpenID Group // Check OpenID Group
@ -271,21 +288,20 @@ class Auth {
/** /**
* Updates user permissions based on the advanced permissions claim. * Updates user permissions based on the advanced permissions claim.
* *
* @param {import('./objects/user/User')} user * @param {import('./objects/user/User')} user
* @param {Object} userinfo * @param {Object} userinfo
*/ */
async updateUserPermissions(user, userinfo) { async updateUserPermissions(user, userinfo) {
const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim
if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything if (!absPermissionsClaim)
// No advanced permissions claim configured, don't set anything
return return
if (user.type === 'admin' || user.type === 'root') if (user.type === 'admin' || user.type === 'root') return
return
const absPermissions = userinfo[absPermissionsClaim] const absPermissions = userinfo[absPermissionsClaim]
if (!absPermissions) if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
if (user.updatePermissionsFromExternalJSON(absPermissions)) { if (user.updatePermissionsFromExternalJSON(absPermissions)) {
Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
@ -295,8 +311,8 @@ class Auth {
/** /**
* Unuse strategy * Unuse strategy
* *
* @param {string} name * @param {string} name
*/ */
unuseAuthStrategy(name) { unuseAuthStrategy(name) {
passport.unuse(name) passport.unuse(name)
@ -304,8 +320,8 @@ class Auth {
/** /**
* Use strategy * Use strategy
* *
* @param {string} name * @param {string} name
*/ */
useAuthStrategy(name) { useAuthStrategy(name) {
if (name === 'openid') { if (name === 'openid') {
@ -335,7 +351,7 @@ class Auth {
* - 'api': Authentication for API use * - 'api': Authentication for API use
* - 'openid': OpenID authentication directly over web * - 'openid': OpenID authentication directly over web
* - 'openid-mobile': OpenID authentication, but done via an mobile device * - 'openid-mobile': OpenID authentication, but done via an mobile device
* *
* @param {import('express').Request} req * @param {import('express').Request} req
* @param {import('express').Response} res * @param {import('express').Response} res
* @param {string} authMethod - The authentication method, default is 'local'. * @param {string} authMethod - The authentication method, default is 'local'.
@ -365,7 +381,7 @@ class Auth {
/** /**
* Informs the client in the right mode about a successfull login and the token * Informs the client in the right mode about a successfull login and the token
* (clients choise is restored from cookies). * (clients choise is restored from cookies).
* *
* @param {import('express').Request} req * @param {import('express').Request} req
* @param {import('express').Response} res * @param {import('express').Response} res
*/ */
@ -391,8 +407,8 @@ class Auth {
/** /**
* Creates all (express) routes required for authentication. * Creates all (express) routes required for authentication.
* *
* @param {import('express').Router} router * @param {import('express').Router} router
*/ */
async initAuthRoutes(router) { async initAuthRoutes(router) {
// Local strategy login route (takes username and password) // Local strategy login route (takes username and password)
@ -422,7 +438,7 @@ class Auth {
} }
// Generate a state on web flow or if no state supplied // Generate a state on web flow or if no state supplied
const state = (!isMobileFlow || !req.query.state) ? OpenIDClient.generators.random() : req.query.state const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state
// Redirect URL for the SSO provider // Redirect URL for the SSO provider
let redirectUri let redirectUri
@ -508,8 +524,7 @@ class Auth {
function isValidRedirectUri(uri) { function isValidRedirectUri(uri) {
// Check if the redirect_uri is in the whitelist // Check if the redirect_uri is in the whitelist
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
(Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
} }
}) })
@ -545,77 +560,79 @@ class Auth {
}) })
// openid strategy callback route (this receives the token from the configured openid login provider) // openid strategy callback route (this receives the token from the configured openid login provider)
router.get('/auth/openid/callback', (req, res, next) => { router.get(
const oidcStrategy = passport._strategy('openid-client') '/auth/openid/callback',
const sessionKey = oidcStrategy._key (req, res, next) => {
const oidcStrategy = passport._strategy('openid-client')
const sessionKey = oidcStrategy._key
if (!req.session[sessionKey]) { if (!req.session[sessionKey]) {
return res.status(400).send('No session') return res.status(400).send('No session')
}
// If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
// The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
// Crucial for API/Mobile clients
if (req.query.code_verifier) {
req.session[sessionKey].code_verifier = req.query.code_verifier
}
function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
Logger.error(JSON.stringify(logMessage, null, 2))
if (response) {
// Depending on the error, it can also have a body
// We also log the request header the passport plugin sents for the URL
const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2))
} }
if (isMobile) { // If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
return res.status(errorCode).send(errorMessage) // The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
} else { // Crucial for API/Mobile clients
return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`) if (req.query.code_verifier) {
req.session[sessionKey].code_verifier = req.query.code_verifier
} }
}
function passportCallback(req, res, next) { function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
return (err, user, info) => { Logger.error(JSON.stringify(logMessage, null, 2))
const isMobile = req.session[sessionKey]?.mobile === true if (response) {
if (err) { // Depending on the error, it can also have a body
return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response) // We also log the request header the passport plugin sents for the URL
const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2))
} }
if (!user) { if (isMobile) {
// Info usually contains the error message from the SSO provider return res.status(errorCode).send(errorMessage)
return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response) } else {
return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
} }
}
req.logIn(user, (loginError) => { function passportCallback(req, res, next) {
if (loginError) { return (err, user, info) => {
return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`) const isMobile = req.session[sessionKey]?.mobile === true
if (err) {
return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
} }
// The id_token does not provide access to the user, but is used to identify the user to the SSO provider if (!user) {
// instead it containts a JWT with userinfo like user email, username, etc. // Info usually contains the error message from the SSO provider
// the client will get to know it anyway in the logout url according to the oauth2 spec return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)
// so it is safe to send it to the client, but we use strict settings }
res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' })
next() req.logIn(user, (loginError) => {
}) if (loginError) {
return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
}
// The id_token does not provide access to the user, but is used to identify the user to the SSO provider
// instead it containts a JWT with userinfo like user email, username, etc.
// the client will get to know it anyway in the logout url according to the oauth2 spec
// so it is safe to send it to the client, but we use strict settings
res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' })
next()
})
}
} }
}
// While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
// While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
// We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided // We set it here again because the passport param can change between requests
// We set it here again because the passport param can change between requests return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)
return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next) },
},
// on a successfull login: read the cookies and react like the client requested (callback or json) // on a successfull login: read the cookies and react like the client requested (callback or json)
this.handleLoginSuccessBasedOnCookie.bind(this)) this.handleLoginSuccessBasedOnCookie.bind(this)
)
/** /**
* Helper route used to auto-populate the openid URLs in config/authentication * Helper route used to auto-populate the openid URLs in config/authentication
* Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration" * Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration"
* *
* @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/ * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/
*/ */
router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => { router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => {
@ -625,7 +642,7 @@ class Auth {
} }
if (!req.query.issuer) { if (!req.query.issuer) {
return res.status(400).send('Invalid request. Query param \'issuer\' is required') return res.status(400).send("Invalid request. Query param 'issuer' is required")
} }
// Strip trailing slash // Strip trailing slash
@ -641,23 +658,26 @@ class Auth {
} }
} catch (error) { } catch (error) {
Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
return res.status(400).send('Invalid request. Query param \'issuer\' is invalid') return res.status(400).send("Invalid request. Query param 'issuer' is invalid")
} }
axios.get(configUrl.toString()).then(({ data }) => { axios
res.json({ .get(configUrl.toString())
issuer: data.issuer, .then(({ data }) => {
authorization_endpoint: data.authorization_endpoint, res.json({
token_endpoint: data.token_endpoint, issuer: data.issuer,
userinfo_endpoint: data.userinfo_endpoint, authorization_endpoint: data.authorization_endpoint,
end_session_endpoint: data.end_session_endpoint, token_endpoint: data.token_endpoint,
jwks_uri: data.jwks_uri, userinfo_endpoint: data.userinfo_endpoint,
id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported end_session_endpoint: data.end_session_endpoint,
jwks_uri: data.jwks_uri,
id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
})
})
.catch((error) => {
Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error)
res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`)
}) })
}).catch((error) => {
Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error)
res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`)
})
}) })
// Logout route // Logout route
@ -683,7 +703,7 @@ class Auth {
let postLogoutRedirectUri = null let postLogoutRedirectUri = null
if (authMethod === 'openid') { if (authMethod === 'openid') {
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const host = req.get('host') const host = req.get('host')
// TODO: ABS does currently not support subfolders for installation // TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl // If we want to support it we need to include a config for the serverurl
@ -717,9 +737,9 @@ class Auth {
/** /**
* middleware to use in express to only allow authenticated users. * middleware to use in express to only allow authenticated users.
* @param {import('express').Request} req * @param {import('express').Request} req
* @param {import('express').Response} res * @param {import('express').Response} res
* @param {import('express').NextFunction} next * @param {import('express').NextFunction} next
*/ */
isAuthenticated(req, res, next) { isAuthenticated(req, res, next) {
// check if session cookie says that we are authenticated // check if session cookie says that we are authenticated
@ -727,14 +747,14 @@ class Auth {
next() next()
} else { } else {
// try JWT to authenticate // try JWT to authenticate
passport.authenticate("jwt")(req, res, next) passport.authenticate('jwt')(req, res, next)
} }
} }
/** /**
* Function to generate a jwt token for a given user * Function to generate a jwt token for a given user
* *
* @param {{ id:string, username:string }} user * @param {{ id:string, username:string }} user
* @returns {string} token * @returns {string} token
*/ */
generateAccessToken(user) { generateAccessToken(user) {
@ -743,15 +763,14 @@ class Auth {
/** /**
* Function to validate a jwt token for a given user * Function to validate a jwt token for a given user
* *
* @param {string} token * @param {string} token
* @returns {Object} tokens data * @returns {Object} tokens data
*/ */
static validateAccessToken(token) { static validateAccessToken(token) {
try { try {
return jwt.verify(token, global.ServerSettings.tokenSecret) return jwt.verify(token, global.ServerSettings.tokenSecret)
} } catch (err) {
catch (err) {
return null return null
} }
} }
@ -760,7 +779,8 @@ class Auth {
* Generate a token which is used to encrpt/protect the jwts. * Generate a token which is used to encrpt/protect the jwts.
*/ */
async initTokenSecret() { async initTokenSecret() {
if (process.env.TOKEN_SECRET) { // User can supply their own token secret if (process.env.TOKEN_SECRET) {
// User can supply their own token secret
Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
} else { } else {
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
@ -779,8 +799,8 @@ class Auth {
/** /**
* Checks if the user in the validated jwt_payload really exists and is active. * Checks if the user in the validated jwt_payload really exists and is active.
* @param {Object} jwt_payload * @param {Object} jwt_payload
* @param {function} done * @param {function} done
*/ */
async jwtAuthCheck(jwt_payload, done) { async jwtAuthCheck(jwt_payload, done) {
// load user by id from the jwt token // load user by id from the jwt token
@ -798,9 +818,9 @@ class Auth {
/** /**
* Checks if a username and password tuple is valid and the user active. * Checks if a username and password tuple is valid and the user active.
* @param {string} username * @param {string} username
* @param {string} password * @param {string} password
* @param {Promise<function>} done * @param {Promise<function>} done
*/ */
async localAuthCheckUserPw(username, password, done) { async localAuthCheckUserPw(username, password, done) {
// Load the user given it's username // Load the user given it's username
@ -841,8 +861,8 @@ class Auth {
/** /**
* Hashes a password with bcrypt. * Hashes a password with bcrypt.
* @param {string} password * @param {string} password
* @returns {Promise<string>} hash * @returns {Promise<string>} hash
*/ */
hashPass(password) { hashPass(password) {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -858,8 +878,8 @@ class Auth {
/** /**
* Return the login info payload for a user * Return the login info payload for a user
* *
* @param {Object} user * @param {Object} user
* @returns {Promise<Object>} jsonPayload * @returns {Promise<Object>} jsonPayload
*/ */
async getUserLoginResponsePayload(user) { async getUserLoginResponsePayload(user) {
@ -874,9 +894,9 @@ class Auth {
} }
/** /**
* *
* @param {string} password * @param {string} password
* @param {import('./models/User')} user * @param {import('./models/User')} user
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
comparePassword(password, user) { comparePassword(password, user) {
@ -887,9 +907,9 @@ class Auth {
/** /**
* User changes their password from request * User changes their password from request
* *
* @param {import('express').Request} req * @param {import('express').Request} req
* @param {import('express').Response} res * @param {import('express').Response} res
*/ */
async userChangePassword(req, res) { async userChangePassword(req, res) {
let { password, newPassword } = req.body let { password, newPassword } = req.body
@ -937,4 +957,4 @@ class Auth {
} }
} }
module.exports = Auth module.exports = Auth

View file

@ -78,7 +78,7 @@ class Server {
this.cronManager = new CronManager(this.podcastManager) this.cronManager = new CronManager(this.podcastManager)
this.apiCacheManager = new ApiCacheManager() this.apiCacheManager = new ApiCacheManager()
this.binaryManager = new BinaryManager() this.binaryManager = new BinaryManager()
this.DLNAManager = new DLNAManager() this.DLNAManager = new DLNAManager(this.auth)
// Routers // Routers
this.apiRouter = new ApiRouter(this) this.apiRouter = new ApiRouter(this)
@ -101,7 +101,7 @@ class Server {
} }
/** /**
* Initialize database, backups, logs, rss feeds, cron jobs & watcher * Initialize , backups, logs, rss feeds, cron jobs & watcher
* Cleanup stale/invalid data * Cleanup stale/invalid data
*/ */
async init() { async init() {

View file

@ -115,7 +115,11 @@ class SocketAuthority {
//DLNA //DLNA
socket.on('dlna_start', (data) => this.Server.DLNAManager.start_playback(socket.id, data)) socket.on('dlna_start', (player, audiobook, start_time, serverAddress) => this.Server.DLNAManager.start_playback(socket.id, player, audiobook, start_time, serverAddress))
socket.on('dlna_exit', () => this.Server.DLNAManager.exit_session(socket.id))
socket.on('dlna_play', () => this.Server.DLNAManager.continue_session(socket.id))
socket.on('dlna_pause', () => this.Server.DLNAManager.pause_session(socket.id))
socket.on('dlna_seek', (time) => this.Server.DLNAManager.seek(socket.id, time))
// Logs // Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))

View file

@ -1,10 +1,8 @@
class DLNAController { class DLNAController {
constructor() {} constructor() {}
async get_Devices(req, res) { async get_Devices(req, res) {
var count = 0 var count = 0
const players = await this.DLNAManager.Client.players const players = await this.DLNAManager.devices
return res.status(200).send(players) return res.status(200).send(players)
} }
middleware(req, res, next) { middleware(req, res, next) {

View file

@ -1,14 +1,80 @@
const { clientEmitter } = require('../SocketAuthority')
const DLNADevice = require('../objects/DLNADevice')
const DLNASession = require('../objects/DLNASession')
const SSDPclient = require('node-ssdp').Client
const Auth = require('../Auth')
class DLNAManager { class DLNAManager {
constructor() { constructor(auth) {
console.log('It worked') this.playback_sessions = []
this.Client = require('dlnacasts')() this.devices = []
this.Client.on('update', function (player) { this.ssdpclient = new SSDPclient()
console.log('all players: ', this.players) this.ssdpclient.on('response', this.add_device.bind(this))
}) setInterval(this.update_sessions.bind(this), 500)
this.ssdpclient.search('urn:schemas-upnp-org:service:AVTransport:1')
this.auth = auth
} }
add_device(header) {
start_playback(id, data) { console.log('header', header.LOCATION)
console.log(id, data) if (
!this.devices ||
!this.devices.find((dev) => {
return dev.url == header.LOCATION
})
) {
console.log('header', header.LOCATION)
this.devices.push(new DLNADevice(header.LOCATION))
console.log(this.devices)
}
}
start_playback(id, player, audiobook, start_time, serverAddress) {
var user = {
id: 42,
username: 'DLNA'
}
var token = this.auth.generateAccessToken(user)
this.playback_sessions.push(
new DLNASession(
id,
this.devices.find((pl) => {
return pl.UDN == player
}),
audiobook,
start_time,
serverAddress,
token
)
)
}
exit_session(id) {
//Stop playback when player is closed
console.log('Stop')
for (let item of this.playback_sessions) {
if (item.socket_id == id) {
item.player.pause()
}
}
this.playback_sessions = this.playback_sessions.filter((item) => !(item.socket_id == id))
console.log('sessions:', this.playback_sessions)
}
update_sessions() {
for (let pb_session of this.playback_sessions) {
pb_session.update()
}
}
pause_session(id) {
var session = this.playback_sessions.find((item) => item.socket_id == id)
console.log('bla', session)
session.player.pause()
}
continue_session(id) {
var session = this.playback_sessions.find((item) => item.socket_id == id)
console.log('playing', session)
session.player.play()
}
seek(id, time) {
console.log(id, time)
var session = this.playback_sessions.find((item) => item.socket_id == id)
session.load(time)
} }
} }

View file

@ -0,0 +1,19 @@
const MediaClient = require('upnp-mediarenderer-client')
class DLNADevice extends MediaClient {
constructor(url) {
console.log(url)
super(url)
this.name = ''
this.udn = ''
this.getDeviceDescription(this.update_Info.bind(this))
}
update_Info(err, desc) {
if (err) throw err
this.name = desc.friendlyName
this.UDN = desc.UDN
}
}
module.exports = DLNADevice

View file

@ -0,0 +1,90 @@
const SocketAuthority = require('../SocketAuthority')
const timespan = require('../libs/jsonwebtoken/lib/timespan')
const clientEmitter = require('../SocketAuthority')
class DLNASession {
constructor(id, player, audiobook, start_time, serverAddress, token) {
this.socket_id = id
this.player = player
this.tracks = audiobook
this.serverAddress = serverAddress
this.playtime = 0
this.is_active = true
this.token = token
this.status = 'PAUSED'
console.log(serverAddress)
this.trackIndex = null
this.player.pause()
this.load(start_time)
this.player.on('status', function (status) {
console.log('Session: ', status)
})
this.socket = SocketAuthority.clients[this.socket_id].socket
}
update() {
if (this.is_active) {
this.player.getTransportInfo(
function (err, info) {
var status = ''
if (info.CurrentTransportState == 'PLAYING') {
status = 'PLAYING'
} else if (info.CurrentTransportState == 'PAUSED_PLAYBACK') {
status = 'PAUSED'
} else if (info.CurrentTransportState == 'STOPPED') {
var t = this.tracks[this.trackIndex]
if (t.duration - this.playtime < 2) {
if (this.trackIndex < this.tracks.length) {
this.load(this.tracks[this.trackIndex + 1].startOffset)
this.playtime = 0
return
} else {
this.socket.emit('dlna_finished')
}
}
}
this.player.getPosition(
function (err, position) {
this.set_status(status, this.trackIndex, position)
console.log('pos:', position) // Current position in seconds
}.bind(this)
)
}.bind(this)
)
}
}
load(time) {
var idx = this.tracks.findIndex((t) => time >= t.startOffset && time < t.startOffset + t.duration)
var track = this.tracks[idx]
if (this.trackIndex != idx) {
this.trackIndex = idx
var options = {
autoplay: false,
contentType: track.mimeType,
metadata: {
type: 'audio' // can be 'video', 'audio' or 'image'
}
}
var url = this.serverAddress + track.contentUrl + '?token=' + track.userToken
console.log(this.tracks[this.trackIndex])
console.log(url)
this.player.load(
url,
options,
function (err, result) {
if (err) throw err
console.log('playing ...', this.playtime - track.startOffset)
this.player.play()
this.player.seek(time - track.startOffset)
}.bind(this)
)
} else {
this.player.seek(time - track.startOffset)
}
}
set_status(val, track, position) {
this.playtime = position
this.status = val
this.socket.emit('dlna_status', { status: val, track_idx: track, pos: position })
}
}
module.exports = DLNASession