Merge branch 'advplyr:master' into master

This commit is contained in:
MagiX13 2025-07-28 21:08:52 +02:00 committed by GitHub
commit 8321ba6291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
138 changed files with 6154 additions and 1541 deletions

272
server/models/ApiKey.js Normal file
View file

@ -0,0 +1,272 @@
const { DataTypes, Model, Op } = require('sequelize')
const jwt = require('jsonwebtoken')
const { LRUCache } = require('lru-cache')
const Logger = require('../Logger')
/**
* @typedef {Object} ApiKeyPermissions
* @property {boolean} download
* @property {boolean} update
* @property {boolean} delete
* @property {boolean} upload
* @property {boolean} createEreader
* @property {boolean} accessAllLibraries
* @property {boolean} accessAllTags
* @property {boolean} accessExplicitContent
* @property {boolean} selectedTagsNotAccessible
* @property {string[]} librariesAccessible
* @property {string[]} itemTagsSelected
*/
class ApiKeyCache {
constructor() {
this.cache = new LRUCache({ max: 100 })
}
getById(id) {
const apiKey = this.cache.get(id)
return apiKey
}
set(apiKey) {
apiKey.fromCache = true
this.cache.set(apiKey.id, apiKey)
}
delete(apiKeyId) {
this.cache.delete(apiKeyId)
}
maybeInvalidate(apiKey) {
if (!apiKey.fromCache) this.delete(apiKey.id)
}
}
const apiKeyCache = new ApiKeyCache()
class ApiKey extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.name
/** @type {string} */
this.description
/** @type {Date} */
this.expiresAt
/** @type {Date} */
this.lastUsedAt
/** @type {boolean} */
this.isActive
/** @type {ApiKeyPermissions} */
this.permissions
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {UUIDV4} */
this.userId
/** @type {UUIDV4} */
this.createdByUserId
// Expanded properties
/** @type {import('./User').User} */
this.user
}
/**
* Same properties as User.getDefaultPermissions
* @returns {ApiKeyPermissions}
*/
static getDefaultPermissions() {
return {
download: true,
update: true,
delete: true,
upload: true,
createEreader: true,
accessAllLibraries: true,
accessAllTags: true,
accessExplicitContent: true,
selectedTagsNotAccessible: false, // Inverts itemTagsSelected
librariesAccessible: [],
itemTagsSelected: []
}
}
/**
* Merge permissions from request with default permissions
* @param {ApiKeyPermissions} reqPermissions
* @returns {ApiKeyPermissions}
*/
static mergePermissionsWithDefault(reqPermissions) {
const permissions = this.getDefaultPermissions()
if (!reqPermissions || typeof reqPermissions !== 'object') {
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permissions: ${reqPermissions}`)
return permissions
}
for (const key in reqPermissions) {
if (reqPermissions[key] === undefined) {
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission key: ${key}`)
continue
}
if (key === 'librariesAccessible' || key === 'itemTagsSelected') {
if (!Array.isArray(reqPermissions[key]) || reqPermissions[key].some((value) => typeof value !== 'string')) {
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid ${key} value: ${reqPermissions[key]}`)
continue
}
permissions[key] = reqPermissions[key]
} else if (typeof reqPermissions[key] !== 'boolean') {
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission value for key ${key}. Should be boolean`)
continue
}
permissions[key] = reqPermissions[key]
}
return permissions
}
/**
* Deactivate expired api keys
* @returns {Promise<number>} Number of api keys affected
*/
static async deactivateExpiredApiKeys() {
const [affectedCount] = await ApiKey.update(
{
isActive: false
},
{
where: {
isActive: true,
expiresAt: {
[Op.lt]: new Date()
}
}
}
)
return affectedCount
}
/**
* Generate a new api key
* @param {string} tokenSecret
* @param {string} keyId
* @param {string} name
* @param {number} [expiresIn] - Seconds until the api key expires or undefined for no expiration
* @returns {Promise<string>}
*/
static async generateApiKey(tokenSecret, keyId, name, expiresIn) {
const options = {}
if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) {
options.expiresIn = expiresIn
}
return new Promise((resolve) => {
jwt.sign(
{
keyId,
name,
type: 'api'
},
tokenSecret,
options,
(err, token) => {
if (err) {
Logger.error(`[ApiKey] Error generating API key: ${err}`)
resolve(null)
} else {
resolve(token)
}
}
)
})
}
/**
* Get an api key by id, from cache or database
* @param {string} apiKeyId
* @returns {Promise<ApiKey | null>}
*/
static async getById(apiKeyId) {
if (!apiKeyId) return null
const cachedApiKey = apiKeyCache.getById(apiKeyId)
if (cachedApiKey) return cachedApiKey
const apiKey = await ApiKey.findByPk(apiKeyId)
if (!apiKey) return null
apiKeyCache.set(apiKey)
return apiKey
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: DataTypes.TEXT,
expiresAt: DataTypes.DATE,
lastUsedAt: DataTypes.DATE,
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
permissions: DataTypes.JSON
},
{
sequelize,
modelName: 'apiKey'
}
)
const { user } = sequelize.models
user.hasMany(ApiKey, {
onDelete: 'CASCADE'
})
ApiKey.belongsTo(user)
user.hasMany(ApiKey, {
foreignKey: 'createdByUserId',
onDelete: 'SET NULL'
})
ApiKey.belongsTo(user, { as: 'createdByUser', foreignKey: 'createdByUserId' })
}
async update(values, options) {
apiKeyCache.maybeInvalidate(this)
return await super.update(values, options)
}
async save(options) {
apiKeyCache.maybeInvalidate(this)
return await super.save(options)
}
async destroy(options) {
apiKeyCache.delete(this.id)
await super.destroy(options)
}
}
module.exports = ApiKey

