mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-07-13 02:45:02 +02:00
Update API Keys to be tied to a user, add apikey lru-cache, handle deactivating expired keys
This commit is contained in:
parent
af1ff12dbb
commit
4d32a22de9
13 changed files with 335 additions and 217 deletions
|
@ -13,102 +13,23 @@
|
||||||
<ui-text-input-with-label v-model.trim="newApiKey.name" :readonly="!isNew" :label="$strings.LabelName" />
|
<ui-text-input-with-label v-model.trim="newApiKey.name" :readonly="!isNew" :label="$strings.LabelName" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isNew" class="w-1/2 px-2">
|
<div v-if="isNew" class="w-1/2 px-2">
|
||||||
<ui-text-input-with-label v-model.trim="newApiKey.expiresIn" :label="$strings.LabelExpiresInSeconds" type="number" />
|
<ui-text-input-with-label v-model.trim="newApiKey.expiresIn" :label="$strings.LabelExpiresInSeconds" type="number" :min="0" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex py-2">
|
<div class="flex items-center pt-4 pb-2 gap-2">
|
||||||
<div class="flex items-center pt-4 px-2">
|
<div class="flex items-center px-2">
|
||||||
<p class="px-3 font-semibold" id="user-enabled-toggle">{{ $strings.LabelEnable }}</p>
|
<p class="px-3 font-semibold" id="user-enabled-toggle">{{ $strings.LabelEnable }}</p>
|
||||||
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newApiKey.isActive" />
|
<ui-toggle-switch :disabled="isExpired && !apiKey.isActive" labeledBy="user-enabled-toggle" v-model="newApiKey.isActive" />
|
||||||
|
</div>
|
||||||
|
<div v-if="isExpired" class="px-2">
|
||||||
|
<p class="text-sm text-error">{{ $strings.LabelExpired }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="newApiKey.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4">
|
<div class="w-full border-t border-b border-black-200 py-4 px-3 mt-4">
|
||||||
<p class="text-lg mb-2 font-semibold">{{ $strings.HeaderPermissions }}</p>
|
<p class="text-lg mb-2 font-semibold">{{ $strings.LabelApiKeyUser }}</p>
|
||||||
<div class="flex items-center my-2 max-w-md">
|
<p class="text-sm mb-2 text-gray-400">{{ $strings.LabelApiKeyUserDescription }}</p>
|
||||||
<div class="w-1/2">
|
<ui-select-input v-model="newApiKey.userId" :disabled="isExpired && !apiKey.isActive" :items="userItems" :placeholder="$strings.LabelSelectUser" :label="$strings.LabelApiKeyUser" label-hidden />
|
||||||
<p id="download-permissions-toggle">{{ $strings.LabelPermissionsDownload }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-1/2">
|
|
||||||
<ui-toggle-switch labeledBy="download-permissions-toggle" v-model="newApiKey.permissions.download" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
|
||||||
<div class="w-1/2">
|
|
||||||
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-1/2">
|
|
||||||
<ui-toggle-switch labeledBy="update-permissions-toggle" v-model="newApiKey.permissions.update" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
|
||||||
<div class="w-1/2">
|
|
||||||
<p id="delete-permissions-toggle">{{ $strings.LabelPermissionsDelete }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-1/2">
|
|
||||||
<ui-toggle-switch labeledBy="delete-permissions-toggle" v-model="newApiKey.permissions.delete" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
|
||||||
<div class="w-1/2">
|
|
||||||
<p id="upload-permissions-toggle">{{ $strings.LabelPermissionsUpload }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-1/2">
|
|
||||||
<ui-toggle-switch labeledBy="upload-permissions-toggle" v-model="newApiKey.permissions.upload" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
|
||||||
<div class="w-1/2">
|
|
||||||
<p id="ereader-permissions-toggle">{{ $strings.LabelPermissionsCreateEreader }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-1/2">
|
|
||||||
<ui-toggle-switch labeledBy="ereader-permissions-toggle" v-model="newApiKey.permissions.createEreader" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
|
||||||
<div class="w-1/2">
|
|
||||||
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-1/2">
|
|
||||||
<ui-toggle-switch labeledBy="explicit-content-permissions-toggle" v-model="newApiKey.permissions.accessExplicitContent" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-md">
|
|
||||||
<div class="w-1/2">
|
|
||||||
<p id="access-all-libs--permissions-toggle">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-1/2">
|
|
||||||
<ui-toggle-switch labeledBy="access-all-libs--permissions-toggle" v-model="newApiKey.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!newApiKey.permissions.accessAllLibraries" class="my-4">
|
|
||||||
<ui-multi-select-dropdown v-model="newApiKey.permissions.librariesAccessible" :items="libraryItems" :label="$strings.LabelLibrariesAccessibleToUser" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-cen~ter my-2 max-w-md">
|
|
||||||
<div class="w-1/2">
|
|
||||||
<p>{{ $strings.LabelPermissionsAccessAllTags }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-1/2">
|
|
||||||
<ui-toggle-switch v-model="newApiKey.permissions.accessAllTags" @input="accessAllTagsToggled" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="!newApiKey.permissions.accessAllTags" class="my-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<ui-multi-select-dropdown v-model="newApiKey.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
|
|
||||||
<div class="flex items-center pt-4 px-2">
|
|
||||||
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
|
|
||||||
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newApiKey.permissions.selectedTagsNotAccessible" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-4 px-2">
|
<div class="flex pt-4 px-2">
|
||||||
|
@ -128,15 +49,17 @@ export default {
|
||||||
apiKey: {
|
apiKey: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
processing: false,
|
processing: false,
|
||||||
newApiKey: {},
|
newApiKey: {},
|
||||||
isNew: true,
|
isNew: true
|
||||||
tags: [],
|
|
||||||
loadingTags: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
@ -160,64 +83,29 @@ export default {
|
||||||
title() {
|
title() {
|
||||||
return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey
|
return this.isNew ? this.$strings.HeaderNewApiKey : this.$strings.HeaderUpdateApiKey
|
||||||
},
|
},
|
||||||
libraries() {
|
userItems() {
|
||||||
return this.$store.state.libraries.libraries
|
return this.users
|
||||||
|
.filter((u) => {
|
||||||
|
// Only show root user if the current user is root
|
||||||
|
return u.type !== 'root' || this.$store.getters['user/getIsRoot']
|
||||||
|
})
|
||||||
|
.map((u) => ({ text: u.username, value: u.id, subtext: u.type }))
|
||||||
},
|
},
|
||||||
libraryItems() {
|
isExpired() {
|
||||||
return this.libraries.map((lib) => ({ text: lib.name, value: lib.id }))
|
if (!this.apiKey || !this.apiKey.expiresAt) return false
|
||||||
},
|
|
||||||
itemTags() {
|
return new Date(this.apiKey.expiresAt).getTime() < Date.now()
|
||||||
return this.tags.map((t) => {
|
|
||||||
return {
|
|
||||||
text: t,
|
|
||||||
value: t
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
tagsSelectionText() {
|
|
||||||
return this.newApiKey.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
accessAllTagsToggled(val) {
|
|
||||||
if (val) {
|
|
||||||
if (this.newApiKey.itemTagsSelected?.length) {
|
|
||||||
this.newApiKey.itemTagsSelected = []
|
|
||||||
}
|
|
||||||
this.newApiKey.permissions.selectedTagsNotAccessible = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fetchAllTags() {
|
|
||||||
this.loadingTags = true
|
|
||||||
this.$axios
|
|
||||||
.$get(`/api/tags`)
|
|
||||||
.then((res) => {
|
|
||||||
this.tags = res.tags
|
|
||||||
this.loadingTags = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to load tags', error)
|
|
||||||
this.loadingTags = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
accessAllLibrariesToggled(val) {
|
|
||||||
if (!val && !this.newApiKey.permissions.librariesAccessible.length) {
|
|
||||||
this.newApiKey.permissions.librariesAccessible = this.libraries.map((l) => l.id)
|
|
||||||
} else if (val && this.newApiKey.permissions.librariesAccessible.length) {
|
|
||||||
this.newApiKey.permissions.librariesAccessible = []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
submitForm() {
|
submitForm() {
|
||||||
if (!this.newApiKey.name) {
|
if (!this.newApiKey.name) {
|
||||||
this.$toast.error(this.$strings.ToastNewApiKeyNameError)
|
this.$toast.error(this.$strings.ToastNameRequired)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!this.newApiKey.permissions.accessAllLibraries && !this.newApiKey.permissions.librariesAccessible.length) {
|
|
||||||
this.$toast.error(this.$strings.ToastNewApiKeyLibraryError)
|
if (!this.newApiKey.userId) {
|
||||||
return
|
this.$toast.error(this.$strings.ToastNewApiKeyUserError)
|
||||||
}
|
|
||||||
if (!this.newApiKey.permissions.accessAllTags && !this.newApiKey.itemTagsSelected.length) {
|
|
||||||
this.$toast.error(this.$strings.ToastNewApiKeyTagError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -228,9 +116,15 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
submitUpdateApiKey() {
|
submitUpdateApiKey() {
|
||||||
var apiKey = {
|
if (this.newApiKey.isActive === this.apiKey.isActive && this.newApiKey.userId === this.apiKey.userId) {
|
||||||
|
this.$toast.info(this.$strings.ToastNoUpdatesNecessary)
|
||||||
|
this.show = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = {
|
||||||
isActive: this.newApiKey.isActive,
|
isActive: this.newApiKey.isActive,
|
||||||
permissions: this.newApiKey.permissions
|
userId: this.newApiKey.userId
|
||||||
}
|
}
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
|
@ -281,33 +175,20 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.fetchAllTags()
|
|
||||||
this.isNew = !this.apiKey
|
this.isNew = !this.apiKey
|
||||||
|
|
||||||
if (this.apiKey) {
|
if (this.apiKey) {
|
||||||
this.newApiKey = {
|
this.newApiKey = {
|
||||||
name: this.apiKey.name,
|
name: this.apiKey.name,
|
||||||
isActive: this.apiKey.isActive,
|
isActive: this.apiKey.isActive,
|
||||||
permissions: { ...this.apiKey.permissions }
|
userId: this.apiKey.userId
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.newApiKey = {
|
this.newApiKey = {
|
||||||
name: null,
|
name: null,
|
||||||
expiresIn: null,
|
expiresIn: null,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
permissions: {
|
userId: null
|
||||||
download: true,
|
|
||||||
update: false,
|
|
||||||
delete: false,
|
|
||||||
upload: false,
|
|
||||||
accessAllLibraries: true,
|
|
||||||
accessAllTags: true,
|
|
||||||
accessExplicitContent: false,
|
|
||||||
selectedTagsNotAccessible: false,
|
|
||||||
createEreader: false,
|
|
||||||
librariesAccessible: [],
|
|
||||||
itemTagsSelected: []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ export default {
|
||||||
processing: Boolean,
|
processing: Boolean,
|
||||||
persistent: {
|
persistent: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true
|
default: false
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
|
@ -99,7 +99,7 @@ export default {
|
||||||
this.preventClickoutside = false
|
this.preventClickoutside = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.processing && this.persistent) return
|
if (this.processing || this.persistent) return
|
||||||
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@
|
||||||
<table v-if="apiKeys.length > 0" id="api-keys">
|
<table v-if="apiKeys.length > 0" id="api-keys">
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ $strings.LabelName }}</th>
|
<th>{{ $strings.LabelName }}</th>
|
||||||
|
<th class="w-44">{{ $strings.LabelApiKeyUser }}</th>
|
||||||
<th class="w-32">{{ $strings.LabelExpiresAt }}</th>
|
<th class="w-32">{{ $strings.LabelExpiresAt }}</th>
|
||||||
<th class="w-32">{{ $strings.LabelLastUsed }}</th>
|
|
||||||
<th class="w-32">{{ $strings.LabelCreatedAt }}</th>
|
<th class="w-32">{{ $strings.LabelCreatedAt }}</th>
|
||||||
<th class="w-32"></th>
|
<th class="w-32"></th>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -15,11 +15,15 @@
|
||||||
<p class="pl-2 truncate">{{ apiKey.name }}</p>
|
<p class="pl-2 truncate">{{ apiKey.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs">{{ apiKey.expiresAt ? $formatJsDatetime(new Date(apiKey.expiresAt), dateFormat, timeFormat) : $strings.LabelExpiresNever }}</td>
|
|
||||||
<td class="text-xs">
|
<td class="text-xs">
|
||||||
<ui-tooltip v-if="apiKey.lastUsedAt" direction="top" :text="$formatJsDatetime(new Date(apiKey.lastUsedAt), dateFormat, timeFormat)">
|
<nuxt-link v-if="apiKey.user" :to="`/config/users/${apiKey.user.id}`" class="text-xs hover:underline">
|
||||||
{{ $dateDistanceFromNow(new Date(apiKey.lastUsedAt).getTime()) }}
|
{{ apiKey.user.username }}
|
||||||
</ui-tooltip>
|
</nuxt-link>
|
||||||
|
<p v-else class="text-xs">Error</p>
|
||||||
|
</td>
|
||||||
|
<td class="text-xs">
|
||||||
|
<p v-if="apiKey.expiresAt" class="text-xs" :title="apiKey.expiresAt">{{ getExpiresAtText(apiKey) }}</p>
|
||||||
|
<p v-else class="text-xs">{{ $strings.LabelExpiresNever }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-xs font-mono">
|
<td class="text-xs font-mono">
|
||||||
<ui-tooltip direction="top" :text="$formatJsDatetime(new Date(apiKey.createdAt), dateFormat, timeFormat)">
|
<ui-tooltip direction="top" :text="$formatJsDatetime(new Date(apiKey.createdAt), dateFormat, timeFormat)">
|
||||||
|
@ -60,6 +64,12 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
getExpiresAtText(apiKey) {
|
||||||
|
if (new Date(apiKey.expiresAt).getTime() < Date.now()) {
|
||||||
|
return this.$strings.LabelExpired
|
||||||
|
}
|
||||||
|
return this.$formatJsDatetime(new Date(apiKey.expiresAt), this.dateFormat, this.timeFormat)
|
||||||
|
},
|
||||||
deleteApiKeyClick(apiKey) {
|
deleteApiKeyClick(apiKey) {
|
||||||
if (this.isDeletingApiKey) return
|
if (this.isDeletingApiKey) return
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
<p v-if="label && !labelHidden" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||||
<button ref="buttonWrapper" type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded-sm shadow-xs pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
<button ref="buttonWrapper" type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded-sm shadow-xs pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||||
<span class="flex items-center">
|
<span class="flex items-center">
|
||||||
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span>
|
<span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small, 'text-gray-400': !selectedText }">{{ selectedText || placeholder }}</span>
|
||||||
<span v-if="selectedSubtext">: </span>
|
<span v-if="selectedSubtext">: </span>
|
||||||
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
|
<span v-if="selectedSubtext" class="font-normal block truncate font-sans text-sm text-gray-400">{{ selectedSubtext }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
@ -36,10 +36,15 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
|
labelHidden: Boolean,
|
||||||
items: {
|
items: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
|
placeholder: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
small: Boolean,
|
small: Boolean,
|
||||||
menuMaxHeight: {
|
menuMaxHeight: {
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||||
</label>
|
</label>
|
||||||
</slot>
|
</slot>
|
||||||
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ export default {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'text'
|
default: 'text'
|
||||||
},
|
},
|
||||||
|
min: [String, Number],
|
||||||
readonly: Boolean,
|
readonly: Boolean,
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
inputClass: String,
|
inputClass: String,
|
||||||
|
|
|
@ -14,12 +14,12 @@
|
||||||
|
|
||||||
<div class="grow" />
|
<div class="grow" />
|
||||||
|
|
||||||
<ui-btn color="bg-primary" small @click="setShowApiKeyModal()">{{ $strings.ButtonAddApiKey }}</ui-btn>
|
<ui-btn color="bg-primary" :disabled="loadingUsers || users.length === 0" small @click="setShowApiKeyModal()">{{ $strings.ButtonAddApiKey }}</ui-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<tables-api-keys-table ref="apiKeysTable" class="pt-2" @edit="setShowApiKeyModal" @numApiKeys="(count) => (numApiKeys = count)" />
|
<tables-api-keys-table ref="apiKeysTable" class="pt-2" @edit="setShowApiKeyModal" @numApiKeys="(count) => (numApiKeys = count)" />
|
||||||
</app-settings-content>
|
</app-settings-content>
|
||||||
<modals-api-key-modal ref="apiKeyModal" v-model="showApiKeyModal" :api-key="selectedApiKey" @created="apiKeyCreated" @deleted="apiKeyDeleted" @updated="apiKeyUpdated" />
|
<modals-api-key-modal ref="apiKeyModal" v-model="showApiKeyModal" :api-key="selectedApiKey" :users="users" @created="apiKeyCreated" @deleted="apiKeyDeleted" @updated="apiKeyUpdated" />
|
||||||
<modals-api-key-created-modal ref="apiKeyCreatedModal" v-model="showApiKeyCreatedModal" :api-key="selectedApiKey" />
|
<modals-api-key-created-modal ref="apiKeyCreatedModal" v-model="showApiKeyCreatedModal" :api-key="selectedApiKey" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -33,10 +33,12 @@ export default {
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
loadingUsers: false,
|
||||||
selectedApiKey: null,
|
selectedApiKey: null,
|
||||||
showApiKeyModal: false,
|
showApiKeyModal: false,
|
||||||
showApiKeyCreatedModal: false,
|
showApiKeyCreatedModal: false,
|
||||||
numApiKeys: 0
|
numApiKeys: 0,
|
||||||
|
users: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -45,7 +47,6 @@ export default {
|
||||||
this.selectedApiKey = apiKey
|
this.selectedApiKey = apiKey
|
||||||
this.showApiKeyCreatedModal = true
|
this.showApiKeyCreatedModal = true
|
||||||
if (this.$refs.apiKeysTable) {
|
if (this.$refs.apiKeysTable) {
|
||||||
console.log('apiKeyCreated', apiKey)
|
|
||||||
this.$refs.apiKeysTable.addApiKey(apiKey)
|
this.$refs.apiKeysTable.addApiKey(apiKey)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -60,9 +61,27 @@ export default {
|
||||||
setShowApiKeyModal(selectedApiKey) {
|
setShowApiKeyModal(selectedApiKey) {
|
||||||
this.selectedApiKey = selectedApiKey
|
this.selectedApiKey = selectedApiKey
|
||||||
this.showApiKeyModal = true
|
this.showApiKeyModal = true
|
||||||
|
},
|
||||||
|
loadUsers() {
|
||||||
|
this.loadingUsers = true
|
||||||
|
this.$axios
|
||||||
|
.$get('/api/users')
|
||||||
|
.then((res) => {
|
||||||
|
this.users = res.users.sort((a, b) => {
|
||||||
|
return a.createdAt - b.createdAt
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loadingUsers = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {},
|
mounted() {
|
||||||
|
this.loadUsers()
|
||||||
|
},
|
||||||
beforeDestroy() {}
|
beforeDestroy() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -242,6 +242,8 @@
|
||||||
"LabelAlreadyInYourLibrary": "Already in your library",
|
"LabelAlreadyInYourLibrary": "Already in your library",
|
||||||
"LabelApiKeyCreated": "API Key \"{0}\" created successfully.",
|
"LabelApiKeyCreated": "API Key \"{0}\" created successfully.",
|
||||||
"LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.",
|
"LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.",
|
||||||
|
"LabelApiKeyUser": "Act on behalf of user",
|
||||||
|
"LabelApiKeyUserDescription": "This API key will have the same permissions as the user it is acting on behalf of. This will appear the same in logs as if the user was making the request.",
|
||||||
"LabelApiToken": "API Token",
|
"LabelApiToken": "API Token",
|
||||||
"LabelAppend": "Append",
|
"LabelAppend": "Append",
|
||||||
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
|
"LabelAudioBitrate": "Audio Bitrate (e.g. 128k)",
|
||||||
|
@ -353,6 +355,7 @@
|
||||||
"LabelExample": "Example",
|
"LabelExample": "Example",
|
||||||
"LabelExpandSeries": "Expand Series",
|
"LabelExpandSeries": "Expand Series",
|
||||||
"LabelExpandSubSeries": "Expand Sub Series",
|
"LabelExpandSubSeries": "Expand Sub Series",
|
||||||
|
"LabelExpired": "Expired",
|
||||||
"LabelExpiresAt": "Expires At",
|
"LabelExpiresAt": "Expires At",
|
||||||
"LabelExpiresInSeconds": "Expires in (seconds)",
|
"LabelExpiresInSeconds": "Expires in (seconds)",
|
||||||
"LabelExpiresNever": "Never",
|
"LabelExpiresNever": "Never",
|
||||||
|
@ -418,7 +421,6 @@
|
||||||
"LabelLastSeen": "Last Seen",
|
"LabelLastSeen": "Last Seen",
|
||||||
"LabelLastTime": "Last Time",
|
"LabelLastTime": "Last Time",
|
||||||
"LabelLastUpdate": "Last Update",
|
"LabelLastUpdate": "Last Update",
|
||||||
"LabelLastUsed": "Last Used",
|
|
||||||
"LabelLayout": "Layout",
|
"LabelLayout": "Layout",
|
||||||
"LabelLayoutSinglePage": "Single page",
|
"LabelLayoutSinglePage": "Single page",
|
||||||
"LabelLayoutSplitPage": "Split page",
|
"LabelLayoutSplitPage": "Split page",
|
||||||
|
@ -556,6 +558,7 @@
|
||||||
"LabelSelectAll": "Select all",
|
"LabelSelectAll": "Select all",
|
||||||
"LabelSelectAllEpisodes": "Select all episodes",
|
"LabelSelectAllEpisodes": "Select all episodes",
|
||||||
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
"LabelSelectEpisodesShowing": "Select {0} episodes showing",
|
||||||
|
"LabelSelectUser": "Select user",
|
||||||
"LabelSelectUsers": "Select users",
|
"LabelSelectUsers": "Select users",
|
||||||
"LabelSendEbookToDevice": "Send Ebook to...",
|
"LabelSendEbookToDevice": "Send Ebook to...",
|
||||||
"LabelSequence": "Sequence",
|
"LabelSequence": "Sequence",
|
||||||
|
@ -1046,6 +1049,7 @@
|
||||||
"ToastMustHaveAtLeastOnePath": "Must have at least one path",
|
"ToastMustHaveAtLeastOnePath": "Must have at least one path",
|
||||||
"ToastNameEmailRequired": "Name and email are required",
|
"ToastNameEmailRequired": "Name and email are required",
|
||||||
"ToastNameRequired": "Name is required",
|
"ToastNameRequired": "Name is required",
|
||||||
|
"ToastNewApiKeyUserError": "Must select a user",
|
||||||
"ToastNewEpisodesFound": "{0} new episodes found",
|
"ToastNewEpisodesFound": "{0} new episodes found",
|
||||||
"ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"",
|
"ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"",
|
||||||
"ToastNewUserCreatedSuccess": "New account created",
|
"ToastNewUserCreatedSuccess": "New account created",
|
||||||
|
|
|
@ -65,7 +65,9 @@ class Auth {
|
||||||
new JwtStrategy(
|
new JwtStrategy(
|
||||||
{
|
{
|
||||||
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
|
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
|
||||||
secretOrKey: Database.serverSettings.tokenSecret
|
secretOrKey: Database.serverSettings.tokenSecret,
|
||||||
|
// Handle expiration manaully in order to disable api keys that are expired
|
||||||
|
ignoreExpiration: true
|
||||||
},
|
},
|
||||||
this.jwtAuthCheck.bind(this)
|
this.jwtAuthCheck.bind(this)
|
||||||
)
|
)
|
||||||
|
@ -1044,6 +1046,7 @@ class Auth {
|
||||||
}
|
}
|
||||||
await Database.updateServerSettings()
|
await Database.updateServerSettings()
|
||||||
|
|
||||||
|
// TODO: Old method of non-expiring tokens
|
||||||
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
// New token secret creation added in v2.1.0 so generate new API tokens for each user
|
||||||
const users = await Database.userModel.findAll({
|
const users = await Database.userModel.findAll({
|
||||||
attributes: ['id', 'username', 'token']
|
attributes: ['id', 'username', 'token']
|
||||||
|
@ -1057,22 +1060,49 @@ class Auth {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the user in the validated jwt_payload really exists and is active.
|
* Checks if the user or api key in the validated jwt_payload 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
|
if (jwt_payload.type === 'api') {
|
||||||
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
|
const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)
|
||||||
|
|
||||||
if (!user?.isActive) {
|
if (!apiKey?.isActive) {
|
||||||
// deny login
|
done(null, null)
|
||||||
done(null, null)
|
return
|
||||||
return
|
}
|
||||||
|
|
||||||
|
// Check if the api key is expired and deactivate it
|
||||||
|
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||||
|
done(null, null)
|
||||||
|
|
||||||
|
apiKey.isActive = false
|
||||||
|
await apiKey.save()
|
||||||
|
Logger.info(`[Auth] API key ${apiKey.id} is expired - deactivated`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await Database.userModel.getUserById(apiKey.userId)
|
||||||
|
done(null, user)
|
||||||
|
} else {
|
||||||
|
// Check if the jwt is expired
|
||||||
|
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// load user by id from the jwt token
|
||||||
|
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
|
||||||
|
|
||||||
|
if (!user?.isActive) {
|
||||||
|
// deny login
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// approve login
|
||||||
|
done(null, user)
|
||||||
}
|
}
|
||||||
// approve login
|
|
||||||
done(null, user)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -670,6 +670,7 @@ class Database {
|
||||||
* Remove playback sessions that are 3 seconds or less
|
* Remove playback sessions that are 3 seconds or less
|
||||||
* Remove duplicate mediaProgresses
|
* Remove duplicate mediaProgresses
|
||||||
* Remove expired auth sessions
|
* Remove expired auth sessions
|
||||||
|
* Deactivate expired api keys
|
||||||
*/
|
*/
|
||||||
async cleanDatabase() {
|
async cleanDatabase() {
|
||||||
// Remove invalid Podcast records
|
// Remove invalid Podcast records
|
||||||
|
@ -802,6 +803,23 @@ WHERE EXISTS (
|
||||||
|
|
||||||
// Remove expired Session records
|
// Remove expired Session records
|
||||||
await this.cleanupExpiredSessions()
|
await this.cleanupExpiredSessions()
|
||||||
|
|
||||||
|
// Deactivate expired api keys
|
||||||
|
await this.deactivateExpiredApiKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate expired api keys
|
||||||
|
*/
|
||||||
|
async deactivateExpiredApiKeys() {
|
||||||
|
try {
|
||||||
|
const affectedCount = await this.apiKeyModel.deactivateExpiredApiKeys()
|
||||||
|
if (affectedCount > 0) {
|
||||||
|
Logger.info(`[Database] Deactivated ${affectedCount} expired api keys`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(`[Database] Error deactivating expired api keys: ${error.message}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -20,7 +20,19 @@ class ApiKeyController {
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async getAll(req, res) {
|
async getAll(req, res) {
|
||||||
const apiKeys = await Database.apiKeyModel.findAll()
|
const apiKeys = await Database.apiKeyModel.findAll({
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.userModel,
|
||||||
|
attributes: ['id', 'username', 'type']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.userModel,
|
||||||
|
as: 'createdByUser',
|
||||||
|
attributes: ['id', 'username', 'type']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
apiKeys: apiKeys.map((a) => a.toJSON())
|
apiKeys: apiKeys.map((a) => a.toJSON())
|
||||||
|
@ -42,10 +54,21 @@ class ApiKeyController {
|
||||||
Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`)
|
Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`)
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
if (!req.body.userId || typeof req.body.userId !== 'string') {
|
||||||
|
Logger.warn(`[ApiKeyController] create: Invalid userId: ${req.body.userId}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
const user = await Database.userModel.getUserById(req.body.userId)
|
||||||
|
if (!user) {
|
||||||
|
Logger.warn(`[ApiKeyController] create: User not found: ${req.body.userId}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
if (user.type === 'root' && !req.user.isRoot) {
|
||||||
|
Logger.warn(`[ApiKeyController] create: Root user API key cannot be created by non-root user`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
const keyId = uuidv4() // Generate key id ahead of time to use in JWT
|
const keyId = uuidv4() // Generate key id ahead of time to use in JWT
|
||||||
|
|
||||||
const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions)
|
|
||||||
const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn)
|
const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn)
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
|
@ -60,9 +83,9 @@ class ApiKeyController {
|
||||||
id: keyId,
|
id: keyId,
|
||||||
name: req.body.name,
|
name: req.body.name,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
permissions,
|
userId: req.body.userId,
|
||||||
userId: req.user.id,
|
isActive: !!req.body.isActive,
|
||||||
isActive: !!req.body.isActive
|
createdByUserId: req.user.id
|
||||||
})
|
})
|
||||||
|
|
||||||
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
|
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
|
||||||
|
@ -76,34 +99,64 @@ class ApiKeyController {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH: /api/api-keys/:id
|
* PATCH: /api/api-keys/:id
|
||||||
* Only isActive and permissions can be updated because name and expiresIn are in the JWT
|
* Only isActive and userId can be updated because name and expiresIn are in the JWT
|
||||||
*
|
*
|
||||||
* @param {RequestWithUser} req
|
* @param {RequestWithUser} req
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
const apiKey = await Database.apiKeyModel.findByPk(req.params.id)
|
const apiKey = await Database.apiKeyModel.findByPk(req.params.id, {
|
||||||
|
include: {
|
||||||
|
model: Database.userModel
|
||||||
|
}
|
||||||
|
})
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
// Only root user can update root user API keys
|
||||||
|
if (apiKey.user.type === 'root' && !req.user.isRoot) {
|
||||||
|
Logger.warn(`[ApiKeyController] update: Root user API key cannot be updated by non-root user`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasUpdates = false
|
||||||
|
if (req.body.userId !== undefined) {
|
||||||
|
if (typeof req.body.userId !== 'string') {
|
||||||
|
Logger.warn(`[ApiKeyController] update: Invalid userId: ${req.body.userId}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
const user = await Database.userModel.getUserById(req.body.userId)
|
||||||
|
if (!user) {
|
||||||
|
Logger.warn(`[ApiKeyController] update: User not found: ${req.body.userId}`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
if (user.type === 'root' && !req.user.isRoot) {
|
||||||
|
Logger.warn(`[ApiKeyController] update: Root user API key cannot be created by non-root user`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
if (apiKey.userId !== req.body.userId) {
|
||||||
|
apiKey.userId = req.body.userId
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (req.body.isActive !== undefined) {
|
if (req.body.isActive !== undefined) {
|
||||||
if (typeof req.body.isActive !== 'boolean') {
|
if (typeof req.body.isActive !== 'boolean') {
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
if (apiKey.isActive !== req.body.isActive) {
|
||||||
apiKey.isActive = req.body.isActive
|
apiKey.isActive = req.body.isActive
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.body.permissions && Object.keys(req.body.permissions).length > 0) {
|
if (hasUpdates) {
|
||||||
const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions)
|
await apiKey.save()
|
||||||
apiKey.permissions = permissions
|
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
|
||||||
|
} else {
|
||||||
|
Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiKey.save()
|
|
||||||
|
|
||||||
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
apiKey: apiKey.toJSON()
|
apiKey: apiKey.toJSON()
|
||||||
})
|
})
|
||||||
|
|
|
@ -36,6 +36,7 @@ class CronManager {
|
||||||
* Closes open share sessions that have not been updated in 24 hours
|
* Closes open share sessions that have not been updated in 24 hours
|
||||||
* Closes open playback sessions that have not been updated in 36 hours
|
* Closes open playback sessions that have not been updated in 36 hours
|
||||||
* Cleans up expired auth sessions
|
* Cleans up expired auth sessions
|
||||||
|
* Deactivates expired api keys
|
||||||
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
|
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
|
||||||
*/
|
*/
|
||||||
initOpenSessionCleanupCron() {
|
initOpenSessionCleanupCron() {
|
||||||
|
@ -44,6 +45,7 @@ class CronManager {
|
||||||
ShareManager.closeStaleOpenShareSessions()
|
ShareManager.closeStaleOpenShareSessions()
|
||||||
await this.playbackSessionManager.closeStaleOpenSessions()
|
await this.playbackSessionManager.closeStaleOpenSessions()
|
||||||
await Database.cleanupExpiredSessions()
|
await Database.cleanupExpiredSessions()
|
||||||
|
await Database.deactivateExpiredApiKeys()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -80,7 +80,11 @@ async function up({ context: { queryInterface, logger } }) {
|
||||||
defaultValue: DataTypes.UUIDV4,
|
defaultValue: DataTypes.UUIDV4,
|
||||||
primaryKey: true
|
primaryKey: true
|
||||||
},
|
},
|
||||||
name: DataTypes.STRING,
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: DataTypes.TEXT,
|
||||||
expiresAt: DataTypes.DATE,
|
expiresAt: DataTypes.DATE,
|
||||||
lastUsedAt: DataTypes.DATE,
|
lastUsedAt: DataTypes.DATE,
|
||||||
isActive: {
|
isActive: {
|
||||||
|
@ -105,6 +109,17 @@ async function up({ context: { queryInterface, logger } }) {
|
||||||
},
|
},
|
||||||
key: 'id'
|
key: 'id'
|
||||||
},
|
},
|
||||||
|
onDelete: 'CASCADE'
|
||||||
|
},
|
||||||
|
createdByUserId: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
references: {
|
||||||
|
model: {
|
||||||
|
tableName: 'users',
|
||||||
|
as: 'createdByUser'
|
||||||
|
},
|
||||||
|
key: 'id'
|
||||||
|
},
|
||||||
onDelete: 'SET NULL'
|
onDelete: 'SET NULL'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
const { DataTypes, Model, Op } = require('sequelize')
|
const { DataTypes, Model, Op } = require('sequelize')
|
||||||
const jwt = require('jsonwebtoken')
|
const jwt = require('jsonwebtoken')
|
||||||
|
const { LRUCache } = require('lru-cache')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -17,6 +18,32 @@ const Logger = require('../Logger')
|
||||||
* @property {string[]} itemTagsSelected
|
* @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 {
|
class ApiKey extends Model {
|
||||||
constructor(values, options) {
|
constructor(values, options) {
|
||||||
super(values, options)
|
super(values, options)
|
||||||
|
@ -25,13 +52,15 @@ class ApiKey extends Model {
|
||||||
this.id
|
this.id
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.name
|
this.name
|
||||||
|
/** @type {string} */
|
||||||
|
this.description
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
this.expiresAt
|
this.expiresAt
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
this.lastUsedAt
|
this.lastUsedAt
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
this.isActive
|
this.isActive
|
||||||
/** @type {Object} */
|
/** @type {ApiKeyPermissions} */
|
||||||
this.permissions
|
this.permissions
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
this.createdAt
|
this.createdAt
|
||||||
|
@ -39,6 +68,8 @@ class ApiKey extends Model {
|
||||||
this.updatedAt
|
this.updatedAt
|
||||||
/** @type {UUIDV4} */
|
/** @type {UUIDV4} */
|
||||||
this.userId
|
this.userId
|
||||||
|
/** @type {UUIDV4} */
|
||||||
|
this.createdByUserId
|
||||||
|
|
||||||
// Expanded properties
|
// Expanded properties
|
||||||
|
|
||||||
|
@ -104,18 +135,24 @@ class ApiKey extends Model {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up expired api keys from the database
|
* Deactivate expired api keys
|
||||||
* @returns {Promise<number>} Number of api keys deleted
|
* @returns {Promise<number>} Number of api keys affected
|
||||||
*/
|
*/
|
||||||
static async cleanupExpiredApiKeys() {
|
static async deactivateExpiredApiKeys() {
|
||||||
const deletedCount = await ApiKey.destroy({
|
const [affectedCount] = await ApiKey.update(
|
||||||
where: {
|
{
|
||||||
expiresAt: {
|
isActive: false
|
||||||
[Op.lt]: new Date()
|
},
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
isActive: true,
|
||||||
|
expiresAt: {
|
||||||
|
[Op.lt]: new Date()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
return deletedCount
|
return affectedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -152,6 +189,24 @@ class ApiKey extends Model {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Initialize model
|
||||||
* @param {import('../Database').sequelize} sequelize
|
* @param {import('../Database').sequelize} sequelize
|
||||||
|
@ -164,7 +219,11 @@ class ApiKey extends Model {
|
||||||
defaultValue: DataTypes.UUIDV4,
|
defaultValue: DataTypes.UUIDV4,
|
||||||
primaryKey: true
|
primaryKey: true
|
||||||
},
|
},
|
||||||
name: DataTypes.STRING,
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: DataTypes.TEXT,
|
||||||
expiresAt: DataTypes.DATE,
|
expiresAt: DataTypes.DATE,
|
||||||
lastUsedAt: DataTypes.DATE,
|
lastUsedAt: DataTypes.DATE,
|
||||||
isActive: {
|
isActive: {
|
||||||
|
@ -182,9 +241,30 @@ class ApiKey extends Model {
|
||||||
|
|
||||||
const { user } = sequelize.models
|
const { user } = sequelize.models
|
||||||
user.hasMany(ApiKey, {
|
user.hasMany(ApiKey, {
|
||||||
onDelete: 'SET NULL'
|
onDelete: 'CASCADE'
|
||||||
})
|
})
|
||||||
ApiKey.belongsTo(user)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue