From 7d91dda24cecd57f41b352afbe8961829a96144f Mon Sep 17 00:00:00 2001 From: Kaze Date: Fri, 19 Dec 2025 17:14:59 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86exploit=E5=AE=9E?= =?UTF-8?q?=E4=BD=93=E7=B1=BB=EF=BC=8C=E5=B0=86=E6=95=B0=E6=8D=AE=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=8D=A2=E6=88=90StringProperty=E4=BB=8E=E8=80=8C?= =?UTF-8?q?=E9=80=82=E9=85=8DJavaFX=E7=9A=84ObservableList=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=EF=BC=8C=E5=90=8C=E6=97=B6=E4=B8=8D=E5=BD=B1=E5=93=8D?= =?UTF-8?q?=E5=8E=9F=E5=85=88=E4=BB=BB=E4=BD=95=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?getter/setter=E5=B7=B2=E7=BB=8F=E5=81=9A=E4=BA=86=E7=9B=B8?= =?UTF-8?q?=E5=BA=94=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/xin/ctkqiang/dto/Exploit.java | 45 +++++++++++---------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/src/main/java/xin/ctkqiang/dto/Exploit.java b/src/main/java/xin/ctkqiang/dto/Exploit.java index d6754a3..2173b5f 100644 --- a/src/main/java/xin/ctkqiang/dto/Exploit.java +++ b/src/main/java/xin/ctkqiang/dto/Exploit.java @@ -1,68 +1,71 @@ package xin.ctkqiang.dto; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + public class Exploit { - private String id; - private String description; - private String date; - private String author; - private String type; - private String platform; - private String cve; + private StringProperty id = new SimpleStringProperty(""); + private StringProperty description = new SimpleStringProperty(""); + private StringProperty date = new SimpleStringProperty(""); + private StringProperty author = new SimpleStringProperty(""); + private StringProperty type = new SimpleStringProperty(""); + private StringProperty platform = new SimpleStringProperty(""); + private StringProperty cve = new SimpleStringProperty(""); public String getId() { - return id; + return id.get(); } public void setId(String id) { - this.id = id; + this.id.set(id); } public String getDescription() { - return description; + return description.get(); } public void setDescription(String description) { - this.description = description; + this.description.set(description); } public String getDate() { - return date; + return date.get(); } public void setDate(String date) { - this.date = date; + this.date.set(date); } public String getAuthor() { - return author; + return author.get(); } public void setAuthor(String author) { - this.author = author; + this.author.set(author); } public String getType() { - return type; + return type.get(); } public void setType(String type) { - this.type = type; + this.type.set(type); } public String getPlatform() { - return platform; + return platform.get(); } public void setPlatform(String platform) { - this.platform = platform; + this.platform.set(platform); } public String getCve() { - return cve == null ? "N/A" : cve; + return cve.get() == null ? "N/A" : cve.get(); } public void setCve(String cve) { - this.cve = cve; + this.cve.set(cve); } } From b6b7b1c59f4c003bed318a47c357ed8dd238924a Mon Sep 17 00:00:00 2001 From: Kaze Date: Fri, 19 Dec 2025 17:15:39 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E6=94=B9pom.xml=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BA=86JavaFX=E7=9A=84=E6=A0=B8=E5=BF=83=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pom.xml b/pom.xml index 6704e0d..0bf4e99 100644 --- a/pom.xml +++ b/pom.xml @@ -102,6 +102,18 @@ 1.10.0 + + + org.openjfx + javafx-controls + 11.0.2 + + + org.openjfx + javafx-fxml + 11.0.2 + + \ No newline at end of file From bd5fbfe23ff6916ec4f59fa630c052e1097373d9 Mon Sep 17 00:00:00 2001 From: Kaze Date: Mon, 22 Dec 2025 23:03:51 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E7=8E=B0=E6=9C=89?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/xin/ctkqiang/App.java | 17 +++++++++ .../controller/ExploitDbController.java | 38 ++++++++++++++----- .../java/xin/ctkqiang/gui/AboutDialog.java | 36 ++++++++++++++++++ 3 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 src/main/java/xin/ctkqiang/App.java create mode 100644 src/main/java/xin/ctkqiang/gui/AboutDialog.java diff --git a/src/main/java/xin/ctkqiang/App.java b/src/main/java/xin/ctkqiang/App.java new file mode 100644 index 0000000..930710b --- /dev/null +++ b/src/main/java/xin/ctkqiang/App.java @@ -0,0 +1,17 @@ +package xin.ctkqiang; + +import javafx.application.Application; +import javafx.stage.Stage; +import xin.ctkqiang.gui.AboutDialog; + +public class App extends Application{ + @Override + public void start(Stage primaryStage) { + AboutDialog.show(); + primaryStage.close(); + } + + public static void main(String[] args) { + launch(args); + } +} diff --git a/src/main/java/xin/ctkqiang/controller/ExploitDbController.java b/src/main/java/xin/ctkqiang/controller/ExploitDbController.java index 68b3601..af9c768 100644 --- a/src/main/java/xin/ctkqiang/controller/ExploitDbController.java +++ b/src/main/java/xin/ctkqiang/controller/ExploitDbController.java @@ -5,6 +5,9 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -13,6 +16,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; + +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + import org.apache.commons.text.StringEscapeUtils; /** @@ -107,7 +114,8 @@ public void Crawl(int pageSize, boolean isExport, String extension) { } // 构建API请求URL,包含分页参数 int want = Math.min(pageSize, total); - // String url = String.format(ExploitDbController.URL + "search?draw=1&start=%d&length=%d", start, pageSize); + // String url = String.format(ExploitDbController.URL + + // "search?draw=1&start=%d&length=%d", start, pageSize); // 初始化状态码和结果列表 int Status = 0; @@ -136,7 +144,7 @@ public void Crawl(int pageSize, boolean isExport, String extension) { // 调试模式下打印响应预览 if (debug) { - System.out.println("响应内容预览:" + Response.body().substring(0, 500)); + System.out.println("响应内容预览:" + Response.body().substring(0, 500)); } // 解析JSON响应 @@ -153,7 +161,6 @@ public void Crawl(int pageSize, boolean isExport, String extension) { System.out.println("共找到 " + Data.size() + " 条记录。"); System.out.println("正在写入数据库..."); - // 调试模式下打印完整数据 if (debug) { System.out.println(Data); @@ -225,13 +232,14 @@ public void Crawl(int pageSize, boolean isExport, String extension) { } } - // 添加到结果列表 - exploits.add(exploit); + // 添加到结果列表 + exploits.add(exploit); + } + if (exploits.size() >= want) + break; + Thread.sleep(1000); } - if (exploits.size() >= want) break; - Thread.sleep(1000); - } - } catch (IOException | InterruptedException e) { + } catch (IOException | InterruptedException e) { String msg = e.getMessage(); if (msg != null && BLOCK_KEYWORDS.stream().anyMatch(k -> msg.toLowerCase().contains(k))) { @@ -410,4 +418,16 @@ private void ExportStatus(int status, String ext) { System.out.println(msg); } + // public ObservableList loadPage(int offset, int pageSize) { + // String sql = "SELECT * FROM exploits LIMIT ? OFFSET ?"; + // ObservableList list = FXCollections.observableArrayList(); + // try ( + // Connection conn = super.getConnection(); + // PreparedStatement ps = conn.prepareStatement(sql)) { + // ps.setInt(1, pageSize); + // } catch (SQLException e) { + + // } + // } + } diff --git a/src/main/java/xin/ctkqiang/gui/AboutDialog.java b/src/main/java/xin/ctkqiang/gui/AboutDialog.java new file mode 100644 index 0000000..6487af9 --- /dev/null +++ b/src/main/java/xin/ctkqiang/gui/AboutDialog.java @@ -0,0 +1,36 @@ +package xin.ctkqiang.gui; + +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; + +public class AboutDialog { + public static void show() { + Stage win = new Stage(); + win.initModality(Modality.APPLICATION_MODAL); + win.setTitle("关于"); + win.setResizable(false); + Label info = new Label( + "ExploitDB GUI v1.0\n" + + "作者:钟智强\n" + + "邮箱:ctkqiang@dingtalk.com\n" + + "GitHub:https://github.com/ctkqiang" + ); + info.setStyle("-fx-font-size: 14px; -fx-line-spacing: 6px"); + + Button nextButton = new Button("下一步 ➡"); + nextButton.setOnAction(e -> { + win.close(); + //TODO 首次运行检测 + 数据库配置 + }); + + VBox root = new VBox(15, info, nextButton); + root.setPadding(new Insets(20)); + win.setScene(new Scene(root, 400, 200)); + win.showAndWait(); + } +} From c8d6d5fbdcfbf75e4f7b736dbc22835cb37494e8 Mon Sep 17 00:00:00 2001 From: Kaze Date: Fri, 6 Feb 2026 17:39:07 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86GUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + pom.xml | 16 +- src/main/java/xin/ctkqiang/App.java | 17 - .../xin/ctkqiang/config/Configuration.java | 48 +- .../controller/DatabaseController.java | 74 +- .../controller/ExploitDbController.java | 31 +- .../java/xin/ctkqiang/gui/AboutDialog.java | 42 +- src/main/java/xin/ctkqiang/gui/App.java | 70 ++ .../xin/ctkqiang/gui/ExceptionDialog.java | 190 +++++ .../xin/ctkqiang/gui/ExceptionHandler.java | 139 +++ .../java/xin/ctkqiang/gui/MainWindow.java | 805 ++++++++++++++++++ src/main/resources/icon.png | Bin 0 -> 3226 bytes 12 files changed, 1365 insertions(+), 69 deletions(-) delete mode 100644 src/main/java/xin/ctkqiang/App.java create mode 100644 src/main/java/xin/ctkqiang/gui/App.java create mode 100644 src/main/java/xin/ctkqiang/gui/ExceptionDialog.java create mode 100644 src/main/java/xin/ctkqiang/gui/ExceptionHandler.java create mode 100644 src/main/java/xin/ctkqiang/gui/MainWindow.java create mode 100644 src/main/resources/icon.png diff --git a/.gitignore b/.gitignore index ab1a582..613dc6c 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,5 @@ out/ # 临时环境变量文件 .env *.sqlite +AGENTS.md +.vscode/launch.json diff --git a/pom.xml b/pom.xml index 0bf4e99..c279739 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,6 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - xin.ctkqiang exploitdb 1.0-SNAPSHOT @@ -42,6 +41,14 @@ xin.ctkqiang.Main + + org.openjfx + javafx-maven-plugin + 0.0.8 + + xin.ctkqiang.gui.App + + @@ -113,7 +120,10 @@ javafx-fxml 11.0.2 - + + org.slf4j + slf4j-simple + 1.7.36 + - \ No newline at end of file diff --git a/src/main/java/xin/ctkqiang/App.java b/src/main/java/xin/ctkqiang/App.java deleted file mode 100644 index 930710b..0000000 --- a/src/main/java/xin/ctkqiang/App.java +++ /dev/null @@ -1,17 +0,0 @@ -package xin.ctkqiang; - -import javafx.application.Application; -import javafx.stage.Stage; -import xin.ctkqiang.gui.AboutDialog; - -public class App extends Application{ - @Override - public void start(Stage primaryStage) { - AboutDialog.show(); - primaryStage.close(); - } - - public static void main(String[] args) { - launch(args); - } -} diff --git a/src/main/java/xin/ctkqiang/config/Configuration.java b/src/main/java/xin/ctkqiang/config/Configuration.java index 2c08799..9bb5363 100644 --- a/src/main/java/xin/ctkqiang/config/Configuration.java +++ b/src/main/java/xin/ctkqiang/config/Configuration.java @@ -6,15 +6,17 @@ public class Configuration { private static String DatabaseMode = Database.MYSQL.getValue(); public static final String DB_NAME = "ExploitDB"; - public static final String DB_URL = "jdbc:mysql://localhost:3306/" + DB_NAME + + // MySQL 配置(可修改) + private static String DB_URL = "jdbc:mysql://localhost:3306/" + DB_NAME + "?serverTimezone=UTC&allowPublicKeyRetrieval=true&useSSL=false"; + private static String DB_USER = "root"; + private static String DB_PASSWORD = ""; + + // SQLite 配置(可修改) + private static String DB_SQLITE_URL = "jdbc:sqlite:ling.sqlite"; - public static final String DB_SQLITE_URL = "jdbc:sqlite:ling.sqlite"; - - public static final String DB_USER = "root"; - public static final String DB_PASSWORD = ""; public static final String DB_DRIVER = "com.mysql.cj.jdbc.Driver"; - public static final boolean DEBUG = false; public static String getDatabaseMode() { @@ -25,4 +27,38 @@ public static void setDatabaseMode(Database databaseMode) { DatabaseMode = databaseMode.getValue(); System.out.println(String.format("🎀 你当前选择的数据库模式是:「%s」~酱酱 ♪(๑˃ᴗ˂)ﻭ \n", getDatabaseMode())); } + + // Getters + public static String getDbUrl() { + return DB_URL; + } + + public static String getDbUser() { + return DB_USER; + } + + public static String getDbPassword() { + return DB_PASSWORD; + } + + public static String getDbSqliteUrl() { + return DB_SQLITE_URL; + } + + // Setters + public static void setDbUrl(String url) { + DB_URL = url; + } + + public static void setDbUser(String user) { + DB_USER = user; + } + + public static void setDbPassword(String password) { + DB_PASSWORD = password; + } + + public static void setDbSqliteUrl(String url) { + DB_SQLITE_URL = url; + } } diff --git a/src/main/java/xin/ctkqiang/controller/DatabaseController.java b/src/main/java/xin/ctkqiang/controller/DatabaseController.java index 7bcd0bb..40c1356 100644 --- a/src/main/java/xin/ctkqiang/controller/DatabaseController.java +++ b/src/main/java/xin/ctkqiang/controller/DatabaseController.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.sql.Connection; import java.sql.DriverManager; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; @@ -35,21 +36,9 @@ public class DatabaseController { /** 数据库名称 */ protected final static String DB_NAME = Configuration.DB_NAME; - /** MYSQL 数据库连接URL */ - protected final static String DB_URL = Configuration.DB_URL; - - /** 数据库用户名 */ - protected final static String DB_USER = Configuration.DB_USER; - - /** 数据库密码 */ - protected final static String DB_PASSWORD = Configuration.DB_PASSWORD; - /** 数据库驱动类名 */ protected final static String DB_DRIVER = Configuration.DB_DRIVER; - /** SQLITE 数据库连接URL */ - protected final static String DB_SQLITE_URL = Configuration.DB_SQLITE_URL; - /** * 静态初始化块 * 在类加载时尝试加载数据库驱动 @@ -68,7 +57,7 @@ public class DatabaseController { * @return 数据库连接对象 * @throws SQLException 如果连接数据库时发生错误 */ - protected Connection getConnection() throws SQLException { + public Connection getConnection() throws SQLException { String mode = Configuration.getDatabaseMode(); String upperMode = mode.toUpperCase(); System.out.println("💡 切换数据库模式 -> " + upperMode); @@ -82,21 +71,21 @@ protected Connection getConnection() throws SQLException { System.err.println("❌ 哎呀呀!SQLite JDBC 驱动不见惹~人家找不到驱动怎么贴贴数据库嘛喵呜呜。゚(゚´ω`゚)゚。"); throw new SQLException("找不到 SQLite 驱动喵~要不要检查一下依赖有没有加对呀?"); } - return DriverManager.getConnection(DatabaseController.DB_SQLITE_URL); + return DriverManager.getConnection(Configuration.getDbSqliteUrl()); case "MYSQL": default: try { - Class.forName(DatabaseController.DB_DRIVER); + Class.forName(DB_DRIVER); System.out.println("📦 MySQL 驱动加载完成!我打扮好了,要去连接数据库小哥哥啦~(๑•̀ㅂ•́)و✧"); } catch (ClassNotFoundException e) { System.err.println("❌ 呜呜呜 MySQL JDBC 驱动不见了~是不是打包的时候忘记带灵儿一起走啦 >///< "); throw new SQLException("MySQL 驱动加载失败了喵~快检查一下 `.jar` 有没有遗漏吧!"); } return DriverManager.getConnection( - DatabaseController.DB_URL, - DatabaseController.DB_USER, - DatabaseController.DB_PASSWORD); + Configuration.getDbUrl(), + Configuration.getDbUser(), + Configuration.getDbPassword()); } } @@ -165,7 +154,7 @@ public void CreateTableIfNotExists() { Class.forName("org.sqlite.JDBC"); // 构建连接(数据库文件叫 ling.sqlite) - try (Connection conn = DriverManager.getConnection(DatabaseController.DB_SQLITE_URL); + try (Connection conn = DriverManager.getConnection(Configuration.getDbSqliteUrl()); Statement stmt = conn.createStatement()) { stmt.execute(query); System.out.println("🎀 SQLite 模式下,表结构初始化好啦!已经准备好可爱的记录了喵~"); @@ -175,13 +164,13 @@ public void CreateTableIfNotExists() { case MYSQL: default: // 确保数据库驱动已加载 - Class.forName(DatabaseController.DB_DRIVER); + Class.forName(DB_DRIVER); // 创建数据库连接并执行建表语句 try (Connection conn = DriverManager.getConnection( - DatabaseController.DB_URL, - DatabaseController.DB_USER, - DatabaseController.DB_PASSWORD); + Configuration.getDbUrl(), + Configuration.getDbUser(), + Configuration.getDbPassword()); Statement stmt = conn.createStatement()) { stmt.execute(query); System.out.println("💾 MySQL 模式下,数据表【内容信息】初始化成功~♡"); @@ -273,7 +262,7 @@ public List GetAllExploits() { Class.forName(DB_DRIVER); // 创建数据库连接并执行查询 - try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD); + try (Connection conn = DriverManager.getConnection(Configuration.getDbUrl(), Configuration.getDbUser(), Configuration.getDbPassword()); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(query)) { @@ -434,7 +423,7 @@ public int ExportToSQL(List exploits) { * @return 完整的文件路径 */ private String FilePath(int size, String extension) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH:mm:ss"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"); LocalDateTime now = LocalDateTime.now(); return "output/Exploits_" + size + "_" + formatter.format(now) + extension; @@ -480,6 +469,41 @@ public int ExportToCSV(List exploits) { return 0; } + /** + * 数据库分页查询 + * + * @param offset 起始位置 + * @param limit 查询数量 + * @return + */ + public List GetExploitsByPage(int offset, int limit) { + String query = "SELECT * FROM records ORDER BY created_at DESC LIMIT ? OFFSET ?"; + List exploits = new ArrayList<>(); + + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(query)) { + stmt.setInt(1, limit); + stmt.setInt(2, offset); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + Exploit e = new Exploit(); + e.setId(rs.getString("id")); + e.setDescription(rs.getString("description")); + e.setDate(rs.getString("date")); + e.setAuthor(rs.getString("author")); + e.setType(rs.getString("type")); + e.setPlatform(rs.getString("platform")); + e.setCve(rs.getString("cve")); + exploits.add(e); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + return exploits; + } + /** * 转义SQL字符串中的特殊字符 * 主要处理单引号,防止SQL注入 diff --git a/src/main/java/xin/ctkqiang/controller/ExploitDbController.java b/src/main/java/xin/ctkqiang/controller/ExploitDbController.java index af9c768..319d98f 100644 --- a/src/main/java/xin/ctkqiang/controller/ExploitDbController.java +++ b/src/main/java/xin/ctkqiang/controller/ExploitDbController.java @@ -5,9 +5,6 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -17,9 +14,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; - import org.apache.commons.text.StringEscapeUtils; /** @@ -46,6 +40,10 @@ public class ExploitDbController extends DatabaseController { private static final List BLOCK_KEYWORDS = Arrays.asList( "firewall", "access denied", "blocked", "forbidden", "connection refused"); + public interface CrawlCallback { + void onPageLoaded(List pageData, int currentCount, int totalCount, int PageNumber); + } + /** * 获取数据库中所有的漏洞信息 * @@ -92,6 +90,18 @@ private int create(String id, String description, String date, String author, St * @param extension 导出文件的扩展名(支持:csv, json, sql) */ public void Crawl(int pageSize, boolean isExport, String extension) { + Crawl(pageSize, isExport, extension, null); + } + + /** + * 爬取Exploit-DB网站的漏洞信息 + * + * @param pageSize 每页获取的记录数量 + * @param isExport 是否导出数据 + * @param extension 导出文件的扩展名(支持:csv, json, sql) + * @param callback 爬取完成后的回调函数 + */ + public void Crawl(int pageSize, boolean isExport, String extension, CrawlCallback callback) { // 获取数据总条数 int total = 0; try { @@ -235,6 +245,15 @@ public void Crawl(int pageSize, boolean isExport, String extension) { // 添加到结果列表 exploits.add(exploit); } + int previousCount = 0; + + if (callback != null) { + List currentPageData = new ArrayList<>( + exploits.subList(previousCount, exploits.size())); + callback.onPageLoaded(currentPageData, exploits.size(), want, pageSize); + previousCount = exploits.size(); // 更新计数 + } + if (exploits.size() >= want) break; Thread.sleep(1000); diff --git a/src/main/java/xin/ctkqiang/gui/AboutDialog.java b/src/main/java/xin/ctkqiang/gui/AboutDialog.java index 6487af9..f4fd0cf 100644 --- a/src/main/java/xin/ctkqiang/gui/AboutDialog.java +++ b/src/main/java/xin/ctkqiang/gui/AboutDialog.java @@ -1,12 +1,15 @@ package xin.ctkqiang.gui; +import javafx.animation.PauseTransition; import javafx.geometry.Insets; +import javafx.geometry.Pos; import javafx.scene.Scene; -import javafx.scene.control.Button; import javafx.scene.control.Label; +import javafx.scene.image.Image; import javafx.scene.layout.VBox; import javafx.stage.Modality; import javafx.stage.Stage; +import javafx.util.Duration; public class AboutDialog { public static void show() { @@ -14,23 +17,38 @@ public static void show() { win.initModality(Modality.APPLICATION_MODAL); win.setTitle("关于"); win.setResizable(false); + + // 加载图标 + try { + win.getIcons().add(new Image(AboutDialog.class.getResourceAsStream("/icon.png"))); + } catch (Exception e) { + System.err.println("图标加载失败: " + e.getMessage()); + } + + Label title = new Label("ExploitDB GUI v1.0"); + title.setStyle("-fx-font-size: 20px; -fx-font-weight: bold;"); + Label info = new Label( - "ExploitDB GUI v1.0\n" + - "作者:钟智强\n" + - "邮箱:ctkqiang@dingtalk.com\n" + - "GitHub:https://github.com/ctkqiang" + "作者:钟智强\n" + + "邮箱:ctkqiang@dingtalk.com\n" + + "GitHub:https://github.com/ctkqiang" ); info.setStyle("-fx-font-size: 14px; -fx-line-spacing: 6px"); + info.setAlignment(Pos.CENTER); - Button nextButton = new Button("下一步 ➡"); - nextButton.setOnAction(e -> { + VBox root = new VBox(15, title, info); + root.setPadding(new Insets(30)); + root.setAlignment(Pos.CENTER); + + win.setScene(new Scene(root, 400, 200)); + + // 2秒后自动关闭 + PauseTransition delay = new PauseTransition(Duration.seconds(2)); + delay.setOnFinished(e -> { win.close(); - //TODO 首次运行检测 + 数据库配置 }); - - VBox root = new VBox(15, info, nextButton); - root.setPadding(new Insets(20)); - win.setScene(new Scene(root, 400, 200)); + delay.play(); + win.showAndWait(); } } diff --git a/src/main/java/xin/ctkqiang/gui/App.java b/src/main/java/xin/ctkqiang/gui/App.java new file mode 100644 index 0000000..2d516a4 --- /dev/null +++ b/src/main/java/xin/ctkqiang/gui/App.java @@ -0,0 +1,70 @@ +package xin.ctkqiang.gui; + +import javafx.application.Application; +import javafx.application.Platform; +import javafx.stage.Stage; + +/** + * GUI 应用程序入口 + */ +public class App extends Application { + + @Override + public void init() throws Exception { + super.init(); + // 设置全局异常处理器 + setupGlobalExceptionHandler(); + } + + @Override + public void start(Stage primaryStage) { + try { + AboutDialog.show(); + new MainWindow().start(primaryStage); + } catch (Exception e) { + // 启动过程中的异常 + ExceptionHandler.showError("程序启动失败", e); + Platform.exit(); + } + } + + @Override + public void stop() throws Exception { + super.stop(); + // 程序退出前的清理 + System.out.println("程序正在退出..."); + } + + /** + * 设置全局异常处理器 + * 捕获所有未处理的异常并显示对话框 + */ + private void setupGlobalExceptionHandler() { + // 处理 JavaFX 应用线程中的未捕获异常 + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + System.err.println("未捕获的异常在线程: " + thread.getName()); + throwable.printStackTrace(); + + // 在 FX 线程显示错误对话框 + if (Platform.isFxApplicationThread()) { + ExceptionHandler.showError(throwable); + } else { + Platform.runLater(() -> { + String title = "程序异常"; + String message = throwable.getMessage(); + if (message == null) { + message = "发生未处理的错误: " + throwable.getClass().getSimpleName(); + } + ExceptionDialog.showException(title, message, throwable); + }); + } + }); + + // 处理 FX 工具包中的异常 + System.setProperty("javafx.embed.singleThread", "true"); + } + + public static void main(String[] args) { + launch(args); + } +} diff --git a/src/main/java/xin/ctkqiang/gui/ExceptionDialog.java b/src/main/java/xin/ctkqiang/gui/ExceptionDialog.java new file mode 100644 index 0000000..ffb04ce --- /dev/null +++ b/src/main/java/xin/ctkqiang/gui/ExceptionDialog.java @@ -0,0 +1,190 @@ +package xin.ctkqiang.gui; + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Accordion; +import javafx.scene.control.Alert; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextArea; +import javafx.scene.control.TitledPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.sql.SQLException; + +/** + * 异常对话框 + * 用于显示程序运行过程中的错误信息 + */ +public class ExceptionDialog { + + /** + * 显示简单错误提示(只显示消息) + * @param title 标题 + * @param message 错误消息 + */ + public static void showError(String title, String message) { + Alert alert = new Alert(AlertType.ERROR); + alert.setTitle("错误"); + alert.setHeaderText(title); + alert.setContentText(message); + alert.showAndWait(); + } + + /** + * 显示警告提示 + * @param title 标题 + * @param message 警告消息 + */ + public static void showWarning(String title, String message) { + Alert alert = new Alert(AlertType.WARNING); + alert.setTitle("警告"); + alert.setHeaderText(title); + alert.setContentText(message); + alert.showAndWait(); + } + + /** + * 显示信息提示 + * @param title 标题 + * @param message 信息消息 + */ + public static void showInfo(String title, String message) { + Alert alert = new Alert(AlertType.INFORMATION); + alert.setTitle("提示"); + alert.setHeaderText(title); + alert.setContentText(message); + alert.showAndWait(); + } + + /** + * 显示详细异常对话框(带堆栈跟踪) + * @param title 标题 + * @param message 简要说明 + * @param throwable 异常对象 + */ + public static void showException(String title, String message, Throwable throwable) { + Stage dialog = new Stage(); + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.setTitle("程序异常"); + dialog.setResizable(true); + dialog.setMinWidth(500); + dialog.setMinHeight(400); + + // 图标 + try { + dialog.getIcons().add(new javafx.scene.image.Image( + ExceptionDialog.class.getResourceAsStream("/icon.png"))); + } catch (Exception ignored) {} + + // 标题和图标 + Label iconLabel = new Label("❌"); + iconLabel.setStyle("-fx-font-size: 48px;"); + + Label titleLabel = new Label(title); + titleLabel.setStyle("-fx-font-size: 18px; -fx-font-weight: bold; -fx-text-fill: #d9534f;"); + + Label messageLabel = new Label(message != null ? message : throwable.getMessage()); + messageLabel.setWrapText(true); + messageLabel.setStyle("-fx-font-size: 12px;"); + + // 异常类型和建议 + VBox infoBox = new VBox(5); + infoBox.setAlignment(Pos.CENTER); + infoBox.getChildren().addAll(titleLabel, messageLabel); + + // 获取异常类型和建议 + String suggestion = getSuggestion(throwable); + if (suggestion != null) { + Label suggestLabel = new Label(suggestion); + suggestLabel.setWrapText(true); + suggestLabel.setStyle("-fx-font-size: 12px; -fx-text-fill: #5bc0de;"); + infoBox.getChildren().add(suggestLabel); + } + + // 详细堆栈(可折叠) + StringWriter sw = new StringWriter(); + throwable.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + + TextArea stackArea = new TextArea(stackTrace); + stackArea.setEditable(false); + stackArea.setPrefHeight(200); + stackArea.setStyle("-fx-font-family: monospace; -fx-font-size: 11px;"); + + TitledPane stackPane = new TitledPane("查看详细错误信息(点击展开)", stackArea); + stackPane.setExpanded(false); + + Accordion accordion = new Accordion(stackPane); + + // 按钮 + Button copyBtn = new Button("📋 复制错误信息"); + copyBtn.setOnAction(e -> { + javafx.scene.input.Clipboard clipboard = javafx.scene.input.Clipboard.getSystemClipboard(); + javafx.scene.input.ClipboardContent content = new javafx.scene.input.ClipboardContent(); + content.putString(stackTrace); + clipboard.setContent(content); + copyBtn.setText("✅ 已复制"); + }); + + Button closeBtn = new Button("关闭"); + closeBtn.setStyle("-fx-background-color: #d9534f; -fx-text-fill: white;"); + closeBtn.setPrefWidth(80); + closeBtn.setOnAction(e -> dialog.close()); + + HBox buttonBox = new HBox(15, copyBtn, closeBtn); + buttonBox.setAlignment(Pos.CENTER); + buttonBox.setPadding(new Insets(10, 0, 0, 0)); + + // 组装 + VBox root = new VBox(15); + root.setPadding(new Insets(20)); + root.setAlignment(Pos.TOP_CENTER); + root.getChildren().addAll(iconLabel, infoBox, accordion, buttonBox); + + Scene scene = new Scene(root, 550, 350); + dialog.setScene(scene); + dialog.showAndWait(); + } + + /** + * 根据异常类型获取建议 + */ + private static String getSuggestion(Throwable e) { + if (e instanceof SQLException) { + String msg = e.getMessage().toLowerCase(); + if (msg.contains("communications link failure") || msg.contains("connection refused")) { + return "💡 建议:请检查 MySQL 服务是否已启动,或切换到 SQLite 模式"; + } else if (msg.contains("access denied")) { + return "💡 建议:请检查数据库用户名和密码是否正确"; + } else if (msg.contains("unknown database")) { + return "💡 建议:请先在 MySQL 中创建数据库 'ExploitDB'"; + } else if (msg.contains("driver")) { + return "💡 建议:数据库驱动未找到,请检查项目依赖配置"; + } + return "💡 建议:请检查数据库配置(系统设置 → 数据库配置)"; + } else if (e instanceof java.net.ConnectException || e.getMessage().contains("Network is unreachable")) { + return "💡 建议:请检查网络连接,或稍后再试"; + } else if (e instanceof java.io.FileNotFoundException) { + return "💡 建议:文件不存在或路径错误,请检查文件路径"; + } else if (e instanceof java.io.IOException) { + return "💡 建议:文件操作失败,请检查磁盘空间和写入权限"; + } else if (e instanceof java.lang.OutOfMemoryError) { + return "💡 建议:内存不足,请尝试减少爬取数量或增加 JVM 内存"; + } else if (e instanceof InterruptedException) { + return "💡 建议:操作被中断"; + } else if (e instanceof NumberFormatException) { + return "💡 建议:输入格式错误,请输入有效的数字"; + } else if (e instanceof IllegalArgumentException) { + return "💡 建议:参数错误,请检查输入值"; + } + return null; + } +} diff --git a/src/main/java/xin/ctkqiang/gui/ExceptionHandler.java b/src/main/java/xin/ctkqiang/gui/ExceptionHandler.java new file mode 100644 index 0000000..77731e6 --- /dev/null +++ b/src/main/java/xin/ctkqiang/gui/ExceptionHandler.java @@ -0,0 +1,139 @@ +package xin.ctkqiang.gui; + +import javafx.application.Platform; + +import java.util.concurrent.Callable; +import java.util.function.Consumer; + +/** + * 异常处理器工具类 + * 提供便捷的异常处理包装方法 + */ +public class ExceptionHandler { + + /** + * 包装 Runnable,在 FX 线程中捕获并显示异常 + * @param action 要执行的操作 + * @return 包装后的 Runnable + */ + public static Runnable wrap(Runnable action) { + return () -> { + try { + action.run(); + } catch (Exception e) { + showError(e); + } + }; + } + + /** + * 包装 Runnable,在 FX 线程中捕获并显示异常,带错误提示 + * @param action 要执行的操作 + * @param errorTitle 错误标题 + * @return 包装后的 Runnable + */ + public static Runnable wrap(Runnable action, String errorTitle) { + return () -> { + try { + action.run(); + } catch (Exception e) { + showError(errorTitle, e); + } + }; + } + + /** + * 在后台线程执行操作,异常在 FX 线程显示 + * @param action 要执行的操作 + * @param onSuccess 成功回调(在 FX 线程) + * @param errorTitle 错误标题 + */ + public static void runAsync(Callable action, Consumer onSuccess, String errorTitle) { + new Thread(() -> { + try { + T result = action.call(); + if (onSuccess != null) { + Platform.runLater(() -> onSuccess.accept(result)); + } + } catch (Exception e) { + Platform.runLater(() -> showError(errorTitle, e)); + } + }).start(); + } + + /** + * 在后台线程执行操作,异常在 FX 线程显示(无返回值) + * @param action 要执行的操作 + * @param onSuccess 成功回调(在 FX 线程) + * @param errorTitle 错误标题 + */ + public static void runAsync(Runnable action, Runnable onSuccess, String errorTitle) { + new Thread(() -> { + try { + action.run(); + if (onSuccess != null) { + Platform.runLater(onSuccess); + } + } catch (Exception e) { + Platform.runLater(() -> showError(errorTitle, e)); + } + }).start(); + } + + /** + * 在 FX 线程显示异常对话框 + * @param e 异常对象 + */ + public static void showError(Throwable e) { + Platform.runLater(() -> { + String title = "操作失败"; + String message = e.getMessage(); + if (message == null || message.isEmpty()) { + message = "发生未知错误"; + } + ExceptionDialog.showException(title, message, e); + }); + } + + /** + * 在 FX 线程显示异常对话框 + * @param title 错误标题 + * @param e 异常对象 + */ + public static void showError(String title, Throwable e) { + Platform.runLater(() -> { + String message = e.getMessage(); + if (message == null || message.isEmpty()) { + message = "发生未知错误"; + } + ExceptionDialog.showException(title, message, e); + }); + } + + /** + * 显示简单错误信息 + * @param title 标题 + * @param message 消息 + */ + public static void showError(String title, String message) { + Platform.runLater(() -> ExceptionDialog.showError(title, message)); + } + + /** + * 显示警告信息 + * @param title 标题 + * @param message 消息 + */ + public static void showWarning(String title, String message) { + Platform.runLater(() -> ExceptionDialog.showWarning(title, message)); + } + + /** + * 显示提示信息 + * @param title 标题 + * @param message 消息 + */ + public static void showInfo(String title, String message) { + Platform.runLater(() -> ExceptionDialog.showInfo(title, message)); + } +} diff --git a/src/main/java/xin/ctkqiang/gui/MainWindow.java b/src/main/java/xin/ctkqiang/gui/MainWindow.java new file mode 100644 index 0000000..ca2c8df --- /dev/null +++ b/src/main/java/xin/ctkqiang/gui/MainWindow.java @@ -0,0 +1,805 @@ +package xin.ctkqiang.gui; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import javafx.application.Application; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.transformation.FilteredList; +import javafx.concurrent.Task; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.PasswordField; +import javafx.scene.control.ProgressBar; +import javafx.scene.control.Spinner; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.TextFormatter; +import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.image.Image; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import xin.ctkqiang.config.Configuration; +import xin.ctkqiang.controller.DatabaseController; +import xin.ctkqiang.controller.ExploitDbController; +import xin.ctkqiang.dto.Database; +import xin.ctkqiang.dto.Exploit; + +public class MainWindow extends Application { + private StackPane crawlerPage; + private StackPane dataPage; + private StackPane settingsPage; + + private Button crawlerBtn; + private Button dataBtn; + private Button settingsBtn; + + @Override + public void start(Stage primaryStage) { + BorderPane root = new BorderPane(); + // MenuBar manuBar = new MenuBar(); + // // 文件菜单 + // Menu fileMenu = new Menu("文件"); + // MenuItem exportItem = new MenuItem("导出csv"); + // MenuItem exitItem = new MenuItem("退出"); + // fileMenu.getItems().addAll(exportItem, exitItem); + + // Menu helpMenu = new Menu("帮助"); + // MenuItem aboutItem = new MenuItem("关于"); + // aboutItem.setOnAction(e -> AboutDialog.show()); + // helpMenu.getItems().add(aboutItem); + + // manuBar.getMenus().addAll(fileMenu, helpMenu); + // root.setTop(manuBar); + + VBox sideBar = new VBox(10); + sideBar.setPadding(new Insets(15)); + sideBar.setStyle("-fx-background-color: #f0f0f0;"); + sideBar.setPrefWidth(150); + + crawlerBtn = createSidebarButton("爬虫控制"); + dataBtn = createSidebarButton("数据浏览"); + settingsBtn = createSidebarButton("系统设置"); + + crawlerBtn.setOnAction(e -> showPage(crawlerPage, crawlerBtn)); + dataBtn.setOnAction(e -> showPage(dataPage, dataBtn)); + settingsBtn.setOnAction(e -> showPage(settingsPage, settingsBtn)); + + sideBar.getChildren().addAll(crawlerBtn, dataBtn, settingsBtn); + root.setLeft(sideBar); + + StackPane contentArea = new StackPane(); + contentArea.setPadding(new Insets(20)); + + crawlerPage = createCrawlerPage(); + dataPage = createDataPage(); + settingsPage = createSettingsPage(); + + crawlerPage.setVisible(true); + dataPage.setVisible(false); + settingsPage.setVisible(false); + + contentArea.getChildren().addAll(crawlerPage, dataPage, settingsPage); + root.setCenter(contentArea); + + HBox statusBar = new HBox(20); + statusBar.setPadding(new Insets(10)); + statusBar.setStyle("-fx-background-color: #e0e0e0;"); + statusBar.setAlignment(Pos.CENTER_LEFT); + root.setBottom(statusBar); + + Scene scene = new Scene(root, 1280, 800); + primaryStage.setTitle("ExploitDB 搜索工具"); + try { + primaryStage.getIcons().add( + new Image(getClass().getResourceAsStream("/icon.png"))); + } catch (Exception e) { + System.err.println("⚠ 图标加载失败: " + e.getMessage()); + } + primaryStage.setScene(scene); + primaryStage.setMinWidth(1100); + primaryStage.setMinHeight(750); + primaryStage.show(); + + setActiveButton(crawlerBtn); + } + + private Button createSidebarButton(String text) { + Button btn = new Button(text); + btn.setPrefWidth(120); + btn.setPrefHeight(40); + btn.setStyle( + "-fx-font-size: 14px;" + + "-fx-background-color: transparent;" + + "-fx-cursor: hand;"); + return btn; + } + + private void showPage(StackPane pageToShow, Button clickedBtn) { + crawlerPage.setVisible(false); + dataPage.setVisible(false); + settingsPage.setVisible(false); + + pageToShow.setVisible(true); + setActiveButton(clickedBtn); + } + + private void setActiveButton(Button activeBtn) { + String normalStyle = "-fx-font-size: 14px;" + + "-fx-background-color: transparent;" + + "-fx-cursor: hand;"; + + crawlerBtn.setStyle(normalStyle); + dataBtn.setStyle(normalStyle); + settingsBtn.setStyle(normalStyle); + + String activeStyle = "-fx-font-size: 14px;" + + "-fx-background-color: #007acc;" + + "-fx-text-fill: white;" + + "-fx-cursor: hand;"; + activeBtn.setStyle(activeStyle); + } + + private StackPane createDataPage() { + BorderPane dataLayout = new BorderPane(); + dataLayout.setPadding(new Insets(10)); + + HBox searchBar = new HBox(10); + searchBar.setPadding(new Insets(0, 0, 10, 0)); + searchBar.setAlignment(Pos.CENTER_LEFT); + + TextField searchField = new TextField(); + searchField.setPromptText("输入关键词搜索..."); + searchField.setPrefWidth(180); + searchField.setMinWidth(120); + + ComboBox authorFilter = new ComboBox<>(); + authorFilter.setPromptText("作者筛选"); + authorFilter.setPrefWidth(130); + authorFilter.setMinWidth(100); + + ComboBox platformFilter = new ComboBox<>(); + platformFilter.setPromptText("平台筛选"); + platformFilter.setPrefWidth(110); + platformFilter.setMinWidth(90); + + Button searchBtn = new Button("🔍 搜索"); + searchBtn.setStyle("-fx-background-color: #007acc; -fx-text-fill: white;"); + searchBtn.setMinWidth(70); + + Button resetBtn = new Button("♻ 重置"); + resetBtn.setMinWidth(60); + + Button refreshBtn = new Button("🔄 刷新"); + refreshBtn.setMinWidth(60); + + ComboBox loadModeBox = new ComboBox<>(); + loadModeBox.getItems().addAll("加载最新1000条", "加载全部"); + loadModeBox.setValue("加载最新1000条"); + loadModeBox.setPrefWidth(180); + loadModeBox.setMinWidth(150); + + Label searchLabel = new Label("搜索:"); + searchLabel.setMinWidth(40); + Label loadModeLabel = new Label("加载模式:"); + loadModeLabel.setMinWidth(60); + + searchBar.getChildren().addAll(searchLabel, searchField, authorFilter, platformFilter, searchBtn, + resetBtn, refreshBtn, loadModeLabel, loadModeBox); + + dataLayout.setTop(searchBar); + + TableView tableView = new TableView<>(); + tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + + TableColumn idCol = new TableColumn<>("ID"); + idCol.setCellValueFactory(new PropertyValueFactory<>("id")); + idCol.setPrefWidth(80); + idCol.setMinWidth(60); + + TableColumn descCol = new TableColumn<>("描述"); + descCol.setCellValueFactory(new PropertyValueFactory<>("description")); + descCol.setPrefWidth(500); + descCol.setMinWidth(300); + + TableColumn dateCol = new TableColumn<>("日期"); + dateCol.setCellValueFactory(new PropertyValueFactory<>("date")); + dateCol.setPrefWidth(120); + dateCol.setMinWidth(100); + + TableColumn authorCol = new TableColumn<>("作者"); + authorCol.setCellValueFactory(new PropertyValueFactory<>("author")); + authorCol.setPrefWidth(150); + authorCol.setMinWidth(100); + + TableColumn typeCol = new TableColumn<>("类型"); + typeCol.setCellValueFactory(new PropertyValueFactory<>("type")); + typeCol.setPrefWidth(100); + typeCol.setMinWidth(80); + + TableColumn platformCol = new TableColumn<>("平台"); + platformCol.setCellValueFactory(new PropertyValueFactory<>("platform")); + platformCol.setPrefWidth(120); + platformCol.setMinWidth(80); + + TableColumn cveCol = new TableColumn<>("CVE"); + cveCol.setCellValueFactory(new PropertyValueFactory<>("cve")); + cveCol.setPrefWidth(150); + cveCol.setMinWidth(120); + + tableView.getColumns().addAll(idCol, descCol, dateCol, typeCol, platformCol, cveCol); + + ObservableList data = FXCollections.observableArrayList(); + FilteredList filteredData = new FilteredList<>(data, p -> true); + tableView.setItems(filteredData); + + Label placeHolder = new Label("暂无数据,请先爬取或点击刷新"); + placeHolder.setStyle("-fx-font-size: 14px; -fx-text-fill: gray"); + tableView.setPlaceholder(placeHolder); + + dataLayout.setCenter(tableView); + + HBox actionBar = new HBox(10); + actionBar.setPadding(new Insets(10, 0, 0, 0)); + actionBar.setAlignment(Pos.CENTER_RIGHT); + + Label countLabel = new Label("共 " + data.size() + " 条记录"); + + Label loadStatusLabel = new Label("就绪"); + ProgressBar loadProgress = new ProgressBar(); + loadProgress.setVisible(false); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + Runnable loadData = () -> { + loadStatusLabel.textProperty().unbind(); + data.clear(); + loadProgress.setVisible(true); + loadProgress.setProgress(-1); + loadStatusLabel.setText(""); + + Task> loadTask = new Task<>() { + @Override + protected List call() throws Exception { + DatabaseController dbController = new DatabaseController(); + boolean loadAll = loadModeBox.getValue().equals("加载全部"); + + if (loadAll) { + updateMessage("正在加载全部数据..."); + return dbController.GetAllExploits(); + } else { + updateMessage("正在加载最新1000条"); + return dbController.GetExploitsByPage(0, 1000); + } + } + + @Override + protected void succeeded() { + super.succeeded(); + List result = getValue(); + data.addAll(result); + + Set authors = result.stream().map(Exploit::getAuthor).filter(s -> s != null && !s.isEmpty()) + .collect(Collectors.toSet()); + authorFilter.getItems().setAll("全部"); + authorFilter.getItems().addAll(authors); + + Set platforms = result.stream().map(Exploit::getPlatform) + .filter(s -> s != null && !s.isEmpty()) + .collect(Collectors.toSet()); + platformFilter.getItems().setAll("全部"); + platformFilter.getItems().addAll(platforms); + + countLabel.setText(String.format("共 %d 条记录%s", + result.size(), + result.size() >= 1000 && !"加载全部".equals(loadModeBox.getValue()) ? "(仅显示最新1000条)" : "")); + loadStatusLabel.textProperty().unbind(); + loadStatusLabel.setText("✅ 加载完成"); + loadProgress.setVisible(false); + } + + @Override + protected void failed() { + super.failed(); + loadStatusLabel.textProperty().unbind(); + Throwable error = getException(); + loadStatusLabel.setText("加载失败: " + error.getMessage()); + loadProgress.setVisible(false); + // 显示异常对话框 + ExceptionHandler.showError("数据加载失败", error); + } + }; + loadStatusLabel.textProperty().bind(loadTask.messageProperty()); + new Thread(loadTask).start(); + }; + + Runnable doSearch = () -> { + String keyword = searchField.getText().toLowerCase().trim(); + String author = authorFilter.getValue(); + String platform = platformFilter.getValue(); + + filteredData.setPredicate(exploit -> { + boolean matchesKeyword = keyword.isEmpty() || exploit.getDescription().toLowerCase().contains(keyword) || exploit.getId().contains(keyword) || exploit.getCve().toLowerCase().contains(keyword); + + boolean matchesAuthor = "全部".equals(author) || author == null || author.equals(exploit.getAuthor()); + + boolean matchesPlatform = "全部".equals(platform) || platform == null || platform.equals(exploit.getPlatform()); + + return matchesKeyword && matchesAuthor && matchesPlatform; + }); + + countLabel.setText(String.format("显示 %d / %d 条记录", filteredData.size(), data.size())); + }; + + searchBtn.setOnAction(e -> doSearch.run()); + resetBtn.setOnAction(e -> { + searchField.clear(); + authorFilter.setValue("全部"); + platformFilter.setValue("全部"); + filteredData.setPredicate(p -> true); + countLabel.setText(String.format("共 %d 条记录", data.size())); + }); + refreshBtn.setOnAction(e -> loadData.run()); + + Button exportCsvButton = new Button("📄 导出 CSV"); + exportCsvButton.setOnAction(e -> { + try { + DatabaseController controller = new DatabaseController(); + int status = controller.ExportToCSV(new ArrayList<>(filteredData)); + loadStatusLabel.textProperty().unbind(); + if (status > 0) { + loadStatusLabel.setText("✅ 导出成功"); + } else { + loadStatusLabel.setText("❌ 导出失败"); + ExceptionHandler.showError("导出失败", "无法导出 CSV 文件,请检查输出目录权限"); + } + } catch (Exception ex) { + ExceptionHandler.showError("导出 CSV 失败", ex); + } + }); + Button exportJsonButton = new Button("📄 导出 JSON"); + exportJsonButton.setOnAction(e -> { + try { + DatabaseController controller = new DatabaseController(); + int status = controller.ExportToJSON(new ArrayList<>(filteredData)); + if (status > 0) { + loadStatusLabel.setText("✅ 导出成功"); + } else { + loadStatusLabel.setText("❌ 导出失败"); + ExceptionHandler.showError("导出失败", "无法导出 JSON 文件,请检查输出目录权限"); + } + } catch (Exception ex) { + ExceptionHandler.showError("导出 JSON 失败", ex); + } + }); + + Button exportYAMLButton = new Button("📄 导出 YAML"); + exportYAMLButton.setOnAction(e -> { + try { + DatabaseController controller = new DatabaseController(); + int status = controller.ExportToYAML(new ArrayList<>(filteredData)); + if (status > 0) { + loadStatusLabel.setText("✅ 导出成功"); + } else { + loadStatusLabel.setText("❌ 导出失败"); + ExceptionHandler.showError("导出失败", "无法导出 YAML 文件,请检查输出目录权限"); + } + } catch (Exception ex) { + ExceptionHandler.showError("导出 YAML 失败", ex); + } + }); + + loadModeBox.setOnAction(e -> loadData.run()); + loadData.run(); + actionBar.getChildren().addAll(countLabel, exportCsvButton, exportJsonButton, exportYAMLButton, loadStatusLabel, loadProgress); + dataLayout.setBottom(actionBar); + + StackPane page = new StackPane(dataLayout); + return page; + } + + private StackPane createSettingsPage() { + VBox settingsLayout = new VBox(20); + settingsLayout.setPadding(new Insets(30)); + settingsLayout.setAlignment(Pos.TOP_CENTER); + + // 标题 + Label title = new Label("系统设置"); + title.setStyle("-fx-font-size: 24px; -fx-font-weight: bold;"); + + // 当前配置状态 + String currentDb = Configuration.getDatabaseMode(); + Label currentConfigLabel = new Label("当前使用: " + ("MySQL".equalsIgnoreCase(currentDb) ? "MySQL" : "SQLite")); + currentConfigLabel.setStyle("-fx-font-size: 14px; -fx-text-fill: #666;"); + + // 数据库设置区域 + Label dbTitle = new Label("数据库配置"); + dbTitle.setStyle("-fx-font-size: 18px; -fx-font-weight: bold;"); + + // 数据库模式选择 + HBox modeBox = new HBox(15); + modeBox.setAlignment(Pos.CENTER_LEFT); + Label modeLabel = new Label("数据库模式:"); + ComboBox modeCombo = new ComboBox<>(); + modeCombo.getItems().addAll("MySQL", "SQLite"); + modeCombo.setValue(Configuration.getDatabaseMode()); + modeCombo.setPrefWidth(150); + modeBox.getChildren().addAll(modeLabel, modeCombo); + + // MySQL 配置区域 + VBox mysqlBox = new VBox(10); + mysqlBox.setPadding(new Insets(10)); + mysqlBox.setStyle("-fx-border-color: #ddd; -fx-border-radius: 5px;"); + + Label mysqlTitle = new Label("MySQL 连接设置"); + mysqlTitle.setStyle("-fx-font-size: 14px; -fx-font-weight: bold;"); + + // URL + HBox urlBox = new HBox(10); + urlBox.setAlignment(Pos.CENTER_LEFT); + Label urlLabel = new Label("连接URL:"); + TextField urlField = new TextField(Configuration.getDbUrl()); + urlField.setPrefWidth(400); + urlBox.getChildren().addAll(urlLabel, urlField); + + // 用户名 + HBox userBox = new HBox(10); + userBox.setAlignment(Pos.CENTER_LEFT); + Label userLabel = new Label("用户名: "); + TextField userField = new TextField(Configuration.getDbUser()); + userField.setPrefWidth(150); + userBox.getChildren().addAll(userLabel, userField); + + // 密码 + HBox pwdBox = new HBox(10); + pwdBox.setAlignment(Pos.CENTER_LEFT); + Label pwdLabel = new Label("密码: "); + PasswordField pwdField = new PasswordField(); + pwdField.setText(Configuration.getDbPassword()); + pwdField.setPrefWidth(150); + pwdBox.getChildren().addAll(pwdLabel, pwdField); + + mysqlBox.getChildren().addAll(mysqlTitle, urlBox, userBox, pwdBox); + + // SQLite 配置区域 + VBox sqliteBox = new VBox(10); + sqliteBox.setPadding(new Insets(10)); + sqliteBox.setStyle("-fx-border-color: #ddd; -fx-border-radius: 5px;"); + + Label sqliteTitle = new Label("SQLite 设置"); + sqliteTitle.setStyle("-fx-font-size: 14px; -fx-font-weight: bold;"); + + HBox sqliteUrlBox = new HBox(10); + sqliteUrlBox.setAlignment(Pos.CENTER_LEFT); + Label sqliteUrlLabel = new Label("数据库文件:"); + TextField sqliteUrlField = new TextField(Configuration.getDbSqliteUrl()); + sqliteUrlField.setPrefWidth(300); + sqliteUrlBox.getChildren().addAll(sqliteUrlLabel, sqliteUrlField); + + sqliteBox.getChildren().addAll(sqliteTitle, sqliteUrlBox); + + // 根据当前模式显示/隐藏配置区域 + String currentMode = Configuration.getDatabaseMode(); + mysqlBox.setVisible("MySQL".equalsIgnoreCase(currentMode)); + mysqlBox.setManaged("MySQL".equalsIgnoreCase(currentMode)); + sqliteBox.setVisible("SQLite".equalsIgnoreCase(currentMode)); + sqliteBox.setManaged("SQLite".equalsIgnoreCase(currentMode)); + + // 模式切换事件 + modeCombo.setOnAction(e -> { + String selected = modeCombo.getValue(); + boolean isMySQL = "MySQL".equals(selected); + mysqlBox.setVisible(isMySQL); + mysqlBox.setManaged(isMySQL); + sqliteBox.setVisible(!isMySQL); + sqliteBox.setManaged(!isMySQL); + }); + + // 按钮区域 + HBox buttonBox = new HBox(15); + buttonBox.setAlignment(Pos.CENTER); + buttonBox.setPadding(new Insets(20, 0, 0, 0)); + + Button testBtn = new Button("🧪 测试连接"); + testBtn.setStyle("-fx-background-color: #5bc0de; -fx-text-fill: white; -fx-font-size: 12px;"); + testBtn.setPrefWidth(120); + testBtn.setMinWidth(100); + + Button saveBtn = new Button("💾 保存设置"); + saveBtn.setStyle("-fx-background-color: #5cb85c; -fx-text-fill: white; -fx-font-size: 12px;"); + saveBtn.setPrefWidth(120); + saveBtn.setMinWidth(100); + + Button resetBtn = new Button("🔄 重置默认"); + resetBtn.setStyle("-fx-font-size: 12px;"); + resetBtn.setPrefWidth(120); + resetBtn.setMinWidth(100); + + buttonBox.getChildren().addAll(testBtn, saveBtn, resetBtn); + + // 状态标签 + Label statusLabel = new Label(""); + statusLabel.setStyle("-fx-font-size: 12px;"); + + // 按钮事件 + testBtn.setOnAction(e -> { + statusLabel.setText("正在测试连接..."); + statusLabel.setStyle("-fx-text-fill: gray;"); + + // 在后台线程测试连接,避免卡死UI + new Thread(() -> { + try { + // 临时应用当前输入框的设置进行测试 + String selectedMode = modeCombo.getValue(); + Configuration.setDatabaseMode(Database.fromValue(selectedMode)); + + if ("MySQL".equals(selectedMode)) { + Configuration.setDbUrl(urlField.getText()); + Configuration.setDbUser(userField.getText()); + Configuration.setDbPassword(pwdField.getText()); + } else { + Configuration.setDbSqliteUrl(sqliteUrlField.getText()); + } + + DatabaseController controller = new DatabaseController(); + controller.getConnection().close(); + + Platform.runLater(() -> { + statusLabel.setText("✅ 连接成功!"); + statusLabel.setStyle("-fx-text-fill: green;"); + }); + } catch (Exception ex) { + Platform.runLater(() -> { + String msg = ex.getMessage(); + if (msg == null || msg.isEmpty()) { + msg = "无法连接到数据库,请检查配置"; + } + statusLabel.setText("❌ 连接失败: " + msg); + statusLabel.setStyle("-fx-text-fill: red;"); + }); + } + }).start(); + }); + + saveBtn.setOnAction(e -> { + // 保存所有设置 + String selectedMode = modeCombo.getValue(); + Configuration.setDatabaseMode(Database.fromValue(selectedMode)); + Configuration.setDbUrl(urlField.getText()); + Configuration.setDbUser(userField.getText()); + Configuration.setDbPassword(pwdField.getText()); + Configuration.setDbSqliteUrl(sqliteUrlField.getText()); + + // 更新当前配置显示 + currentConfigLabel.setText("当前使用: " + ("MySQL".equalsIgnoreCase(selectedMode) ? "🐬 MySQL" : "📦 SQLite")); + + statusLabel.setText("✅ 设置已保存"); + statusLabel.setStyle("-fx-text-fill: green;"); + }); + + resetBtn.setOnAction(e -> { + // 重置为默认值 + modeCombo.setValue("MySQL"); + urlField.setText("jdbc:mysql://localhost:3306/ExploitDB?serverTimezone=UTC&allowPublicKeyRetrieval=true&useSSL=false"); + userField.setText("root"); + pwdField.clear(); + sqliteUrlField.setText("jdbc:sqlite:ling.sqlite"); + + mysqlBox.setVisible(true); + mysqlBox.setManaged(true); + sqliteBox.setVisible(false); + sqliteBox.setManaged(false); + + statusLabel.setText("已重置为默认值,点击保存生效"); + statusLabel.setStyle("-fx-text-fill: gray;"); + }); + + // 组装布局 + settingsLayout.getChildren().addAll( + title, + currentConfigLabel, + new Label(""), // 间距 + dbTitle, + modeBox, + mysqlBox, + sqliteBox, + buttonBox, + statusLabel + ); + + return new StackPane(settingsLayout); + } + + private StackPane createCrawlerPage() { + VBox crawlerLayout = new VBox(15); + crawlerLayout.setPadding(new Insets(20)); + crawlerLayout.setAlignment(Pos.TOP_CENTER); + + Label title = new Label("🕷 Exploit-DB 搜索工具"); + title.setStyle("-fx-font-size: 24px; -fx-font-weight: bold;"); + + HBox settingsBox = new HBox(10); + settingsBox.setAlignment(Pos.CENTER); + + Label pageLabel = new Label("爬取条数:"); + Spinner itemSpinner = new Spinner<>(1, 80000, 50); + itemSpinner.setEditable(true); + itemSpinner.setPrefWidth(100); + + TextField editor = itemSpinner.getEditor(); + editor.setTextFormatter(new TextFormatter<>(change -> { + String newText = change.getControlNewText(); + if (newText.matches("\\d*")) { // 只允许数字 + return change; + } + return null; // 拒绝这次修改 + })); + + editor.focusedProperty().addListener((obs, wasFocused, isFocused) -> { + if (!isFocused) { + try { + int val = Integer.parseInt(editor.getText()); + itemSpinner.getValueFactory().setValue(val); + } catch (NumberFormatException e) { + editor.setText(itemSpinner.getValue().toString()); + } + } + }); + + settingsBox.getChildren().addAll(pageLabel, itemSpinner); + + VBox progressBox = new VBox(10); + progressBox.setAlignment(Pos.CENTER); + progressBox.setPadding(new Insets(20, 0, 20, 0)); + + ProgressBar progressBar = new ProgressBar(0); + progressBar.setPrefWidth(400); + progressBar.setVisible(false); + + Label statusLabel = new Label("就绪 - 等待开始"); + statusLabel.setStyle("-fx-font-size: 14px;"); + + statusLabel.setText(""); + statusLabel.setStyle("-fx-font-size: 12px; -fx-text-fill: gray;"); + + progressBox.getChildren().addAll(progressBar, statusLabel); + + TableView previewTable = new TableView<>(); + previewTable.setPrefHeight(300); + + TableColumn idCol = new TableColumn<>("编号"); + idCol.setCellValueFactory(new PropertyValueFactory<>("id")); + idCol.setPrefWidth(60); + + TableColumn descCol = new TableColumn<>("描述"); + descCol.setCellValueFactory(new PropertyValueFactory<>("description")); + descCol.setPrefWidth(300); + + TableColumn dateCol = new TableColumn<>("日期"); + dateCol.setCellValueFactory(new PropertyValueFactory<>("date")); + dateCol.setPrefWidth(100); + + TableColumn authorCol = new TableColumn<>("作者"); + authorCol.setCellValueFactory(new PropertyValueFactory<>("author")); + authorCol.setPrefWidth(100); + + previewTable.getColumns().addAll(idCol, descCol, dateCol, authorCol); + + ObservableList previewData = FXCollections.observableArrayList(); + previewTable.setItems(previewData); + + HBox buttonBox = new HBox(15); + buttonBox.setAlignment(Pos.CENTER); + + Button startBtn = new Button("▶ 开始爬取"); + startBtn.setStyle("-fx-font-size: 14px; -fx-background-color: #28a745; -fx-text-fill: white;"); + startBtn.setPrefWidth(120); + + Button stopBtn = new Button("■ 停止爬取"); + stopBtn.setStyle("-fx-font-size: 14px; -fx-background-color: #dc3545; -fx-text-fill: white;"); + stopBtn.setPrefWidth(120); + stopBtn.setDisable(true); + + buttonBox.getChildren().addAll(startBtn, stopBtn); + + crawlerLayout.getChildren().addAll(title, settingsBox, progressBox, new Label("实时预览: "), previewTable, + buttonBox); + + final boolean[] isRunning = { false }; + + startBtn.setOnAction(e -> { + int pages = itemSpinner.getValue(); + + previewData.clear(); + progressBar.setVisible(true); + progressBar.setProgress(0); + statusLabel.setText("正在连接 Exploit-DB..."); + startBtn.setDisable(true); + stopBtn.setDisable(false); + isRunning[0] = true; + + Task crawlTask = new Task<>() { + @Override + protected Void call() throws Exception { + ExploitDbController controller = new ExploitDbController(); + + controller.Crawl(pages, false, "csv", (pageData, currentCount, totalCount, pageNumber) -> { + Platform.runLater(() -> { + double progress = (double) currentCount / totalCount; + progressBar.setProgress(progress); + + statusLabel.setText(String.format("正在爬取第 %d 页... (已获取 %d / %d 条记录)", pageNumber, + currentCount, totalCount)); + + statusLabel.setText(String.format("当前批次:%d 条新数据 | 总计%d 条", pageData.size(), currentCount)); + + previewData.addAll(pageData); + if (previewData.size() > 100) { + previewData.remove(0, previewData.size() - 100); + } + + previewTable.scrollTo(previewData.size() - 1); + }); + }); + + return null; + } + + @Override + protected void succeeded() { + super.succeeded(); + Platform.runLater(() -> { + progressBar.setProgress(1.0); + statusLabel.setText("✅ 爬取完成!共获取 " + previewData.size() + " 条数据"); + startBtn.setDisable(false); + stopBtn.setDisable(true); + isRunning[0] = false; + }); + } + + @Override + protected void failed() { + Throwable error = getException(); + Platform.runLater(() -> { + statusLabel.setText("❌ 爬取失败: " + error.getMessage()); + startBtn.setDisable(false); + stopBtn.setDisable(true); + isRunning[0] = false; + // 显示异常对话框 + ExceptionHandler.showError("爬取数据失败", error); + }); + } + }; + + new Thread(crawlTask).start(); + }); + // TODO: 完全停止需要更复杂的实现(中断线程) + stopBtn.setOnAction(e -> { + isRunning[0] = false; + statusLabel.setText("已停止(当前批次完成后)"); + startBtn.setDisable(false); + stopBtn.setDisable(true); + }); + + StackPane page = new StackPane(crawlerLayout); + return page; + } + + public static void main(String[] args) { + launch(args); + } +} diff --git a/src/main/resources/icon.png b/src/main/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1a4f59b97e95c21178ce424c49c859eb2da4b435 GIT binary patch literal 3226 zcmV;L3}y3)P)GTh~ooQM(?KsX@(?*soOO`8Hk`-;VsEwN_ z?j%40AU0y(UJpo(CrxIY3mo9#;l6v${mwbxIak$W(g%ui=? zS|XiUkYpySWplYN=H&CTUhbh3uD?Xod_GS)o&MdD$q-Mb@P!i$&xGln@^f><$F=)j zt_{s{{lN@9<30wbgG~7&gky2inKW5>?#tHC$p0!bG)?2nB%RGM6^wIZB*3{|H^;7w z@b39hKDsf@wZS=hhC^JsIOx%qKGohN#-2F9h^nY!p|W^Wev4%)3Oe!k94Bu~;Ix@J`qEmeoo2?|e(v2L z;lZSjL?X?UXAYa$g3(|=XVR0E-A=maxYa+x{ZS8d{xEw5l|SFJoX>i_oW40hP>@v~ zBBHQ37B;R|bfWm-EitHj#LtJ_<2<{ii6_@AVa6LK5RP(x(obBJkKXIyqf@7edxkjo z;fH*B`ZS|=?lL_%fYYX@xyeN!8t3l7B#~$gn?d16Utc3}mE`#KF(Ro9ic-*55Kz&S z!W%_i1)q)h!u-#*QNFRWmFCJK*}ac}F*h;E3^nd!toL)?eDx?le&vT8`I}ex$@OmMq`f*U4Ng!^!S31T@hoaVJTk zB9|J47bG%y-n%%=SJu|jRO!I&^)o1bZ>le6sP`^!zxfaR)9XJ$$>hi+;>70T1Og#K zfgqkK50hi#oI3siZyb4zGbc`p;xvj-z?eJ8^h{8$75?Js72F*U(LL%zdFU322q>7P zC<+(*Js9;mb}X-9de$!inB)#Y>+j}n}~?P@puX?m*}!OBfUtn`BCf(~ik(M6gAVD))651U-XZPfj)x79_&+F%psmF>zDR z*^fB=t6q@*^OCRAGd>C|`L)droVz_kMvxIBKv7uWf~Ph#lFela#S#>m4CK-&-hTU^ zQS%B0osJTRjrxWfStLn39w#ewk0=)ICznZy9vw!54!urA0dzWrx$%DbZ}l)R?x(7} zL{dA)j4w!4u|?3-G3JdRDXI_`T6WLHSz6^Fnn>b{Bq%E>VRTHQSc1?juy5M9mgk<` z!?z9{ph(clWD;0yCYG;g<*}XH*t_?u?BBnaZyb7o!-o#?^@A^P;NSsTT3VSMyupRf zhp}0$RFyfHkvKuj+_KC?_XGc;5rv{uA`2FF5KX4Z%g&vSB6{u(aPYuOJhAIZUi#+C z?0arMZ7bKYa_wdw`~B@4*#8VKzr3GsedjPwy!dUlKYy4_dk@p~^dVM0^%9+5JxTJv`F+Tf@X%U<`ViRL#W8`1QRSNvq^!0a3qdS{D1X+kUL&8_3NLZ z_0j!|XRG<-L5?#6SxVQwz>eqsm@T`W#ai3WV6=qO_u+hhjI*Cl@t-Fz@&4({T)y3p zVk@I#+dksDG6tlE6kMNEG*a0#l@2p=^J#H+K2JEFrrd5q5#@z)JuEfj>}@~OLR=Rv zUf`D}Pw=lldxwAf*?-YG&YII3`dC`Vk2_#@8fqEJohgC zo5yL=hG;U(5|#Ay&Vvo>*aJcV{tOwFWZgVm5Bu!Sl~=?EfX>D{;QBsD< z*@98Y3E>zhwwrMXa;s|wYyTn3+H7t*o%3{dwBWQC;TxY~W@G}Ltl!bxNN1&i zr9~-fEHRvxG**3@TsBUhJH$KJ11L5-4K-EROeW6WnPOFQnILa?XarC$Jkixs!SSnO z%teHB?zwsMV=reWRHD))&Bwwdlk>z<35s16U^QZOIpIlofDXJ|Rk(X#kD%>9F2d@tKD70op78Qe0 zPhDdZt*h5k)7*~1VrN$B%Al0_nP7;NM0iH0;F}Lg!yCX8^x~Ha9E{B4lOsY_wb0bD z8k5ydqsz?k83{1OI;h0n6%2_}-pn zytsWSwRO!bX<5c~Uoq|Lx6;tLff-v1S3*wirYacBxVRQ{aw}Snsc{t@>vqt%YAd>` zcFL=2+0b6chI$)qONu!mh9om-b{9|(lKkS_p(rW~L*ilM(h7OV#akx^s42Hn>9BCI ze}eZe4-r&t-0^CxSoR2yZdk)JySDO`9h-P;`zCho*hs#}#mdf=G&Hr-*4D=P2XW@2 z36_*Q*tew}y{e;cGQbPlns5j|vpZIWwiv&>%Zt>ED#|jft$=| zwADK3tS`rAFbW}Ac=xku8mmfKR$oD>vxot2nqsRNRb2GZjZq$WBiy+^O<2p?=iCSnJoAL3Sqdldes_Qgf0U}SV%|E_&(Zh0`BY9^TbH|deoG76 zmsL|LDXmGvt4Z9c(hw^e3sqZD6#fqY0RR8OQ!9}G000I_L_t&o0HDKThgIx!l>h($ M07*qoM6N<$f=fa)qW}N^ literal 0 HcmV?d00001