View file

@ -377,8 +377,17 @@ class Book extends Model {
if (typeof payload.metadata[key] == 'number') {
payload.metadata[key] = String(payload.metadata[key])
}
if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && this[key] !== payload.metadata[key]) {
// Sanitize description HTML
if (key === 'description' && payload.metadata[key]) {
const sanitizedDescription = htmlSanitizer.sanitize(payload.metadata[key])
if (sanitizedDescription !== payload.metadata[key]) {
Logger.debug(`[Book] "${this.title}" Sanitized description from "${payload.metadata[key]}" to "${sanitizedDescription}"`)
payload.metadata[key] = sanitizedDescription
}
}
this[key] = payload.metadata[key] || null
if (key === 'title') {

View file

@ -183,7 +183,7 @@ class MediaProgress extends Model {
* @param {import('./User').ProgressUpdatePayload} progressPayload
* @returns {Promise<MediaProgress>}
*/
applyProgressUpdate(progressPayload) {
async applyProgressUpdate(progressPayload) {
if (!this.extraData) this.extraData = {}
if (progressPayload.isFinished !== undefined) {
if (progressPayload.isFinished && !this.isFinished) {
@ -222,13 +222,13 @@ class MediaProgress extends Model {
const markAsFinishedPercentComplete = Number(progressPayload.markAsFinishedPercentComplete) / 100
shouldMarkAsFinished = markAsFinishedPercentComplete < this.progress
if (shouldMarkAsFinished) {
Logger.debug(`[MediaProgress] Marking media progress as finished because progress (${this.progress}) is greater than ${markAsFinishedPercentComplete}`)
Logger.info(`[MediaProgress] Marking media progress as finished because progress (${this.progress}) is greater than ${markAsFinishedPercentComplete} (media item ${this.mediaItemId})`)
}
} else {
const markAsFinishedTimeRemaining = isNullOrNaN(progressPayload.markAsFinishedTimeRemaining) ? 10 : Number(progressPayload.markAsFinishedTimeRemaining)
shouldMarkAsFinished = timeRemaining < markAsFinishedTimeRemaining
if (shouldMarkAsFinished) {
Logger.debug(`[MediaProgress] Marking media progress as finished because time remaining (${timeRemaining}) is less than ${markAsFinishedTimeRemaining} seconds`)
Logger.info(`[MediaProgress] Marking media progress as finished because time remaining (${timeRemaining}) is less than ${markAsFinishedTimeRemaining} seconds (media item ${this.mediaItemId})`)
}
}
}
@ -243,13 +243,23 @@ class MediaProgress extends Model {
this.finishedAt = null
}
await this.save()
// For local sync
if (progressPayload.lastUpdate) {
this.updatedAt = progressPayload.lastUpdate
this.changed('updatedAt', true)
if (isNaN(new Date(progressPayload.lastUpdate))) {
Logger.warn(`[MediaProgress] Invalid date provided for lastUpdate: ${progressPayload.lastUpdate} (media item ${this.mediaItemId})`)
} else {
const escapedDate = this.sequelize.escape(new Date(progressPayload.lastUpdate))
Logger.info(`[MediaProgress] Manually setting updatedAt to ${escapedDate} (media item ${this.mediaItemId})`)
await this.sequelize.query(`UPDATE "mediaProgresses" SET "updatedAt" = ${escapedDate} WHERE "id" = '${this.id}'`)
await this.reload()
}
}
return this.save({ silent: !!progressPayload.lastUpdate })
return this
}
}

View file

@ -2,6 +2,7 @@ const { DataTypes, Model } = require('sequelize')
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const Logger = require('../Logger')
const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
const htmlSanitizer = require('../utils/htmlSanitizer')
/**
* @typedef PodcastExpandedProperties
@ -220,6 +221,15 @@ class Podcast extends Model {
newKey = 'itunesPageURL'
}
if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && payload.metadata[key] !== this[newKey]) {
// Sanitize description HTML
if (key === 'description' && payload.metadata[key]) {
const sanitizedDescription = htmlSanitizer.sanitize(payload.metadata[key])
if (sanitizedDescription !== payload.metadata[key]) {
Logger.debug(`[Podcast] "${this.title}" Sanitized description from "${payload.metadata[key]}" to "${sanitizedDescription}"`)
payload.metadata[key] = sanitizedDescription
}
}
this[newKey] = payload.metadata[key] || null
if (key === 'title') {

View file

@ -185,6 +185,7 @@ class PodcastEpisode extends Model {
const track = structuredClone(this.audioFile)
track.startOffset = 0
track.title = this.audioFile.metadata.filename
track.index = 1 // Podcast episodes only have one track
track.contentUrl = `/api/items/${libraryItemId}/file/${track.ino}`
return track
}

88
server/models/Session.js Normal file
View file

@ -0,0 +1,88 @@
const { DataTypes, Model, Op } = require('sequelize')
class Session extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.ipAddress
/** @type {string} */
this.userAgent
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {UUIDV4} */
this.userId
/** @type {Date} */
this.expiresAt
// Expanded properties
/** @type {import('./User').User} */
this.user
}
static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt) {
const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt })
return session
}
/**
* Clean up expired sessions from the database
* @returns {Promise<number>} Number of sessions deleted
*/
static async cleanupExpiredSessions() {
const deletedCount = await Session.destroy({
where: {
expiresAt: {
[Op.lt]: new Date()
}
}
})
return deletedCount
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
ipAddress: DataTypes.STRING,
userAgent: DataTypes.STRING,
refreshToken: {
type: DataTypes.STRING,
allowNull: false
},
expiresAt: {
type: DataTypes.DATE,
allowNull: false
}
},
{
sequelize,
modelName: 'session'
}
)
const { user } = sequelize.models
user.hasMany(Session, {
onDelete: 'CASCADE',
foreignKey: {
allowNull: false
}
})
Session.belongsTo(user)
}
}
module.exports = Session

