WIP: support user/pass as auth method instead of current apikey usage

This commit is contained in:
arabcoders
2025-05-14 20:50:56 +03:00
parent 1af94641c4
commit 4a4c9ddb54
14 changed files with 554 additions and 117 deletions

View File

@@ -19,7 +19,7 @@
</div>
<div class="navbar-menu" :class="{ 'is-active': showMenu }">
<div class="navbar-start" v-if="hasAPISettings && !showConnection">
<div class="navbar-start">
<NuxtLink class="navbar-item" to="/backends" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-server"/></span>
<span>Backends</span>
@@ -54,22 +54,19 @@
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/tools/plex_token" @click.native="(e) => changeRoute(e)"
v-if="hasAPISettings">
<NuxtLink class="navbar-item" to="/tools/plex_token" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-key"/></span>
<span>Plex Token</span>
</NuxtLink>
<NuxtLink class="navbar-item" to="/tools/sub_users" @click.native="(e) => changeRoute(e)"
v-if="hasAPISettings">
<NuxtLink class="navbar-item" to="/tools/sub_users" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-users"/></span>
<span>Sub Users</span>
</NuxtLink>
<hr class="navbar-divider">
<NuxtLink class="navbar-item" to="/processes" @click.native="(e) => changeRoute(e)"
v-if="hasAPISettings">
<NuxtLink class="navbar-item" to="/processes" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-microchip"/></span>
<span>Processes</span>
</NuxtLink>
@@ -123,7 +120,7 @@
</a>
<div class="navbar-dropdown">
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)" v-if="hasAPISettings">
<NuxtLink class="navbar-item" to="/console" @click.native="(e) => changeRoute(e)">
<span class="icon"><i class="fas fa-terminal"/></span>
<span>Console</span>
</NuxtLink>
@@ -152,19 +149,17 @@
</div>
</div>
<div class="navbar-end pr-3">
<template v-if="hasAPISettings && !showConnection">
<div class="navbar-item">
<NuxtLink class="button is-dark" v-tooltip="'Guides'" to="/help">
<span class="icon"><i class="fas fa-circle-question"/></span>
</NuxtLink>
</div>
<div class="navbar-item" v-if="hasAPISettings && !showConnection">
<div class="navbar-item">
<button class="button is-dark" @click="showUserSelection = !showUserSelection" v-tooltip="'Change User'">
<span class="icon"><i class="fas fa-users"/></span>
</button>
</div>
</template>
<div class="navbar-item">
<button class="button is-dark" @click="showConnection = !showConnection"
@@ -172,14 +167,20 @@
<span class="icon"><i class="fas fa-cog"/></span>
</button>
</div>
<div class="navbar-item">
<button class="button is-dark" @click="logout" v-tooltip="'Logout'">
<span class="icon"><i class="fas fa-sign-out"/></span>
</button>
</div>
</div>
</div>
</nav>
<div>
<div>
<no-api v-if="!hasAPISettings"/>
<Connection v-if="showConnection" @update="data => handleConnection(data)"/>
<NuxtPage v-if="!showConnection && hasAPISettings"/>
<NuxtPage v-if="!showConnection"/>
</div>
<div class="columns is-multiline is-mobile mt-3">
@@ -229,13 +230,13 @@ import request from '~/utils/request'
import Markdown from '~/components/Markdown'
import UserSelection from '~/components/UserSelection'
import Connection from '~/components/Connection'
import { useAuthStore } from '~/store/auth'
const selectedTheme = useStorage('theme', 'auto')
const showUserSelection = ref(false)
const showConnection = ref(false)
const api_url = useStorage('api_user', 'main')
const api_token = useStorage('api_token', '')
const auth = useAuthStore()
const bg_enable = useStorage('bg_enable', true)
const bg_opacity = useStorage('bg_opacity', 0.95)
const api_version = ref()
@@ -299,12 +300,6 @@ const applyPreferredColorScheme = scheme => {
onMounted(async () => {
try {
applyPreferredColorScheme(selectedTheme.value)
if ('' === api_token.value || '' === api_url.value) {
showConnection.value = true
return
}
await getVersion()
await loadImage()
} catch (e) {
@@ -336,8 +331,6 @@ const getVersion = async () => {
}
}
const hasAPISettings = computed(() => '' !== api_token.value && '' !== api_url.value)
const closeOverlay = () => loadFile.value = ''
const openMenu = e => {
@@ -366,7 +359,7 @@ const handleConnection = data => {
}
watch(bgImage, async v => {
if (false === hasAPISettings.value || false === bg_enable.value) {
if (false === bg_enable.value) {
return
}
@@ -392,12 +385,11 @@ watch(bgImage, async v => {
}, {immediate: true})
watch(bg_opacity, v => {
if (false === hasAPISettings.value || false === bg_enable.value) {
if (false === bg_enable.value) {
return
}
document.querySelector('body').setAttribute("style", `opacity: ${v}`)
})
watch(hasAPISettings, async () => await loadImage())
watch(breakpoints.active(), async () => await loadImage())
watch(bg_enable, async v => {
if (true === v) {
@@ -423,7 +415,7 @@ watch(bg_enable, async v => {
})
const loadImage = async () => {
if (!bg_enable.value || !hasAPISettings) {
if (!bg_enable.value) {
return
}
@@ -456,4 +448,15 @@ const loadImage = async () => {
}
}
const logout = async () => {
if (!confirm('Logout?')) {
return false
}
auth.logout()
await navigateTo('/auth')
}
</script>

View File

@@ -0,0 +1,9 @@
<template>
<NuxtPage />
</template>
<script setup>
import 'assets/css/bulma.css'
import 'assets/css/style.css'
import 'assets/css/all.css'
</script>

View File

@@ -0,0 +1,36 @@
import { storeToRefs } from 'pinia'
import { useAuthStore } from '~/store/auth'
import { useStorage } from '@vueuse/core'
let next_check = 0
export default defineNuxtRouteMiddleware(async to => {
if (to.fullPath.startsWith('/auth') || to.fullPath.startsWith('/v1/api')) {
return
}
const { authenticated } = storeToRefs(useAuthStore())
const token = useStorage('token', null)
if (token.value) {
if (Date.now() > next_check) {
console.debug('Validating user token...')
const { validate } = useAuthStore()
if (!await validate(token.value)) {
token.value = null
abortNavigation()
console.error('Token is invalid, redirecting to login page...')
return navigateTo('/auth')
}
console.debug('Token is valid.')
next_check = Date.now() + 1000 * 60 * 5
}
authenticated.value = true
}
if (!token.value && to?.name !== 'auth') {
abortNavigation()
return navigateTo('/auth')
}
})

View File

@@ -9,7 +9,7 @@ try {
extraNitro = {
devProxy: {
'/v1/api/': {
target: API_URL,
target: API_URL + '/v1/api/',
changeOrigin: true
}
}
@@ -20,23 +20,28 @@ try {
export default defineNuxtConfig({
ssr: false,
devtools: {enabled: true},
devtools: { enabled: true },
devServer: {
port: 8081,
host: "0.0.0.0",
},
runtimeConfig: {
public: {
domain: '/',
version: '1.0.0',
}
},
app: {
head: {
"meta": [
{"charset": "utf-8"},
{"name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0"},
{"name": "theme-color", "content": "#000000"}
{ "charset": "utf-8" },
{ "name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0" },
{ "name": "theme-color", "content": "#000000" }
],
},
buildAssetsDir: "assets",
pageTransition: {name: 'page', mode: 'out-in'}
pageTransition: { name: 'page', mode: 'out-in' }
},
router: {
@@ -48,6 +53,7 @@ export default defineNuxtConfig({
modules: [
'@vueuse/nuxt',
'floating-vue/nuxt',
'@pinia/nuxt',
],
nitro: {

View File

@@ -11,6 +11,8 @@
},
"web-types": "./web-types.json",
"dependencies": {
"@microsoft/fetch-event-source": "^2.0.1",
"@pinia/nuxt": "^0.11.0",
"@vueuse/core": "^13.1.0",
"@vueuse/nuxt": "^13.1.0",
"@xterm/addon-fit": "^0.10.0",
@@ -23,6 +25,7 @@
"marked-base-url": "^1.1.3",
"moment": "^2.30.1",
"nuxt": "^3.17.1",
"pinia": "^3.0.2",
"plyr": "^3.7.8",
"vue": "^3.4.21",
"vue-router": "^4.5.1",

144
frontend/pages/auth.vue Normal file
View File

@@ -0,0 +1,144 @@
<template>
<div>
<div class="hero is-dark is-fullheight">
<div class="hero-body">
<div class="container" style="background-color: unset !important;">
<div class="columns is-centered">
<div class="column is-6-tablet is-6-desktop is-4-widescreen">
<div class="box" v-if="error">
<span class="icon"><i class="fa fa-info" /></span>
<span class="has-text-danger">{{ error }}</span>
</div>
<form method="post" @submit.prevent="formValidate()" class="box">
<div class="field">
<label for="user-id" class="label">
{{ signup ? 'Create an account' : 'Login' }}
</label>
<div class="control has-icons-left">
<input id="user-id" type="text" placeholder="Username" class="input" required
autocomplete="username" name="username" v-model="user.username" autofocus>
<span class="icon is-left"><i class="fa fa-user" /></span>
</div>
</div>
<div class="field">
<label for="user-password" class="label">Password</label>
<div class="field-body">
<div class="field">
<div class="field has-addons">
<div class="control is-expanded has-icons-left">
<input class="input" id="user-password" v-model="user.password"
required placeholder="Password"
:type="false === form_expose ? 'password' : 'text'">
<span class="icon is-left"><i class="fa fa-lock" /></span>
</div>
<div class="control">
<button type="button" class="button is-primary"
@click="form_expose = !form_expose">
<span class="icon" v-if="!form_expose"><i
class="fas fa-eye" /></span>
<span class="icon" v-else><i class="fas fa-eye-slash" /></span>
</button>
</div>
</div>
</div>
</div>
</div>
<div class="field">
<button type="submit" class="button is-fullwidth is-dark is-light">
<span class="icon"><i class="fa fa-sign-in" /></span>
<span>
{{ signup ? 'Create an account' : 'Login' }}
</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useStorage } from '@vueuse/core'
import { useAuthStore } from '~/store/auth'
definePageMeta({ name: "auth", layout: 'guest' })
useHead({ title: 'Login / Signup' })
const error = ref('')
const form_expose = ref(false)
const signup = ref(false)
const token = useStorage('api_token', '')
const auth = useAuthStore()
const user = ref({ username: '', password: '' })
onMounted(async () => {
const req = await request('/system/auth/has_user')
if (204 === req.status) {
signup.value = true
}
});
const formValidate = async () => {
if (user.value.username.length < 1) {
error.value = 'Username is required.'
return false
}
if (user.value.password.length < 1) {
error.value = 'Password is required.'
return false
}
if (false === /^[a-z_0-9]+$/.test(user.value.username)) {
error.value = 'Username can only contain lowercase letters, numbers and underscores.'
return false
}
if (signup.value) {
return await do_signup()
}
return await do_login()
}
const do_login = async () => {
try {
await auth.login(user.value.username, user.value.password)
if (auth.authenticated) {
notification('success', 'Success', 'Login successful. Redirecting...')
return await navigateTo('/')
}
throw new Error('Login failed. Please check your username and password.')
}
catch (e) {
console.log(e)
error.value = e.message
return false
}
}
const do_signup = async () => {
try {
const state = await auth.signup(user.value.username, user.value.password)
if (false === state) {
error.value = 'Failed to create an account.'
return false
}
return await do_login()
}
catch (e) {
console.log(e)
error.value = e.message
return false
}
}
</script>

View File

@@ -3,7 +3,7 @@
<div class="columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon"><i class="fas fa-globe" :class="{'fa-spin': isLoading }"/>&nbsp;</span>
<span class="icon"><i class="fas fa-globe" :class="{ 'fa-spin': isLoading }" />&nbsp;</span>
<NuxtLink to="/logs">Logs</NuxtLink>
: {{ filename }}
</span>
@@ -12,46 +12,46 @@
<div class="field is-grouped">
<div class="control">
<button v-if="!autoScroll" @click="scrollToBottom" class="button is-primary"
v-tooltip.bottom="'Go to bottom'">
v-tooltip.bottom="'Go to bottom'">
<span class="icon"><i class="fas fa-arrow-down"></i></span>
</button>
</div>
<div class="control has-icons-left" v-if="toggleFilter">
<input type="search" v-model.lazy="query" class="input" id="filter" placeholder="Filter">
<span class="icon is-left"><i class="fas fa-filter"/></span>
<span class="icon is-left"><i class="fas fa-filter" /></span>
</div>
<div class="control">
<button class="button is-danger is-light" v-tooltip.bottom="'Filter log lines.'"
@click="toggleFilter = !toggleFilter">
<span class="icon"><i class="fas fa-filter"/></span>
@click="toggleFilter = !toggleFilter">
<span class="icon"><i class="fas fa-filter" /></span>
</button>
</div>
<p class="control">
<button class="button is-danger" v-tooltip.bottom="'Delete Logfile.'" @click="deleteFile">
<span class="icon"><i class="fas fa-trash"/></span>
<span class="icon"><i class="fas fa-trash" /></span>
</button>
</p>
<p class="control">
<button class="button is-purple is-light" v-tooltip.bottom="'Download the entire logfile.'"
@click="downloadFile" :class="{ 'is-loading': isDownloading }">
<span class="icon"><i class="fas fa-download"/></span>
@click="downloadFile" :class="{ 'is-loading': isDownloading }">
<span class="icon"><i class="fas fa-download" /></span>
</button>
</p>
<p class="control">
<button class="button is-warning" @click="wrapLines = !wrapLines" v-tooltip.bottom="'Toggle wrap line'">
<span class="icon"><i class="fas fa-text-width"/></span>
<span class="icon"><i class="fas fa-text-width" /></span>
</button>
</p>
<p class="control">
<button class="button" v-tooltip.bottom="'Copy showing logs'"
@click="() => copyText(filterItems.map(i => i.text).join('\n'))">
<span class="icon"><i class="fas fa-copy"/></span>
@click="() => copyText(filterItems.map(i => i.text).join('\n'))">
<span class="icon"><i class="fas fa-copy" /></span>
</button>
</p>
</div>
@@ -67,34 +67,35 @@
<div class="column is-12">
<div class="logbox is-grid" ref="logContainer" v-if="!error" @scroll.passive="handleScroll">
<code id="logView" class="p-1 logline is-block" :class="{ 'is-pre-wrap': wrapLines, 'is-pre': !wrapLines }">
<span class="is-block m-0 notification is-info is-dark has-text-centered" v-if="reachedEnd && !query">
<span class="notification-title">
<span class="icon"><i class="fas fa-exclamation-triangle"/></span>
No more logs available for this file.
</span>
</span>
<span v-for="item in filterItems" :key="item.id" class="is-block">
<span v-if="item.date">[<span class="has-tooltip" :title="item.date">{{ formatDate(item.date) }}</span>]:&nbsp;</span>
<span v-if="item?.item_id"><span class="is-clickable has-tooltip" @click="goto_history_item(item)"><span
class="icon"><i class="fas fa-history"/></span><span>View</span></span>&nbsp;</span>
<span>{{ item.text }}</span>
</span>
<span class="is-block" v-if="filterItems.length < 1">
<span class="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query">
<span class="notification-title is-danger">
<span class="icon"><i class="fas fa-filter"/></span>
No logs match this query: <u>{{ query }}</u>
</span>
</span>
<span v-else>
<span class="has-text-danger">No logs available</span></span>
</span>
</code>
<span class="is-block m-0 notification is-info is-dark has-text-centered" v-if="reachedEnd && !query">
<span class="notification-title">
<span class="icon"><i class="fas fa-exclamation-triangle" /></span>
No more logs available for this file.
</span>
</span>
<span v-for="item in filterItems" :key="item.id" class="is-block">
<span v-if="item.date">[<span class="has-tooltip" :title="item.date">{{ formatDate(item.date)
}}</span>]:&nbsp;</span>
<span v-if="item?.item_id"><span class="is-clickable has-tooltip" @click="goto_history_item(item)"><span
class="icon"><i class="fas fa-history" /></span><span>View</span></span>&nbsp;</span>
<span>{{ item.text }}</span>
</span>
<span class="is-block" v-if="filterItems.length < 1">
<span class="is-block m-0 notification is-warning is-dark has-text-centered" v-if="query">
<span class="notification-title is-danger">
<span class="icon"><i class="fas fa-filter" /></span>
No logs match this query: <u>{{ query }}</u>
</span>
</span>
<span v-else>
<span class="has-text-danger">No logs available</span></span>
</span>
</code>
<div ref="bottomMarker"></div>
</div>
<Message v-if="error" title="API Error" message_class="has-background-warning-90 has-text-dark" :message="error"
:use-close="true" @close="router.push('/logs')"/>
:use-close="true" @close="router.push('/logs')" />
</div>
</div>
</div>
@@ -107,11 +108,11 @@
max-width: 100%;
}
#logView > span:nth-child(even) {
#logView>span:nth-child(even) {
color: #ffc9d4;
}
#logView > span:nth-child(odd) {
#logView>span:nth-child(odd) {
color: #e3c981;
}
@@ -143,14 +144,15 @@ div.logbox pre {
<script setup>
import Message from '~/components/Message'
import moment from 'moment'
import {useStorage} from '@vueuse/core'
import {disableOpacity, enableOpacity, goto_history_item, notification, parse_api_response} from '~/utils/index'
import { useStorage } from '@vueuse/core'
import { disableOpacity, enableOpacity, goto_history_item, notification, parse_api_response } from '~/utils/index'
import request from '~/utils/request'
import { fetchEventSource } from '@microsoft/fetch-event-source';
const router = useRouter()
const filename = useRoute().params.filename
useHead({title: `Logs : ${filename}`})
useHead({ title: `Logs : ${filename}` })
const query = ref()
const data = ref([])
@@ -165,12 +167,7 @@ const reachedEnd = ref(false)
const offset = ref(0)
let scrollTimeout = null
const bg_enable = useStorage('bg_enable', true)
const bg_opacity = useStorage('bg_opacity', 0.95)
const api_path = useStorage('api_path', '/v1/api')
const api_url = useStorage('api_url', '')
const api_token = useStorage('api_token', '')
const token = useStorage('token', '')
watch(toggleFilter, async () => {
if (!toggleFilter.value) {
@@ -194,6 +191,8 @@ const logContainer = ref(null)
/** @type {Ref<HTMLPreElement|null>} */
const bottomMarker = ref(null)
const ctrl = new AbortController();
const loadContent = async () => {
try {
isLoading.value = true
@@ -234,7 +233,7 @@ const loadContent = async () => {
// Auto-scroll only if the user was already at the bottom
await nextTick(() => {
if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({behavior: 'auto'})
bottomMarker.value.scrollIntoView({ behavior: 'auto' })
}
})
@@ -274,7 +273,7 @@ const scrollToBottom = () => {
autoScroll.value = true
nextTick(() => {
if (bottomMarker.value) {
bottomMarker.value.scrollIntoView({behavior: 'smooth'})
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
}
})
}
@@ -298,32 +297,46 @@ const watchLog = () => {
}
// noinspection JSValidateTypes
stream.value = new EventSource(`${api_url.value}${api_path.value}/log/${filename}?stream=1&apikey=${api_token.value}`)
stream.value.addEventListener('data', async (e) => {
let lines = e.data.split(/\n/g);
for (let x = 0; x < lines.length; x++) {
try {
const line = String(lines[x])
if (!line.trim()) {
continue
}
data.value.push(JSON.parse(line))
await nextTick(() => {
if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({behavior: 'smooth'})
}
})
} catch (error) {
console.error(error)
stream.value = fetchEventSource(`/v1/api/log/${filename}?stream=1`, {
onmessage: async evt => {
if ('data' !== evt.event) {
return
}
}
});
let lines = evt.data.split(/\n/g);
for (let x = 0; x < lines.length; x++) {
try {
const line = String(lines[x])
if (!line.trim()) {
continue
}
data.value.push(JSON.parse(line))
await nextTick(() => {
if (autoScroll.value && bottomMarker.value) {
bottomMarker.value.scrollIntoView({ behavior: 'smooth' })
}
})
} catch (error) {
console.error(error)
}
}
},
headers: {
Authorization: `Token ${token.value}`,
},
signal: ctrl.signal,
})
}
const closeStream = () => {
if (stream.value) {
stream.value.close()
try {
stream.value.close()
ctrl.abort()
} catch (e) {
}
stream.value = null
}
}
@@ -362,7 +375,7 @@ const deleteFile = async () => {
try {
closeStream();
const response = await request(`/log/${filename}`, {method: 'DELETE'})
const response = await request(`/log/${filename}`, { method: 'DELETE' })
if (response.ok) {
notification('success', 'Information', `Logfile '${filename}' has been deleted.`)
@@ -377,7 +390,7 @@ const deleteFile = async () => {
json = await response.json()
} catch (e) {
json = {
error: {code: response.status, message: response.statusText}
error: { code: response.status, message: response.statusText }
}
}

91
frontend/store/auth.js Normal file
View File

@@ -0,0 +1,91 @@
import { defineStore } from 'pinia';
import { useStorage } from '@vueuse/core'
export const useAuthStore = defineStore('auth', () => {
const state = reactive({
token: null, authenticated: false, loading: false,
});
const actions = {
async signup(username, password) {
if (!username || !password) {
throw new Error('Please provide a valid username and password');
}
const req = await request('/system/auth/signup', {
method: 'POST',
body: JSON.stringify({ username: username, password: password })
})
if (201 === req.status) {
return true
}
const json = await parse_api_response(req)
throw new Error(json.error.message);
},
async login(username, password) {
if (!username || !password) {
throw new Error('Please provide a valid username and password');
}
this.loading = true;
try {
const response = await request(`/system/auth/login`, {
method: 'POST',
body: JSON.stringify({ username: username, password: password }),
});
const json = await parse_api_response(response)
if (200 !== response.status) {
throw new Error(json.error.message);
}
if (!json?.token) {
throw new Error('Error. API did not return a token.');
}
const token = useStorage('token', null);
token.value = json.token;
this.token = json.token;
this.authenticated = true;
} finally {
this.loading = false;
}
}, async logout() {
const token = useStorage('api_token', null);
this.authenticated = false;
token.value = null;
return true
}, async validate(token) {
try {
const response = await request('/system/auth/me', {
method: 'GET',
headers: {
Authorization: 'Token ' + token,
}
});
if (200 !== response.status) {
this.token = null;
this.authenticated = false;
return false;
}
this.token = token;
this.authenticated = true;
return true
} catch (e) {
this.token = null;
this.authenticated = false;
return false;
}
}
}
return { ...toRefs(state), ...actions };
});

View File

@@ -7,6 +7,7 @@
*/
class Cache {
supportedEngines = ['session', 'local']
namespace = ''
constructor(engine = 'session', namespace = '') {
if (!this.supportedEngines.includes(engine)) {

View File

@@ -1,8 +1,6 @@
import {useStorage} from "@vueuse/core";
import { useStorage } from "@vueuse/core";
const api_path = useStorage('api_path', '/v1/api')
const api_url = useStorage('api_url', '')
const api_token = useStorage('api_token', '')
const token = useStorage('token', '')
const api_user = useStorage('api_user', 'main')
/**
@@ -15,18 +13,18 @@ const api_user = useStorage('api_user', 'main')
* @returns {Promise<Response>}
*/
export default async function request(url, options = {}) {
if (!api_token.value) {
throw new Error('API token is not set');
}
options = options || {};
options.method = options.method || 'GET';
options.headers = options.headers || {};
if (options.headers['Authorization'] === undefined) {
options.headers['Authorization'] = 'Bearer ' + api_token.value;
if (options.headers['Authorization'] === undefined && token.value) {
options.headers['Authorization'] = 'Token ' + token.value;
}
if (options.headers['Content-Type'] === undefined) {
options.headers['Content-Type'] = 'application/json';
}
if (options.headers['Accept'] === undefined) {
options.headers['Accept'] = 'application/json';
}
@@ -35,6 +33,6 @@ export default async function request(url, options = {}) {
options.headers['X-User'] = api_user.value;
}
return fetch(`${api_url.value}${api_path.value}${url}`, options);
return fetch(`/v1/api${url}`, options);
}

View File

@@ -653,6 +653,11 @@
semver "^7.5.3"
tar "^7.4.0"
"@microsoft/fetch-event-source@^2.0.1":
version "2.0.1"
resolved "https://registry.yarnpkg.com/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz#9ceecc94b49fbaa15666e38ae8587f64acce007d"
integrity sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==
"@napi-rs/wasm-runtime@^0.2.9":
version "0.2.9"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.9.tgz#7278122cf94f3b36d8170a8eee7d85356dfa6a96"
@@ -909,6 +914,34 @@
unimport "^5.0.0"
untyped "^2.0.0"
"@nuxt/kit@^3.9.0":
version "3.17.3"
resolved "https://registry.yarnpkg.com/@nuxt/kit/-/kit-3.17.3.tgz#58a54b0155b1a3b277f434de024d7d5e738c38e2"
integrity sha512-aw6u6mT3TnM/MmcCRDMv3i9Sbm5/ZMSJgDl+N+WsrWNDIQ2sWmsqdDkjb/HyXF20SNwc2891hRBkaQr3hG2mhA==
dependencies:
c12 "^3.0.3"
consola "^3.4.2"
defu "^6.1.4"
destr "^2.0.5"
errx "^0.1.0"
exsolve "^1.0.5"
ignore "^7.0.4"
jiti "^2.4.2"
klona "^2.0.6"
knitwork "^1.2.0"
mlly "^1.7.4"
ohash "^2.0.11"
pathe "^2.0.3"
pkg-types "^2.1.0"
scule "^1.3.0"
semver "^7.7.1"
std-env "^3.9.0"
tinyglobby "^0.2.13"
ufo "^1.6.1"
unctx "^2.4.1"
unimport "^5.0.1"
untyped "^2.0.0"
"@nuxt/schema@3.17.1", "@nuxt/schema@^3.16.2":
version "3.17.1"
resolved "https://registry.yarnpkg.com/@nuxt/schema/-/schema-3.17.1.tgz#91a7a47adb82eb445e261e9602bd8de41074a91f"
@@ -1132,6 +1165,13 @@
"@parcel/watcher-win32-ia32" "2.5.1"
"@parcel/watcher-win32-x64" "2.5.1"
"@pinia/nuxt@^0.11.0":
version "0.11.0"
resolved "https://registry.yarnpkg.com/@pinia/nuxt/-/nuxt-0.11.0.tgz#f97d0c4cbaa3a8b6606eee562ec5c9098fcd1905"
integrity sha512-QGFlUAkeVAhPCTXacrtNP4ti24sGEleVzmxcTALY9IkS6U5OUox7vmNL1pkqBeW39oSNq/UC5m40ofDEPHB1fg==
dependencies:
"@nuxt/kit" "^3.9.0"
"@pkgjs/parseargs@^0.11.0":
version "0.11.0"
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
@@ -1579,6 +1619,13 @@
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz#cbe97fe0162b365edc1dba80e173f90492535343"
integrity sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==
"@vue/devtools-api@^7.7.2":
version "7.7.6"
resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-7.7.6.tgz#4af5dbc77bcc8543f0a8e6f029f598ed978d6c7d"
integrity sha512-b2Xx0KvXZObePpXPYHvBRRJLDQn5nhKjXh7vUhMEtWxz1AYNFOVIsh5+HLP8xDGL7sy+Q7hXeUxPHB/KgbtsPw==
dependencies:
"@vue/devtools-kit" "^7.7.6"
"@vue/devtools-core@^7.7.2":
version "7.7.6"
resolved "https://registry.yarnpkg.com/@vue/devtools-core/-/devtools-core-7.7.6.tgz#7e2ef93c05af809e5ed159ffc1b910f030976b83"
@@ -4750,6 +4797,13 @@ picomatch@^4.0.2:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab"
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
pinia@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/pinia/-/pinia-3.0.2.tgz#0616c2e1b39915f253c7626db3c81b7cdad695da"
integrity sha512-sH2JK3wNY809JOeiiURUR0wehJ9/gd9qFN2Y828jCbxEzKEmEt0pzCXwqiSTfuRsK9vQsOflSdnbdBOGrhtn+g==
dependencies:
"@vue/devtools-api" "^7.7.2"
pkg-types@^1.0.3, pkg-types@^1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
@@ -5953,6 +6007,26 @@ unimport@^5.0.0:
unplugin "^2.2.2"
unplugin-utils "^0.2.4"
unimport@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/unimport/-/unimport-5.0.1.tgz#c823ace5819fc810c25435450b22ebc4ab8b11f9"
integrity sha512-1YWzPj6wYhtwHE+9LxRlyqP4DiRrhGfJxdtH475im8ktyZXO3jHj/3PZ97zDdvkYoovFdi0K4SKl3a7l92v3sQ==
dependencies:
acorn "^8.14.1"
escape-string-regexp "^5.0.0"
estree-walker "^3.0.3"
local-pkg "^1.1.1"
magic-string "^0.30.17"
mlly "^1.7.4"
pathe "^2.0.3"
picomatch "^4.0.2"
pkg-types "^2.1.0"
scule "^1.3.0"
strip-literal "^3.0.0"
tinyglobby "^0.2.13"
unplugin "^2.3.2"
unplugin-utils "^0.2.4"
unixify@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unixify/-/unixify-1.0.0.tgz#3a641c8c2ffbce4da683a5c70f03a462940c2090"

View File

@@ -14,6 +14,7 @@ use App\Libs\TokenUtil;
use App\Libs\Traits\APITraits;
use Psr\Http\Message\ResponseInterface as iResponse;
use Psr\Http\Message\ServerRequestInterface as iRequest;
use Throwable;
final class Auth
{
@@ -30,6 +31,64 @@ final class Auth
return api_response(empty($user) || empty($password) ? Status::NO_CONTENT : Status::OK);
}
#[Get(self::URL . '/me[/]', name: 'system.auth.me')]
public function me(iRequest $request): iResponse
{
$user = Config::get('system.user');
$pass = Config::get('system.password');
if (empty($user) || empty($pass)) {
return api_error('System user or password is not configured.', Status::INTERNAL_SERVER_ERROR);
}
foreach ($request->getHeader('Authorization') as $auth) {
[$type, $value] = explode(' ', $auth, 2);
$type = strtolower(trim($type));
if (false === in_array($type, ['bearer', 'token'])) {
continue;
}
$tokens[$type] = trim($value);
}
if (empty($tokens['token'])) {
return api_error('This endpoint only works with user tokens.', Status::UNAUTHORIZED);
}
$token = rawurldecode($tokens['token']);
try {
$decoded = TokenUtil::decode($token);
if (false === $decoded) {
throw new \RuntimeException('Failed to decode token.');
}
} catch (Throwable) {
return api_error('Failed to decode token.', Status::UNAUTHORIZED);
}
$parts = explode('.', $decoded, 2);
if (2 !== count($parts)) {
return api_error('Invalid token.', Status::UNAUTHORIZED);
}
[$signature, $payload] = $parts;
if (false === TokenUtil::verify($payload, $signature)) {
return api_error('Invalid token.', Status::UNAUTHORIZED);
}
try {
$payload = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
return api_response(Status::OK, [
'username' => ag($payload, 'username', '??'),
'created_at' => makeDate(ag($payload, 'iat', 0)),
]);
} catch (Throwable) {
return api_error('Failed to decode payload.', Status::UNAUTHORIZED);
}
}
#[Post(self::URL . '/signup[/]', name: 'system.auth.signup')]
public function do_signup(iRequest $request): iResponse
{

View File

@@ -99,7 +99,7 @@ final class AuthorizationMiddleware implements MiddlewareInterface
*
* @return bool True if the tken is valid. False otherwise.
*/
private function validateToken(?string $token): bool
public static function validateToken(?string $token): bool
{
if (empty($token)) {
return false;

View File

@@ -20,7 +20,7 @@ final class ServeStatic implements LoggerAwareInterface
use LoggerAwareTrait;
private finfo|null $mimeType = null;
private const array CONTENT_TYPE = [
'css' => 'text/css; charset=utf-8',
'js' => 'text/javascript; charset=utf-8',