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;
}
</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" />
</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()

View File

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

View File

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

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>
<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 toggleHttps(rest: any) {
const newHttps = !rest.https;
const payload = {
name: rest.name,
host: rest.host,
login: rest.login,
https: newHttps
// пароль не передаём, он останется прежним
};
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,
login: form.value.login,
host: form.value.host,
https: form.value.https,
login: form.value.login,
...(form.value.password ? { password: form.value.password } : {})
};
if (modalMode.value === 'create') {
await fetch('/api/admin/restaurants', {
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 {
await fetch(`/api/admin/restaurants/${form.value.id}`, {
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);
}
}

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

View File

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

View File

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

View File

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

View File

@@ -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);
SessionStore sessionStore = LocalSessionStore.create(vertx);
SessionHandler sessionHandler = SessionHandler.create(sessionStore)
.setSessionCookieName("admin.session")
.setCookieHttpOnlyFlag(true)
.setCookieSecureFlag(false)
.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());
});
// Получить метаданные всех настроек (для построения формы)

View File

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

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

View File

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

View File

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