View file

@ -1,9 +1,11 @@
const uuidv4 = require('uuid').v4
const sequelize = require('sequelize')
const { LRUCache } = require('lru-cache')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const { isNullOrNaN } = require('../utils')
const { LRUCache } = require('lru-cache')
const TokenManager = require('../auth/TokenManager')
class UserCache {
constructor() {
@ -190,7 +192,7 @@ class User extends Model {
static async createRootUser(username, pash, auth) {
const userId = uuidv4()
const token = await auth.generateAccessToken({ id: userId, username })
const token = auth.generateAccessToken({ id: userId, username })
const newUser = {
id: userId,
@ -209,18 +211,106 @@ class User extends Model {
}
/**
* Create user from openid userinfo
* Finds an existing user by OpenID subject identifier, or by email/username based on server settings,
* or creates a new user if configured to do so.
*
* @param {Object} userinfo
* @param {import('../Auth')} auth
* @returns {Promise<User>}
*/
static async createUserFromOpenIdUserInfo(userinfo, auth) {
static async findOrCreateUserFromOpenIdUserInfo(userinfo) {
let user = await this.getUserByOpenIDSub(userinfo.sub)
// Matched by sub
if (user) {
Logger.debug(`[User] openid: User found by sub`)
return user
}
// Match existing user by email
if (global.ServerSettings.authOpenIDMatchExistingBy === 'email') {
if (userinfo.email) {
// Only disallow when email_verified explicitly set to false (allow both if not set or true)
if (userinfo.email_verified === false) {
Logger.warn(`[User] openid: User not found and email "${userinfo.email}" is not verified`)
return null
} else {
Logger.info(`[User] openid: User not found, checking existing with email "${userinfo.email}"`)
user = await this.getUserByEmail(userinfo.email)
if (user?.authOpenIDSub) {
Logger.warn(`[User] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`)
return null // User is linked to a different OpenID subject; do not proceed.
}
}
} else {
Logger.warn(`[User] openid: User not found and no email in userinfo`)
// We deny login, because if the admin whishes to match email, it makes sense to require it
return null
}
}
// Match existing user by username
else if (global.ServerSettings.authOpenIDMatchExistingBy === 'username') {
let username
if (userinfo.preferred_username) {
Logger.info(`[User] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`)
username = userinfo.preferred_username
} else if (userinfo.username) {
Logger.info(`[User] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`)
username = userinfo.username
} else {
Logger.warn(`[User] openid: User not found and neither preferred_username nor username in userinfo`)
return null
}
user = await this.getUserByUsername(username)
if (user?.authOpenIDSub) {
Logger.warn(`[User] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`)
return null // User is linked to a different OpenID subject; do not proceed.
}
}
// Found existing user via email or username
if (user) {
if (!user.isActive) {
Logger.warn(`[User] openid: User found but is not active`)
return null
}
// Update user with OpenID sub
if (!user.extraData) user.extraData = {}
user.extraData.authOpenIDSub = userinfo.sub
user.changed('extraData', true)
await user.save()
Logger.debug(`[User] openid: User found by email/username`)
return user
}
// If no existing user was matched, auto-register if configured
if (global.ServerSettings.authOpenIDAutoRegister) {
Logger.info(`[User] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
user = await this.createUserFromOpenIdUserInfo(userinfo)
return user
}
Logger.warn(`[User] openid: User not found and auto-register is disabled`)
return null
}
/**
* Create user from openid userinfo
* @param {Object} userinfo
* @returns {Promise<User>}
*/
static async createUserFromOpenIdUserInfo(userinfo) {
const userId = uuidv4()
// TODO: Ensure username is unique?
const username = userinfo.preferred_username || userinfo.name || userinfo.sub
const email = userinfo.email && userinfo.email_verified ? userinfo.email : null
const token = await auth.generateAccessToken({ id: userId, username })
const token = TokenManager.generateAccessToken({ id: userId, username })
const newUser = {
id: userId,
@ -520,7 +610,11 @@ class User extends Model {
username: this.username,
email: this.email,
type: this.type,
// TODO: Old non-expiring token
token: this.type === 'root' && hideRootToken ? '' : this.token,
// TODO: Temporary flag not saved in db that is set in Auth.js jwtAuthCheck
// Necessary to detect apps using old tokens that no longer match the old token stored on the user
isOldToken: this.isOldToken,
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
seriesHideFromContinueListening: [...seriesHideFromContinueListening],
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],