This commit is contained in:
2026-04-20 13:42:41 +03:00
parent fd3cbb019f
commit ec0671c5e8
16 changed files with 465 additions and 117 deletions

View File

@@ -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>

View File

@@ -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()

View File

@@ -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)
// Загружаем настройки до монтирования
const settingsStore = useSettingsStore()
settingsStore.loadSettings().then(() => {
app.mount('#app') app.mount('#app')
})

View File

@@ -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')

View 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 }
})

View File

@@ -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);
} }
} }

View File

@@ -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>

View File

@@ -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: '' })

View File

@@ -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;

View File

@@ -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"));

View File

@@ -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());
}); });
// Получить метаданные всех настроек (для построения формы) // Получить метаданные всех настроек (для построения формы)

View File

@@ -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) {

View 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;
}
}

View File

@@ -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;
});
}
} }

View File

@@ -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());

View File

@@ -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))