Merge branch 'main' into alpha

This commit is contained in:
Sashegdev
2026-04-22 15:26:39 +03:00
13 changed files with 1597 additions and 482 deletions
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 SashegDev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1 -1
View File
@@ -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>
+3
View File
@@ -13,6 +13,9 @@
<maven.compiler.source>21</maven.compiler.source> <maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target> <maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.organization.name>ZernMC</project.organization.name>
<project.inceptionYear>2026</project.inceptionYear>
<project.description>ZernMC Launcher - just a minimalistic launcher by SashegDev</project.description>
<mainClass>me.sashegdev.zernmc.launcher.Main</mainClass> <mainClass>me.sashegdev.zernmc.launcher.Main</mainClass>
</properties> </properties>
@@ -10,9 +10,12 @@ 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.HttpURLConnection;
import java.net.URL;
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.util.List;
public class AuthManager { public class AuthManager {
@@ -20,6 +23,18 @@ public class AuthManager {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
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 final String PERM_VIEW_PACKS = "view_packs";
public static final String PERM_DOWNLOAD_PACK = "download_pack";
public static boolean loadSavedSession() { public static boolean loadSavedSession() {
if (!Files.exists(AUTH_FILE)) return false; if (!Files.exists(AUTH_FILE)) return false;
@@ -29,6 +44,8 @@ public class AuthManager {
if (loaded == null || loaded.accessToken == null) return false; if (loaded == null || loaded.accessToken == null) return false;
session = loaded; session = loaded;
userInfo = fetchUserInfo();
if (isAccessTokenExpired()) { if (isAccessTokenExpired()) {
return tryRefresh(); return tryRefresh();
} }
@@ -38,6 +55,7 @@ public class AuthManager {
} }
} }
// ====================== АВТОРИЗАЦИЯ ======================
public static AuthResult login(String username, String password) { public static AuthResult login(String username, String password) {
return authRequest("/auth/login", username, password); return authRequest("/auth/login", username, password);
} }
@@ -49,17 +67,13 @@ 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)); String body = GSON.toJson(new LoginRequest(username, password));
//System.out.println(ZAnsi.cyan("[AUTH] Отправка запроса: " + endpoint));
SimpleHttpResponse resp = post(endpoint, body); SimpleHttpResponse resp = post(endpoint, body);
//System.out.println(ZAnsi.cyan("[AUTH] Ответ: HTTP " + resp.statusCode()));
if (resp.statusCode() == 200) { if (resp.statusCode() == 200) {
session = GSON.fromJson(resp.body(), AuthSession.class); session = GSON.fromJson(resp.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) { } else if (resp.statusCode() == 422) {
return AuthResult.fail("Ошибка валидации: " + extractError(resp.body())); return AuthResult.fail("Ошибка валидации: " + extractError(resp.body()));
@@ -67,7 +81,6 @@ public class AuthManager {
return AuthResult.fail(extractError(resp.body())); return AuthResult.fail(extractError(resp.body()));
} }
} catch (Exception e) { } catch (Exception e) {
//System.err.println(ZAnsi.red("[AUTH] Исключение: " + e.getMessage()));
e.printStackTrace(); e.printStackTrace();
return AuthResult.fail("Ошибка соединения: " + e.getMessage()); return AuthResult.fail("Ошибка соединения: " + e.getMessage());
} }
@@ -80,6 +93,7 @@ public class AuthManager {
} 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) {}
} }
@@ -118,11 +132,13 @@ public class AuthManager {
AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class); AuthSession newSession = GSON.fromJson(resp.body(), AuthSession.class);
newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn; newSession.expiresAt = System.currentTimeMillis() / 1000L + newSession.expiresIn;
session = newSession; session = newSession;
userInfo = fetchUserInfo();
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,19 +152,82 @@ public class AuthManager {
} }
} }
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception { // ==================== ПОЛУЧЕНИЕ ИНФОРМАЦИИ О ПОЛЬЗОВАТЕЛЕ ====================
String fullUrl = ZHttpClient.getBaseUrl() + endpoint; private static UserInfo fetchUserInfo() {
if (!isLoggedIn() || session.accessToken == null) return null;
try {
// Используем существующий метод ZHttpClient.get() + вручную добавляем токен
java.net.HttpURLConnection conn = null; java.net.HttpURLConnection conn = null;
try { try {
java.net.URL url = java.net.URI.create(fullUrl).toURL(); URL url = new URL(ZHttpClient.getBaseUrl() + "/admin/me");
conn = (java.net.HttpURLConnection) url.openConnection(); conn = (java.net.HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
conn.setConnectTimeout(10000);
conn.setReadTimeout(10000);
int responseCode = conn.getResponseCode();
if (responseCode != 200) return null;
StringBuilder response = new StringBuilder();
try (var reader = new java.io.BufferedReader(
new java.io.InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
response.append(line);
}
}
return GSON.fromJson(response.toString(), UserInfo.class);
} finally {
if (conn != null) conn.disconnect();
}
} catch (Exception e) {
System.err.println("Не удалось получить UserInfo: " + e.getMessage());
return null;
}
}
// ==================== ПРОВЕРКИ ПРАВ ====================
public static boolean hasPass() {
if (userInfo != null) return userInfo.has_pass;
return getRole() >= ROLE_PASS_HOLDER;
}
public static boolean canViewPacks() {
if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_VIEW_PACKS);
}
return hasPass(); // fallback для старых аккаунтов
}
public static boolean canDownloadPacks() {
if (userInfo != null && userInfo.permissions != null) {
return userInfo.permissions.contains(PERM_DOWNLOAD_PACK);
}
return hasPass(); // fallback
}
public static int getRole() {
return session != null ? session.role : ROLE_USER;
}
// ====================== POST ======================
private static SimpleHttpResponse post(String endpoint, String jsonBody) throws Exception {
String fullUrl = ZHttpClient.getBaseUrl() + endpoint;
HttpURLConnection conn = null;
try {
URL url = new URL(fullUrl);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST"); conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
conn.setRequestProperty("Accept", "application/json"); conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0"); conn.setRequestProperty("User-Agent", "ZernMC-Launcher/1.0");
conn.setRequestProperty("Connection", "close");
// Добавляем токен авторизации, если есть сессия
if (session != null && session.accessToken != null) { if (session != null && session.accessToken != null) {
conn.setRequestProperty("Authorization", "Bearer " + session.accessToken); conn.setRequestProperty("Authorization", "Bearer " + session.accessToken);
} }
@@ -157,19 +236,19 @@ public class AuthManager {
conn.setConnectTimeout(15000); conn.setConnectTimeout(15000);
conn.setReadTimeout(15000); conn.setReadTimeout(15000);
try (java.io.OutputStream os = conn.getOutputStream()) {
byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8); byte[] input = jsonBody.getBytes(StandardCharsets.UTF_8);
os.write(input, 0, input.length); conn.setFixedLengthStreamingMode(input.length);
try (var os = conn.getOutputStream()) {
os.write(input);
os.flush();
} }
int statusCode = conn.getResponseCode(); int statusCode = conn.getResponseCode();
var is = (statusCode >= 200 && statusCode < 300) ? conn.getInputStream() : conn.getErrorStream();
java.io.InputStream is = (statusCode >= 200 && statusCode < 300)
? conn.getInputStream()
: conn.getErrorStream();
String responseBody; String responseBody;
try (java.util.Scanner scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) { try (var scanner = new java.util.Scanner(is, StandardCharsets.UTF_8.name())) {
responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : ""; responseBody = scanner.useDelimiter("\\A").hasNext() ? scanner.next() : "";
} }
@@ -183,19 +262,13 @@ public class AuthManager {
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();
if (json.has("detail")) { if (json.has("detail")) {
if (json.get("detail").isJsonArray()) { if (json.get("detail").isJsonArray()) {
return json.getAsJsonArray("detail").get(0).getAsJsonObject() return json.getAsJsonArray("detail").get(0).getAsJsonObject().get("msg").getAsString();
.get("msg").getAsString();
} }
return json.get("detail").getAsString(); return json.get("detail").getAsString();
} }
if (json.has("error")) {
return json.get("error").getAsString();
}
} catch (Exception ignored) {} } catch (Exception ignored) {}
return body.length() > 200 ? body.substring(0, 200) + "..." : body; return body.length() > 200 ? body.substring(0, 200) + "..." : body;
} }
@@ -224,7 +297,6 @@ public class AuthManager {
} }
// ====================== ВНУТРЕННИЕ КЛАССЫ ====================== // ====================== ВНУТРЕННИЕ КЛАССЫ ======================
public static class AuthSession { public static class AuthSession {
@SerializedName("access_token") public String accessToken; @SerializedName("access_token") public String accessToken;
@SerializedName("refresh_token") public String refreshToken; @SerializedName("refresh_token") public String refreshToken;
@@ -232,12 +304,30 @@ 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;
}
public static class UserInfo {
public int id;
public String username;
public String uuid;
public int role;
public String role_name;
public boolean has_pass;
public List<String> permissions;
public boolean hasPermission(String perm) {
return permissions != null && permissions.contains(perm);
}
} }
private static class LoginRequest { private static class LoginRequest {
final String username; final String username;
final String password; final String password;
LoginRequest(String u, String p) { this.username = u; this.password = p; } LoginRequest(String u, String p) {
this.username = u;
this.password = p;
}
} }
public static class AuthResult { public static class AuthResult {
@@ -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,86 @@ 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)); }
if (!AuthManager.canViewPacks()) {
JsonObject root = JsonParser.parseString(response).getAsJsonObject(); throw new IOException("Для просмотра сборок требуется активная проходка");
// Проверяем, есть ли поле "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 +311,18 @@ 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("/")) { if (!AuthManager.canDownloadPacks()) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1); throw new IOException("Для скачивания сборок требуется активная проходка");
} }
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 +330,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 +344,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 +356,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 +374,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 (скачать новые файлы, удалить старые)
*/ */
@@ -486,17 +514,6 @@ public class PackDownloader {
return sb.toString(); return sb.toString();
} }
/**
* Парсинг даты из строки
*/
private LocalDateTime parseDateTime(String dateTimeStr) {
try {
return LocalDateTime.parse(dateTimeStr, DATE_FORMATTER);
} catch (Exception e) {
return null;
}
}
// ====================== Вложенные классы ====================== // ====================== Вложенные классы ======================
public static class PackManifest { public static class PackManifest {
@@ -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();
}
} }
+590
View File
@@ -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
}
+420 -226
View File
@@ -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,26 +92,76 @@ 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_rate_limit_db():
"""Инициализация БД для rate limiting"""
conn = sqlite3.connect(str(RATE_LIMIT_DB))
conn.executescript("""
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT PRIMARY KEY,
attempts INTEGER DEFAULT 1,
first_attempt REAL NOT NULL,
last_attempt REAL NOT NULL,
blocked_until REAL
);
""")
conn.commit()
conn.close()
def init_db(): def init_db():
conn = get_db() """Инициализация основной БД"""
with get_db() as conn:
conn.executescript(""" conn.executescript("""
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE COLLATE NOCASE, username TEXT UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
uuid TEXT UNIQUE NOT NULL, uuid TEXT UNIQUE NOT NULL,
role INTEGER DEFAULT 0,
created_at REAL NOT NULL, created_at REAL NOT NULL,
last_login REAL 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 ( CREATE TABLE IF NOT EXISTS passes (
@@ -109,35 +182,64 @@ def init_db():
PRIMARY KEY (user_id, pass_code) PRIMARY KEY (user_id, pass_code)
); );
CREATE TABLE IF NOT EXISTS refresh_tokens ( CREATE TABLE IF NOT EXISTS pass_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, requester_id INTEGER NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL, target_username TEXT NOT NULL,
expires_at REAL 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);
""") """)
conn.commit()
conn.close() # Добавляем колонку 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
access_token = create_jwt({ ) -> dict:
"sub": user_id, """Получение текущего пользователя"""
"username": username, if not credentials:
"uuid": uuid, raise HTTPException(
"type": "access", status_code=status.HTTP_401_UNAUTHORIZED,
"exp": now + ACCESS_TOKEN_EXPIRE_SECONDS detail="Не авторизован",
}) headers={"WWW-Authenticate": "Bearer"},
refresh_token = create_jwt({
"sub": user_id,
"type": "refresh",
"exp": now + REFRESH_TOKEN_EXPIRE_SECONDS
})
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
conn.execute("DELETE FROM refresh_tokens WHERE user_id = ?", (user_id,))
conn.execute(
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
(user_id, token_hash, now + REFRESH_TOKEN_EXPIRE_SECONDS)
)
conn.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=ACCESS_TOKEN_EXPIRE_SECONDS,
username=username,
uuid=uuid
) )
payload = verify_jwt(credentials.credentials)
if not payload or payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен"
)
with get_db() as conn:
user = conn.execute(
"SELECT id, username, uuid, role, is_active, banned_until FROM users WHERE id = ?",
(payload["sub"],)
).fetchone()
if not user:
raise HTTPException(401, "Пользователь не найден")
if not user["is_active"]:
raise HTTPException(403, "Аккаунт деактивирован")
if user["banned_until"] and user["banned_until"] > time.time():
raise HTTPException(403, "Аккаунт забанен")
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,155 +575,44 @@ 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()
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
}) })
log_audit(user["id"], "refresh_token", f"Token refreshed from {ip}", ip)
return { return {
"passes": passes, "passes": passes,
"has_active": any(p["is_active"] for p in passes) "has_active": any(p["is_active"] for p in passes)
+33 -17
View File
@@ -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,6 +9,9 @@ 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
@@ -21,7 +24,8 @@ 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 roles import Permissions, has_permission
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@@ -54,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)
@@ -485,9 +491,16 @@ async def activate_pass_page():
# ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ====================== # ====================== ЭНДПОИНТЫ ДЛЯ ПАКОВ ======================
@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():
@@ -497,7 +510,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()
@@ -527,11 +539,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"
# Читаем тело запроса # Читаем тело запроса
@@ -833,13 +856,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 - список версий загрузчика"""
@@ -886,7 +904,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)
@@ -1084,7 +1101,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()
-77
View File
@@ -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
View File
@@ -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