рефакторинг + новая система модерации
This commit is contained in:
@@ -56,7 +56,7 @@
|
|||||||
<versionInfo>
|
<versionInfo>
|
||||||
<fileVersion>${project.version}.0</fileVersion>
|
<fileVersion>${project.version}.0</fileVersion>
|
||||||
<txtFileVersion>${project.version}</txtFileVersion>
|
<txtFileVersion>${project.version}</txtFileVersion>
|
||||||
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription>
|
<fileDescription>ZernMC Launcher — A Little Minecraft Launcher</fileDescription>
|
||||||
<productVersion>${project.version}.0</productVersion>
|
<productVersion>${project.version}.0</productVersion>
|
||||||
<txtProductVersion>${project.version}</txtProductVersion>
|
<txtProductVersion>${project.version}</txtProductVersion>
|
||||||
<productName>ZernMC Launcher</productName>
|
<productName>ZernMC Launcher</productName>
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@
|
|||||||
<versionInfo>
|
<versionInfo>
|
||||||
<fileVersion>${project.version}.0</fileVersion>
|
<fileVersion>${project.version}.0</fileVersion>
|
||||||
<txtFileVersion>${project.version}</txtFileVersion>
|
<txtFileVersion>${project.version}</txtFileVersion>
|
||||||
<fileDescription>ZernMC Launcher — самописный Minecraft лаунчер</fileDescription>
|
<fileDescription>ZernMC Launcher — A Little Minecraft Launcher</fileDescription>
|
||||||
<productVersion>${project.version}.0</productVersion>
|
<productVersion>${project.version}.0</productVersion>
|
||||||
<txtProductVersion>${project.version}</txtProductVersion>
|
<txtProductVersion>${project.version}</txtProductVersion>
|
||||||
<productName>ZernMC Launcher</productName>
|
<productName>ZernMC Launcher</productName>
|
||||||
|
|||||||
@@ -1,25 +1,39 @@
|
|||||||
package me.sashegdev.zernmc.launcher.auth;
|
package me.sashegdev.zernmc.launcher.auth;
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
import com.google.gson.*;
|
||||||
import com.google.gson.GsonBuilder;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import com.google.gson.JsonParser;
|
|
||||||
import com.google.gson.annotations.SerializedName;
|
import com.google.gson.annotations.SerializedName;
|
||||||
import me.sashegdev.zernmc.launcher.utils.Config;
|
import me.sashegdev.zernmc.launcher.utils.Config;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public class AuthManager {
|
public class AuthManager {
|
||||||
|
|
||||||
private static final Path AUTH_FILE = Config.getConfigDir().resolve("auth.json");
|
private static final Path AUTH_FILE = Config.getConfigDir().resolve("auth.json");
|
||||||
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(15))
|
||||||
|
.build();
|
||||||
|
|
||||||
private static volatile AuthSession session = null;
|
private static volatile AuthSession session = null;
|
||||||
|
private static volatile UserInfo userInfo = null;
|
||||||
|
|
||||||
|
// Роли
|
||||||
|
public static final int ROLE_USER = 0;
|
||||||
|
public static final int ROLE_PASS_HOLDER = 1;
|
||||||
|
public static final int ROLE_MODERATOR = 2;
|
||||||
|
public static final int ROLE_ELDER = 3;
|
||||||
|
public static final int ROLE_CREATOR = 4;
|
||||||
|
|
||||||
public static boolean loadSavedSession() {
|
public static boolean loadSavedSession() {
|
||||||
if (!Files.exists(AUTH_FILE)) return false;
|
if (!Files.exists(AUTH_FILE)) return false;
|
||||||
@@ -29,6 +43,12 @@ public class AuthManager {
|
|||||||
if (loaded == null || loaded.accessToken == null) return false;
|
if (loaded == null || loaded.accessToken == null) return false;
|
||||||
|
|
||||||
session = loaded;
|
session = loaded;
|
||||||
|
|
||||||
|
// Получаем информацию о пользователе
|
||||||
|
if (session.username != null) {
|
||||||
|
userInfo = fetchUserInfo();
|
||||||
|
}
|
||||||
|
|
||||||
if (isAccessTokenExpired()) {
|
if (isAccessTokenExpired()) {
|
||||||
return tryRefresh();
|
return tryRefresh();
|
||||||
}
|
}
|
||||||
@@ -48,27 +68,37 @@ public class AuthManager {
|
|||||||
|
|
||||||
private static AuthResult authRequest(String endpoint, String username, String password) {
|
private static AuthResult authRequest(String endpoint, String username, String password) {
|
||||||
try {
|
try {
|
||||||
String body = GSON.toJson(new LoginRequest(username, password));
|
JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("username", username);
|
||||||
|
body.addProperty("password", password);
|
||||||
|
|
||||||
//System.out.println(ZAnsi.cyan("[AUTH] Отправка запроса: " + endpoint));
|
String jsonBody = GSON.toJson(body);
|
||||||
|
|
||||||
SimpleHttpResponse resp = post(endpoint, body);
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(ZHttpClient.getBaseUrl() + endpoint))
|
||||||
|
.timeout(Duration.ofSeconds(15))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("User-Agent", "ZernMC-Launcher/1.0")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
|
||||||
|
.build();
|
||||||
|
|
||||||
//System.out.println(ZAnsi.cyan("[AUTH] Ответ: HTTP " + resp.statusCode()));
|
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
if (resp.statusCode() == 200) {
|
if (response.statusCode() == 200) {
|
||||||
session = GSON.fromJson(resp.body(), AuthSession.class);
|
session = GSON.fromJson(response.body(), AuthSession.class);
|
||||||
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
|
session.expiresAt = System.currentTimeMillis() / 1000L + session.expiresIn;
|
||||||
saveSession();
|
saveSession();
|
||||||
|
|
||||||
|
// Получаем информацию о пользователе
|
||||||
|
userInfo = fetchUserInfo();
|
||||||
|
|
||||||
return AuthResult.ok();
|
return AuthResult.ok();
|
||||||
} else if (resp.statusCode() == 422) {
|
|
||||||
return AuthResult.fail("Ошибка валидации: " + extractError(resp.body()));
|
|
||||||
} else {
|
} else {
|
||||||
return AuthResult.fail(extractError(resp.body()));
|
String error = extractError(response.body());
|
||||||
|
return AuthResult.fail(error);
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
//System.err.println(ZAnsi.red("[AUTH] Исключение: " + e.getMessage()));
|
|
||||||
e.printStackTrace();
|
|
||||||
return AuthResult.fail("Ошибка соединения: " + e.getMessage());
|
return AuthResult.fail("Ошибка соединения: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,10 +106,21 @@ public class AuthManager {
|
|||||||
public static void logout() {
|
public static void logout() {
|
||||||
if (session != null && session.refreshToken != null) {
|
if (session != null && session.refreshToken != null) {
|
||||||
try {
|
try {
|
||||||
post("/auth/logout", "{\"refresh_token\":\"" + session.refreshToken + "\"}");
|
JsonObject body = new JsonObject();
|
||||||
|
body.addProperty("refresh_token", session.refreshToken);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(ZHttpClient.getBaseUrl() + "/auth/logout"))
|
||||||
|
.timeout(Duration.ofSeconds(10))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(body)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
session = null;
|
session = null;
|
||||||
|
userInfo = null;
|
||||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,12 +136,59 @@ public class AuthManager {
|
|||||||
return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000";
|
return session != null ? session.uuid : "00000000-0000-0000-0000-000000000000";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int getRole() {
|
||||||
|
return session != null ? session.role : ROLE_USER;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String getRoleName() {
|
||||||
|
return session != null ? session.roleName : "Игрок";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean hasPass() {
|
||||||
|
return getRole() >= ROLE_PASS_HOLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isModerator() {
|
||||||
|
return getRole() >= ROLE_MODERATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isElder() {
|
||||||
|
return getRole() >= ROLE_ELDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isCreator() {
|
||||||
|
return getRole() == ROLE_CREATOR;
|
||||||
|
}
|
||||||
|
|
||||||
public static String getAccessToken() {
|
public static String getAccessToken() {
|
||||||
if (session == null) return "0";
|
if (session == null) return null;
|
||||||
if (isAccessTokenExpired()) {
|
if (isAccessTokenExpired()) {
|
||||||
tryRefresh();
|
tryRefresh();
|
||||||
}
|
}
|
||||||
return session != null && session.accessToken != null ? session.accessToken : "0";
|
return session != null ? session.accessToken : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserInfo fetchUserInfo() {
|
||||||
|
if (!isLoggedIn()) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(ZHttpClient.getBaseUrl() + "/admin/me"))
|
||||||
|
.timeout(Duration.ofSeconds(10))
|
||||||
|
.header("Authorization", "Bearer " + session.accessToken)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (response.statusCode() == 200) {
|
||||||
|
return GSON.fromJson(response.body(), UserInfo.class);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.err.println("Не удалось получить информацию о пользователе: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isAccessTokenExpired() {
|
private static boolean isAccessTokenExpired() {
|
||||||
@@ -110,19 +198,34 @@ public class AuthManager {
|
|||||||
|
|
||||||
private static boolean tryRefresh() {
|
private static boolean tryRefresh() {
|
||||||
if (session == null || session.refreshToken == null) return false;
|
if (session == null || session.refreshToken == null) return false;
|
||||||
try {
|
|
||||||
String body = "{\"refresh_token\":\"" + session.refreshToken + "\"}";
|
|
||||||
SimpleHttpResponse resp = post("/auth/refresh", body);
|
|
||||||
|
|
||||||
if (resp.statusCode() == 200) {
|
try {
|
||||||
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
|
JsonObject body = new JsonObject();
|
||||||
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
|
body.addProperty("refresh_token", session.refreshToken);
|
||||||
session = newSession;
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(ZHttpClient.getBaseUrl() + "/auth/refresh"))
|
||||||
|
.timeout(Duration.ofSeconds(15))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(GSON.toJson(body)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (response.statusCode() == 200) {
|
||||||
|
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
|
||||||
|
String newAccessToken = json.get("access_token").getAsString();
|
||||||
|
int expiresIn = json.get("expires_in").getAsInt();
|
||||||
|
|
||||||
|
session.accessToken = newAccessToken;
|
||||||
|
session.expiresAt = System.currentTimeMillis() / 1000L + expiresIn;
|
||||||
saveSession();
|
saveSession();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
session = null;
|
session = null;
|
||||||
|
userInfo = null;
|
||||||
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
try { Files.deleteIfExists(AUTH_FILE); } catch (Exception ignored) {}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -136,50 +239,6 @@ public class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
|
|
||||||
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
|
|
||||||
|
|
||||||
java.net.HttpURLConnection conn = null;
|
|
||||||
try {
|
|
||||||
java.net.URL url = java.net.URI.create(fullUrl).toURL();
|
|
||||||
conn = (java.net.HttpURLConnection) url.openConnection();
|
|
||||||
conn.setRequestMethod("POST");
|
|
||||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
|
||||||
conn.setRequestProperty("Accept", "application/json");
|
|
||||||
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
|
|
||||||
|
|
||||||
// Добавляем токен авторизации, если есть сессия
|
|
||||||
if (session != null && session.accessToken != null) {
|
|
||||||
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.setDoOutput(true);
|
|
||||||
conn.setConnectTimeout(15000);
|
|
||||||
conn.setReadTimeout(15000);
|
|
||||||
|
|
||||||
try (java.io.OutputStream os = conn.getOutputStream()) {
|
|
||||||
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
|
|
||||||
os.write(input, 0, input.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
int statusCode = conn.getResponseCode();
|
|
||||||
|
|
||||||
java.io.InputStream is = (statusCode >= 200 && statusCode < 300)
|
|
||||||
? conn.getInputStream()
|
|
||||||
: conn.getErrorStream();
|
|
||||||
|
|
||||||
String responseBody;
|
|
||||||
try (java.util.Scanner scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
|
|
||||||
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new SimpleHttpResponse(statusCode, responseBody);
|
|
||||||
|
|
||||||
} finally {
|
|
||||||
if (conn != null) conn.disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String extractError(String body) {
|
private static String extractError(String body) {
|
||||||
try {
|
try {
|
||||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||||
@@ -199,38 +258,6 @@ public class AuthManager {
|
|||||||
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean hasActivePass() {
|
|
||||||
if (!isLoggedIn()) return false;
|
|
||||||
try {
|
|
||||||
String response = ZHttpClient.get("/auth/pass/my");
|
|
||||||
return response.contains("\"is_active\":true");
|
|
||||||
} catch (Exception e) {
|
|
||||||
System.err.println("Не удалось проверить проходки: " + e.getMessage());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String activatePass(String passCode) {
|
|
||||||
try {
|
|
||||||
String json = "{\"pass_code\":\"" + passCode.toUpperCase() + "\"}";
|
|
||||||
SimpleHttpResponse resp = post("/auth/pass/activate", json);
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("[AUTH] Активация проходки: HTTP " + resp.statusCode()));
|
|
||||||
|
|
||||||
if (resp.statusCode() == 200) {
|
|
||||||
return "Проходка успешно активирована!";
|
|
||||||
} else if (resp.statusCode() == 401) {
|
|
||||||
return "Ошибка: Требуется авторизация. Перезайдите в аккаунт.";
|
|
||||||
} else {
|
|
||||||
String error = extractError(resp.body());
|
|
||||||
return "Ошибка: " + error;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
return "Ошибка соединения: " + e.getMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
|
// ====================== ВНУТРЕННИЕ КЛАССЫ ======================
|
||||||
|
|
||||||
public static class AuthSession {
|
public static class AuthSession {
|
||||||
@@ -240,12 +267,20 @@ public class AuthManager {
|
|||||||
public transient long expiresAt;
|
public transient long expiresAt;
|
||||||
public String username;
|
public String username;
|
||||||
public String uuid;
|
public String uuid;
|
||||||
|
public int role;
|
||||||
|
@SerializedName("role_name") public String roleName;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class LoginRequest {
|
public static class UserInfo {
|
||||||
final String username;
|
public int id;
|
||||||
final String password;
|
public String username;
|
||||||
LoginRequest(String u, String p) { this.username = u; this.password = p; }
|
public String uuid;
|
||||||
|
public int role;
|
||||||
|
public String role_name;
|
||||||
|
public long created_at;
|
||||||
|
public Long last_login;
|
||||||
|
public boolean has_pass;
|
||||||
|
public List<String> permissions;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class AuthResult {
|
public static class AuthResult {
|
||||||
@@ -256,17 +291,3 @@ public class AuthManager {
|
|||||||
public static AuthResult fail(String msg) { return new AuthResult(false, msg); }
|
public static AuthResult fail(String msg) { return new AuthResult(false, msg); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====================== ВСПОМОГАТЕЛЬНЫЙ КЛАСС ======================
|
|
||||||
class SimpleHttpResponse {
|
|
||||||
final int statusCode;
|
|
||||||
final String body;
|
|
||||||
|
|
||||||
SimpleHttpResponse(int statusCode, String body) {
|
|
||||||
this.statusCode = statusCode;
|
|
||||||
this.body = body;
|
|
||||||
}
|
|
||||||
|
|
||||||
int statusCode() { return statusCode; }
|
|
||||||
String body() { return body; }
|
|
||||||
}
|
|
||||||
@@ -81,30 +81,15 @@ public class LaunchMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void installServerPack() throws Exception {
|
private void installServerPack() throws Exception {
|
||||||
if (!AuthManager.hasActivePass()) {
|
// Проверяем наличие проходки
|
||||||
|
if (!AuthManager.hasPass()) {
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
System.out.println(ZAnsi.brightRed("У вас нет активной проходки!"));
|
System.out.println(ZAnsi.brightRed("У вас нет активной проходки!"));
|
||||||
System.out.println(ZAnsi.white("Чтобы скачивать сборки с сервера ZernMC, необходимо активировать проходку."));
|
System.out.println(ZAnsi.white("Чтобы скачивать сборки с сервера ZernMC, необходимо активировать проходку."));
|
||||||
System.out.println();
|
System.out.println();
|
||||||
System.out.print(ZAnsi.white("Введите код проходки (ZERN-XXXXXXX) или Enter для отмены: "));
|
System.out.println(ZAnsi.white("Обратитесь к администратору для получения проходки."));
|
||||||
|
ConsoleUtils.pause();
|
||||||
String code = Input.readLine();
|
return;
|
||||||
if (code.isEmpty()) return;
|
|
||||||
|
|
||||||
String result = AuthManager.activatePass(code);
|
|
||||||
System.out.println(ZAnsi.cyan(result));
|
|
||||||
|
|
||||||
if (!result.contains("успешно")) {
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Повторная проверка
|
|
||||||
if (!AuthManager.hasActivePass()) {
|
|
||||||
System.out.println(ZAnsi.brightRed("Не удалось активировать проходку."));
|
|
||||||
ConsoleUtils.pause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
@@ -529,7 +514,7 @@ public class LaunchMenu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void launchExistingInstance(Instance instance) {
|
private void launchExistingInstance(Instance instance) {
|
||||||
if (instance.isServerPack() && !AuthManager.hasActivePass()) {
|
if (instance.isServerPack() && !AuthManager.hasPass()) {
|
||||||
ConsoleUtils.clearScreen();
|
ConsoleUtils.clearScreen();
|
||||||
System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
|
System.out.println(ZAnsi.brightRed("Для запуска серверной сборки требуется активная проходка!"));
|
||||||
ConsoleUtils.pause();
|
ConsoleUtils.pause();
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.google.gson.JsonArray;
|
|||||||
import com.google.gson.JsonElement;
|
import com.google.gson.JsonElement;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import com.google.gson.JsonParser;
|
import com.google.gson.JsonParser;
|
||||||
|
|
||||||
|
import me.sashegdev.zernmc.launcher.auth.AuthManager;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
import me.sashegdev.zernmc.launcher.utils.ProgressBar;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
import me.sashegdev.zernmc.launcher.utils.ZAnsi;
|
||||||
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
import me.sashegdev.zernmc.launcher.utils.ZHttpClient;
|
||||||
@@ -37,70 +39,83 @@ public class PackDownloader {
|
|||||||
* Получить список доступных паков с сервера
|
* Получить список доступных паков с сервера
|
||||||
*/
|
*/
|
||||||
public List<ServerPack> getAvailablePacks() throws Exception {
|
public List<ServerPack> getAvailablePacks() throws Exception {
|
||||||
String response = ZHttpClient.get("/packs");
|
String accessToken = AuthManager.getAccessToken();
|
||||||
|
if (accessToken == null) {
|
||||||
// Для отладки - выведем ответ сервера
|
throw new IOException("Не авторизован. Требуется проходка для просмотра сборок.");
|
||||||
System.out.println(ZAnsi.cyan("Ответ сервера: " + response));
|
|
||||||
|
|
||||||
JsonObject root = JsonParser.parseString(response).getAsJsonObject();
|
|
||||||
|
|
||||||
// Проверяем, есть ли поле "packs"
|
|
||||||
if (!root.has("packs")) {
|
|
||||||
System.out.println(ZAnsi.yellow("Сервер вернул неожиданный формат ответа"));
|
|
||||||
return new ArrayList<>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Используем HttpURLConnection для GET с авторизацией
|
||||||
|
java.net.HttpURLConnection connection = null;
|
||||||
|
try {
|
||||||
|
java.net.URL url = new java.net.URL(ZHttpClient.getBaseUrl() + "/packs");
|
||||||
|
connection = (java.net.HttpURLConnection) url.openConnection();
|
||||||
|
connection.setRequestMethod("GET");
|
||||||
|
connection.setRequestProperty("Accept", "application/json");
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||||
|
connection.setConnectTimeout(15000);
|
||||||
|
connection.setReadTimeout(15000);
|
||||||
|
|
||||||
|
int responseCode = connection.getResponseCode();
|
||||||
|
|
||||||
|
if (responseCode == 403) {
|
||||||
|
throw new IOException("Для просмотра сборок требуется активная проходка");
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder response = new StringBuilder();
|
||||||
|
try (java.io.InputStream is = responseCode < 400 ? connection.getInputStream() : connection.getErrorStream();
|
||||||
|
java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, "UTF-8"))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
response.append(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseCode != 200) {
|
||||||
|
throw new IOException("HTTP " + responseCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsePacksResponse(response.toString());
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
if (connection != null) {
|
||||||
|
connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ServerPack> parsePacksResponse(String responseBody) {
|
||||||
|
JsonObject root = JsonParser.parseString(responseBody).getAsJsonObject();
|
||||||
JsonArray packsArray = root.getAsJsonArray("packs");
|
JsonArray packsArray = root.getAsJsonArray("packs");
|
||||||
List<ServerPack> result = new ArrayList<>();
|
List<ServerPack> result = new ArrayList<>();
|
||||||
|
|
||||||
for (JsonElement elem : packsArray) {
|
for (JsonElement elem : packsArray) {
|
||||||
JsonObject pack = elem.getAsJsonObject();
|
JsonObject pack = elem.getAsJsonObject();
|
||||||
|
|
||||||
// Пропускаем паки с ошибками
|
if (pack.has("error") || (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString()))) {
|
||||||
if (pack.has("error")) {
|
|
||||||
System.out.println(ZAnsi.yellow("Пак имеет ошибку: " + pack.get("error").getAsString()));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Пропускаем паки со статусом not_scanned
|
|
||||||
if (pack.has("status") && "not_scanned".equals(pack.get("status").getAsString())) {
|
|
||||||
System.out.println(ZAnsi.yellow("Пак " + pack.get("name").getAsString() + " не отсканирован на сервере"));
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Пробуем получить name или pack_name (разные форматы)
|
String name = pack.get("name").getAsString();
|
||||||
String name = null;
|
|
||||||
if (pack.has("name")) {
|
|
||||||
name = pack.get("name").getAsString();
|
|
||||||
} else if (pack.has("pack_name")) {
|
|
||||||
name = pack.get("pack_name").getAsString();
|
|
||||||
} else {
|
|
||||||
continue; // Пропускаем если нет имени
|
|
||||||
}
|
|
||||||
|
|
||||||
int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
|
int version = pack.has("version") ? pack.get("version").getAsInt() : 0;
|
||||||
|
|
||||||
// Получаем остальные поля (могут отсутствовать)
|
|
||||||
String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown";
|
String minecraftVersion = pack.has("minecraft_version") ? pack.get("minecraft_version").getAsString() : "unknown";
|
||||||
String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla";
|
String loaderType = pack.has("loader_type") ? pack.get("loader_type").getAsString() : "vanilla";
|
||||||
String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull() ? pack.get("loader_version").getAsString() : "";
|
String loaderVersion = pack.has("loader_version") && !pack.get("loader_version").isJsonNull()
|
||||||
|
? pack.get("loader_version").getAsString() : "";
|
||||||
int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0;
|
int filesCount = pack.has("files_count") ? pack.get("files_count").getAsInt() : 0;
|
||||||
|
|
||||||
// Парсим дату, если есть
|
|
||||||
LocalDateTime updatedAt = null;
|
LocalDateTime updatedAt = null;
|
||||||
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
|
if (pack.has("updated_at") && !pack.get("updated_at").isJsonNull()) {
|
||||||
try {
|
try {
|
||||||
updatedAt = parseDateTime(pack.get("updated_at").getAsString());
|
updatedAt = LocalDateTime.parse(pack.get("updated_at").getAsString(),
|
||||||
} catch (Exception e) {
|
DateTimeFormatter.ISO_DATE_TIME);
|
||||||
// Игнорируем ошибки парсинга даты
|
} catch (Exception ignored) {}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.add(new ServerPack(name, version, minecraftVersion,
|
result.add(new ServerPack(name, version, minecraftVersion, loaderType,
|
||||||
loaderType, loaderVersion, updatedAt, filesCount));
|
loaderVersion, updatedAt, filesCount));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println(ZAnsi.yellow("Ошибка парсинга пака: " + e.getMessage()));
|
System.err.println("Ошибка парсинга пака: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,21 +308,15 @@ public class PackDownloader {
|
|||||||
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
|
private DiffResponse getDiff(String packName, Map<String, String> localFiles) throws Exception {
|
||||||
String json = gson.toJson(localFiles);
|
String json = gson.toJson(localFiles);
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("Отправка diff запроса для " + packName));
|
// Получаем токен авторизации
|
||||||
System.out.println(ZAnsi.cyan("JSON размер: " + json.length() + " байт"));
|
String accessToken = AuthManager.getAccessToken();
|
||||||
System.out.println(ZAnsi.cyan("JSON тело: " + json));
|
if (accessToken == null) {
|
||||||
|
throw new IOException("Не авторизован. Требуется проходка для скачивания сборок.");
|
||||||
String baseUrl = ZHttpClient.getBaseUrl();
|
|
||||||
if (baseUrl.endsWith("/")) {
|
|
||||||
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
|
|
||||||
}
|
}
|
||||||
String url = baseUrl + "/pack/" + packName + "/diff";
|
|
||||||
|
|
||||||
System.out.println(ZAnsi.cyan("URL: " + url));
|
String url = ZHttpClient.getBaseUrl() + "/pack/" + packName + "/diff";
|
||||||
|
|
||||||
// ПРОБЛЕМА: стандартный HttpClient может отправлять chunked encoding
|
|
||||||
// РЕШЕНИЕ: используем HttpURLConnection вместо HttpClient
|
|
||||||
|
|
||||||
|
// Используем HttpURLConnection для полного контроля
|
||||||
java.net.HttpURLConnection connection = null;
|
java.net.HttpURLConnection connection = null;
|
||||||
try {
|
try {
|
||||||
java.net.URL urlObj = new java.net.URL(url);
|
java.net.URL urlObj = new java.net.URL(url);
|
||||||
@@ -315,6 +324,7 @@ public class PackDownloader {
|
|||||||
connection.setRequestMethod("POST");
|
connection.setRequestMethod("POST");
|
||||||
connection.setRequestProperty("Content-Type", "application/json");
|
connection.setRequestProperty("Content-Type", "application/json");
|
||||||
connection.setRequestProperty("Accept", "application/json");
|
connection.setRequestProperty("Accept", "application/json");
|
||||||
|
connection.setRequestProperty("Authorization", "Bearer " + accessToken);
|
||||||
connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length));
|
connection.setRequestProperty("Content-Length", String.valueOf(json.getBytes("UTF-8").length));
|
||||||
connection.setDoOutput(true);
|
connection.setDoOutput(true);
|
||||||
connection.setConnectTimeout(30000);
|
connection.setConnectTimeout(30000);
|
||||||
@@ -328,7 +338,6 @@ public class PackDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int responseCode = connection.getResponseCode();
|
int responseCode = connection.getResponseCode();
|
||||||
System.out.println(ZAnsi.cyan("Diff ответ: HTTP " + responseCode));
|
|
||||||
|
|
||||||
// Читаем ответ
|
// Читаем ответ
|
||||||
StringBuilder response = new StringBuilder();
|
StringBuilder response = new StringBuilder();
|
||||||
@@ -341,10 +350,13 @@ public class PackDownloader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String responseBody = response.toString();
|
String responseBody = response.toString();
|
||||||
System.out.println(ZAnsi.cyan("Тело ответа: " + responseBody));
|
|
||||||
|
if (responseCode == 403) {
|
||||||
|
throw new IOException("Для скачивания сборок требуется активная проходка. Обратитесь к администратору.");
|
||||||
|
}
|
||||||
|
|
||||||
if (responseCode != 200) {
|
if (responseCode != 200) {
|
||||||
throw new IOException("HTTP " + responseCode + ": " + responseBody);
|
throw new IOException("HTTP " + responseCode + ": " + extractErrorFromResponse(responseBody));
|
||||||
}
|
}
|
||||||
|
|
||||||
return gson.fromJson(responseBody, DiffResponse.class);
|
return gson.fromJson(responseBody, DiffResponse.class);
|
||||||
@@ -356,6 +368,16 @@ public class PackDownloader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String extractErrorFromResponse(String body) {
|
||||||
|
try {
|
||||||
|
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||||
|
if (json.has("detail")) {
|
||||||
|
return json.get("detail").getAsString();
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return body.length() > 200 ? body.substring(0, 200) + "..." : body;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Применить diff (скачать новые файлы, удалить старые)
|
* Применить diff (скачать новые файлы, удалить старые)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ public class ZAnsi {
|
|||||||
return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString();
|
return Ansi.ansi().fg(Ansi.Color.CYAN).a(text).reset().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String brightCyan(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
public static String yellow(String text) {
|
public static String yellow(String text) {
|
||||||
return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString();
|
return Ansi.ansi().fg(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||||
}
|
}
|
||||||
@@ -47,6 +51,27 @@ public class ZAnsi {
|
|||||||
return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString();
|
return Ansi.ansi().fg(Ansi.Color.BLUE).a(text).reset().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String brightBlue(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.BLUE).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String magenta(String text) {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.MAGENTA).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String brightMagenta(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.MAGENTA).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пурпурный как brightPurple (используем magenta)
|
||||||
|
public static String purple(String text) {
|
||||||
|
return brightMagenta(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String brightPurple(String text) {
|
||||||
|
return brightMagenta(text);
|
||||||
|
}
|
||||||
|
|
||||||
public static String white(String text) {
|
public static String white(String text) {
|
||||||
return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString();
|
return Ansi.ansi().fg(Ansi.Color.WHITE).a(text).reset().toString();
|
||||||
}
|
}
|
||||||
@@ -55,7 +80,28 @@ public class ZAnsi {
|
|||||||
return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString();
|
return Ansi.ansi().fgBright(Ansi.Color.WHITE).a(text).reset().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Стили
|
public static String black(String text) {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.BLACK).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Фоновые цвета ===
|
||||||
|
public static String bgGreen(String text) {
|
||||||
|
return Ansi.ansi().bg(Ansi.Color.GREEN).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String bgRed(String text) {
|
||||||
|
return Ansi.ansi().bg(Ansi.Color.RED).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String bgYellow(String text) {
|
||||||
|
return Ansi.ansi().bg(Ansi.Color.YELLOW).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String bgBlue(String text) {
|
||||||
|
return Ansi.ansi().bg(Ansi.Color.BLUE).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Стили ===
|
||||||
public static String bold(String text) {
|
public static String bold(String text) {
|
||||||
return Ansi.ansi().bold().a(text).reset().toString();
|
return Ansi.ansi().bold().a(text).reset().toString();
|
||||||
}
|
}
|
||||||
@@ -64,17 +110,73 @@ public class ZAnsi {
|
|||||||
return Ansi.ansi().reset().toString();
|
return Ansi.ansi().reset().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Комбинированные удобные методы
|
// === Комбинированные удобные методы ===
|
||||||
public static String header(String text) {
|
public static String header(String text) {
|
||||||
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString();
|
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a(text).reset().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String success(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.GREEN).bold().a("[✓] " + text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String error(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.RED).bold().a("[✗] " + text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String warning(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.YELLOW).bold().a("[!] " + text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String info(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.CYAN).bold().a("[i] " + text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
public static String selected(String text) {
|
public static String selected(String text) {
|
||||||
return Ansi.ansi()
|
return Ansi.ansi()
|
||||||
.bgBright(Ansi.Color.WHITE)
|
.bgBright(Ansi.Color.WHITE)
|
||||||
.fgBlack()
|
.fg(Ansi.Color.BLACK)
|
||||||
|
.bold()
|
||||||
.a(" > " + text + " ")
|
.a(" > " + text + " ")
|
||||||
.reset()
|
.reset()
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static String dim(String text) {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.BLACK).a(text).reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Цветной текст для ролей ===
|
||||||
|
public static String roleUser(String text) {
|
||||||
|
return white(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String rolePassHolder(String text) {
|
||||||
|
return brightGreen(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String roleModerator(String text) {
|
||||||
|
return brightBlue(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String roleElder(String text) {
|
||||||
|
return brightPurple(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String roleCreator(String text) {
|
||||||
|
return brightRed(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Очистка экрана ===
|
||||||
|
public static String clearScreen() {
|
||||||
|
return Ansi.ansi().eraseScreen().cursor(1, 1).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Прогресс бар символы ===
|
||||||
|
public static String progressChar() {
|
||||||
|
return Ansi.ansi().fgBright(Ansi.Color.CYAN).a("█").reset().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String progressEmpty() {
|
||||||
|
return Ansi.ansi().fg(Ansi.Color.BLACK).a("░").reset().toString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
# admin_router.py
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Request, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
import structlog
|
||||||
|
import time
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from auth import get_db, require_role, log_audit, get_current_user
|
||||||
|
from roles import (
|
||||||
|
ROLE_PERMISSIONS, UserRole, ROLE_NAMES, has_permission, Permissions,
|
||||||
|
ROLE_USER, ROLE_PASS_HOLDER, ROLE_MODERATOR, ROLE_ELDER, ROLE_CREATOR
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
# ====================== МОДЕЛИ ======================
|
||||||
|
class UpdateRoleRequest(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
role: int = Field(..., ge=0, le=4)
|
||||||
|
|
||||||
|
class PassRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
class PassDecision(BaseModel):
|
||||||
|
request_id: int
|
||||||
|
approved: bool
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
class CreatePassDirectRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
expires_days: Optional[int] = Field(None, ge=1, le=365)
|
||||||
|
max_uses: int = Field(1, ge=1, le=10)
|
||||||
|
|
||||||
|
class BanUserRequest(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
days: int = Field(..., ge=1, le=365)
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
# ====================== ЭНДПОИНТЫ ======================
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
async def list_users(
|
||||||
|
current_user: dict = Depends(require_role(ROLE_MODERATOR)),
|
||||||
|
search: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""Список пользователей (модераторы видят всех, но без sensitive данных)"""
|
||||||
|
with get_db() as conn:
|
||||||
|
query = "SELECT id, username, uuid, role, created_at, last_login, is_active"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if current_user["role"] < ROLE_ELDER:
|
||||||
|
# Модераторы не видят забаненных
|
||||||
|
query += " FROM users WHERE is_active = 1"
|
||||||
|
else:
|
||||||
|
query += " FROM users"
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query += " AND (username LIKE ? OR email LIKE ?)"
|
||||||
|
params.extend([f"%{search}%", f"%{search}%"])
|
||||||
|
|
||||||
|
query += " ORDER BY role DESC, username"
|
||||||
|
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
users = []
|
||||||
|
for row in rows:
|
||||||
|
user_data = {
|
||||||
|
"id": row["id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"uuid": row["uuid"],
|
||||||
|
"role": row["role"],
|
||||||
|
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"last_login": row["last_login"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Elder и Creator видят больше информации
|
||||||
|
if current_user["role"] >= ROLE_ELDER:
|
||||||
|
user_data["is_active"] = row["is_active"]
|
||||||
|
# Получаем информацию о проходке
|
||||||
|
pass_info = conn.execute("""
|
||||||
|
SELECT code, expires_at, activated_at
|
||||||
|
FROM user_passes up
|
||||||
|
JOIN passes p ON up.pass_code = p.code
|
||||||
|
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||||
|
LIMIT 1
|
||||||
|
""", (row["id"], time.time())).fetchone()
|
||||||
|
|
||||||
|
if pass_info:
|
||||||
|
user_data["has_pass"] = True
|
||||||
|
user_data["pass_expires"] = pass_info["expires_at"]
|
||||||
|
|
||||||
|
users.append(user_data)
|
||||||
|
|
||||||
|
return {"users": users, "total": len(users)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}")
|
||||||
|
async def get_user_detail(
|
||||||
|
user_id: int,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_MODERATOR))
|
||||||
|
):
|
||||||
|
"""Детальная информация о пользователе"""
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute("""
|
||||||
|
SELECT id, username, email, uuid, role, created_at, last_login, is_active, banned_until
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
""", (user_id,)).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Пользователь не найден")
|
||||||
|
|
||||||
|
# Модераторы не видят email обычных пользователей
|
||||||
|
if current_user["role"] < ROLE_ELDER and row["role"] < ROLE_MODERATOR:
|
||||||
|
email = None
|
||||||
|
else:
|
||||||
|
email = row["email"]
|
||||||
|
|
||||||
|
# Получаем активную проходку
|
||||||
|
pass_info = None
|
||||||
|
if row["role"] >= ROLE_PASS_HOLDER or current_user["role"] >= ROLE_ELDER:
|
||||||
|
pass_row = conn.execute("""
|
||||||
|
SELECT p.code, p.expires_at, up.activated_at
|
||||||
|
FROM user_passes up
|
||||||
|
JOIN passes p ON up.pass_code = p.code
|
||||||
|
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||||
|
LIMIT 1
|
||||||
|
""", (user_id, time.time())).fetchone()
|
||||||
|
|
||||||
|
if pass_row:
|
||||||
|
pass_info = {
|
||||||
|
"code": pass_row["code"][:8] + "..." if current_user["role"] < ROLE_ELDER else pass_row["code"],
|
||||||
|
"expires_at": pass_row["expires_at"],
|
||||||
|
"activated_at": pass_row["activated_at"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Логи действий (только для Elder+)
|
||||||
|
actions = []
|
||||||
|
if current_user["role"] >= ROLE_ELDER:
|
||||||
|
action_rows = conn.execute("""
|
||||||
|
SELECT action, details, timestamp FROM audit_log
|
||||||
|
WHERE user_id = ? ORDER BY timestamp DESC LIMIT 20
|
||||||
|
""", (user_id,)).fetchall()
|
||||||
|
actions = [dict(row) for row in action_rows]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"email": email,
|
||||||
|
"uuid": row["uuid"],
|
||||||
|
"role": row["role"],
|
||||||
|
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"last_login": row["last_login"],
|
||||||
|
"is_active": row["is_active"],
|
||||||
|
"banned_until": row["banned_until"],
|
||||||
|
"has_pass": pass_info is not None,
|
||||||
|
"pass_info": pass_info,
|
||||||
|
"recent_actions": actions if current_user["role"] >= ROLE_ELDER else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/role")
|
||||||
|
async def update_user_role(
|
||||||
|
user_id: int,
|
||||||
|
body: UpdateRoleRequest,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
request: Request = None
|
||||||
|
):
|
||||||
|
"""Изменение роли пользователя"""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
target = conn.execute(
|
||||||
|
"SELECT id, username, role FROM users WHERE id = ?",
|
||||||
|
(user_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, "Пользователь не найден")
|
||||||
|
|
||||||
|
# Проверки прав
|
||||||
|
if target["role"] == ROLE_CREATOR and current_user["role"] != ROLE_CREATOR:
|
||||||
|
raise HTTPException(403, "Нельзя изменить роль создателя")
|
||||||
|
|
||||||
|
if target["role"] >= current_user["role"] and current_user["role"] != ROLE_CREATOR:
|
||||||
|
raise HTTPException(403, "Нельзя изменять роль пользователя с равным или высшим уровнем")
|
||||||
|
|
||||||
|
if body.role > current_user["role"] and current_user["role"] != ROLE_CREATOR:
|
||||||
|
raise HTTPException(403, f"Нельзя назначить роль выше своей ({ROLE_NAMES[current_user['role']]})")
|
||||||
|
|
||||||
|
# Elder не может создавать других Elder (только Creator)
|
||||||
|
if body.role == ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
|
||||||
|
raise HTTPException(403, "Только создатель может назначать Elder Moderator")
|
||||||
|
|
||||||
|
# Проверяем, нужно ли выдать/отозвать проходку
|
||||||
|
old_role = target["role"]
|
||||||
|
new_role = body.role
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET role = ? WHERE id = ?",
|
||||||
|
(new_role, user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Управление проходками при изменении роли
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if new_role >= ROLE_PASS_HOLDER and old_role < ROLE_PASS_HOLDER:
|
||||||
|
# Выдаем проходку если её нет
|
||||||
|
existing = conn.execute("""
|
||||||
|
SELECT 1 FROM user_passes up
|
||||||
|
JOIN passes p ON up.pass_code = p.code
|
||||||
|
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||||
|
""", (user_id, now)).fetchone()
|
||||||
|
|
||||||
|
if not existing:
|
||||||
|
# Создаем автоматическую проходку
|
||||||
|
pass_code = f"AUTO_{secrets.token_hex(8).upper()}"
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
|
||||||
|
VALUES (?, ?, NULL, 1, 1)
|
||||||
|
""", (pass_code, target["username"]))
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (user_id, pass_code, now))
|
||||||
|
|
||||||
|
logger.info("Auto-pass issued", user=target["username"], role=new_role)
|
||||||
|
|
||||||
|
elif new_role < ROLE_PASS_HOLDER and old_role >= ROLE_PASS_HOLDER:
|
||||||
|
# Отзываем проходку
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE passes SET is_active = 0
|
||||||
|
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
|
||||||
|
""", (user_id,))
|
||||||
|
|
||||||
|
logger.info("Auto-pass revoked", user=target["username"])
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
current_user["id"],
|
||||||
|
"role_change",
|
||||||
|
f"Changed role of {target['username']} from {old_role} to {new_role}",
|
||||||
|
ip
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Role updated", admin=current_user["username"], target=target["username"], new_role=new_role)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user_id": user_id,
|
||||||
|
"username": target["username"],
|
||||||
|
"old_role": old_role,
|
||||||
|
"old_role_name": ROLE_NAMES.get(old_role, "Неизвестно"),
|
||||||
|
"new_role": new_role,
|
||||||
|
"new_role_name": ROLE_NAMES.get(new_role, "Неизвестно")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pass/grant")
|
||||||
|
async def grant_pass(
|
||||||
|
body: CreatePassDirectRequest,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
request: Request = None
|
||||||
|
):
|
||||||
|
"""Выдача проходки пользователю (Elder+ могут выдавать)"""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
# Проверяем право на прямую выдачу
|
||||||
|
if current_user["role"] < ROLE_CREATOR and not has_permission(current_user["role"], Permissions.APPROVE_PASS):
|
||||||
|
raise HTTPException(403, "Недостаточно прав для выдачи проходки")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
target = conn.execute(
|
||||||
|
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
|
||||||
|
(body.username,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, f"Пользователь {body.username} не найден")
|
||||||
|
|
||||||
|
# Проверяем, есть ли уже активная проходка
|
||||||
|
existing = conn.execute("""
|
||||||
|
SELECT p.code FROM user_passes up
|
||||||
|
JOIN passes p ON up.pass_code = p.code
|
||||||
|
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||||
|
""", (target["id"], time.time())).fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(409, f"У пользователя {body.username} уже есть активная проходка")
|
||||||
|
|
||||||
|
# Создаем проходку
|
||||||
|
pass_code = secrets.token_hex(12).upper()
|
||||||
|
now = time.time()
|
||||||
|
expires_at = now + (body.expires_days * 86400) if body.expires_days else None
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO passes (code, owner, expires_at, max_uses, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, 1)
|
||||||
|
""", (pass_code, target["username"], expires_at, body.max_uses))
|
||||||
|
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO user_passes (user_id, pass_code, activated_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""", (target["id"], pass_code, now))
|
||||||
|
|
||||||
|
# Обновляем роль если нужно
|
||||||
|
if target["role"] < ROLE_PASS_HOLDER:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET role = ? WHERE id = ?",
|
||||||
|
(ROLE_PASS_HOLDER, target["id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
current_user["id"],
|
||||||
|
"grant_pass",
|
||||||
|
f"Granted pass to {target['username']} (expires: {body.expires_days}d, max_uses: {body.max_uses})",
|
||||||
|
ip
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Pass granted", admin=current_user["username"], target=target["username"], code=pass_code)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"pass_code": pass_code,
|
||||||
|
"username": target["username"],
|
||||||
|
"expires_at": expires_at,
|
||||||
|
"expires_days": body.expires_days,
|
||||||
|
"max_uses": body.max_uses
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/pass/revoke/{username}")
|
||||||
|
async def revoke_pass(
|
||||||
|
username: str,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
request: Request = None
|
||||||
|
):
|
||||||
|
"""Отзыв проходки у пользователя"""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
target = conn.execute(
|
||||||
|
"SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE",
|
||||||
|
(username,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, f"Пользователь {username} не найден")
|
||||||
|
|
||||||
|
# Отзываем проходку
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE passes SET is_active = 0
|
||||||
|
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
|
||||||
|
""", (target["id"],))
|
||||||
|
|
||||||
|
# Понижаем роль если она была только из-за проходки
|
||||||
|
if target["role"] == ROLE_PASS_HOLDER:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET role = ? WHERE id = ?",
|
||||||
|
(ROLE_USER, target["id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_audit(current_user["id"], "revoke_pass", f"Revoked pass from {username}", ip)
|
||||||
|
logger.info("Pass revoked", admin=current_user["username"], target=username)
|
||||||
|
|
||||||
|
return {"success": True, "message": f"Проходка {username} отозвана"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user/ban")
|
||||||
|
async def ban_user(
|
||||||
|
body: BanUserRequest,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
request: Request = None
|
||||||
|
):
|
||||||
|
"""Бан пользователя (Elder+ могут банить)"""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
target = conn.execute(
|
||||||
|
"SELECT id, username, role FROM users WHERE id = ?",
|
||||||
|
(body.user_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, "Пользователь не найден")
|
||||||
|
|
||||||
|
# Нельзя забанить создателя
|
||||||
|
if target["role"] == ROLE_CREATOR:
|
||||||
|
raise HTTPException(403, "Нельзя забанить создателя")
|
||||||
|
|
||||||
|
# Elder не может банить других Elder
|
||||||
|
if target["role"] >= ROLE_ELDER and current_user["role"] != ROLE_CREATOR:
|
||||||
|
raise HTTPException(403, "Недостаточно прав для бана этого пользователя")
|
||||||
|
|
||||||
|
banned_until = time.time() + (body.days * 86400)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET is_active = 0, banned_until = ? WHERE id = ?",
|
||||||
|
(banned_until, target["id"])
|
||||||
|
)
|
||||||
|
|
||||||
|
# Отзываем проходку при бане
|
||||||
|
conn.execute("""
|
||||||
|
UPDATE passes SET is_active = 0
|
||||||
|
WHERE code IN (SELECT pass_code FROM user_passes WHERE user_id = ?)
|
||||||
|
""", (target["id"],))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
current_user["id"],
|
||||||
|
"ban_user",
|
||||||
|
f"Banned {target['username']} for {body.days} days. Reason: {body.reason}",
|
||||||
|
ip
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("User banned", admin=current_user["username"], target=target["username"], days=body.days)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"username": target["username"],
|
||||||
|
"banned_until": banned_until,
|
||||||
|
"days": body.days
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user/unban/{user_id}")
|
||||||
|
async def unban_user(
|
||||||
|
user_id: int,
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
request: Request = None
|
||||||
|
):
|
||||||
|
"""Разбан пользователя"""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
target = conn.execute(
|
||||||
|
"SELECT id, username FROM users WHERE id = ?",
|
||||||
|
(user_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(404, "Пользователь не найден")
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET is_active = 1, banned_until = NULL WHERE id = ?",
|
||||||
|
(user_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
log_audit(current_user["id"], "unban_user", f"Unbanned {target['username']}", ip)
|
||||||
|
logger.info("User unbanned", admin=current_user["username"], target=target["username"])
|
||||||
|
|
||||||
|
return {"success": True, "username": target["username"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit")
|
||||||
|
async def get_audit_log(
|
||||||
|
current_user: dict = Depends(require_role(ROLE_ELDER)),
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
user_id: Optional[int] = None
|
||||||
|
):
|
||||||
|
"""Просмотр логов аудита (только Elder+)"""
|
||||||
|
with get_db() as conn:
|
||||||
|
query = """
|
||||||
|
SELECT al.*, u.username
|
||||||
|
FROM audit_log al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
query += " WHERE al.user_id = ?"
|
||||||
|
params.append(user_id)
|
||||||
|
|
||||||
|
query += " ORDER BY al.timestamp DESC LIMIT ? OFFSET ?"
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
|
||||||
|
total = conn.execute("SELECT COUNT(*) as count FROM audit_log").fetchone()["count"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"logs": [dict(row) for row in rows],
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def get_admin_stats(
|
||||||
|
current_user: dict = Depends(require_role(ROLE_MODERATOR))
|
||||||
|
):
|
||||||
|
"""Статистика для админов"""
|
||||||
|
with get_db() as conn:
|
||||||
|
# Общая статистика
|
||||||
|
total_users = conn.execute("SELECT COUNT(*) as count FROM users").fetchone()["count"]
|
||||||
|
|
||||||
|
# Статистика по ролям
|
||||||
|
role_stats = conn.execute("""
|
||||||
|
SELECT role, COUNT(*) as count
|
||||||
|
FROM users
|
||||||
|
GROUP BY role
|
||||||
|
ORDER BY role DESC
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
# Активные проходки
|
||||||
|
active_passes = conn.execute("""
|
||||||
|
SELECT COUNT(*) as count FROM user_passes up
|
||||||
|
JOIN passes p ON up.pass_code = p.code
|
||||||
|
WHERE p.expires_at IS NULL OR p.expires_at > ?
|
||||||
|
""", (time.time(),)).fetchone()["count"]
|
||||||
|
|
||||||
|
# Забаненные пользователи
|
||||||
|
banned_users = conn.execute("""
|
||||||
|
SELECT COUNT(*) as count FROM users
|
||||||
|
WHERE is_active = 0 AND banned_until > ?
|
||||||
|
""", (time.time(),)).fetchone()["count"]
|
||||||
|
|
||||||
|
# Недавние регистрации (последние 7 дней)
|
||||||
|
week_ago = time.time() - (7 * 86400)
|
||||||
|
recent_registrations = conn.execute("""
|
||||||
|
SELECT COUNT(*) as count FROM users WHERE created_at > ?
|
||||||
|
""", (week_ago,)).fetchone()["count"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_users": total_users,
|
||||||
|
"active_passes": active_passes,
|
||||||
|
"banned_users": banned_users,
|
||||||
|
"recent_registrations_7d": recent_registrations,
|
||||||
|
"roles_distribution": [
|
||||||
|
{"role": r["role"], "role_name": ROLE_NAMES.get(r["role"], "Неизвестно"), "count": r["count"]}
|
||||||
|
for r in role_stats
|
||||||
|
],
|
||||||
|
"my_info": {
|
||||||
|
"role": current_user["role"],
|
||||||
|
"role_name": ROLE_NAMES.get(current_user["role"], "Неизвестно"),
|
||||||
|
"username": current_user["username"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
async def get_my_info(current_user: dict = Depends(get_current_user)):
|
||||||
|
"""Информация о текущем пользователе с правами"""
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute("""
|
||||||
|
SELECT id, username, email, uuid, role, created_at, last_login
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
""", (current_user["id"],)).fetchone()
|
||||||
|
|
||||||
|
# Проверяем наличие активной проходки
|
||||||
|
has_pass = False
|
||||||
|
if row["role"] >= ROLE_PASS_HOLDER:
|
||||||
|
pass_row = conn.execute("""
|
||||||
|
SELECT 1 FROM user_passes up
|
||||||
|
JOIN passes p ON up.pass_code = p.code
|
||||||
|
WHERE up.user_id = ? AND (p.expires_at IS NULL OR p.expires_at > ?)
|
||||||
|
""", (current_user["id"], time.time())).fetchone()
|
||||||
|
has_pass = pass_row is not None
|
||||||
|
|
||||||
|
permissions = list(ROLE_PERMISSIONS.get(row["role"], set()))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"email": row["email"],
|
||||||
|
"uuid": row["uuid"],
|
||||||
|
"role": row["role"],
|
||||||
|
"role_name": ROLE_NAMES.get(row["role"], "Неизвестно"),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"last_login": row["last_login"],
|
||||||
|
"has_pass": has_pass,
|
||||||
|
"permissions": permissions
|
||||||
|
}
|
||||||
+445
-252
@@ -8,25 +8,33 @@ import time
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
from fastapi import APIRouter, HTTPException, Request, Depends, status
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
import re
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
# ====================== КОНФИГ ======================
|
# ====================== КОНФИГ ======================
|
||||||
AUTH_DB = Path("data/auth.db")
|
AUTH_DB = Path("data/auth.db")
|
||||||
AUTH_DB.parent.mkdir(exist_ok=True)
|
AUTH_DB.parent.mkdir(exist_ok=True)
|
||||||
|
|
||||||
SECRET_KEY = Path("data/.secret_key")
|
SECRET_KEY = Path("data/.secret_key")
|
||||||
|
RATE_LIMIT_DB = Path("data/rate_limit.db")
|
||||||
|
|
||||||
ACCESS_TOKEN_EXPIRE_SECONDS = 24 * 3600 # 24 часа
|
# Токены
|
||||||
REFRESH_TOKEN_EXPIRE_SECONDS = 30 * 86400 # 30 дней
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 часа
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS = 30
|
||||||
|
|
||||||
|
# Лимиты
|
||||||
|
MAX_LOGIN_ATTEMPTS = 5
|
||||||
|
LOGIN_BLOCK_MINUTES = 15
|
||||||
|
|
||||||
# ====================== СЕКРЕТНЫЙ КЛЮЧ ======================
|
# ====================== СЕКРЕТНЫЙ КЛЮЧ ======================
|
||||||
def _get_secret() -> bytes:
|
def _get_secret() -> bytes:
|
||||||
|
"""Безопасное получение/создание секретного ключа"""
|
||||||
if SECRET_KEY.exists():
|
if SECRET_KEY.exists():
|
||||||
return SECRET_KEY.read_bytes()
|
return SECRET_KEY.read_bytes()
|
||||||
key = secrets.token_bytes(64)
|
key = secrets.token_bytes(64)
|
||||||
@@ -36,31 +44,46 @@ def _get_secret() -> bytes:
|
|||||||
|
|
||||||
_SECRET = _get_secret()
|
_SECRET = _get_secret()
|
||||||
|
|
||||||
def create_jwt(payload: dict) -> str:
|
# ====================== JWT ФУНКЦИИ ======================
|
||||||
|
def create_jwt(payload: dict, expires_in: int = None) -> str:
|
||||||
|
"""Создание JWT токена"""
|
||||||
|
if expires_in is None:
|
||||||
|
expires_in = ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||||
|
|
||||||
|
payload = payload.copy()
|
||||||
|
payload["exp"] = time.time() + expires_in
|
||||||
|
payload["iat"] = time.time()
|
||||||
|
payload["jti"] = secrets.token_hex(16)
|
||||||
|
|
||||||
header = base64.urlsafe_b64encode(
|
header = base64.urlsafe_b64encode(
|
||||||
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
|
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
|
||||||
).rstrip(b'=').decode()
|
).rstrip(b'=').decode()
|
||||||
|
|
||||||
body = base64.urlsafe_b64encode(
|
body = base64.urlsafe_b64encode(
|
||||||
json.dumps(payload).encode()
|
json.dumps(payload).encode()
|
||||||
).rstrip(b'=').decode()
|
).rstrip(b'=').decode()
|
||||||
|
|
||||||
msg = f"{header}.{body}".encode()
|
msg = f"{header}.{body}".encode()
|
||||||
sig = hmac.new(_SECRET, msg, hashlib.sha256).digest()
|
sig = hmac.new(_SECRET, msg, hashlib.sha256).digest()
|
||||||
|
|
||||||
return f"{header}.{body}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}"
|
return f"{header}.{body}.{base64.urlsafe_b64encode(sig).rstrip(b'=').decode()}"
|
||||||
|
|
||||||
def verify_jwt(token: str) -> Optional[dict]:
|
def verify_jwt(token: str) -> Optional[dict]:
|
||||||
|
"""Верификация JWT токена"""
|
||||||
try:
|
try:
|
||||||
parts = token.split(".")
|
parts = token.split(".")
|
||||||
if len(parts) != 3:
|
if len(parts) != 3:
|
||||||
return None
|
return None
|
||||||
header, body, sig = parts
|
|
||||||
msg = f"{header}.{body}".encode()
|
|
||||||
expected = hmac.new(_SECRET, msg, hashlib.sha256).digest()
|
|
||||||
|
|
||||||
# Исправлено: правильный паддинг для base64url
|
header, body, sig = parts
|
||||||
|
|
||||||
sig_padded = sig + '=' * (4 - len(sig) % 4)
|
sig_padded = sig + '=' * (4 - len(sig) % 4)
|
||||||
|
expected_sig = base64.urlsafe_b64decode(sig_padded)
|
||||||
|
|
||||||
|
msg = f"{header}.{body}".encode()
|
||||||
if not hmac.compare_digest(
|
if not hmac.compare_digest(
|
||||||
base64.urlsafe_b64decode(sig_padded),
|
hmac.new(_SECRET, msg, hashlib.sha256).digest(),
|
||||||
expected
|
expected_sig
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -69,75 +92,154 @@ def verify_jwt(token: str) -> Optional[dict]:
|
|||||||
|
|
||||||
if payload.get("exp", 0) < time.time():
|
if payload.get("exp", 0) < time.time():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# ====================== БАЗА ДАННЫХ ======================
|
# ====================== БАЗА ДАННЫХ ======================
|
||||||
|
@contextmanager
|
||||||
def get_db():
|
def get_db():
|
||||||
conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False)
|
"""Контекстный менеджер для БД"""
|
||||||
|
conn = sqlite3.connect(str(AUTH_DB), check_same_thread=False, timeout=10)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def init_db():
|
def init_rate_limit_db():
|
||||||
conn = get_db()
|
"""Инициализация БД для rate limiting"""
|
||||||
|
conn = sqlite3.connect(str(RATE_LIMIT_DB))
|
||||||
conn.executescript("""
|
conn.executescript("""
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
ip TEXT PRIMARY KEY,
|
||||||
username TEXT UNIQUE COLLATE NOCASE,
|
attempts INTEGER DEFAULT 1,
|
||||||
password_hash TEXT NOT NULL,
|
first_attempt REAL NOT NULL,
|
||||||
uuid TEXT UNIQUE NOT NULL,
|
last_attempt REAL NOT NULL,
|
||||||
created_at REAL NOT NULL,
|
blocked_until REAL
|
||||||
last_login REAL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS passes (
|
|
||||||
code TEXT PRIMARY KEY,
|
|
||||||
owner TEXT,
|
|
||||||
is_active BOOLEAN DEFAULT 1,
|
|
||||||
activated_by INTEGER REFERENCES users(id),
|
|
||||||
activated_at REAL,
|
|
||||||
expires_at REAL,
|
|
||||||
max_uses INTEGER DEFAULT 1,
|
|
||||||
uses INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_passes (
|
|
||||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
pass_code TEXT REFERENCES passes(code),
|
|
||||||
activated_at REAL NOT NULL,
|
|
||||||
PRIMARY KEY (user_id, pass_code)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token_hash TEXT NOT NULL,
|
|
||||||
expires_at REAL NOT NULL
|
|
||||||
);
|
);
|
||||||
""")
|
""")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Инициализация основной БД"""
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
uuid TEXT UNIQUE NOT NULL,
|
||||||
|
role INTEGER DEFAULT 0,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
last_login REAL,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
banned_until REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
jti TEXT NOT NULL,
|
||||||
|
expires_at REAL NOT NULL,
|
||||||
|
revoked BOOLEAN DEFAULT 0,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
session_token TEXT UNIQUE NOT NULL,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
expires_at REAL NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS passes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
owner TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
activated_by INTEGER REFERENCES users(id),
|
||||||
|
activated_at REAL,
|
||||||
|
expires_at REAL,
|
||||||
|
max_uses INTEGER DEFAULT 1,
|
||||||
|
uses INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_passes (
|
||||||
|
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
pass_code TEXT REFERENCES passes(code),
|
||||||
|
activated_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, pass_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pass_requests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
requester_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
target_username TEXT NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
decision_reason TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
reviewed_by INTEGER REFERENCES users(id),
|
||||||
|
reviewed_at REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
timestamp REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(timestamp);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refresh_user ON refresh_tokens(user_id);
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Добавляем колонку role если её нет
|
||||||
|
cursor = conn.execute("PRAGMA table_info(users)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if "role" not in columns:
|
||||||
|
conn.execute("ALTER TABLE users ADD COLUMN role INTEGER DEFAULT 0")
|
||||||
|
logger.info("Added role column to users table")
|
||||||
|
|
||||||
|
init_rate_limit_db()
|
||||||
logger.info("Auth database initialized")
|
logger.info("Auth database initialized")
|
||||||
|
|
||||||
# ====================== ХЕЛПЕРЫ ======================
|
# ====================== ХЕЛПЕРЫ ======================
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
salt = secrets.token_hex(16)
|
"""Хэширование пароля"""
|
||||||
|
salt = secrets.token_hex(32)
|
||||||
hash_obj = hashlib.pbkdf2_hmac(
|
hash_obj = hashlib.pbkdf2_hmac(
|
||||||
'sha256',
|
'sha256',
|
||||||
password.encode(),
|
password.encode('utf-8'),
|
||||||
salt.encode(),
|
salt.encode('utf-8'),
|
||||||
300000
|
300000
|
||||||
)
|
)
|
||||||
return f"{salt}${hash_obj.hex()}"
|
return f"{salt}${hash_obj.hex()}"
|
||||||
|
|
||||||
def verify_password(password: str, stored: str) -> bool:
|
def verify_password(password: str, stored: str) -> bool:
|
||||||
|
"""Верификация пароля"""
|
||||||
try:
|
try:
|
||||||
salt, stored_hash = stored.split('$')
|
salt, stored_hash = stored.split('$')
|
||||||
hash_obj = hashlib.pbkdf2_hmac(
|
hash_obj = hashlib.pbkdf2_hmac(
|
||||||
'sha256',
|
'sha256',
|
||||||
password.encode(),
|
password.encode('utf-8'),
|
||||||
salt.encode(),
|
salt.encode('utf-8'),
|
||||||
300000
|
300000
|
||||||
)
|
)
|
||||||
return hmac.compare_digest(hash_obj.hex(), stored_hash)
|
return hmac.compare_digest(hash_obj.hex(), stored_hash)
|
||||||
@@ -145,123 +247,326 @@ def verify_password(password: str, stored: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def generate_uuid() -> str:
|
def generate_uuid() -> str:
|
||||||
|
"""Генерация UUID"""
|
||||||
return f"{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}"
|
return f"{secrets.token_hex(4)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(6)}"
|
||||||
|
|
||||||
|
def check_rate_limit(ip: str) -> tuple[bool, Optional[int]]:
|
||||||
|
"""Проверка rate limiting"""
|
||||||
|
conn = sqlite3.connect(str(RATE_LIMIT_DB))
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT attempts, blocked_until FROM login_attempts WHERE ip = ?",
|
||||||
|
(ip,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
blocked_until = row[1]
|
||||||
|
if blocked_until and blocked_until > now:
|
||||||
|
return False, int(blocked_until - now)
|
||||||
|
|
||||||
|
if row[0] >= MAX_LOGIN_ATTEMPTS:
|
||||||
|
blocked_until = now + (LOGIN_BLOCK_MINUTES * 60)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE login_attempts SET blocked_until = ? WHERE ip = ?",
|
||||||
|
(blocked_until, ip)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return False, LOGIN_BLOCK_MINUTES * 60
|
||||||
|
return True, None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def record_login_attempt(ip: str, success: bool):
|
||||||
|
"""Запись попытки входа"""
|
||||||
|
conn = sqlite3.connect(str(RATE_LIMIT_DB))
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if success:
|
||||||
|
conn.execute("DELETE FROM login_attempts WHERE ip = ?", (ip,))
|
||||||
|
else:
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO login_attempts (ip, attempts, first_attempt, last_attempt)
|
||||||
|
VALUES (?, 1, ?, ?)
|
||||||
|
ON CONFLICT(ip) DO UPDATE SET
|
||||||
|
attempts = attempts + 1,
|
||||||
|
last_attempt = ?
|
||||||
|
""", (ip, now, now, now))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def log_audit(user_id: int, action: str, details: str, ip_address: str):
|
||||||
|
"""Логирование действий"""
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO audit_log (user_id, action, details, ip_address, timestamp) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(user_id, action, details, ip_address, time.time())
|
||||||
|
)
|
||||||
|
|
||||||
# ====================== МОДЕЛИ ======================
|
# ====================== МОДЕЛИ ======================
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str = Field(..., min_length=3, max_length=32)
|
||||||
password: str
|
password: str = Field(..., min_length=6, max_length=128)
|
||||||
|
|
||||||
|
@field_validator('username')
|
||||||
|
def validate_username(cls, v):
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_]+$', v):
|
||||||
|
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
|
||||||
|
return v.lower()
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
username: str = Field(..., min_length=3, max_length=16, pattern=r"^[a-zA-Z0-9_]+$")
|
username: str = Field(..., min_length=3, max_length=32)
|
||||||
password: str = Field(..., min_length=6, max_length=128)
|
password: str = Field(..., min_length=6, max_length=128)
|
||||||
|
|
||||||
|
@field_validator('username')
|
||||||
|
def validate_username(cls, v):
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_]+$', v):
|
||||||
|
raise ValueError('Имя пользователя может содержать только буквы, цифры и подчеркивания')
|
||||||
|
return v.lower()
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
expires_in: int
|
expires_in: int
|
||||||
|
token_type: str = "bearer"
|
||||||
username: str
|
username: str
|
||||||
uuid: str
|
uuid: str
|
||||||
|
role: int
|
||||||
|
role_name: str
|
||||||
|
|
||||||
# ====================== ROUTER ======================
|
# ====================== DEPENDENCIES ======================
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
bearer = HTTPBearer(auto_error=False)
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
def _issue_tokens(conn, user_id: int, username: str, uuid: str) -> TokenResponse:
|
async def get_current_user(
|
||||||
now = time.time()
|
credentials: HTTPAuthorizationCredentials = Depends(bearer),
|
||||||
|
request: Request = None
|
||||||
|
) -> dict:
|
||||||
|
"""Получение текущего пользователя"""
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Не авторизован",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
access_token = create_jwt({
|
payload = verify_jwt(credentials.credentials)
|
||||||
"sub": user_id,
|
if not payload or payload.get("type") != "access":
|
||||||
"username": username,
|
raise HTTPException(
|
||||||
"uuid": uuid,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
"type": "access",
|
detail="Недействительный токен"
|
||||||
"exp": now + ACCESS_TOKEN_EXPIRE_SECONDS
|
)
|
||||||
})
|
|
||||||
|
|
||||||
refresh_token = create_jwt({
|
with get_db() as conn:
|
||||||
"sub": user_id,
|
user = conn.execute(
|
||||||
"type": "refresh",
|
"SELECT id, username, uuid, role, is_active, banned_until FROM users WHERE id = ?",
|
||||||
"exp": now + REFRESH_TOKEN_EXPIRE_SECONDS
|
(payload["sub"],)
|
||||||
})
|
).fetchone()
|
||||||
|
|
||||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
if not user:
|
||||||
|
raise HTTPException(401, "Пользователь не найден")
|
||||||
|
|
||||||
conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", (user_id,))
|
if not user["is_active"]:
|
||||||
conn.execute(
|
raise HTTPException(403, "Аккаунт деактивирован")
|
||||||
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
|
|
||||||
(user_id, token_hash, now + REFRESH_TOKEN_EXPIRE_SECONDS)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return TokenResponse(
|
if user["banned_until"] and user["banned_until"] > time.time():
|
||||||
access_token=access_token,
|
raise HTTPException(403, "Аккаунт забанен")
|
||||||
refresh_token=refresh_token,
|
|
||||||
expires_in=ACCESS_TOKEN_EXPIRE_SECONDS,
|
|
||||||
username=username,
|
|
||||||
uuid=uuid
|
|
||||||
)
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": user["id"],
|
||||||
|
"username": user["username"],
|
||||||
|
"uuid": user["uuid"],
|
||||||
|
"role": user["role"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def require_role(min_role: int):
|
||||||
|
"""Декоратор для проверки роли"""
|
||||||
|
async def dependency(current_user: dict = Depends(get_current_user)):
|
||||||
|
if current_user["role"] < min_role:
|
||||||
|
from roles import ROLE_NAMES
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Требуется роль {ROLE_NAMES.get(min_role, 'неизвестная')}"
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
return dependency
|
||||||
|
|
||||||
|
# ====================== ЭНДПОИНТЫ ======================
|
||||||
@router.post("/register", response_model=TokenResponse)
|
@router.post("/register", response_model=TokenResponse)
|
||||||
async def register(body: RegisterRequest, request: Request):
|
async def register(body: RegisterRequest, request: Request):
|
||||||
conn = get_db()
|
"""Регистрация нового пользователя"""
|
||||||
try:
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
allowed, wait = check_rate_limit(ip)
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT 1 FROM users WHERE username = ? COLLATE NOCASE",
|
"SELECT username FROM users WHERE username = ?",
|
||||||
(body.username,)
|
(body.username,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
raise HTTPException(status_code=409, detail="Имя пользователя уже занято")
|
raise HTTPException(409, "Пользователь с таким именем уже существует")
|
||||||
|
|
||||||
uuid = generate_uuid()
|
uuid = generate_uuid()
|
||||||
pw_hash = hash_password(body.password)
|
pw_hash = hash_password(body.password)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"INSERT INTO users (username, password_hash, uuid, created_at) VALUES (?, ?, ?, ?)",
|
"""INSERT INTO users (username, password_hash, uuid, created_at, role)
|
||||||
(body.username, pw_hash, uuid, now)
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
(body.username, pw_hash, uuid, now, 0) # role 0 = обычный пользователь
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
user_id = cursor.lastrowid
|
user_id = cursor.lastrowid
|
||||||
tokens = _issue_tokens(conn, user_id, body.username, uuid)
|
|
||||||
|
|
||||||
logger.info("User registered", username=body.username, user_id=user_id)
|
# Создаем сессию
|
||||||
return tokens
|
session_token = secrets.token_urlsafe(32)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(user_id, session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60))
|
||||||
|
)
|
||||||
|
|
||||||
except HTTPException:
|
# Токены
|
||||||
raise
|
access_token = create_jwt({
|
||||||
except Exception as e:
|
"sub": user_id,
|
||||||
logger.error("Register error", exc_info=True)
|
"username": body.username,
|
||||||
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
|
"uuid": uuid,
|
||||||
finally:
|
"role": 0,
|
||||||
conn.close()
|
"type": "access",
|
||||||
|
"jti": session_token
|
||||||
|
})
|
||||||
|
|
||||||
|
refresh_token = create_jwt({
|
||||||
|
"sub": user_id,
|
||||||
|
"type": "refresh",
|
||||||
|
"jti": secrets.token_hex(16)
|
||||||
|
}, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400)
|
||||||
|
|
||||||
|
refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(user_id, refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now)
|
||||||
|
)
|
||||||
|
|
||||||
|
log_audit(user_id, "register", f"User registered from {ip}", ip)
|
||||||
|
logger.info("User registered", username=body.username, user_id=user_id, ip=ip)
|
||||||
|
|
||||||
|
from roles import ROLE_NAMES
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||||
|
username=body.username,
|
||||||
|
uuid=uuid,
|
||||||
|
role=0,
|
||||||
|
role_name=ROLE_NAMES[0]
|
||||||
|
)
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(body: LoginRequest, request: Request):
|
async def login(body: LoginRequest, request: Request):
|
||||||
conn = get_db()
|
"""Вход в систему"""
|
||||||
try:
|
ip = request.client.host if request.client else "unknown"
|
||||||
row = conn.execute(
|
|
||||||
"SELECT id, username, password_hash, uuid FROM users WHERE username = ? COLLATE NOCASE",
|
allowed, wait = check_rate_limit(ip)
|
||||||
|
if not allowed:
|
||||||
|
raise HTTPException(429, f"Слишком много попыток. Подождите {wait} секунд")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
user = conn.execute(
|
||||||
|
"SELECT id, username, uuid, password_hash, role, is_active, banned_until FROM users WHERE username = ?",
|
||||||
(body.username,)
|
(body.username,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not row or not verify_password(body.password, row["password_hash"]):
|
if not user or not verify_password(body.password, user["password_hash"]):
|
||||||
|
record_login_attempt(ip, False)
|
||||||
|
log_audit(0, "login_failed", f"Failed login for {body.username} from {ip}", ip)
|
||||||
raise HTTPException(401, "Неверное имя пользователя или пароль")
|
raise HTTPException(401, "Неверное имя пользователя или пароль")
|
||||||
|
|
||||||
|
if not user["is_active"]:
|
||||||
|
raise HTTPException(403, "Аккаунт деактивирован")
|
||||||
|
|
||||||
|
if user["banned_until"] and user["banned_until"] > time.time():
|
||||||
|
raise HTTPException(403, "Аккаунт забанен")
|
||||||
|
|
||||||
|
record_login_attempt(ip, True)
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET last_login = ? WHERE id = ?",
|
"UPDATE users SET last_login = ? WHERE id = ?",
|
||||||
(time.time(), row["id"])
|
(now, user["id"])
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
logger.info("User logged in", username=body.username, user_id=row["id"])
|
# Создаем сессию
|
||||||
return _issue_tokens(conn, row["id"], row["username"], row["uuid"])
|
session_token = secrets.token_urlsafe(32)
|
||||||
finally:
|
conn.execute(
|
||||||
conn.close()
|
"INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
(user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60))
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = create_jwt({
|
||||||
|
"sub": user["id"],
|
||||||
|
"username": user["username"],
|
||||||
|
"uuid": user["uuid"],
|
||||||
|
"role": user["role"],
|
||||||
|
"type": "access",
|
||||||
|
"jti": session_token
|
||||||
|
})
|
||||||
|
|
||||||
|
refresh_token = create_jwt({
|
||||||
|
"sub": user["id"],
|
||||||
|
"type": "refresh",
|
||||||
|
"jti": secrets.token_hex(16)
|
||||||
|
}, expires_in=REFRESH_TOKEN_EXPIRE_DAYS * 86400)
|
||||||
|
|
||||||
|
refresh_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO refresh_tokens (user_id, token_hash, jti, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(user["id"], refresh_hash, secrets.token_hex(16), now + (REFRESH_TOKEN_EXPIRE_DAYS * 86400), now)
|
||||||
|
)
|
||||||
|
|
||||||
|
log_audit(user["id"], "login", f"User logged in from {ip}", ip)
|
||||||
|
logger.info("User logged in", username=user["username"], user_id=user["id"], ip=ip)
|
||||||
|
|
||||||
|
from roles import ROLE_NAMES
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
expires_in=ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||||
|
username=user["username"],
|
||||||
|
uuid=user["uuid"],
|
||||||
|
role=user["role"],
|
||||||
|
role_name=ROLE_NAMES.get(user["role"], "Неизвестно")
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(current_user: dict = Depends(get_current_user), request: Request = None):
|
||||||
|
"""Выход из системы"""
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE user_sessions SET is_active = 0 WHERE user_id = ?",
|
||||||
|
(current_user["id"],)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?",
|
||||||
|
(current_user["id"],)
|
||||||
|
)
|
||||||
|
|
||||||
|
log_audit(current_user["id"], "logout", f"User logged out from {ip}", ip)
|
||||||
|
|
||||||
|
logger.info("User logged out", user_id=current_user["id"], ip=ip)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
@router.post("/refresh")
|
@router.post("/refresh")
|
||||||
async def refresh(body: dict):
|
async def refresh(body: dict, request: Request):
|
||||||
|
"""Обновление access токена"""
|
||||||
refresh_token = body.get("refresh_token")
|
refresh_token = body.get("refresh_token")
|
||||||
if not refresh_token:
|
if not refresh_token:
|
||||||
raise HTTPException(400, "refresh_token обязателен")
|
raise HTTPException(400, "refresh_token обязателен")
|
||||||
@@ -270,158 +575,46 @@ async def refresh(body: dict):
|
|||||||
if not payload or payload.get("type") != "refresh":
|
if not payload or payload.get("type") != "refresh":
|
||||||
raise HTTPException(401, "Недействительный refresh token")
|
raise HTTPException(401, "Недействительный refresh token")
|
||||||
|
|
||||||
conn = get_db()
|
ip = request.client.host if request.client else "unknown"
|
||||||
try:
|
|
||||||
|
with get_db() as conn:
|
||||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
||||||
row = conn.execute(
|
token_row = conn.execute(
|
||||||
"SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?",
|
"SELECT user_id, revoked FROM refresh_tokens WHERE token_hash = ? AND expires_at > ?",
|
||||||
(token_hash, time.time())
|
(token_hash, time.time())
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not row:
|
if not token_row or token_row["revoked"]:
|
||||||
raise HTTPException(401, "Refresh token истёк или недействителен")
|
raise HTTPException(401, "Refresh token истёк или недействителен")
|
||||||
|
|
||||||
user_row = conn.execute(
|
user = conn.execute(
|
||||||
"SELECT id, username, uuid FROM users WHERE id = ?",
|
"SELECT id, username, uuid, role FROM users WHERE id = ? AND is_active = 1",
|
||||||
(row["user_id"],)
|
(token_row["user_id"],)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
if not user_row:
|
if not user:
|
||||||
raise HTTPException(401, "Пользователь не найден")
|
raise HTTPException(401, "Пользователь не найден или заблокирован")
|
||||||
|
|
||||||
return _issue_tokens(conn, user_row["id"], user_row["username"], user_row["uuid"])
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
@router.post("/logout")
|
|
||||||
async def logout(body: dict):
|
|
||||||
refresh_token = body.get("refresh_token")
|
|
||||||
if refresh_token:
|
|
||||||
conn = get_db()
|
|
||||||
try:
|
|
||||||
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM refresh_tokens WHERE token_hash = ?",
|
|
||||||
(token_hash,)
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
return {"success": True}
|
|
||||||
|
|
||||||
# ====================== ПРОХОДКИ ======================
|
|
||||||
|
|
||||||
class ActivatePassRequest(BaseModel):
|
|
||||||
pass_code: str = Field(..., min_length=8, max_length=20)
|
|
||||||
|
|
||||||
@router.post("/pass/activate")
|
|
||||||
async def activate_pass_endpoint(
|
|
||||||
body: ActivatePassRequest,
|
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(bearer)
|
|
||||||
):
|
|
||||||
if not credentials:
|
|
||||||
raise HTTPException(401, "Требуется авторизация")
|
|
||||||
|
|
||||||
payload = verify_jwt(credentials.credentials)
|
|
||||||
if not payload or payload.get("type") != "access":
|
|
||||||
raise HTTPException(401, "Недействительный токен")
|
|
||||||
|
|
||||||
user_id = payload["sub"]
|
|
||||||
username = payload["username"]
|
|
||||||
pass_code = body.pass_code.upper().strip()
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
try:
|
|
||||||
pass_row = conn.execute(
|
|
||||||
"SELECT code, expires_at, uses, max_uses, owner FROM passes WHERE code = ?",
|
|
||||||
(pass_code,)
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not pass_row:
|
|
||||||
raise HTTPException(404, "Проходка не найдена")
|
|
||||||
|
|
||||||
# Проверка срока
|
|
||||||
if pass_row["expires_at"] and pass_row["expires_at"] < time.time():
|
|
||||||
raise HTTPException(410, "Проходка истекла")
|
|
||||||
|
|
||||||
# Проверка лимита использований
|
|
||||||
if pass_row["uses"] >= pass_row["max_uses"]:
|
|
||||||
raise HTTPException(410, "Проходка уже использована")
|
|
||||||
|
|
||||||
# Проверка владельца
|
|
||||||
if pass_row["owner"] is not None:
|
|
||||||
if pass_row["owner"] != username:
|
|
||||||
raise HTTPException(409, "Проходка уже активирована другим пользователем")
|
|
||||||
|
|
||||||
# Уже активирована этим пользователем
|
|
||||||
return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"}
|
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
session_token = secrets.token_urlsafe(32)
|
||||||
# Активация
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO user_passes (user_id, pass_code, activated_at) VALUES (?, ?, ?)",
|
"INSERT INTO user_sessions (user_id, session_token, ip_address, user_agent, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
(user_id, pass_code, now)
|
(user["id"], session_token, ip, request.headers.get("user-agent", ""), now, now + (ACCESS_TOKEN_EXPIRE_MINUTES * 60))
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.execute(
|
new_access_token = create_jwt({
|
||||||
"""UPDATE passes
|
"sub": user["id"],
|
||||||
SET uses = uses + 1,
|
"username": user["username"],
|
||||||
owner = ?,
|
"uuid": user["uuid"],
|
||||||
activated_by = ?,
|
"role": user["role"],
|
||||||
activated_at = ?
|
"type": "access",
|
||||||
WHERE code = ?""",
|
"jti": session_token
|
||||||
(username, user_id, now, pass_code)
|
})
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
log_audit(user["id"], "refresh_token", f"Token refreshed from {ip}", ip)
|
||||||
|
|
||||||
logger.info("Pass activated", user_id=user_id, username=username, pass_code=pass_code)
|
|
||||||
return {"success": True, "message": "Проходка успешно активирована!"}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Pass activation error", exc_info=True)
|
|
||||||
raise HTTPException(500, f"Ошибка сервера: {str(e)}")
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
@router.get("/pass/my")
|
|
||||||
async def get_my_passes(credentials: HTTPAuthorizationCredentials = Depends(bearer)):
|
|
||||||
if not credentials:
|
|
||||||
raise HTTPException(401, "Требуется авторизация")
|
|
||||||
|
|
||||||
payload = verify_jwt(credentials.credentials)
|
|
||||||
if not payload:
|
|
||||||
raise HTTPException(401, "Недействительный токен")
|
|
||||||
|
|
||||||
user_id = payload["sub"]
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
try:
|
|
||||||
rows = conn.execute("""
|
|
||||||
SELECT p.code, p.expires_at, p.is_active, up.activated_at
|
|
||||||
FROM user_passes up
|
|
||||||
JOIN passes p ON up.pass_code = p.code
|
|
||||||
WHERE up.user_id = ?
|
|
||||||
""", (user_id,)).fetchall()
|
|
||||||
|
|
||||||
passes = []
|
|
||||||
now = time.time()
|
|
||||||
for row in rows:
|
|
||||||
expires = row["expires_at"]
|
|
||||||
is_active = row["is_active"] and (expires is None or expires > now)
|
|
||||||
passes.append({
|
|
||||||
"code": row["code"],
|
|
||||||
"activated_at": row["activated_at"],
|
|
||||||
"expires_at": expires,
|
|
||||||
"is_active": is_active
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"passes": passes,
|
"access_token": new_access_token,
|
||||||
"has_active": any(p["is_active"] for p in passes)
|
"expires_in": ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||||
|
"token_type": "bearer"
|
||||||
}
|
}
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
+35
-17
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.responses import FileResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -9,17 +9,23 @@ import logging
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||||
|
|
||||||
|
from cachetools import TTLCache
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
from pack_manager import DATA_DIR, scan_pack, get_cached_manifest, PACKS_DIR
|
||||||
from models import PackMeta
|
from models import PackMeta
|
||||||
from middleware import LoggingMiddleware
|
from middleware import LoggingMiddleware
|
||||||
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
|
from cli import parse_args, run_test_mode, run_production_mode, run_development_mode
|
||||||
from log_manager import init_logging
|
from log_manager import init_logging
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import base64
|
import base64
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from auth import router as auth_router, init_db, verify_jwt
|
from auth import get_current_user, router as auth_router, init_db, verify_jwt
|
||||||
|
from server.roles import Permissions, has_permission
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -52,6 +58,8 @@ async def lifespan(app: FastAPI):
|
|||||||
PACKS_DIR.mkdir(exist_ok=True)
|
PACKS_DIR.mkdir(exist_ok=True)
|
||||||
DATA_DIR.mkdir(exist_ok=True)
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
BLOCKED_HOSTS = []
|
||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
@@ -147,9 +155,16 @@ async def health():
|
|||||||
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
|
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
|
||||||
|
|
||||||
@app.get("/packs")
|
@app.get("/packs")
|
||||||
async def list_packs():
|
async def list_packs(current_user: dict = Depends(get_current_user)):
|
||||||
"""List all available packs"""
|
"""List all available packs - требует проходку для просмотра"""
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
# Проверяем, есть ли право на просмотр сборок
|
||||||
|
if not has_permission(current_user["role"], Permissions.VIEW_PACKS):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Для просмотра сборок требуется активная проходка"
|
||||||
|
)
|
||||||
|
|
||||||
packs = []
|
packs = []
|
||||||
|
|
||||||
for pack_dir in PACKS_DIR.iterdir():
|
for pack_dir in PACKS_DIR.iterdir():
|
||||||
@@ -159,7 +174,6 @@ async def list_packs():
|
|||||||
try:
|
try:
|
||||||
with open(meta_path, 'r', encoding='utf-8') as f:
|
with open(meta_path, 'r', encoding='utf-8') as f:
|
||||||
meta = json.load(f)
|
meta = json.load(f)
|
||||||
# Исправлено: конвертируем updated_at в строку если это datetime
|
|
||||||
updated_at = meta.get("updated_at")
|
updated_at = meta.get("updated_at")
|
||||||
if updated_at and isinstance(updated_at, datetime):
|
if updated_at and isinstance(updated_at, datetime):
|
||||||
updated_at = updated_at.isoformat()
|
updated_at = updated_at.isoformat()
|
||||||
@@ -189,11 +203,22 @@ async def list_packs():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/pack/{pack_name}/diff")
|
@app.post("/pack/{pack_name}/diff")
|
||||||
async def get_pack_diff(pack_name: str, request: Request):
|
async def get_pack_diff(
|
||||||
"""
|
pack_name: str,
|
||||||
Client sends: { "mods/jei.jar": "sha256_hash", ... }
|
request: Request,
|
||||||
|
current_user: dict = Depends(get_current_user) # Добавляем зависимость
|
||||||
|
):
|
||||||
|
"""Client sends: { "mods/jei.jar": "sha256_hash", ... }
|
||||||
Server returns diff information
|
Server returns diff information
|
||||||
"""
|
ТРЕБУЕТ ПРОХОДКУ ДЛЯ СКАЧИВАНИЯ"""
|
||||||
|
|
||||||
|
# Проверяем наличие проходки
|
||||||
|
if not has_permission(current_user["role"], Permissions.DOWNLOAD_PACK):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Для скачивания сборок требуется активная проходка. Обратитесь к администратору."
|
||||||
|
)
|
||||||
|
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
# Читаем тело запроса
|
# Читаем тело запроса
|
||||||
@@ -495,13 +520,8 @@ async def get_launcher_full_info():
|
|||||||
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
proxy_client = httpx.AsyncClient(timeout=60.0, follow_redirects=True)
|
||||||
|
|
||||||
# Кэш для часто запрашиваемых данных (5 минут)
|
# Кэш для часто запрашиваемых данных (5 минут)
|
||||||
from cachetools import TTLCache
|
|
||||||
proxy_cache = TTLCache(maxsize=50, ttl=300)
|
proxy_cache = TTLCache(maxsize=50, ttl=300)
|
||||||
|
|
||||||
# Список заблокированных/проблемных хостов (можно обновлять)
|
|
||||||
BLOCKED_HOSTS = []
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/proxy/fabric/versions/loader")
|
@app.get("/proxy/fabric/versions/loader")
|
||||||
async def proxy_fabric_versions(request: Request):
|
async def proxy_fabric_versions(request: Request):
|
||||||
"""Прокси для Fabric Meta API - список версий загрузчика"""
|
"""Прокси для Fabric Meta API - список версий загрузчика"""
|
||||||
@@ -548,7 +568,6 @@ async def proxy_fabric_installer_latest(request: Request):
|
|||||||
xml = response.text
|
xml = response.text
|
||||||
|
|
||||||
# Парсим последнюю версию из XML
|
# Парсим последнюю версию из XML
|
||||||
import re
|
|
||||||
match = re.search(r'<latest>([^<]+)</latest>', xml)
|
match = re.search(r'<latest>([^<]+)</latest>', xml)
|
||||||
if match:
|
if match:
|
||||||
version = match.group(1)
|
version = match.group(1)
|
||||||
@@ -746,7 +765,6 @@ async def proxy_download(request: Request):
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Проверяем, что URL ведет на разрешенный домен
|
# Проверяем, что URL ведет на разрешенный домен
|
||||||
from urllib.parse import urlparse
|
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
domain = parsed.netloc.lower()
|
domain = parsed.netloc.lower()
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
import structlog
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
PASSES_FILE = Path("data/passes.json")
|
|
||||||
|
|
||||||
def load_passes():
|
|
||||||
if not PASSES_FILE.exists():
|
|
||||||
PASSES_FILE.parent.mkdir(exist_ok=True)
|
|
||||||
default = {"passes": {}}
|
|
||||||
PASSES_FILE.write_text(json.dumps(default, indent=2, ensure_ascii=False))
|
|
||||||
return default
|
|
||||||
try:
|
|
||||||
return json.loads(PASSES_FILE.read_text(encoding="utf-8"))
|
|
||||||
except:
|
|
||||||
return {"passes": {}}
|
|
||||||
|
|
||||||
def save_passes(data):
|
|
||||||
PASSES_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
||||||
|
|
||||||
def activate_pass(pass_code: str, username: str, user_id: int) -> dict:
|
|
||||||
data = load_passes()
|
|
||||||
pass_code = pass_code.upper().strip()
|
|
||||||
|
|
||||||
if pass_code not in data["passes"]:
|
|
||||||
return {"success": False, "error": "Проходка не найдена"}
|
|
||||||
|
|
||||||
p = data["passes"][pass_code]
|
|
||||||
|
|
||||||
if not p.get("is_active", True):
|
|
||||||
return {"success": False, "error": "Проходка деактивирована"}
|
|
||||||
|
|
||||||
if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp():
|
|
||||||
return {"success": False, "error": "Проходка истекла"}
|
|
||||||
|
|
||||||
if p.get("owner") is not None:
|
|
||||||
if p.get("owner") != username:
|
|
||||||
return {"success": False, "error": "Проходка уже активирована другим пользователем"}
|
|
||||||
return {"success": True, "message": "Проходка уже активирована на вашем аккаунте"}
|
|
||||||
|
|
||||||
# Активация
|
|
||||||
now = datetime.now().timestamp()
|
|
||||||
p["owner"] = username
|
|
||||||
p["activated_at"] = now
|
|
||||||
p["uses"] = p.get("uses", 0) + 1
|
|
||||||
|
|
||||||
save_passes(data)
|
|
||||||
|
|
||||||
logger.info("Pass activated", pass_code=pass_code, username=username)
|
|
||||||
return {"success": True, "message": "Проходка успешно активирована!"}
|
|
||||||
|
|
||||||
def has_active_pass(username: str) -> bool:
|
|
||||||
data = load_passes()
|
|
||||||
for p in data["passes"].values():
|
|
||||||
if p.get("owner") == username:
|
|
||||||
if p.get("expires_at") and p.get("expires_at") < datetime.now().timestamp():
|
|
||||||
continue
|
|
||||||
if p.get("is_active", True):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_user_passes(username: str) -> list:
|
|
||||||
data = load_passes()
|
|
||||||
result = []
|
|
||||||
now = datetime.now().timestamp()
|
|
||||||
for p in data["passes"].values():
|
|
||||||
if p.get("owner") == username:
|
|
||||||
result.append({
|
|
||||||
"code": p["code"],
|
|
||||||
"activated_at": p.get("activated_at"),
|
|
||||||
"expires_at": p.get("expires_at"),
|
|
||||||
"is_active": p.get("is_active", True) and (not p.get("expires_at") or p.get("expires_at") > now)
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
# roles.py
|
||||||
|
from enum import IntEnum
|
||||||
|
from typing import Dict, Set
|
||||||
|
|
||||||
|
class UserRole(IntEnum):
|
||||||
|
USER = 0 # Обычный пользователь
|
||||||
|
PASS_HOLDER = 1 # Пользователь с проходкой
|
||||||
|
MODERATOR = 2 # Модератор
|
||||||
|
ELDER = 3 # Elder Moderator
|
||||||
|
CREATOR = 4 # Создатель
|
||||||
|
|
||||||
|
ROLE_NAMES: Dict[int, str] = {
|
||||||
|
UserRole.USER: "Игрок",
|
||||||
|
UserRole.PASS_HOLDER: "Игрок [Проходка]",
|
||||||
|
UserRole.MODERATOR: "Модератор",
|
||||||
|
UserRole.ELDER: "Elder Moderator",
|
||||||
|
UserRole.CREATOR: "Создатель"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Права доступа
|
||||||
|
class Permissions:
|
||||||
|
# Базовые права
|
||||||
|
DOWNLOAD_PACK = "download_pack" # Скачивание сборок
|
||||||
|
VIEW_PACKS = "view_packs" # Просмотр списка сборок
|
||||||
|
|
||||||
|
# Права модератора
|
||||||
|
REQUEST_PASS = "request_pass" # Запрос проходки для игрока
|
||||||
|
VIEW_USER_LIST = "view_user_list" # Просмотр списка пользователей
|
||||||
|
|
||||||
|
# Права Elder Moderator
|
||||||
|
APPROVE_PASS = "approve_pass" # Одобрение проходок
|
||||||
|
REJECT_PASS = "reject_pass" # Отклонение проходок
|
||||||
|
VIEW_PASS_REQUESTS = "view_pass_requests" # Просмотр запросов проходок
|
||||||
|
MANAGE_MODERATORS = "manage_moderators" # Управление модераторами
|
||||||
|
|
||||||
|
# Права создателя
|
||||||
|
DIRECT_PASS = "direct_pass" # Прямая выдача проходки
|
||||||
|
MANAGE_ELDER = "manage_elder" # Управление Elder
|
||||||
|
MANAGE_SERVER = "manage_server" # Управление сервером
|
||||||
|
VIEW_AUDIT_LOG = "view_audit_log" # Просмотр логов
|
||||||
|
|
||||||
|
# Маппинг ролей на права
|
||||||
|
ROLE_PERMISSIONS: Dict[int, Set[str]] = {
|
||||||
|
UserRole.USER: {
|
||||||
|
# Обычный игрок НЕ может даже смотреть сборки!
|
||||||
|
# Только авторизоваться и смотреть свой профиль
|
||||||
|
},
|
||||||
|
UserRole.PASS_HOLDER: {
|
||||||
|
Permissions.VIEW_PACKS, # Может видеть список сборок
|
||||||
|
Permissions.DOWNLOAD_PACK, # Может скачивать сборки
|
||||||
|
},
|
||||||
|
UserRole.MODERATOR: {
|
||||||
|
Permissions.VIEW_PACKS,
|
||||||
|
Permissions.DOWNLOAD_PACK,
|
||||||
|
Permissions.REQUEST_PASS, # Может запрашивать проходки для игроков
|
||||||
|
Permissions.VIEW_USER_LIST, # Может видеть список пользователей
|
||||||
|
},
|
||||||
|
UserRole.ELDER: {
|
||||||
|
Permissions.VIEW_PACKS,
|
||||||
|
Permissions.DOWNLOAD_PACK,
|
||||||
|
Permissions.REQUEST_PASS,
|
||||||
|
Permissions.VIEW_USER_LIST,
|
||||||
|
Permissions.APPROVE_PASS, # Может одобрять проходки
|
||||||
|
Permissions.REJECT_PASS, # Может отклонять проходки
|
||||||
|
Permissions.VIEW_PASS_REQUESTS,
|
||||||
|
Permissions.MANAGE_MODERATORS, # Может управлять модераторами
|
||||||
|
},
|
||||||
|
UserRole.CREATOR: {
|
||||||
|
Permissions.VIEW_PACKS,
|
||||||
|
Permissions.DOWNLOAD_PACK,
|
||||||
|
Permissions.REQUEST_PASS,
|
||||||
|
Permissions.VIEW_USER_LIST,
|
||||||
|
Permissions.APPROVE_PASS,
|
||||||
|
Permissions.REJECT_PASS,
|
||||||
|
Permissions.VIEW_PASS_REQUESTS,
|
||||||
|
Permissions.MANAGE_MODERATORS,
|
||||||
|
Permissions.DIRECT_PASS, # Прямая выдача проходки
|
||||||
|
Permissions.MANAGE_ELDER, # Управление Elder
|
||||||
|
Permissions.MANAGE_SERVER, # Управление сервером
|
||||||
|
Permissions.VIEW_AUDIT_LOG, # Просмотр логов
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_permission(role: int, permission: str) -> bool:
|
||||||
|
"""Проверка наличия права у роли"""
|
||||||
|
return permission in ROLE_PERMISSIONS.get(role, set())
|
||||||
|
|
||||||
|
def require_permission(permission: str):
|
||||||
|
"""Декоратор для проверки права"""
|
||||||
|
from functools import wraps
|
||||||
|
from fastapi import HTTPException, Depends
|
||||||
|
from auth import get_current_user
|
||||||
|
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, current_user: dict = Depends(get_current_user), **kwargs):
|
||||||
|
if not has_permission(current_user["role"], permission):
|
||||||
|
raise HTTPException(403, f"Недостаточно прав. Требуется право: {permission}")
|
||||||
|
return await func(*args, current_user=current_user, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
Reference in New Issue
Block a user