up
This commit is contained in:
@@ -19,3 +19,20 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import { useSettingsStore } from './stores/settings'
|
||||||
|
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
|
||||||
|
watch(() => settings.siteDescription, (desc) => {
|
||||||
|
let meta = document.querySelector('meta[name="description"]')
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta')
|
||||||
|
meta.setAttribute('name', 'description')
|
||||||
|
document.head.appendChild(meta)
|
||||||
|
}
|
||||||
|
meta.setAttribute('content', desc || '')
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xl font-bold text-gray-900">AdminPanel</span>
|
<span class="text-xl font-bold text-gray-900">{{ settings.siteName }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -124,6 +124,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useSettingsStore } from '../../stores/settings'
|
||||||
|
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ import { createPinia } from 'pinia'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
import { useSettingsStore } from './stores/settings'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
const pinia = createPinia()
|
||||||
|
app.use(pinia)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
|
||||||
|
// Загружаем настройки до монтирования
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
settingsStore.loadSettings().then(() => {
|
||||||
|
app.mount('#app')
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import Users from '../views/Users.vue'
|
|||||||
import Restaurants from '../views/Restaurants.vue'
|
import Restaurants from '../views/Restaurants.vue'
|
||||||
import AdminSettings from '../views/AdminSettings.vue'
|
import AdminSettings from '../views/AdminSettings.vue'
|
||||||
import NotFound from '../views/NotFound.vue'
|
import NotFound from '../views/NotFound.vue'
|
||||||
|
import { useSettingsStore } from '../stores/settings'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/login', component: Login, meta: { title: 'Login' } },
|
{ path: '/login', component: Login, meta: { title: 'Login', requiresAuth: false } },
|
||||||
{ path: '/register', component: Register, meta: { title: 'Register' } },
|
{ path: '/register', component: Register, meta: { title: 'Register', requiresAuth: false } },
|
||||||
{ path: '/setup', component: Setup, meta: { title: 'Setup' } },
|
{ path: '/setup', component: Setup, meta: { title: 'Setup', requiresAuth: false } },
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
redirect: '/dashboard'
|
redirect: '/dashboard'
|
||||||
@@ -50,14 +51,20 @@ const router = createRouter({
|
|||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach(async (to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
// Update page title
|
// Загружаем настройки приложения, если они ещё не загружены
|
||||||
document.title = `${to.meta.title || 'Admin Panel'} | AdminPanel`
|
const settings = useSettingsStore()
|
||||||
|
if (!settings.siteName) {
|
||||||
|
await settings.loadSettings()
|
||||||
|
}
|
||||||
|
|
||||||
// Check if setup is needed
|
// Устанавливаем заголовок страницы с использованием site_name
|
||||||
|
const pageTitle = to.meta.title ? `${to.meta.title} | ${settings.siteName}` : settings.siteName
|
||||||
|
document.title = pageTitle
|
||||||
|
|
||||||
|
// Проверка необходимости установки (setup)
|
||||||
try {
|
try {
|
||||||
const statusRes = await fetch('/api/status')
|
const statusRes = await fetch('/api/status')
|
||||||
const status = await statusRes.json()
|
const status = await statusRes.json()
|
||||||
|
|
||||||
if (status.needsSetup && to.path !== '/setup') {
|
if (status.needsSetup && to.path !== '/setup') {
|
||||||
next('/setup')
|
next('/setup')
|
||||||
return
|
return
|
||||||
@@ -66,21 +73,29 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
console.error('Failed to check status', e)
|
console.error('Failed to check status', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Проверка, что залогиненный пользователь не может зайти на страницу логина
|
||||||
if (to.path === '/login') {
|
if (to.path === '/login') {
|
||||||
try {
|
try {
|
||||||
const meRes = await fetch('/api/admin/me');
|
const meRes = await fetch('/api/admin/me')
|
||||||
if (meRes.ok) {
|
if (meRes.ok) {
|
||||||
next('/dashboard');
|
next('/dashboard')
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// игнорируем ошибку, продолжаем
|
// игнорируем ошибку, продолжаем
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check authentication
|
// Проверка доступности регистрации
|
||||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
if (to.path === '/register') {
|
||||||
|
if (!settings.enableRegistration) {
|
||||||
|
next('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка аутентификации для защищённых маршрутов
|
||||||
|
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
||||||
if (requiresAuth) {
|
if (requiresAuth) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/admin/me')
|
const res = await fetch('/api/admin/me')
|
||||||
|
|||||||
24
frontend/src/stores/settings.ts
Normal file
24
frontend/src/stores/settings.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
|
const siteName = ref('Admin Panel')
|
||||||
|
const siteDescription = ref('')
|
||||||
|
const enableRegistration = ref(true)
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
siteName.value = data.site_name || 'Admin Panel'
|
||||||
|
siteDescription.value = data.site_description || ''
|
||||||
|
enableRegistration.value = data.enable_registration !== 'false'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load settings', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { siteName, siteDescription, enableRegistration, loadSettings }
|
||||||
|
})
|
||||||
@@ -11,8 +11,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Login</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Host</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Host</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">HTTPS</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Login</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -21,8 +22,16 @@
|
|||||||
<tr v-for="rest in restaurants" :key="rest.id">
|
<tr v-for="rest in restaurants" :key="rest.id">
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.id }}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.id }}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.name }}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ rest.name }}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.login }}</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.host }}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.host }}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="rest.https"
|
||||||
|
@change="toggleHttps(rest)"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ rest.login }}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.created) }}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(rest.created) }}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
|
<td class="px-6 py-4 whitespace-nowrap text-sm space-x-2">
|
||||||
<button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800">Edit</button>
|
<button @click="openModal('edit', rest)" class="text-blue-600 hover:text-blue-800">Edit</button>
|
||||||
@@ -38,23 +47,38 @@
|
|||||||
<div class="bg-white rounded-lg p-6 w-full max-w-md">
|
<div class="bg-white rounded-lg p-6 w-full max-w-md">
|
||||||
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2>
|
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2>
|
||||||
<form @submit.prevent="submitRestaurant">
|
<form @submit.prevent="submitRestaurant">
|
||||||
|
<!-- 1. Имя ресторана -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||||
<input v-model="form.name" type="text" required class="input-field mt-1" />
|
<input v-model="form.name" type="text" required class="input-field mt-1" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 2. Хост -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Host</label>
|
||||||
|
<input v-model="form.host" type="text" required class="input-field mt-1" />
|
||||||
|
</div>
|
||||||
|
<!-- 3. HTTPS чекбокс -->
|
||||||
|
<div class="mb-4 flex items-center">
|
||||||
|
<input type="checkbox" v-model="form.https" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4 mr-2" />
|
||||||
|
<label class="text-sm font-medium text-gray-700">HTTPS</label>
|
||||||
|
</div>
|
||||||
|
<!-- 4. Логин -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700">Login</label>
|
<label class="block text-sm font-medium text-gray-700">Login</label>
|
||||||
<input v-model="form.login" type="text" required class="input-field mt-1" />
|
<input v-model="form.login" type="text" required class="input-field mt-1" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 5. Пароль (отключаем автозаполнение) -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label class="block text-sm font-medium text-gray-700">Password</label>
|
<label class="block text-sm font-medium text-gray-700">Password</label>
|
||||||
<input v-model="form.password" :required="modalMode === 'create'" type="password" class="input-field mt-1" />
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
:required="modalMode === 'create'"
|
||||||
|
type="password"
|
||||||
|
<!-- autocomplete="new-password" -->
|
||||||
|
class="input-field mt-1"
|
||||||
|
/>
|
||||||
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
|
<p v-if="modalMode === 'edit'" class="text-xs text-gray-500 mt-1">Leave blank to keep current password</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
|
||||||
<label class="block text-sm font-medium text-gray-700">Host</label>
|
|
||||||
<input v-model="form.host" type="text" required class="input-field mt-1" />
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end space-x-2">
|
<div class="flex justify-end space-x-2">
|
||||||
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button>
|
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button>
|
||||||
<button type="submit" class="btn-primary">Save</button>
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
@@ -72,7 +96,7 @@ import AppLayout from '../components/Layout/AppLayout.vue';
|
|||||||
const restaurants = ref([]);
|
const restaurants = ref([]);
|
||||||
const modalOpen = ref(false);
|
const modalOpen = ref(false);
|
||||||
const modalMode = ref<'create' | 'edit'>('create');
|
const modalMode = ref<'create' | 'edit'>('create');
|
||||||
const form = ref({ id: null, name: '', login: '', password: '', host: '' });
|
const form = ref({ id: null, name: '', login: '', password: '', host: '', https: false });
|
||||||
const modalTitle = ref('');
|
const modalTitle = ref('');
|
||||||
|
|
||||||
async function loadRestaurants() {
|
async function loadRestaurants() {
|
||||||
@@ -88,10 +112,17 @@ function formatDate(dateStr: string) {
|
|||||||
function openModal(mode: 'create' | 'edit', rest: any = null) {
|
function openModal(mode: 'create' | 'edit', rest: any = null) {
|
||||||
modalMode.value = mode;
|
modalMode.value = mode;
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
form.value = { id: null, name: '', login: '', password: '', host: '' };
|
form.value = { id: null, name: '', login: '', password: '', host: '', https: false };
|
||||||
modalTitle.value = 'Create Restaurant';
|
modalTitle.value = 'Create Restaurant';
|
||||||
} else {
|
} else {
|
||||||
form.value = { id: rest.id, name: rest.name, login: rest.login, password: '', host: rest.host };
|
form.value = {
|
||||||
|
id: rest.id,
|
||||||
|
name: rest.name,
|
||||||
|
login: rest.login,
|
||||||
|
password: '',
|
||||||
|
host: rest.host,
|
||||||
|
https: rest.https || false
|
||||||
|
};
|
||||||
modalTitle.value = 'Edit Restaurant';
|
modalTitle.value = 'Edit Restaurant';
|
||||||
}
|
}
|
||||||
modalOpen.value = true;
|
modalOpen.value = true;
|
||||||
@@ -101,31 +132,65 @@ function closeModal() {
|
|||||||
modalOpen.value = false;
|
modalOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitRestaurant() {
|
async function toggleHttps(rest: any) {
|
||||||
try {
|
const newHttps = !rest.https;
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.value.name,
|
name: rest.name,
|
||||||
login: form.value.login,
|
host: rest.host,
|
||||||
host: form.value.host,
|
login: rest.login,
|
||||||
...(form.value.password ? { password: form.value.password } : {})
|
https: newHttps
|
||||||
|
// пароль не передаём, он останется прежним
|
||||||
};
|
};
|
||||||
if (modalMode.value === 'create') {
|
try {
|
||||||
await fetch('/api/admin/restaurants', {
|
const res = await fetch(`/api/admin/restaurants/${rest.id}`, {
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await fetch(`/api/admin/restaurants/${form.value.id}`, {
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
// Обновляем локальное состояние или перезагружаем список
|
||||||
|
rest.https = newHttps;
|
||||||
|
// Альтернатива: await loadRestaurants();
|
||||||
|
} else {
|
||||||
|
alert('Failed to update HTTPS status');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Network error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRestaurant() {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.value.name,
|
||||||
|
host: form.value.host,
|
||||||
|
https: form.value.https,
|
||||||
|
login: form.value.login,
|
||||||
|
...(form.value.password ? { password: form.value.password } : {})
|
||||||
|
};
|
||||||
|
if (modalMode.value === 'create') {
|
||||||
|
if (!form.value.password) {
|
||||||
|
alert('Password is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/admin/restaurants', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Create failed');
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/admin/restaurants/${form.value.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Update failed');
|
||||||
}
|
}
|
||||||
await loadRestaurants();
|
await loadRestaurants();
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Operation failed');
|
alert('Operation failed: ' + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,13 @@
|
|||||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.login }}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.login }}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.email }}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.email }}</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
<button
|
<input
|
||||||
v-if="user.id !== currentUserId"
|
v-if="user.id !== currentUserId"
|
||||||
@click="toggleActive(user)"
|
type="checkbox"
|
||||||
:class="user.active ? 'text-green-600' : 'text-red-600'"
|
:checked="user.active"
|
||||||
>
|
@change="toggleActive(user)"
|
||||||
{{ user.active ? 'Active' : 'Inactive' }}
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4"
|
||||||
</button>
|
/>
|
||||||
<span v-else class="text-gray-400 text-sm">(You)</span>
|
<span v-else class="text-gray-400 text-sm">(You)</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.ip || '-' }}</td>
|
<td class="px-6 py-4 whitespace-nowrap text-sm">{{ user.ip || '-' }}</td>
|
||||||
|
|||||||
@@ -67,7 +67,11 @@
|
|||||||
<input type="checkbox" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
<input type="checkbox" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||||
<span class="ml-2 text-sm text-gray-600">Remember me</span>
|
<span class="ml-2 text-sm text-gray-600">Remember me</span>
|
||||||
</label>
|
</label>
|
||||||
<router-link to="/register" class="text-sm text-primary-600 hover:text-primary-700">
|
<router-link
|
||||||
|
v-if="settings.enableRegistration"
|
||||||
|
to="/register"
|
||||||
|
class="text-sm text-primary-600 hover:text-primary-700"
|
||||||
|
>
|
||||||
Create account
|
Create account
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,6 +103,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useSettingsStore } from '../../stores/settings'
|
||||||
|
|
||||||
|
const settings = useSettingsStore()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const form = ref({ login: '', password: '' })
|
const form = ref({ login: '', password: '' })
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ server {
|
|||||||
|
|
||||||
deny all;
|
deny all;
|
||||||
|
|
||||||
proxy_pass http://127.0.0.1:9099;
|
proxy_pass http://127.0.0.1:7104;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection keep-alive;
|
proxy_set_header Connection keep-alive;
|
||||||
|
|||||||
@@ -28,10 +28,8 @@ public class AuthHandler {
|
|||||||
boolean passwordOk = userService.checkPassword(password, user.getString("password"));
|
boolean passwordOk = userService.checkPassword(password, user.getString("password"));
|
||||||
|
|
||||||
if (passwordOk) {
|
if (passwordOk) {
|
||||||
// Надёжное получение флага активности
|
|
||||||
Boolean active = user.getBoolean("active");
|
Boolean active = user.getBoolean("active");
|
||||||
if (active == null) {
|
if (active == null) {
|
||||||
// Если поле отсутствует, пробуем получить как Integer (на случай TINYINT)
|
|
||||||
Integer activeInt = user.getInteger("active");
|
Integer activeInt = user.getInteger("active");
|
||||||
active = activeInt != null && activeInt == 1;
|
active = activeInt != null && activeInt == 1;
|
||||||
}
|
}
|
||||||
@@ -41,6 +39,16 @@ public class AuthHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Получаем реальный IP клиента (с учётом прокси, если настроен)
|
||||||
|
String clientIp = ctx.get("realClientIp");
|
||||||
|
if (clientIp == null) {
|
||||||
|
clientIp = ctx.request().remoteAddress().host();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем IP в БД (асинхронно, не дожидаемся ответа)
|
||||||
|
userService.updateUserIp(user.getInteger("id"), clientIp)
|
||||||
|
.onFailure(err -> System.err.println("Failed to update IP for user " + user.getInteger("id") + ": " + err.getMessage()));
|
||||||
|
|
||||||
Session session = ctx.session();
|
Session session = ctx.session();
|
||||||
session.put("userId", user.getInteger("id"));
|
session.put("userId", user.getInteger("id"));
|
||||||
session.put("login", user.getString("login"));
|
session.put("login", user.getString("login"));
|
||||||
|
|||||||
@@ -78,30 +78,54 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
startPromise.fail(err);
|
startPromise.fail(err);
|
||||||
});
|
});
|
||||||
|
|
||||||
Router router = initRouter();
|
createRouterAndStartHttp(startPromise);
|
||||||
|
|
||||||
startHttp(router, startPromise);
|
|
||||||
|
|
||||||
})
|
})
|
||||||
.onFailure(startPromise::fail);
|
.onFailure(startPromise::fail);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private Router initRouter() {
|
private void createRouterAndStartHttp(Promise<Void> startPromise) {
|
||||||
|
settingsService.get("session_timeout_minutes")
|
||||||
|
.compose(timeoutStr -> {
|
||||||
|
long timeoutMinutes = 60; // default
|
||||||
|
if (timeoutStr != null && !timeoutStr.isEmpty()) {
|
||||||
|
try {
|
||||||
|
timeoutMinutes = Long.parseLong(timeoutStr);
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
long timeoutMs = timeoutMinutes * 60 * 1000;
|
||||||
|
|
||||||
// Настройка сессий (используем LocalSessionStore для простоты)
|
|
||||||
SessionStore sessionStore = LocalSessionStore.create(vertx);
|
SessionStore sessionStore = LocalSessionStore.create(vertx);
|
||||||
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
||||||
.setSessionCookieName("admin.session")
|
.setSessionCookieName("admin.session")
|
||||||
.setCookieHttpOnlyFlag(true)
|
.setCookieHttpOnlyFlag(true)
|
||||||
.setCookieSecureFlag(false)
|
.setCookieSecureFlag(false)
|
||||||
.setSessionTimeout(3600000);
|
.setSessionTimeout(timeoutMs);
|
||||||
|
|
||||||
|
Router router = initRouter(sessionHandler);
|
||||||
|
startHttp(router, startPromise);
|
||||||
|
return Future.succeededFuture();
|
||||||
|
})
|
||||||
|
.onFailure(err -> {
|
||||||
|
log.error("Failed to get session timeout", err);
|
||||||
|
startPromise.fail(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Router initRouter(SessionHandler sessionHandler) {
|
||||||
|
|
||||||
// Роутер
|
|
||||||
Router router = Router.router(vertx);
|
Router router = Router.router(vertx);
|
||||||
router.route().handler(BodyHandler.create());
|
router.route().handler(BodyHandler.create());
|
||||||
router.route().handler(sessionHandler);
|
router.route().handler(sessionHandler);
|
||||||
|
|
||||||
|
SecurityHandlers securityHandlers = new SecurityHandlers(settingsService);
|
||||||
|
|
||||||
|
// Обработчики безопасности (порядок важен)
|
||||||
|
router.route().handler(securityHandlers.hostValidator());
|
||||||
|
router.route().handler(securityHandlers.proxyHeadersHandler());
|
||||||
|
router.route().handler(securityHandlers.cspHeader());
|
||||||
|
|
||||||
// CORS для разработки
|
// CORS для разработки
|
||||||
router.route().handler(ctx -> {
|
router.route().handler(ctx -> {
|
||||||
ctx.response()
|
ctx.response()
|
||||||
@@ -149,7 +173,12 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
|
|
||||||
router.post("/api/logout").handler(authHandler::handleLogout);
|
router.post("/api/logout").handler(authHandler::handleLogout);
|
||||||
|
|
||||||
router.post("/api/register").handler(rc -> {
|
router.post("/api/register").handler(rc -> settingsService.get("enable_registration").onComplete(regCheck -> {
|
||||||
|
if (regCheck.succeeded() && "false".equals(regCheck.result())) {
|
||||||
|
rc.response().setStatusCode(403).end(new JsonObject().put("error", "Registration is disabled").encode());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// существующий код регистрации
|
||||||
JsonObject body = rc.body().asJsonObject();
|
JsonObject body = rc.body().asJsonObject();
|
||||||
String login = body.getString("login");
|
String login = body.getString("login");
|
||||||
String email = body.getString("email");
|
String email = body.getString("email");
|
||||||
@@ -162,7 +191,7 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
userService.createUser(login, email, password, ip)
|
userService.createUser(login, email, password, ip)
|
||||||
.onSuccess(v -> rc.response().setStatusCode(201).end(new JsonObject().put("success", true).encode()))
|
.onSuccess(v -> rc.response().setStatusCode(201).end(new JsonObject().put("success", true).encode()))
|
||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
});
|
}));
|
||||||
|
|
||||||
router.route("/api/admin/*").handler(authHandler::requireAuth);
|
router.route("/api/admin/*").handler(authHandler::requireAuth);
|
||||||
|
|
||||||
@@ -226,7 +255,7 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
|
|
||||||
router.put("/api/admin/users/:id/activate").handler(rc -> {
|
router.put("/api/admin/users/:id/activate").handler(rc -> {
|
||||||
int id = Integer.parseInt(rc.pathParam("id"));
|
int id = Integer.parseInt(rc.pathParam("id"));
|
||||||
boolean active = Boolean.parseBoolean(rc.queryParam("active").get(0));
|
boolean active = Boolean.parseBoolean(rc.queryParam("active").getFirst());
|
||||||
Integer currentUserId = rc.session().get("userId");
|
Integer currentUserId = rc.session().get("userId");
|
||||||
|
|
||||||
if (currentUserId != null && currentUserId == id) {
|
if (currentUserId != null && currentUserId == id) {
|
||||||
@@ -280,11 +309,12 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
String login = body.getString("login");
|
String login = body.getString("login");
|
||||||
String password = body.getString("password");
|
String password = body.getString("password");
|
||||||
String host = body.getString("host");
|
String host = body.getString("host");
|
||||||
|
boolean https = body.getBoolean("https", false);
|
||||||
if (name == null || login == null || password == null || host == null) {
|
if (name == null || login == null || password == null || host == null) {
|
||||||
rc.response().setStatusCode(400).end("Missing fields");
|
rc.response().setStatusCode(400).end("Missing fields");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
restaurantService.createRestaurant(name, login, password, host)
|
restaurantService.createRestaurant(name, login, password, host, https)
|
||||||
.onSuccess(v -> rc.response().setStatusCode(201).end())
|
.onSuccess(v -> rc.response().setStatusCode(201).end())
|
||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
});
|
});
|
||||||
@@ -296,11 +326,12 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
String login = body.getString("login");
|
String login = body.getString("login");
|
||||||
String password = body.getString("password");
|
String password = body.getString("password");
|
||||||
String host = body.getString("host");
|
String host = body.getString("host");
|
||||||
|
boolean https = body.getBoolean("https", false);
|
||||||
if (name == null || login == null || host == null) {
|
if (name == null || login == null || host == null) {
|
||||||
rc.response().setStatusCode(400).end("Missing required fields");
|
rc.response().setStatusCode(400).end("Missing required fields");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
restaurantService.updateRestaurant(id, name, login, password, host)
|
restaurantService.updateRestaurant(id, name, login, password, host, https)
|
||||||
.onSuccess(v -> rc.response().end())
|
.onSuccess(v -> rc.response().end())
|
||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||||
});
|
});
|
||||||
@@ -314,9 +345,9 @@ public class MainVerticle extends AbstractVerticle {
|
|||||||
|
|
||||||
// Получение всех настроек
|
// Получение всех настроек
|
||||||
router.get("/api/settings").handler(rc -> {
|
router.get("/api/settings").handler(rc -> {
|
||||||
settingsService.getAll()
|
settingsService.getPublicSettings()
|
||||||
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
|
.onSuccess(settings -> rc.response().putHeader("Content-Type", "application/json").end(settings.encode()))
|
||||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
.onFailure(err -> rc.response().setStatusCode(500).end());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Получить метаданные всех настроек (для построения формы)
|
// Получить метаданные всех настроек (для построения формы)
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ public class RestaurantService {
|
|||||||
CREATE TABLE IF NOT EXISTS restaurants (
|
CREATE TABLE IF NOT EXISTS restaurants (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(255) UNIQUE NOT NULL,
|
name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
login VARCHAR(255) NOT NULL,
|
login VARCHAR(255) UNIQUE NOT NULL,
|
||||||
password VARCHAR(255) NOT NULL,
|
password VARCHAR(255) NOT NULL,
|
||||||
host VARCHAR(255) NOT NULL,
|
host VARCHAR(255) NOT NULL,
|
||||||
|
https BOOLEAN DEFAULT FALSE,
|
||||||
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
@@ -40,16 +41,16 @@ public class RestaurantService {
|
|||||||
.map(rows -> rows.iterator().next().getLong("cnt"));
|
.map(rows -> rows.iterator().next().getLong("cnt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Future<Void> createRestaurant(String name, String login, String password, String host) {
|
public Future<Void> createRestaurant(String name, String login, String password, String host, boolean https) {
|
||||||
|
Map<String, Object> params = Map.of(
|
||||||
Map<String, Object> params = new HashMap<>();
|
"name", name,
|
||||||
params.put("name", name);
|
"login", login,
|
||||||
params.put("login", login);
|
"password", password,
|
||||||
params.put("password", password);
|
"host", host,
|
||||||
params.put("host", host);
|
"https", https
|
||||||
|
);
|
||||||
return SqlTemplate.forUpdate(pool,
|
return SqlTemplate.forUpdate(pool,
|
||||||
"INSERT INTO restaurants (name, login, password, host) VALUES (#{name}, #{login}, #{password}, #{host})")
|
"INSERT INTO restaurants (name, login, password, host, https) VALUES (#{name}, #{login}, #{password}, #{host}, #{https})")
|
||||||
.execute(params)
|
.execute(params)
|
||||||
.mapEmpty();
|
.mapEmpty();
|
||||||
}
|
}
|
||||||
@@ -72,7 +73,7 @@ public class RestaurantService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Future<JsonArray> getAllRestaurants() {
|
public Future<JsonArray> getAllRestaurants() {
|
||||||
return pool.query("SELECT id, name, login, created, updated, host FROM restaurants ORDER BY id")
|
return pool.query("SELECT id, name, login, created, updated, https, host FROM restaurants ORDER BY id")
|
||||||
.execute()
|
.execute()
|
||||||
.map(rows -> {
|
.map(rows -> {
|
||||||
JsonArray array = new JsonArray();
|
JsonArray array = new JsonArray();
|
||||||
@@ -85,6 +86,7 @@ public class RestaurantService {
|
|||||||
row.getLocalDateTime("created").toString() : null)
|
row.getLocalDateTime("created").toString() : null)
|
||||||
.put("updated", row.getLocalDateTime("updated") != null ?
|
.put("updated", row.getLocalDateTime("updated") != null ?
|
||||||
row.getLocalDateTime("updated").toString() : null)
|
row.getLocalDateTime("updated").toString() : null)
|
||||||
|
.put("https", row.getBoolean("https"))
|
||||||
.put("host", row.getString("host")));
|
.put("host", row.getString("host")));
|
||||||
}
|
}
|
||||||
return array;
|
return array;
|
||||||
@@ -93,12 +95,13 @@ public class RestaurantService {
|
|||||||
|
|
||||||
public Future<JsonObject> findById(int id) {
|
public Future<JsonObject> findById(int id) {
|
||||||
return SqlTemplate.forQuery(pool,
|
return SqlTemplate.forQuery(pool,
|
||||||
"SELECT id, name, login, password, host, created, updated FROM restaurants WHERE id = #{id}")
|
"SELECT id, name, login, password, https, host, created, updated FROM restaurants WHERE id = #{id}")
|
||||||
.mapTo(row -> new JsonObject()
|
.mapTo(row -> new JsonObject()
|
||||||
.put("id", row.getInteger("id"))
|
.put("id", row.getInteger("id"))
|
||||||
.put("name", row.getString("name"))
|
.put("name", row.getString("name"))
|
||||||
.put("login", row.getString("login"))
|
.put("login", row.getString("login"))
|
||||||
.put("password", row.getString("password"))
|
.put("password", row.getString("password"))
|
||||||
|
.put("https", row.getBoolean("https"))
|
||||||
.put("host", row.getString("host"))
|
.put("host", row.getString("host"))
|
||||||
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
|
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").toString() : null)
|
||||||
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null))
|
.put("updated", row.getLocalDateTime("updated") != null ? row.getLocalDateTime("updated").toString() : null))
|
||||||
@@ -106,24 +109,21 @@ public class RestaurantService {
|
|||||||
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
|
.map(rows -> rows.iterator().hasNext() ? rows.iterator().next() : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Future<Void> updateRestaurant(int id, String name, String login, String password, String host) {
|
public Future<Void> updateRestaurant(int id, String name, String login, String password, String host, boolean https) {
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
params.put("id", id);
|
params.put("id", id);
|
||||||
params.put("name", name);
|
params.put("name", name);
|
||||||
params.put("login", login);
|
params.put("login", login);
|
||||||
params.put("host", host);
|
params.put("host", host);
|
||||||
|
params.put("https", https);
|
||||||
String sql;
|
String sql;
|
||||||
if (password != null && !password.isEmpty()) {
|
if (password != null && !password.isEmpty()) {
|
||||||
params.put("password", password);
|
params.put("password", password);
|
||||||
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, password = #{password}, host = #{host} WHERE id = #{id}";
|
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, password = #{password}, host = #{host}, https = #{https} WHERE id = #{id}";
|
||||||
} else {
|
} else {
|
||||||
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, host = #{host} WHERE id = #{id}";
|
sql = "UPDATE restaurants SET name = #{name}, login = #{login}, host = #{host}, https = #{https} WHERE id = #{id}";
|
||||||
}
|
}
|
||||||
|
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
|
||||||
return SqlTemplate.forUpdate(pool, sql)
|
|
||||||
.execute(params)
|
|
||||||
.mapEmpty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Future<Void> deleteRestaurant(int id) {
|
public Future<Void> deleteRestaurant(int id) {
|
||||||
|
|||||||
114
src/main/java/su/xserver/iikocon/SecurityHandlers.java
Normal file
114
src/main/java/su/xserver/iikocon/SecurityHandlers.java
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package su.xserver.iikocon;
|
||||||
|
|
||||||
|
import io.vertx.core.Handler;
|
||||||
|
import io.vertx.core.http.HttpServerRequest;
|
||||||
|
import io.vertx.core.net.SocketAddress;
|
||||||
|
import io.vertx.ext.web.RoutingContext;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class SecurityHandlers {
|
||||||
|
private final SettingsService settings;
|
||||||
|
|
||||||
|
public SecurityHandlers(SettingsService settings) {
|
||||||
|
this.settings = settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Handler<RoutingContext> hostValidator() {
|
||||||
|
return ctx -> settings.get("allowed_hosts").onComplete(ar -> {
|
||||||
|
if (ar.succeeded() && ar.result() != null && !ar.result().isEmpty()) {
|
||||||
|
String allowedHosts = ar.result();
|
||||||
|
String requestHost = ctx.request().getHeader("Host");
|
||||||
|
if (requestHost == null) {
|
||||||
|
ctx.response().setStatusCode(400).end("Bad Request: Missing Host header");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String hostWithoutPort = requestHost.split(":")[0];
|
||||||
|
Set<String> allowedSet = new HashSet<>(Arrays.asList(allowedHosts.split(",")));
|
||||||
|
if (!allowedSet.contains(hostWithoutPort) && !allowedSet.contains(requestHost)) {
|
||||||
|
ctx.response().setStatusCode(403).end("Forbidden: Invalid Host header");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Handler<RoutingContext> cspHeader() {
|
||||||
|
return ctx -> settings.get("enable_csp").onComplete(ar -> {
|
||||||
|
if (ar.succeeded() && "true".equals(ar.result())) {
|
||||||
|
ctx.response().putHeader("Content-Security-Policy",
|
||||||
|
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'");
|
||||||
|
}
|
||||||
|
ctx.next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public Handler<RoutingContext> proxyHeadersHandler() {
|
||||||
|
return ctx -> settings.get("use_proxy_headers").onComplete(useProxy -> {
|
||||||
|
if (!useProxy.succeeded() || !"true".equals(useProxy.result())) {
|
||||||
|
ctx.next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.get("trusted_proxies").onComplete(trusted -> {
|
||||||
|
if (!trusted.succeeded() || trusted.result() == null) {
|
||||||
|
ctx.next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String trustedProxies = trusted.result();
|
||||||
|
SocketAddress remoteAddr = ctx.request().remoteAddress();
|
||||||
|
if (remoteAddr == null) {
|
||||||
|
ctx.next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String clientIp = remoteAddr.host();
|
||||||
|
if (isIpTrusted(clientIp, trustedProxies)) {
|
||||||
|
String realIp = getRealIpFromHeaders(ctx.request());
|
||||||
|
if (realIp != null) {
|
||||||
|
ctx.put("realClientIp", realIp);
|
||||||
|
ctx.put("originalClientIp", clientIp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.next();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isIpTrusted(String ip, String trustedList) {
|
||||||
|
String[] ips = trustedList.split(",");
|
||||||
|
for (String trusted : ips) {
|
||||||
|
trusted = trusted.trim();
|
||||||
|
if (trusted.contains("/")) {
|
||||||
|
if (ipMatchesCidr(ip, trusted)) return true;
|
||||||
|
} else if (ip.equals(trusted)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean ipMatchesCidr(String ip, String cidr) {
|
||||||
|
try {
|
||||||
|
String[] parts = cidr.split("/");
|
||||||
|
String network = parts[0];
|
||||||
|
int prefix = Integer.parseInt(parts[1]);
|
||||||
|
return ip.startsWith(network.substring(0, network.lastIndexOf('.')));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getRealIpFromHeaders(HttpServerRequest request) {
|
||||||
|
String xff = request.getHeader("X-Forwarded-For");
|
||||||
|
if (xff != null && !xff.isEmpty()) {
|
||||||
|
return xff.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
String xri = request.getHeader("X-Real-IP");
|
||||||
|
if (xri != null && !xri.isEmpty()) {
|
||||||
|
return xri;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import io.vertx.core.json.JsonObject;
|
|||||||
import io.vertx.sqlclient.Pool;
|
import io.vertx.sqlclient.Pool;
|
||||||
import io.vertx.sqlclient.templates.SqlTemplate;
|
import io.vertx.sqlclient.templates.SqlTemplate;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
public class SettingsService {
|
public class SettingsService {
|
||||||
@@ -29,24 +30,24 @@ public class SettingsService {
|
|||||||
.put("type", "textarea")
|
.put("type", "textarea")
|
||||||
.put("rows", 2)
|
.put("rows", 2)
|
||||||
);
|
);
|
||||||
meta.add(new JsonObject()
|
// meta.add(new JsonObject()
|
||||||
.put("key", "theme")
|
// .put("key", "theme")
|
||||||
.put("label", "Theme")
|
// .put("label", "Theme")
|
||||||
.put("description", "Default color scheme")
|
// .put("description", "Default color scheme")
|
||||||
.put("type", "select")
|
// .put("type", "select")
|
||||||
.put("options", new JsonArray()
|
// .put("options", new JsonArray()
|
||||||
.add(new JsonObject().put("value", "light").put("label", "Light"))
|
// .add(new JsonObject().put("value", "light").put("label", "Light"))
|
||||||
.add(new JsonObject().put("value", "dark").put("label", "Dark"))
|
// .add(new JsonObject().put("value", "dark").put("label", "Dark"))
|
||||||
.add(new JsonObject().put("value", "auto").put("label", "Auto (system preference)"))
|
// .add(new JsonObject().put("value", "auto").put("label", "Auto (system preference)"))
|
||||||
)
|
// )
|
||||||
);
|
// );
|
||||||
meta.add(new JsonObject()
|
// meta.add(new JsonObject()
|
||||||
.put("key", "items_per_page")
|
// .put("key", "items_per_page")
|
||||||
.put("label", "Items Per Page")
|
// .put("label", "Items Per Page")
|
||||||
.put("description", "Number of items shown in tables")
|
// .put("description", "Number of items shown in tables")
|
||||||
.put("type", "number")
|
// .put("type", "number")
|
||||||
.put("required", true)
|
// .put("required", true)
|
||||||
);
|
// );
|
||||||
meta.add(new JsonObject()
|
meta.add(new JsonObject()
|
||||||
.put("key", "enable_registration")
|
.put("key", "enable_registration")
|
||||||
.put("label", "Allow Public Registration")
|
.put("label", "Allow Public Registration")
|
||||||
@@ -59,17 +60,17 @@ public class SettingsService {
|
|||||||
.put("description", "When enabled, only admins can access the site")
|
.put("description", "When enabled, only admins can access the site")
|
||||||
.put("type", "boolean")
|
.put("type", "boolean")
|
||||||
);
|
);
|
||||||
meta.add(new JsonObject()
|
// meta.add(new JsonObject()
|
||||||
.put("key", "default_language")
|
// .put("key", "default_language")
|
||||||
.put("label", "Default Language")
|
// .put("label", "Default Language")
|
||||||
.put("description", "Interface language")
|
// .put("description", "Interface language")
|
||||||
.put("type", "select")
|
// .put("type", "select")
|
||||||
.put("options", new JsonArray()
|
// .put("options", new JsonArray()
|
||||||
.add(new JsonObject().put("value", "en").put("label", "English"))
|
// .add(new JsonObject().put("value", "en").put("label", "English"))
|
||||||
.add(new JsonObject().put("value", "ru").put("label", "Русский"))
|
// .add(new JsonObject().put("value", "ru").put("label", "Русский"))
|
||||||
.add(new JsonObject().put("value", "es").put("label", "Español"))
|
// .add(new JsonObject().put("value", "es").put("label", "Español"))
|
||||||
)
|
// )
|
||||||
);
|
// );
|
||||||
meta.add(new JsonObject()
|
meta.add(new JsonObject()
|
||||||
.put("key", "session_timeout_minutes")
|
.put("key", "session_timeout_minutes")
|
||||||
.put("label", "Session Timeout (minutes)")
|
.put("label", "Session Timeout (minutes)")
|
||||||
@@ -77,19 +78,45 @@ public class SettingsService {
|
|||||||
.put("type", "number")
|
.put("type", "number")
|
||||||
.put("required", true)
|
.put("required", true)
|
||||||
);
|
);
|
||||||
|
// meta.add(new JsonObject()
|
||||||
|
// .put("key", "logo_url")
|
||||||
|
// .put("label", "Logo URL")
|
||||||
|
// .put("description", "Path or URL to custom logo image")
|
||||||
|
// .put("type", "text")
|
||||||
|
// );
|
||||||
|
|
||||||
|
// Безопасность и прокси
|
||||||
meta.add(new JsonObject()
|
meta.add(new JsonObject()
|
||||||
.put("key", "logo_url")
|
.put("key", "use_proxy_headers")
|
||||||
.put("label", "Logo URL")
|
.put("label", "Use Proxy Headers")
|
||||||
.put("description", "Path or URL to custom logo image")
|
.put("description", "Respect X-Forwarded-* headers from trusted proxies")
|
||||||
|
.put("type", "boolean")
|
||||||
|
);
|
||||||
|
meta.add(new JsonObject()
|
||||||
|
.put("key", "trusted_proxies")
|
||||||
|
.put("label", "Trusted Proxies")
|
||||||
|
.put("description", "Comma-separated IP addresses of trusted proxies (e.g., 127.0.0.1,10.0.0.0/8)")
|
||||||
.put("type", "text")
|
.put("type", "text")
|
||||||
);
|
);
|
||||||
|
meta.add(new JsonObject()
|
||||||
|
.put("key", "enable_csp")
|
||||||
|
.put("label", "Enable CSP")
|
||||||
|
.put("description", "Add Content-Security-Policy header")
|
||||||
|
.put("type", "boolean")
|
||||||
|
);
|
||||||
|
meta.add(new JsonObject()
|
||||||
|
.put("key", "allowed_hosts")
|
||||||
|
.put("label", "Allowed Hosts")
|
||||||
|
.put("description", "Comma-separated list of allowed Host headers (empty = allow all)")
|
||||||
|
.put("type", "text")
|
||||||
|
);
|
||||||
|
|
||||||
return Future.succeededFuture(meta);
|
return Future.succeededFuture(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Future<JsonObject> getAllWithDefaults() {
|
public Future<JsonObject> getAllWithDefaults() {
|
||||||
return getAll().compose(values -> {
|
return getAll().compose(values -> {
|
||||||
JsonObject result = new JsonObject();
|
JsonObject result = new JsonObject();
|
||||||
// Получаем метаданные, чтобы знать ключи
|
|
||||||
return getMetadata().map(meta -> {
|
return getMetadata().map(meta -> {
|
||||||
for (Object item : meta) {
|
for (Object item : meta) {
|
||||||
JsonObject m = (JsonObject) item;
|
JsonObject m = (JsonObject) item;
|
||||||
@@ -107,13 +134,17 @@ public class SettingsService {
|
|||||||
return switch (key) {
|
return switch (key) {
|
||||||
case "site_name" -> "Admin Panel";
|
case "site_name" -> "Admin Panel";
|
||||||
case "site_description" -> "";
|
case "site_description" -> "";
|
||||||
case "theme" -> "light";
|
// case "theme" -> "light";
|
||||||
case "items_per_page" -> "20";
|
// case "items_per_page" -> "20";
|
||||||
case "enable_registration" -> "true";
|
case "enable_registration" -> "true";
|
||||||
case "maintenance_mode" -> "false";
|
case "maintenance_mode" -> "false";
|
||||||
case "default_language" -> "en";
|
// case "default_language" -> "en";
|
||||||
case "session_timeout_minutes" -> "60";
|
case "session_timeout_minutes" -> "60";
|
||||||
case "logo_url" -> "";
|
// case "logo_url" -> "";
|
||||||
|
case "use_proxy_headers" -> "true";
|
||||||
|
case "trusted_proxies" -> "127.0.0.1";
|
||||||
|
case "enable_csp" -> "true";
|
||||||
|
case "allowed_hosts" -> "";
|
||||||
default -> "";
|
default -> "";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -170,4 +201,21 @@ public class SettingsService {
|
|||||||
return json;
|
return json;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Публичные настройки (для фронтенда)
|
||||||
|
public Future<JsonObject> getPublicSettings() {
|
||||||
|
return getAll().map(all -> {
|
||||||
|
JsonObject publicOnly = new JsonObject();
|
||||||
|
// Только безопасные для отображения ключи
|
||||||
|
List<String> publicKeys = List.of(
|
||||||
|
"site_name", "site_description", "enable_registration"
|
||||||
|
);
|
||||||
|
for (String key : publicKeys) {
|
||||||
|
String val = all.getString(key);
|
||||||
|
if (val != null) publicOnly.put(key, val);
|
||||||
|
}
|
||||||
|
return publicOnly;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,11 @@ public class SetupHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String ip = ctx.request().remoteAddress().host();
|
String clientIp = ctx.get("realClientIp");
|
||||||
userService.createUser(login, email, password, ip, true).onComplete(cr -> {
|
if (clientIp == null) {
|
||||||
|
clientIp = ctx.request().remoteAddress().host();
|
||||||
|
}
|
||||||
|
userService.createUser(login, email, password, clientIp, true).onComplete(cr -> {
|
||||||
if (cr.succeeded()) {
|
if (cr.succeeded()) {
|
||||||
ctx.response().setStatusCode(201)
|
ctx.response().setStatusCode(201)
|
||||||
.end(new JsonObject().put("success", true).encode());
|
.end(new JsonObject().put("success", true).encode());
|
||||||
|
|||||||
@@ -139,6 +139,12 @@ public class UserService {
|
|||||||
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
|
return SqlTemplate.forUpdate(pool, sql).execute(params).mapEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Future<Void> updateUserIp(int userId, String ip) {
|
||||||
|
return pool.preparedQuery("UPDATE users SET ip = ? WHERE id = ?")
|
||||||
|
.execute(Tuple.of(ip, userId))
|
||||||
|
.mapEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
public Future<Void> deleteUser(int id) {
|
public Future<Void> deleteUser(int id) {
|
||||||
return SqlTemplate.forUpdate(pool, "DELETE FROM users WHERE id = #{id}")
|
return SqlTemplate.forUpdate(pool, "DELETE FROM users WHERE id = #{id}")
|
||||||
.execute(Collections.singletonMap("id", id))
|
.execute(Collections.singletonMap("id", id))
|
||||||
|
|||||||
Reference in New Issue
Block a user