mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-10-04 17:22:38 +02:00
Update:Add server setting for backupPath and allow overriding with BACKUP_PATH env variable #2973
This commit is contained in:
parent
a75ad5d659
commit
7bc70effb0
1
index.js
1
index.js
@ -10,6 +10,7 @@ if (isDev) {
|
|||||||
if (devEnv.FFmpegPath) process.env.FFMPEG_PATH = devEnv.FFmpegPath
|
if (devEnv.FFmpegPath) process.env.FFMPEG_PATH = devEnv.FFmpegPath
|
||||||
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
|
if (devEnv.FFProbePath) process.env.FFPROBE_PATH = devEnv.FFProbePath
|
||||||
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
if (devEnv.SkipBinariesCheck) process.env.SKIP_BINARIES_CHECK = '1'
|
||||||
|
if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath
|
||||||
process.env.SOURCE = 'local'
|
process.env.SOURCE = 'local'
|
||||||
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || ''
|
||||||
}
|
}
|
||||||
|
@ -2,12 +2,12 @@ const Logger = require('../Logger')
|
|||||||
const { encodeUriPath } = require('../utils/fileUtils')
|
const { encodeUriPath } = require('../utils/fileUtils')
|
||||||
|
|
||||||
class BackupController {
|
class BackupController {
|
||||||
constructor() { }
|
constructor() {}
|
||||||
|
|
||||||
getAll(req, res) {
|
getAll(req, res) {
|
||||||
res.json({
|
res.json({
|
||||||
backups: this.backupManager.backups.map(b => b.toJSON()),
|
backups: this.backupManager.backups.map((b) => b.toJSON()),
|
||||||
backupLocation: this.backupManager.backupLocation
|
backupLocation: this.backupManager.backupPath
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ class BackupController {
|
|||||||
await this.backupManager.removeBackup(req.backup)
|
await this.backupManager.removeBackup(req.backup)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
backups: this.backupManager.backups.map(b => b.toJSON())
|
backups: this.backupManager.backups.map((b) => b.toJSON())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,9 +33,9 @@ class BackupController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* api/backups/:id/download
|
* api/backups/:id/download
|
||||||
*
|
*
|
||||||
* @param {*} req
|
* @param {*} req
|
||||||
* @param {*} res
|
* @param {*} res
|
||||||
*/
|
*/
|
||||||
download(req, res) {
|
download(req, res) {
|
||||||
if (global.XAccel) {
|
if (global.XAccel) {
|
||||||
@ -50,9 +50,9 @@ class BackupController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {import('express').Request} req
|
* @param {import('express').Request} req
|
||||||
* @param {import('express').Response} res
|
* @param {import('express').Response} res
|
||||||
*/
|
*/
|
||||||
apply(req, res) {
|
apply(req, res) {
|
||||||
this.backupManager.requestApplyBackup(this.apiCacheManager, req.backup, res)
|
this.backupManager.requestApplyBackup(this.apiCacheManager, req.backup, res)
|
||||||
@ -65,7 +65,7 @@ class BackupController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.params.id) {
|
if (req.params.id) {
|
||||||
req.backup = this.backupManager.backups.find(b => b.id === req.params.id)
|
req.backup = this.backupManager.backups.find((b) => b.id === req.params.id)
|
||||||
if (!req.backup) {
|
if (!req.backup) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,6 @@ const Backup = require('../objects/Backup')
|
|||||||
|
|
||||||
class BackupManager {
|
class BackupManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.BackupPath = Path.join(global.MetadataPath, 'backups')
|
|
||||||
this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items')
|
this.ItemsMetadataPath = Path.join(global.MetadataPath, 'items')
|
||||||
this.AuthorsMetadataPath = Path.join(global.MetadataPath, 'authors')
|
this.AuthorsMetadataPath = Path.join(global.MetadataPath, 'authors')
|
||||||
|
|
||||||
@ -26,8 +25,8 @@ class BackupManager {
|
|||||||
this.backups = []
|
this.backups = []
|
||||||
}
|
}
|
||||||
|
|
||||||
get backupLocation() {
|
get backupPath() {
|
||||||
return this.BackupPath
|
return global.ServerSettings.backupPath
|
||||||
}
|
}
|
||||||
|
|
||||||
get backupSchedule() {
|
get backupSchedule() {
|
||||||
@ -43,9 +42,9 @@ class BackupManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const backupsDirExists = await fs.pathExists(this.BackupPath)
|
const backupsDirExists = await fs.pathExists(this.backupPath)
|
||||||
if (!backupsDirExists) {
|
if (!backupsDirExists) {
|
||||||
await fs.ensureDir(this.BackupPath)
|
await fs.ensureDir(this.backupPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.loadBackups()
|
await this.loadBackups()
|
||||||
@ -87,11 +86,14 @@ class BackupManager {
|
|||||||
return res.status(500).send('Invalid backup file')
|
return res.status(500).send('Invalid backup file')
|
||||||
}
|
}
|
||||||
|
|
||||||
const tempPath = Path.join(this.BackupPath, fileUtils.sanitizeFilename(backupFile.name))
|
const tempPath = Path.join(this.backupPath, fileUtils.sanitizeFilename(backupFile.name))
|
||||||
const success = await backupFile.mv(tempPath).then(() => true).catch((error) => {
|
const success = await backupFile
|
||||||
Logger.error('[BackupManager] Failed to move backup file', path, error)
|
.mv(tempPath)
|
||||||
return false
|
.then(() => true)
|
||||||
})
|
.catch((error) => {
|
||||||
|
Logger.error('[BackupManager] Failed to move backup file', path, error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return res.status(500).send('Failed to move backup file into backups directory')
|
return res.status(500).send('Failed to move backup file into backups directory')
|
||||||
}
|
}
|
||||||
@ -122,7 +124,7 @@ class BackupManager {
|
|||||||
|
|
||||||
backup.fileSize = await getFileSize(backup.fullPath)
|
backup.fileSize = await getFileSize(backup.fullPath)
|
||||||
|
|
||||||
const existingBackupIndex = this.backups.findIndex(b => b.id === backup.id)
|
const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id)
|
||||||
if (existingBackupIndex >= 0) {
|
if (existingBackupIndex >= 0) {
|
||||||
Logger.warn(`[BackupManager] Backup already exists with id ${backup.id} - overwriting`)
|
Logger.warn(`[BackupManager] Backup already exists with id ${backup.id} - overwriting`)
|
||||||
this.backups.splice(existingBackupIndex, 1, backup)
|
this.backups.splice(existingBackupIndex, 1, backup)
|
||||||
@ -131,7 +133,7 @@ class BackupManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
backups: this.backups.map(b => b.toJSON())
|
backups: this.backups.map((b) => b.toJSON())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -139,7 +141,7 @@ class BackupManager {
|
|||||||
var backupSuccess = await this.runBackup()
|
var backupSuccess = await this.runBackup()
|
||||||
if (backupSuccess) {
|
if (backupSuccess) {
|
||||||
res.json({
|
res.json({
|
||||||
backups: this.backups.map(b => b.toJSON())
|
backups: this.backups.map((b) => b.toJSON())
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
res.sendStatus(500)
|
res.sendStatus(500)
|
||||||
@ -147,10 +149,10 @@ class BackupManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {import('./ApiCacheManager')} apiCacheManager
|
* @param {import('./ApiCacheManager')} apiCacheManager
|
||||||
* @param {Backup} backup
|
* @param {Backup} backup
|
||||||
* @param {import('express').Response} res
|
* @param {import('express').Response} res
|
||||||
*/
|
*/
|
||||||
async requestApplyBackup(apiCacheManager, backup, res) {
|
async requestApplyBackup(apiCacheManager, backup, res) {
|
||||||
Logger.info(`[BackupManager] Applying backup at "${backup.fullPath}"`)
|
Logger.info(`[BackupManager] Applying backup at "${backup.fullPath}"`)
|
||||||
@ -176,7 +178,7 @@ class BackupManager {
|
|||||||
Logger.info(`[BackupManager] Extracted backup sqlite db to temp path ${tempDbPath}`)
|
Logger.info(`[BackupManager] Extracted backup sqlite db to temp path ${tempDbPath}`)
|
||||||
|
|
||||||
// Verify extract - Abandon backup if sqlite file did not extract
|
// Verify extract - Abandon backup if sqlite file did not extract
|
||||||
if (!await fs.pathExists(tempDbPath)) {
|
if (!(await fs.pathExists(tempDbPath))) {
|
||||||
Logger.error(`[BackupManager] Sqlite file not found after extract - abandon backup apply and reconnect db`)
|
Logger.error(`[BackupManager] Sqlite file not found after extract - abandon backup apply and reconnect db`)
|
||||||
await zip.close()
|
await zip.close()
|
||||||
await Database.reconnect()
|
await Database.reconnect()
|
||||||
@ -218,12 +220,12 @@ class BackupManager {
|
|||||||
|
|
||||||
async loadBackups() {
|
async loadBackups() {
|
||||||
try {
|
try {
|
||||||
const filesInDir = await fs.readdir(this.BackupPath)
|
const filesInDir = await fs.readdir(this.backupPath)
|
||||||
|
|
||||||
for (let i = 0; i < filesInDir.length; i++) {
|
for (let i = 0; i < filesInDir.length; i++) {
|
||||||
const filename = filesInDir[i]
|
const filename = filesInDir[i]
|
||||||
if (filename.endsWith('.audiobookshelf')) {
|
if (filename.endsWith('.audiobookshelf')) {
|
||||||
const fullFilePath = Path.join(this.BackupPath, filename)
|
const fullFilePath = Path.join(this.backupPath, filename)
|
||||||
|
|
||||||
let zip = null
|
let zip = null
|
||||||
let data = null
|
let data = null
|
||||||
@ -239,14 +241,16 @@ class BackupManager {
|
|||||||
|
|
||||||
const backup = new Backup({ details, fullPath: fullFilePath })
|
const backup = new Backup({ details, fullPath: fullFilePath })
|
||||||
|
|
||||||
if (!backup.serverVersion) { // Backups before v2
|
if (!backup.serverVersion) {
|
||||||
|
// Backups before v2
|
||||||
Logger.error(`[BackupManager] Old unsupported backup was found "${backup.filename}"`)
|
Logger.error(`[BackupManager] Old unsupported backup was found "${backup.filename}"`)
|
||||||
} else if (!backup.key) { // Backups before sqlite migration
|
} else if (!backup.key) {
|
||||||
|
// Backups before sqlite migration
|
||||||
Logger.warn(`[BackupManager] Old unsupported backup was found "${backup.filename}" (pre sqlite migration)`)
|
Logger.warn(`[BackupManager] Old unsupported backup was found "${backup.filename}" (pre sqlite migration)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
backup.fileSize = await getFileSize(backup.fullPath)
|
backup.fileSize = await getFileSize(backup.fullPath)
|
||||||
const existingBackupWithId = this.backups.find(b => b.id === backup.id)
|
const existingBackupWithId = this.backups.find((b) => b.id === backup.id)
|
||||||
if (existingBackupWithId) {
|
if (existingBackupWithId) {
|
||||||
Logger.warn(`[BackupManager] Backup already loaded with id ${backup.id} - ignoring`)
|
Logger.warn(`[BackupManager] Backup already loaded with id ${backup.id} - ignoring`)
|
||||||
} else {
|
} else {
|
||||||
@ -267,7 +271,7 @@ class BackupManager {
|
|||||||
// Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)
|
// Check if Metadata Path is inside Config Path (otherwise there will be an infinite loop as the archiver tries to zip itself)
|
||||||
Logger.info(`[BackupManager] Running Backup`)
|
Logger.info(`[BackupManager] Running Backup`)
|
||||||
const newBackup = new Backup()
|
const newBackup = new Backup()
|
||||||
newBackup.setData(this.BackupPath)
|
newBackup.setData(this.backupPath)
|
||||||
|
|
||||||
await fs.ensureDir(this.AuthorsMetadataPath)
|
await fs.ensureDir(this.AuthorsMetadataPath)
|
||||||
|
|
||||||
@ -296,7 +300,7 @@ class BackupManager {
|
|||||||
|
|
||||||
newBackup.fileSize = await getFileSize(newBackup.fullPath)
|
newBackup.fileSize = await getFileSize(newBackup.fullPath)
|
||||||
|
|
||||||
const existingIndex = this.backups.findIndex(b => b.id === newBackup.id)
|
const existingIndex = this.backups.findIndex((b) => b.id === newBackup.id)
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
this.backups.splice(existingIndex, 1, newBackup)
|
this.backups.splice(existingIndex, 1, newBackup)
|
||||||
} else {
|
} else {
|
||||||
@ -318,7 +322,7 @@ class BackupManager {
|
|||||||
try {
|
try {
|
||||||
Logger.debug(`[BackupManager] Removing Backup "${backup.fullPath}"`)
|
Logger.debug(`[BackupManager] Removing Backup "${backup.fullPath}"`)
|
||||||
await fs.remove(backup.fullPath)
|
await fs.remove(backup.fullPath)
|
||||||
this.backups = this.backups.filter(b => b.id !== backup.id)
|
this.backups = this.backups.filter((b) => b.id !== backup.id)
|
||||||
Logger.info(`[BackupManager] Backup "${backup.id}" Removed`)
|
Logger.info(`[BackupManager] Backup "${backup.id}" Removed`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[BackupManager] Failed to remove backup`, error)
|
Logger.error(`[BackupManager] Failed to remove backup`, error)
|
||||||
@ -425,4 +429,4 @@ class BackupManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
module.exports = BackupManager
|
module.exports = BackupManager
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
const Path = require('path')
|
||||||
const packageJson = require('../../../package.json')
|
const packageJson = require('../../../package.json')
|
||||||
const { BookshelfView } = require('../../utils/constants')
|
const { BookshelfView } = require('../../utils/constants')
|
||||||
const Logger = require('../../Logger')
|
const Logger = require('../../Logger')
|
||||||
@ -25,6 +26,7 @@ class ServerSettings {
|
|||||||
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
|
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
|
||||||
|
|
||||||
// Backups
|
// Backups
|
||||||
|
this.backupPath = Path.join(global.MetadataPath, 'backups')
|
||||||
this.backupSchedule = false // If false then auto-backups are disabled
|
this.backupSchedule = false // If false then auto-backups are disabled
|
||||||
this.backupsToKeep = 2
|
this.backupsToKeep = 2
|
||||||
this.maxBackupSize = 1
|
this.maxBackupSize = 1
|
||||||
@ -97,6 +99,7 @@ class ServerSettings {
|
|||||||
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||||
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
||||||
|
|
||||||
|
this.backupPath = settings.backupPath || Path.join(global.MetadataPath, 'backups')
|
||||||
this.backupSchedule = settings.backupSchedule || false
|
this.backupSchedule = settings.backupSchedule || false
|
||||||
this.backupsToKeep = settings.backupsToKeep || 2
|
this.backupsToKeep = settings.backupsToKeep || 2
|
||||||
this.maxBackupSize = settings.maxBackupSize || 1
|
this.maxBackupSize = settings.maxBackupSize || 1
|
||||||
@ -147,22 +150,26 @@ class ServerSettings {
|
|||||||
this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('openid', 0), 1)
|
this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('openid', 0), 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback to local
|
// fallback to local
|
||||||
if (!Array.isArray(this.authActiveAuthMethods) || this.authActiveAuthMethods.length == 0) {
|
if (!Array.isArray(this.authActiveAuthMethods) || this.authActiveAuthMethods.length == 0) {
|
||||||
this.authActiveAuthMethods = ['local']
|
this.authActiveAuthMethods = ['local']
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrations
|
// Migrations
|
||||||
if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0
|
if (settings.storeCoverWithBook != undefined) {
|
||||||
|
// storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0
|
||||||
this.storeCoverWithItem = !!settings.storeCoverWithBook
|
this.storeCoverWithItem = !!settings.storeCoverWithBook
|
||||||
}
|
}
|
||||||
if (settings.storeMetadataWithBook != undefined) { // storeMetadataWithBook was renamed to storeMetadataWithItem in 2.0.0
|
if (settings.storeMetadataWithBook != undefined) {
|
||||||
|
// storeMetadataWithBook was renamed to storeMetadataWithItem in 2.0.0
|
||||||
this.storeMetadataWithItem = !!settings.storeMetadataWithBook
|
this.storeMetadataWithItem = !!settings.storeMetadataWithBook
|
||||||
}
|
}
|
||||||
if (settings.homeBookshelfView == undefined) { // homeBookshelfView was added in 2.1.3
|
if (settings.homeBookshelfView == undefined) {
|
||||||
|
// homeBookshelfView was added in 2.1.3
|
||||||
this.homeBookshelfView = settings.bookshelfView
|
this.homeBookshelfView = settings.bookshelfView
|
||||||
}
|
}
|
||||||
if (settings.metadataFileFormat == undefined) { // metadataFileFormat was added in 2.2.21
|
if (settings.metadataFileFormat == undefined) {
|
||||||
|
// metadataFileFormat was added in 2.2.21
|
||||||
// All users using old settings will stay abs until changed
|
// All users using old settings will stay abs until changed
|
||||||
this.metadataFileFormat = 'abs'
|
this.metadataFileFormat = 'abs'
|
||||||
}
|
}
|
||||||
@ -176,9 +183,15 @@ class ServerSettings {
|
|||||||
if (this.logLevel !== Logger.logLevel) {
|
if (this.logLevel !== Logger.logLevel) {
|
||||||
Logger.setLogLevel(this.logLevel)
|
Logger.setLogLevel(this.logLevel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.BACKUP_PATH && this.backupPath !== process.env.BACKUP_PATH) {
|
||||||
|
Logger.info(`[ServerSettings] Using backup path from environment variable ${process.env.BACKUP_PATH}`)
|
||||||
|
this.backupPath = process.env.BACKUP_PATH
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toJSON() { // Use toJSONForBrowser if sending to client
|
toJSON() {
|
||||||
|
// Use toJSONForBrowser if sending to client
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
tokenSecret: this.tokenSecret, // Do not return to client
|
tokenSecret: this.tokenSecret, // Do not return to client
|
||||||
@ -192,6 +205,7 @@ class ServerSettings {
|
|||||||
metadataFileFormat: this.metadataFileFormat,
|
metadataFileFormat: this.metadataFileFormat,
|
||||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||||
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
rateLimitLoginWindow: this.rateLimitLoginWindow,
|
||||||
|
backupPath: this.backupPath,
|
||||||
backupSchedule: this.backupSchedule,
|
backupSchedule: this.backupSchedule,
|
||||||
backupsToKeep: this.backupsToKeep,
|
backupsToKeep: this.backupsToKeep,
|
||||||
maxBackupSize: this.maxBackupSize,
|
maxBackupSize: this.maxBackupSize,
|
||||||
@ -249,14 +263,7 @@ class ServerSettings {
|
|||||||
* Auth settings required for openid to be valid
|
* Auth settings required for openid to be valid
|
||||||
*/
|
*/
|
||||||
get isOpenIDAuthSettingsValid() {
|
get isOpenIDAuthSettingsValid() {
|
||||||
return this.authOpenIDIssuerURL &&
|
return this.authOpenIDIssuerURL && this.authOpenIDAuthorizationURL && this.authOpenIDTokenURL && this.authOpenIDUserInfoURL && this.authOpenIDJwksURL && this.authOpenIDClientID && this.authOpenIDClientSecret && this.authOpenIDTokenSigningAlgorithm
|
||||||
this.authOpenIDAuthorizationURL &&
|
|
||||||
this.authOpenIDTokenURL &&
|
|
||||||
this.authOpenIDUserInfoURL &&
|
|
||||||
this.authOpenIDJwksURL &&
|
|
||||||
this.authOpenIDClientID &&
|
|
||||||
this.authOpenIDClientSecret &&
|
|
||||||
this.authOpenIDTokenSigningAlgorithm
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get authenticationSettings() {
|
get authenticationSettings() {
|
||||||
@ -297,8 +304,8 @@ class ServerSettings {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update server settings
|
* Update server settings
|
||||||
*
|
*
|
||||||
* @param {Object} payload
|
* @param {Object} payload
|
||||||
* @returns {boolean} true if updates were made
|
* @returns {boolean} true if updates were made
|
||||||
*/
|
*/
|
||||||
update(payload) {
|
update(payload) {
|
||||||
|
Loading…
Reference in New Issue
Block a user