mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 19:49:22 -05:00
Compare commits
15 Commits
jwt_auth_r
...
session_mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e678fe6e2f | ||
|
|
3845940245 | ||
|
|
6c63e2131c | ||
|
|
e25e2b238f | ||
|
|
99110f587a | ||
|
|
b553e959e2 | ||
|
|
f7b94a4b6d | ||
|
|
264ae928a9 | ||
|
|
f5248a9f00 | ||
|
|
3473ff594a | ||
|
|
20bb6e13b5 | ||
|
|
a05d32b1d7 | ||
|
|
c6b3521cb6 | ||
|
|
2444504c6a | ||
|
|
d38532c07a |
@@ -81,7 +81,7 @@
|
||||
</div>
|
||||
<div class="w-full md:w-1/3">
|
||||
<p v-if="!isMediaItemShareSession" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
||||
<p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ _session.userId }}</p>
|
||||
<p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ username }}</p>
|
||||
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
||||
<p class="mb-1">{{ playMethodName }}</p>
|
||||
@@ -132,6 +132,9 @@ export default {
|
||||
_session() {
|
||||
return this.session || {}
|
||||
},
|
||||
username() {
|
||||
return this._session.user?.username || this._session.userId || ''
|
||||
},
|
||||
deviceInfo() {
|
||||
return this._session.deviceInfo || {}
|
||||
},
|
||||
|
||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.25.1",
|
||||
"version": "2.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.25.1",
|
||||
"version": "2.26.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.25.1",
|
||||
"version": "2.26.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
<widgets-online-indicator :value="!!userOnline" />
|
||||
<h1 class="text-xl pl-2">{{ username }}</h1>
|
||||
</div>
|
||||
<div v-if="legacyToken" class="flex text-xs mt-4">
|
||||
<div v-if="legacyToken" class="text-xs space-y-2 mt-4">
|
||||
<ui-text-input-with-label label="Legacy API Token" :value="legacyToken" readonly show-copy />
|
||||
|
||||
<p class="text-warning" v-html="$strings.MessageAuthenticationLegacyTokenWarning" />
|
||||
</div>
|
||||
<div class="w-full h-px bg-white/10 my-2" />
|
||||
<div class="py-2">
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<p class="text-xs truncate" v-html="getDeviceInfoString(session.deviceInfo)" />
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p>
|
||||
<p class="text-xs font-mono">{{ $elapsedPrettyLocalized(session.timeListening) }}</p>
|
||||
</td>
|
||||
<td class="text-center hover:underline" @click.stop="clickCurrentTime(session)">
|
||||
<p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p>
|
||||
|
||||
@@ -37,6 +37,48 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false, useMilliseconds =
|
||||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPrettyLocalized = (seconds, useFullNames = false, useMilliseconds = false) => {
|
||||
if (isNaN(seconds) || seconds === null) return ''
|
||||
|
||||
try {
|
||||
const df = new Intl.DurationFormat(Vue.prototype.$languageCodes.current, {
|
||||
style: useFullNames ? 'long' : 'short'
|
||||
})
|
||||
|
||||
const duration = {}
|
||||
|
||||
if (seconds < 60) {
|
||||
if (useMilliseconds && seconds < 1) {
|
||||
duration.milliseconds = Math.floor(seconds * 1000)
|
||||
} else {
|
||||
duration.seconds = Math.floor(seconds)
|
||||
}
|
||||
} else if (seconds < 3600) {
|
||||
// 1 hour
|
||||
duration.minutes = Math.floor(seconds / 60)
|
||||
} else if (seconds < 86400) {
|
||||
// 1 day
|
||||
duration.hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (minutes > 0) {
|
||||
duration.minutes = minutes
|
||||
}
|
||||
} else {
|
||||
duration.days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
if (hours > 0) {
|
||||
duration.hours = hours
|
||||
}
|
||||
}
|
||||
|
||||
return df.format(duration)
|
||||
} catch (error) {
|
||||
// Handle not supported
|
||||
console.warn('Intl.DurationFormat not supported, not localizing duration')
|
||||
return Vue.prototype.$elapsedPretty(seconds, useFullNames, useMilliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
Vue.prototype.$secondsToTimestamp = (seconds, includeMs = false, alwaysIncludeHours = false) => {
|
||||
if (!seconds) {
|
||||
return alwaysIncludeHours ? '00:00:00' : '0:00'
|
||||
|
||||
@@ -723,6 +723,7 @@
|
||||
"MessageAddToPlayerQueue": "Add to player queue",
|
||||
"MessageAppriseDescription": "To use this feature you will need to have an instance of <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> running or an api that will handle those same requests. <br />The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at <code>http://192.168.1.1:8337</code> then you would put <code>http://192.168.1.1:8337/notify</code>.",
|
||||
"MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.",
|
||||
"MessageAuthenticationLegacyTokenWarning": "Legacy API tokens will be removed in the future. Use <a href=\"/config/api-keys\">API Keys</a> instead.",
|
||||
"MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.",
|
||||
"MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users are required to re-login.",
|
||||
"MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in <code>/metadata/items</code> & <code>/metadata/authors</code>. Backups <strong>do not</strong> include any files stored in your library folders.",
|
||||
|
||||
@@ -278,7 +278,7 @@
|
||||
"LabelCollapseSeries": "Sorozat összecsukása",
|
||||
"LabelCollapseSubSeries": "Alszéria összecsukása",
|
||||
"LabelCollection": "Gyűjtemény",
|
||||
"LabelCollections": "Gyűjtemény",
|
||||
"LabelCollections": "Gyűjtemények",
|
||||
"LabelComplete": "Kész",
|
||||
"LabelConfirmPassword": "Jelszó megerősítése",
|
||||
"LabelContinueListening": "Hallgatás folytatása",
|
||||
@@ -757,6 +757,7 @@
|
||||
"MessageConfirmRemoveAuthor": "Biztosan eltávolítja a(z) \"{0}\" szerzőt?",
|
||||
"MessageConfirmRemoveCollection": "Biztosan eltávolítja a(z) \"{0}\" gyűjteményt?",
|
||||
"MessageConfirmRemoveEpisode": "Biztosan eltávolítja a(z) \"{0}\" epizódot?",
|
||||
"MessageConfirmRemoveEpisodeNote": "Megjegyzés: Ez nem törli a hangfájlt, kivéve, ha a \"Hangfájl végleges törlése\" be van kapcsolva",
|
||||
"MessageConfirmRemoveEpisodes": "Biztosan eltávolítja a(z) {0} epizódot?",
|
||||
"MessageConfirmRemoveListeningSessions": "Biztosan eltávolítja a(z) {0} hallgatási munkamenetet?",
|
||||
"MessageConfirmRemoveMetadataFiles": "Biztos, hogy az összes metaadatot el akarja távolítani {0} fájl van könyvtár mappáiban?",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"ButtonAuthors": "作者",
|
||||
"ButtonBack": "返回",
|
||||
"ButtonBatchEditPopulateFromExisting": "用现有内容填充",
|
||||
"ButtonBatchEditPopulateMapDetails": "填充地图详细信息",
|
||||
"ButtonBatchEditPopulateMapDetails": "填入此项详情",
|
||||
"ButtonBrowseForFolder": "浏览文件夹",
|
||||
"ButtonCancel": "取消",
|
||||
"ButtonCancelEncode": "取消编码",
|
||||
@@ -73,7 +73,7 @@
|
||||
"ButtonQuickMatch": "快速匹配",
|
||||
"ButtonReScan": "重新扫描",
|
||||
"ButtonRead": "读取",
|
||||
"ButtonReadLess": "阅读较少",
|
||||
"ButtonReadLess": "收起",
|
||||
"ButtonReadMore": "阅读更多",
|
||||
"ButtonRefresh": "刷新",
|
||||
"ButtonRemove": "移除",
|
||||
@@ -714,7 +714,7 @@
|
||||
"MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.",
|
||||
"MessageBackupsLocationPathEmpty": "备份位置路径不能为空",
|
||||
"MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "使用此项目的数据填充已启用的地图详细信息字段",
|
||||
"MessageBatchEditPopulateMapDetailsItemHelp": "提取此项目的信息,填入上方所有勾选的编辑框中",
|
||||
"MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.",
|
||||
"MessageBookshelfNoCollections": "你尚未进行任何收藏",
|
||||
"MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.",
|
||||
@@ -757,7 +757,7 @@
|
||||
"MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?",
|
||||
"MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?",
|
||||
"MessageConfirmRemoveEpisodeNote": "注意: 除非切换 \"硬删除文件\", 否则不会删除音频文件",
|
||||
"MessageConfirmRemoveEpisodeNote": "注意:此操作不会删除音频文件,除非勾选“完全删除文件”选项",
|
||||
"MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?",
|
||||
"MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?",
|
||||
"MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?",
|
||||
@@ -918,9 +918,9 @@
|
||||
"NoteUploaderUnsupportedFiles": "不支持的文件将被忽略. 选择或删除文件夹时, 将忽略不在项目文件夹中的其他文件.",
|
||||
"NotificationOnBackupCompletedDescription": "备份完成时触发",
|
||||
"NotificationOnBackupFailedDescription": "备份失败时触发",
|
||||
"NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载时触发",
|
||||
"NotificationOnEpisodeDownloadedDescription": "当播客节目自动下载完成时触发",
|
||||
"NotificationOnRSSFeedDisabledDescription": "由于尝试失败次数过多而导致剧集自动下载被禁用时触发",
|
||||
"NotificationOnRSSFeedFailedDescription": "当 RSS 源请求自动下载剧集失败时触发",
|
||||
"NotificationOnRSSFeedFailedDescription": "当用于自动下载剧集的 RSS 源请求失败时触发",
|
||||
"NotificationOnTestDescription": "测试通知系统的事件",
|
||||
"PlaceholderNewCollection": "输入收藏夹名称",
|
||||
"PlaceholderNewFolderPath": "输入文件夹路径",
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.25.1",
|
||||
"version": "2.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.25.1",
|
||||
"version": "2.26.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.25.1",
|
||||
"version": "2.26.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -240,7 +240,7 @@ class Server {
|
||||
* Running in development allows cors to allow testing the mobile apps in the browser
|
||||
* or env variable ALLOW_CORS = '1'
|
||||
*/
|
||||
if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
|
||||
if (global.AllowCors || Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) {
|
||||
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
|
||||
if (global.AllowCors || Logger.isDev || allowedOrigins.some((o) => o === req.get('origin'))) {
|
||||
res.header('Access-Control-Allow-Origin', req.get('origin'))
|
||||
|
||||
@@ -121,7 +121,7 @@ class OidcAuthStrategy {
|
||||
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
|
||||
}
|
||||
|
||||
let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this)
|
||||
let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo)
|
||||
|
||||
if (!user?.isActive) {
|
||||
throw new Error('User not active or not found')
|
||||
|
||||
@@ -81,6 +81,18 @@ class TokenManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT token for a given user
|
||||
* TODO: Old method with no expiration
|
||||
* @deprecated
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string}
|
||||
*/
|
||||
static generateAccessToken(user) {
|
||||
return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate a jwt token for a given user
|
||||
* TODO: Old method with no expiration
|
||||
@@ -90,7 +102,7 @@ class TokenManager {
|
||||
* @returns {string}
|
||||
*/
|
||||
generateAccessToken(user) {
|
||||
return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret)
|
||||
return TokenManager.generateAccessToken(user)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,26 +57,24 @@ class SessionController {
|
||||
}
|
||||
|
||||
let where = null
|
||||
const include = [
|
||||
{
|
||||
model: Database.models.device
|
||||
}
|
||||
]
|
||||
|
||||
if (userId) {
|
||||
where = {
|
||||
userId
|
||||
}
|
||||
} else {
|
||||
include.push({
|
||||
model: Database.userModel,
|
||||
attributes: ['id', 'username']
|
||||
})
|
||||
}
|
||||
|
||||
const { rows, count } = await Database.playbackSessionModel.findAndCountAll({
|
||||
where,
|
||||
include,
|
||||
include: [
|
||||
{
|
||||
model: Database.deviceModel
|
||||
},
|
||||
{
|
||||
model: Database.userModel,
|
||||
attributes: ['id', 'username']
|
||||
}
|
||||
],
|
||||
order: [[orderKey, orderDesc]],
|
||||
limit: itemsPerPage,
|
||||
offset: itemsPerPage * page
|
||||
|
||||
@@ -439,7 +439,16 @@ class UserController {
|
||||
const page = toNumber(req.query.page, 0)
|
||||
|
||||
const start = page * itemsPerPage
|
||||
const sessions = listeningSessions.slice(start, start + itemsPerPage)
|
||||
// Map user to sessions to match the format of the sessions endpoint
|
||||
const sessions = listeningSessions.slice(start, start + itemsPerPage).map((session) => {
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
id: req.reqUser.id,
|
||||
username: req.reqUser.username
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const payload = {
|
||||
total: listeningSessions.length,
|
||||
|
||||
@@ -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() {
|
||||
@@ -213,10 +215,9 @@ class User extends Model {
|
||||
* or creates a new user if configured to do so.
|
||||
*
|
||||
* @param {Object} userinfo
|
||||
* @param {import('../Auth')} auth
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
static async findOrCreateUserFromOpenIdUserInfo(userinfo, auth) {
|
||||
static async findOrCreateUserFromOpenIdUserInfo(userinfo) {
|
||||
let user = await this.getUserByOpenIDSub(userinfo.sub)
|
||||
|
||||
// Matched by sub
|
||||
@@ -290,7 +291,7 @@ class User extends Model {
|
||||
// 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, auth)
|
||||
user = await this.createUserFromOpenIdUserInfo(userinfo)
|
||||
return user
|
||||
}
|
||||
|
||||
@@ -301,16 +302,15 @@ class User extends Model {
|
||||
/**
|
||||
* Create user from openid userinfo
|
||||
* @param {Object} userinfo
|
||||
* @param {import('../Auth')} auth
|
||||
* @returns {Promise<User>}
|
||||
*/
|
||||
static async createUserFromOpenIdUserInfo(userinfo, auth) {
|
||||
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 = auth.generateAccessToken({ id: userId, username })
|
||||
const token = TokenManager.generateAccessToken({ id: userId, username })
|
||||
|
||||
const newUser = {
|
||||
id: userId,
|
||||
|
||||
Reference in New Issue
Block a user