mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			273 lines
		
	
	
		
			6.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			273 lines
		
	
	
		
			6.5 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 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
 |