up
This commit is contained in:
@@ -19,3 +19,20 @@
|
||||
opacity: 0;
|
||||
}
|
||||
</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" />
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
@@ -124,6 +124,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useSettingsStore } from '../../stores/settings'
|
||||
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -3,8 +3,15 @@ import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
import { useSettingsStore } from './stores/settings'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
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 AdminSettings from '../views/AdminSettings.vue'
|
||||
import NotFound from '../views/NotFound.vue'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: Login, meta: { title: 'Login' } },
|
||||
{ path: '/register', component: Register, meta: { title: 'Register' } },
|
||||
{ path: '/setup', component: Setup, meta: { title: 'Setup' } },
|
||||
{ path: '/login', component: Login, meta: { title: 'Login', requiresAuth: false } },
|
||||
{ path: '/register', component: Register, meta: { title: 'Register', requiresAuth: false } },
|
||||
{ path: '/setup', component: Setup, meta: { title: 'Setup', requiresAuth: false } },
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard'
|
||||
@@ -50,14 +51,20 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
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 {
|
||||
const statusRes = await fetch('/api/status')
|
||||
const status = await statusRes.json()
|
||||
|
||||
if (status.needsSetup && to.path !== '/setup') {
|
||||
next('/setup')
|
||||
return
|
||||
@@ -66,21 +73,29 @@ router.beforeEach(async (to, from, next) => {
|
||||
console.error('Failed to check status', e)
|
||||
}
|
||||
|
||||
// Проверка, что залогиненный пользователь не может зайти на страницу логина
|
||||
if (to.path === '/login') {
|
||||
try {
|
||||
const meRes = await fetch('/api/admin/me');
|
||||
const meRes = await fetch('/api/admin/me')
|
||||
if (meRes.ok) {
|
||||
next('/dashboard');
|
||||
return;
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
} 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) {
|
||||
try {
|
||||
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>
|
||||
<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">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">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">Actions</th>
|
||||
</tr>
|
||||
@@ -21,8 +22,16 @@
|
||||
<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.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">
|
||||
<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 space-x-2">
|
||||
<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">
|
||||
<h2 class="text-xl font-bold mb-4">{{ modalTitle }}</h2>
|
||||
<form @submit.prevent="submitRestaurant">
|
||||
<!-- 1. Имя ресторана -->
|
||||
<div class="mb-4">
|
||||
<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" />
|
||||
</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">
|
||||
<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" />
|
||||
</div>
|
||||
<!-- 5. Пароль (отключаем автозаполнение) -->
|
||||
<div class="mb-4">
|
||||
<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>
|
||||
</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">
|
||||
<button type="button" @click="closeModal" class="btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
@@ -72,7 +96,7 @@ import AppLayout from '../components/Layout/AppLayout.vue';
|
||||
const restaurants = ref([]);
|
||||
const modalOpen = ref(false);
|
||||
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('');
|
||||
|
||||
async function loadRestaurants() {
|
||||
@@ -88,10 +112,17 @@ function formatDate(dateStr: string) {
|
||||
function openModal(mode: 'create' | 'edit', rest: any = null) {
|
||||
modalMode.value = mode;
|
||||
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';
|
||||
} 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';
|
||||
}
|
||||
modalOpen.value = true;
|
||||
@@ -101,31 +132,65 @@ function closeModal() {
|
||||
modalOpen.value = false;
|
||||
}
|
||||
|
||||
async function submitRestaurant() {
|
||||
try {
|
||||
async function toggleHttps(rest: any) {
|
||||
const newHttps = !rest.https;
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
login: form.value.login,
|
||||
host: form.value.host,
|
||||
...(form.value.password ? { password: form.value.password } : {})
|
||||
name: rest.name,
|
||||
host: rest.host,
|
||||
login: rest.login,
|
||||
https: newHttps
|
||||
// пароль не передаём, он останется прежним
|
||||
};
|
||||
if (modalMode.value === 'create') {
|
||||
await fetch('/api/admin/restaurants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
await fetch(`/api/admin/restaurants/${form.value.id}`, {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/restaurants/${rest.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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();
|
||||
closeModal();
|
||||
} 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.email }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button
|
||||
<input
|
||||
v-if="user.id !== currentUserId"
|
||||
@click="toggleActive(user)"
|
||||
:class="user.active ? 'text-green-600' : 'text-red-600'"
|
||||
>
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
</button>
|
||||
type="checkbox"
|
||||
:checked="user.active"
|
||||
@change="toggleActive(user)"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 w-4 h-4"
|
||||
/>
|
||||
<span v-else class="text-gray-400 text-sm">(You)</span>
|
||||
</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" />
|
||||
<span class="ml-2 text-sm text-gray-600">Remember me</span>
|
||||
</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
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -99,6 +103,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSettingsStore } from '../../stores/settings'
|
||||
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const router = useRouter()
|
||||
const form = ref({ login: '', password: '' })
|
||||
|
||||
@@ -32,7 +32,7 @@ server {
|
||||
|
||||
deny all;
|
||||
|
||||
proxy_pass http://127.0.0.1:9099;
|
||||
proxy_pass http://127.0.0.1:7104;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection keep-alive;
|
||||
|
||||
@@ -28,10 +28,8 @@ public class AuthHandler {
|
||||
boolean passwordOk = userService.checkPassword(password, user.getString("password"));
|
||||
|
||||
if (passwordOk) {
|
||||
// Надёжное получение флага активности
|
||||
Boolean active = user.getBoolean("active");
|
||||
if (active == null) {
|
||||
// Если поле отсутствует, пробуем получить как Integer (на случай TINYINT)
|
||||
Integer activeInt = user.getInteger("active");
|
||||
active = activeInt != null && activeInt == 1;
|
||||
}
|
||||
@@ -41,6 +39,16 @@ public class AuthHandler {
|
||||
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.put("userId", user.getInteger("id"));
|
||||
session.put("login", user.getString("login"));
|
||||
|
||||
@@ -78,30 +78,54 @@ public class MainVerticle extends AbstractVerticle {
|
||||
startPromise.fail(err);
|
||||
});
|
||||
|
||||
Router router = initRouter();
|
||||
|
||||
startHttp(router, startPromise);
|
||||
createRouterAndStartHttp(startPromise);
|
||||
|
||||
})
|
||||
.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);
|
||||
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
|
||||
.setSessionCookieName("admin.session")
|
||||
.setCookieHttpOnlyFlag(true)
|
||||
.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.route().handler(BodyHandler.create());
|
||||
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 для разработки
|
||||
router.route().handler(ctx -> {
|
||||
ctx.response()
|
||||
@@ -149,7 +173,12 @@ public class MainVerticle extends AbstractVerticle {
|
||||
|
||||
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();
|
||||
String login = body.getString("login");
|
||||
String email = body.getString("email");
|
||||
@@ -162,7 +191,7 @@ public class MainVerticle extends AbstractVerticle {
|
||||
userService.createUser(login, email, password, ip)
|
||||
.onSuccess(v -> rc.response().setStatusCode(201).end(new JsonObject().put("success", true).encode()))
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
}));
|
||||
|
||||
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 -> {
|
||||
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");
|
||||
|
||||
if (currentUserId != null && currentUserId == id) {
|
||||
@@ -280,11 +309,12 @@ public class MainVerticle extends AbstractVerticle {
|
||||
String login = body.getString("login");
|
||||
String password = body.getString("password");
|
||||
String host = body.getString("host");
|
||||
boolean https = body.getBoolean("https", false);
|
||||
if (name == null || login == null || password == null || host == null) {
|
||||
rc.response().setStatusCode(400).end("Missing fields");
|
||||
return;
|
||||
}
|
||||
restaurantService.createRestaurant(name, login, password, host)
|
||||
restaurantService.createRestaurant(name, login, password, host, https)
|
||||
.onSuccess(v -> rc.response().setStatusCode(201).end())
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
@@ -296,11 +326,12 @@ public class MainVerticle extends AbstractVerticle {
|
||||
String login = body.getString("login");
|
||||
String password = body.getString("password");
|
||||
String host = body.getString("host");
|
||||
boolean https = body.getBoolean("https", false);
|
||||
if (name == null || login == null || host == null) {
|
||||
rc.response().setStatusCode(400).end("Missing required fields");
|
||||
return;
|
||||
}
|
||||
restaurantService.updateRestaurant(id, name, login, password, host)
|
||||
restaurantService.updateRestaurant(id, name, login, password, host, https)
|
||||
.onSuccess(v -> rc.response().end())
|
||||
.onFailure(err -> rc.response().setStatusCode(500).end(err.getMessage()));
|
||||
});
|
||||
@@ -314,9 +345,9 @@ public class MainVerticle extends AbstractVerticle {
|
||||
|
||||
// Получение всех настроек
|
||||
router.get("/api/settings").handler(rc -> {
|
||||
settingsService.getAll()
|
||||
settingsService.getPublicSettings()
|
||||
.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 (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
login VARCHAR(255) NOT NULL,
|
||||
login VARCHAR(255) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
host VARCHAR(255) NOT NULL,
|
||||
https BOOLEAN DEFAULT FALSE,
|
||||
created TIMESTAMP DEFAULT 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"));
|
||||
}
|
||||
|
||||
public Future<Void> createRestaurant(String name, String login, String password, String host) {
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("name", name);
|
||||
params.put("login", login);
|
||||
params.put("password", password);
|
||||
params.put("host", host);
|
||||
|
||||
public Future<Void> createRestaurant(String name, String login, String password, String host, boolean https) {
|
||||
Map<String, Object> params = Map.of(
|
||||
"name", name,
|
||||
"login", login,
|
||||
"password", password,
|
||||
"host", host,
|
||||
"https", https
|
||||
);
|
||||
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)
|
||||
.mapEmpty();
|
||||
}
|
||||
@@ -72,7 +73,7 @@ public class RestaurantService {
|
||||
}
|
||||
|
||||
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()
|
||||
.map(rows -> {
|
||||
JsonArray array = new JsonArray();
|
||||
@@ -85,6 +86,7 @@ public class RestaurantService {
|
||||
row.getLocalDateTime("created").toString() : null)
|
||||
.put("updated", row.getLocalDateTime("updated") != null ?
|
||||
row.getLocalDateTime("updated").toString() : null)
|
||||
.put("https", row.getBoolean("https"))
|
||||
.put("host", row.getString("host")));
|
||||
}
|
||||
return array;
|
||||
@@ -93,12 +95,13 @@ public class RestaurantService {
|
||||
|
||||
public Future<JsonObject> findById(int id) {
|
||||
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()
|
||||
.put("id", row.getInteger("id"))
|
||||
.put("name", row.getString("name"))
|
||||
.put("login", row.getString("login"))
|
||||
.put("password", row.getString("password"))
|
||||
.put("https", row.getBoolean("https"))
|
||||
.put("host", row.getString("host"))
|
||||
.put("created", row.getLocalDateTime("created") != null ? row.getLocalDateTime("created").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);
|
||||
}
|
||||
|
||||
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<>();
|
||||
params.put("id", id);
|
||||
params.put("name", name);
|
||||
params.put("login", login);
|
||||
params.put("host", host);
|
||||
|
||||
params.put("https", https);
|
||||
String sql;
|
||||
if (password != null && !password.isEmpty()) {
|
||||
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 {
|
||||
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) {
|
||||
|
||||
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.templates.SqlTemplate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class SettingsService {
|
||||
@@ -29,24 +30,24 @@ public class SettingsService {
|
||||
.put("type", "textarea")
|
||||
.put("rows", 2)
|
||||
);
|
||||
meta.add(new JsonObject()
|
||||
.put("key", "theme")
|
||||
.put("label", "Theme")
|
||||
.put("description", "Default color scheme")
|
||||
.put("type", "select")
|
||||
.put("options", new JsonArray()
|
||||
.add(new JsonObject().put("value", "light").put("label", "Light"))
|
||||
.add(new JsonObject().put("value", "dark").put("label", "Dark"))
|
||||
.add(new JsonObject().put("value", "auto").put("label", "Auto (system preference)"))
|
||||
)
|
||||
);
|
||||
meta.add(new JsonObject()
|
||||
.put("key", "items_per_page")
|
||||
.put("label", "Items Per Page")
|
||||
.put("description", "Number of items shown in tables")
|
||||
.put("type", "number")
|
||||
.put("required", true)
|
||||
);
|
||||
// meta.add(new JsonObject()
|
||||
// .put("key", "theme")
|
||||
// .put("label", "Theme")
|
||||
// .put("description", "Default color scheme")
|
||||
// .put("type", "select")
|
||||
// .put("options", new JsonArray()
|
||||
// .add(new JsonObject().put("value", "light").put("label", "Light"))
|
||||
// .add(new JsonObject().put("value", "dark").put("label", "Dark"))
|
||||
// .add(new JsonObject().put("value", "auto").put("label", "Auto (system preference)"))
|
||||
// )
|
||||
// );
|
||||
// meta.add(new JsonObject()
|
||||
// .put("key", "items_per_page")
|
||||
// .put("label", "Items Per Page")
|
||||
// .put("description", "Number of items shown in tables")
|
||||
// .put("type", "number")
|
||||
// .put("required", true)
|
||||
// );
|
||||
meta.add(new JsonObject()
|
||||
.put("key", "enable_registration")
|
||||
.put("label", "Allow Public Registration")
|
||||
@@ -59,17 +60,17 @@ public class SettingsService {
|
||||
.put("description", "When enabled, only admins can access the site")
|
||||
.put("type", "boolean")
|
||||
);
|
||||
meta.add(new JsonObject()
|
||||
.put("key", "default_language")
|
||||
.put("label", "Default Language")
|
||||
.put("description", "Interface language")
|
||||
.put("type", "select")
|
||||
.put("options", new JsonArray()
|
||||
.add(new JsonObject().put("value", "en").put("label", "English"))
|
||||
.add(new JsonObject().put("value", "ru").put("label", "Русский"))
|
||||
.add(new JsonObject().put("value", "es").put("label", "Español"))
|
||||
)
|
||||
);
|
||||
// meta.add(new JsonObject()
|
||||
// .put("key", "default_language")
|
||||
// .put("label", "Default Language")
|
||||
// .put("description", "Interface language")
|
||||
// .put("type", "select")
|
||||
// .put("options", new JsonArray()
|
||||
// .add(new JsonObject().put("value", "en").put("label", "English"))
|
||||
// .add(new JsonObject().put("value", "ru").put("label", "Русский"))
|
||||
// .add(new JsonObject().put("value", "es").put("label", "Español"))
|
||||
// )
|
||||
// );
|
||||
meta.add(new JsonObject()
|
||||
.put("key", "session_timeout_minutes")
|
||||
.put("label", "Session Timeout (minutes)")
|
||||
@@ -77,19 +78,45 @@ public class SettingsService {
|
||||
.put("type", "number")
|
||||
.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()
|
||||
.put("key", "logo_url")
|
||||
.put("label", "Logo URL")
|
||||
.put("description", "Path or URL to custom logo image")
|
||||
.put("key", "use_proxy_headers")
|
||||
.put("label", "Use Proxy Headers")
|
||||
.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")
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
public Future<JsonObject> getAllWithDefaults() {
|
||||
return getAll().compose(values -> {
|
||||
JsonObject result = new JsonObject();
|
||||
// Получаем метаданные, чтобы знать ключи
|
||||
return getMetadata().map(meta -> {
|
||||
for (Object item : meta) {
|
||||
JsonObject m = (JsonObject) item;
|
||||
@@ -107,13 +134,17 @@ public class SettingsService {
|
||||
return switch (key) {
|
||||
case "site_name" -> "Admin Panel";
|
||||
case "site_description" -> "";
|
||||
case "theme" -> "light";
|
||||
case "items_per_page" -> "20";
|
||||
// case "theme" -> "light";
|
||||
// case "items_per_page" -> "20";
|
||||
case "enable_registration" -> "true";
|
||||
case "maintenance_mode" -> "false";
|
||||
case "default_language" -> "en";
|
||||
// case "default_language" -> "en";
|
||||
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 -> "";
|
||||
};
|
||||
}
|
||||
@@ -170,4 +201,21 @@ public class SettingsService {
|
||||
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;
|
||||
}
|
||||
|
||||
String ip = ctx.request().remoteAddress().host();
|
||||
userService.createUser(login, email, password, ip, true).onComplete(cr -> {
|
||||
String clientIp = ctx.get("realClientIp");
|
||||
if (clientIp == null) {
|
||||
clientIp = ctx.request().remoteAddress().host();
|
||||
}
|
||||
userService.createUser(login, email, password, clientIp, true).onComplete(cr -> {
|
||||
if (cr.succeeded()) {
|
||||
ctx.response().setStatusCode(201)
|
||||
.end(new JsonObject().put("success", true).encode());
|
||||
|
||||
@@ -139,6 +139,12 @@ public class UserService {
|
||||
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) {
|
||||
return SqlTemplate.forUpdate(pool, "DELETE FROM users WHERE id = #{id}")
|
||||
.execute(Collections.singletonMap("id", id))
|
||||
|
||||
Reference in New Issue
Block a user