diff --git a/.classpath b/.classpath deleted file mode 100644 index df66b20..0000000 --- a/.classpath +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..491cbae --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +SPRING_DATASOURCE_URL=jdbc:mysql://localhost:3306/blog_db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai +SPRING_DATASOURCE_USERNAME=your_username +SPRING_DATASOURCE_PASSWORD=your_password + +MAIL_HOST=smtp.qq.com +MAIL_PORT=465 +MAIL_USERNAME=your@qq.com +MAIL_PASSWORD=your-app-code +MAIL_PROTOCOL=smtps + +# Upload directory override (optional). Default is ${user.dir}/uploads +APP_UPLOAD_DIR=C:/data/blog/uploads diff --git a/.factorypath b/.factorypath deleted file mode 100644 index b9002e8..0000000 --- a/.factorypath +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b222778 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + - name: Run tests + env: + SPRING_PROFILES_ACTIVE: test + run: mvn -B -ntp test + - name: Package + env: + SPRING_PROFILES_ACTIVE: test + run: mvn -B -ntp -DskipTests package diff --git a/.gitignore b/.gitignore index f5c105a..3e0ae84 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,56 @@ /target/ *.log +# Compiled artifacts +*.class +*.jar + # IntelliJ /.idea/ *.iml +# Visual Studio / Visual Studio Code +/.vs/ +/.vscode/ + # OS Thumbs.db .DS_Store + +# Eclipse +/.settings/ +.classpath +.project +.factorypath + +# Local database dumps / backups +/blog_db_backup.sql +*.sql.bak +*.dump +/backups/ +/backup/ + +# Local runtime data +uploads/* +!uploads/.gitkeep + +# Local config / secrets +.env +.env.* +application-local.yml +application-local.yaml +application-local.properties +src/main/resources/application-local.yml +src/main/resources/application-dev-local.yml +src/main/resources/application-prod-local.yml + +# Root-level stray compiled or source artifacts +/com/ + +# Logs +logs/ + +# Local Spring Boot config (do not commit secrets) +application-local.yml +application-local.yaml +application-local.properties diff --git a/.project b/.project deleted file mode 100644 index 52f6c05..0000000 --- a/.project +++ /dev/null @@ -1,34 +0,0 @@ - - - blog-system - - - - - - org.eclipse.jdt.core.javabuilder - - - - - org.eclipse.m2e.core.maven2Builder - - - - - - org.eclipse.jdt.core.javanature - org.eclipse.m2e.core.maven2Nature - - - - 1772106509438 - - 30 - - org.eclipse.core.resources.regexFilterMatcher - node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ - - - - diff --git a/.settings/org.eclipse.core.resources.prefs b/.settings/org.eclipse.core.resources.prefs deleted file mode 100644 index 839d647..0000000 --- a/.settings/org.eclipse.core.resources.prefs +++ /dev/null @@ -1,5 +0,0 @@ -eclipse.preferences.version=1 -encoding//src/main/java=UTF-8 -encoding//src/main/resources=UTF-8 -encoding//src/test/java=UTF-8 -encoding/=UTF-8 diff --git a/.settings/org.eclipse.jdt.apt.core.prefs b/.settings/org.eclipse.jdt.apt.core.prefs deleted file mode 100644 index dfa4f3a..0000000 --- a/.settings/org.eclipse.jdt.apt.core.prefs +++ /dev/null @@ -1,4 +0,0 @@ -eclipse.preferences.version=1 -org.eclipse.jdt.apt.aptEnabled=true -org.eclipse.jdt.apt.genSrcDir=target/generated-sources/annotations -org.eclipse.jdt.apt.genTestSrcDir=target/generated-test-sources/test-annotations diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs deleted file mode 100644 index 5b72ee1..0000000 --- a/.settings/org.eclipse.jdt.core.prefs +++ /dev/null @@ -1,20 +0,0 @@ -eclipse.preferences.version=1 -org.eclipse.jdt.core.compiler.annotation.missingNonNullByDefaultAnnotation=ignore -org.eclipse.jdt.core.compiler.annotation.nonnull=org.springframework.lang.NonNull -org.eclipse.jdt.core.compiler.annotation.nonnullbydefault=org.springframework.lang.NonNullApi -org.eclipse.jdt.core.compiler.annotation.nullable=org.springframework.lang.Nullable -org.eclipse.jdt.core.compiler.annotation.nullanalysis=enabled -org.eclipse.jdt.core.compiler.codegen.methodParameters=generate -org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 -org.eclipse.jdt.core.compiler.compliance=17 -org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled -org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning -org.eclipse.jdt.core.compiler.problem.nullAnnotationInferenceConflict=warning -org.eclipse.jdt.core.compiler.problem.nullReference=warning -org.eclipse.jdt.core.compiler.problem.nullSpecViolation=warning -org.eclipse.jdt.core.compiler.problem.potentialNullReference=warning -org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore -org.eclipse.jdt.core.compiler.problem.syntacticNullAnalysisForFields=enabled -org.eclipse.jdt.core.compiler.processAnnotations=enabled -org.eclipse.jdt.core.compiler.release=enabled -org.eclipse.jdt.core.compiler.source=17 diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs deleted file mode 100644 index f897a7f..0000000 --- a/.settings/org.eclipse.m2e.core.prefs +++ /dev/null @@ -1,4 +0,0 @@ -activeProfiles= -eclipse.preferences.version=1 -resolveWorkspaceProjects=true -version=1 diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index e012065..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "java.compile.nullAnalysis.mode": "automatic", - "java.configuration.updateBuildConfiguration": "interactive" -} \ No newline at end of file diff --git a/README.md b/README.md index 7d189be..a06a44c 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,9 @@ CREATE DATABASE blog_db CHARACTER SET utf8mb4; ``` 2) 配置数据库与基础参数 -编辑 `src/main/resources/application.yml`,设置: -- spring.datasource.url/username/password -- JPA 配置(已默认 ddl-auto: update) -- Thymeleaf 与日志级别等 +- 公共配置:`src/main/resources/application.yml`(端口、Multipart、日志、上传白名单等) +- 开发环境:`src/main/resources/application-dev.yml`(本地 MySQL、JPA validate、Flyway) +- 生产环境:`src/main/resources/application-prod.yml`(从环境变量读取数据源与邮件,JPA validate、Flyway) 3) 配置邮件(用于邮箱验证码) - 支持环境变量(推荐)或直接在 yml 中配置: @@ -82,7 +81,12 @@ spring: 4) 启动 ```bash -mvn spring-boot:run +# 开发(dev) +mvn spring-boot:run -Dspring-boot.run.profiles=dev + +# 生产(prod) +# 需注入 SPRING_DATASOURCE_URL/USERNAME/PASSWORD 等环境变量 +SPRING_PROFILES_ACTIVE=prod java -jar target/blog-*.jar ``` 访问地址: @@ -147,7 +151,8 @@ mvn spring-boot:run - 登录记录 LoginRecord:user(可空)、time、ip、ua、success - 操作日志 ActionLog:user、time、action、detail -> JPA 配置为 `ddl-auto: update`,开发期会自动迁移建表,生产环境请改为 `validate` 并显式维护迁移脚本(Flyway/Liquibase)。 +> 数据库迁移 +> 本分支已接入 Flyway,JPA 配置为 `ddl-auto: validate`。首次在已有库上运行时通过 `baseline-on-migrate: true` 做基线,后续按 `db/migration` 版本化迁移。 ## 登录记录与操作日志实现说明 - Spring Security @@ -157,9 +162,23 @@ mvn spring-boot:run - 个人中心页面仅展示最近 20 条(可按需分页拓展) ## 图片上传与正文渲染 +- 控制器仅负责鉴权与调用服务:`UploadService.storeImage/loadImage` +- 服务实现包含:目录创建、大小限制、MIME/扩展名白名单校验、防路径穿越、随机文件名、类型识别返回 `Content-Type` - 编辑页支持本地图片上传后自动在正文插入 ``,详情页按 HTML 渲染 - 请确保存储位置与访问接口已在控制器中放行或加鉴权 +## 测试 +- 服务层:`LocalUploadServiceTest` + - 覆盖:正常上传、空文件、非法扩展名、非法 MIME、超大文件、缺失文件读取 +- 启动验证:`SmokeTest`(加载应用上下文) +- 运行: + - `mvn test` + +## CI(GitHub Actions) +- 默认工作流:Java 17 + Maven + - 执行 `mvn -B -ntp test` 与 `mvn -B -ntp -DskipTests package` + - 可在 Pull Request 中自动验证构建与测试 + ## 搜索与筛选/排序组合 - 搜索支持标题/正文/标签名模糊匹配 - 排序(最新/最热/阅读最多)可与分类、标签、搜索自由组合,所有链接均保留当前组合条件 @@ -193,26 +212,28 @@ src/ ``` ## 配置与环境变量 -- 基础数据库:`spring.datasource.url` / `username` / `password` -- JPA:`spring.jpa.hibernate.ddl-auto`(开发期 `update`,生产建议 `validate`) -- 邮件:`spring.mail.*`(见上文“配置邮件”) -- 可选环境变量(示例): - - `MAIL_HOST`、`MAIL_PORT`、`MAIL_USERNAME`、`MAIL_PASSWORD`、`MAIL_PROTOCOL` - - `APP_BASE_URL`(用于生成邮件中的链接,可按需扩展) +- Profiles + - `application.yml`:公共配置(含 `spring.profiles.active: dev`) + - `application-dev.yml`:本地数据库/邮件、`ddl-auto: validate`、`flyway.enabled: true` + - `application-prod.yml`:从环境变量读取数据库/邮件、`ddl-auto: validate`、`flyway.enabled: true` +- 数据库(生产建议用环境变量) + - `SPRING_DATASOURCE_URL` / `SPRING_DATASOURCE_USERNAME` / `SPRING_DATASOURCE_PASSWORD` +- 邮件:`MAIL_HOST` / `MAIL_PORT` / `MAIL_USERNAME` / `MAIL_PASSWORD` / `MAIL_PROTOCOL` +- 上传目录:`APP_UPLOAD_DIR`(默认 `${user.dir}/uploads`) ## 构建与运行 -- 开发运行: - ``` - mvn spring-boot:run - ``` -- 打包可执行 Jar: - ``` - mvn -DskipTests package - java -jar target/blog-*.jar - ``` -- 常见 JVM 参数(按需选择): - - `-Xms512m -Xmx512m` 固定堆内存 - - `-Dserver.port=8080` 修改端口 +- 构建: + - `mvn clean package` +- 开发: + - `mvn spring-boot:run -Dspring-boot.run.profiles=dev` +- 生产: + - `SPRING_PROFILES_ACTIVE=prod` 并注入数据源/邮件环境变量后运行 Jar + - 常用 JVM 参数:`-Xms512m -Xmx512m`、`-Dserver.port=8080` + +## 数据库迁移(Flyway) +- 现有数据库首次接入:已启用 `baseline-on-migrate: true`,基线版本 `1` +- 自定义迁移:在 `src/main/resources/db/migration/` 新增 `V{N}__{desc}.sql` +- 示例:`V2__set_admin_role.sql`(将 admin 设为 `ROLE_ADMIN`、其余空角色补为 `ROLE_USER`) ## 生产部署示例 - 反向代理(Nginx): @@ -231,6 +252,120 @@ src/ - SSL:申请证书后将 `listen 443 ssl` 与 `ssl_certificate`/`ssl_certificate_key` 加入上面配置。 - 数据库迁移:生产环境建议使用 Flyway/Liquibase 管理 SQL 迁移,避免 `ddl-auto` 修改结构。 +## 外网部署详细步骤(内网穿透方案) + +### 1. 注册 natapp 账号 +1. 访问:https://natapp.cn/ +2. 点击右上角 "注册" +3. 填写邮箱、密码完成注册 +4. 登录账号 + +### 2. 创建隧道 +1. 登录后进入 "购买隧道" 页面 +2. 选择 "免费隧道"(或者付费隧道,更稳定) +3. 配置隧道信息: + - 隧道名称:随意填写(如:myblog) + - 隧道协议:选择 `http` + - 本地端口:填写 `8080`(项目端口) + - 域名:可以选择随机域名或自定义域名(需要付费) +4. 点击 "购买"(免费隧道直接获取) + +### 3. 下载 natapp 客户端 +1. 进入 "我的隧道" 页面 +2. 找到刚创建的隧道 +3. 点击 "客户端下载" +4. 根据系统选择下载: + - Windows:`natapp_windows_amd64.zip` + - Mac:`natapp_darwin_amd64.zip` + - Linux:`natapp_linux_amd64.zip` + +## 仓库规范与目录 +- 标准结构:仅保留 src/、pom.xml、README.md、.gitignore 等必要文件 +- IDE/本地文件不纳入版本库:.settings/、.vscode/、.classpath、.project、.factorypath +- 上传目录不纳入版本库:uploads/*(仅保留占位 uploads/.gitkeep) +- 根目录若出现 com/** 这类历史遗留副本,不再跟踪,源码以 src/main/java/** 为准 + +## 外部化配置说明 +- 示例配置:src/main/resources/application-example.yml、.env.example +- 支持通过环境变量或 profile 覆盖默认值(参考 Spring Boot Externalized Configuration) +- 上传目录可配置:app.upload-dir 或 APP_UPLOAD_DIR,默认 ${user.dir}/uploads + +### 4. 获取 authtoken +1. 在 "我的隧道" 页面 +2. 找到你的隧道 +3. 复制 **authtoken**(一串随机字符) + +### 5. 启动项目 +在终端中启动 Spring Boot 项目: +```bash +cd blog-system-template +mvn spring-boot:run +``` +确保项目在 `http://localhost:8080` 正常运行。 + +### 6. 运行 natapp 客户端 +**Windows 系统:** +1. 解压下载的 `natapp_windows_amd64.zip` +2. 打开命令提示符(CMD)或 PowerShell +3. 进入 natapp 解压目录 +4. 运行命令: + ```bash + natapp.exe -authtoken=你的authtoken + ``` + +**Mac/Linux 系统:** +1. 解压下载的压缩包 +2. 打开终端 +3. 进入 natapp 解压目录 +4. 运行命令: + ```bash + chmod +x natapp + ./natapp -authtoken=你的authtoken + ``` + +### 7. 获取外网访问地址 +natapp 启动成功后,会显示类似信息: +``` +Tunnel Status online +Version 2.3.9 +Forwarding http://abc123.natappfree.cc -> http://127.0.0.1:8080 +Web Interface http://127.0.0.1:4040 +Total Connections 0 +Avg Conn Time 0.00ms +``` +其中 `http://abc123.natappfree.cc` 就是你的外网访问地址! + +### 8. 测试访问 +在浏览器中访问你的外网地址: +- 首页:`http://abc123.natappfree.cc/` +- 登录:`http://abc123.natappfree.cc/auth/login` +- 后台:`http://abc123.natappfree.cc/admin` + +### 9. 保持隧道运行 +**Windows 后台运行:** +```bash +start /b natapp.exe -authtoken=你的authtoken +``` + +**Mac/Linux 后台运行:** +```bash +nohup ./natapp -authtoken=你的authtoken & +``` + +### 10. 其他内网穿透服务 +| 服务 | 免费方案 | 优点 | 缺点 | +|------|---------|------|------| +| **natapp.cn** | 有免费隧道 | 国内访问快、操作简单 | 免费域名随机 | +| **ngrok.cc** | 有免费隧道 | 稳定性好 | 免费隧道有限 | +| **cloudflare tunnel** | 完全免费 | 无限流量、稳定 | 配置稍复杂 | +| **花生壳** | 有免费版 | 老牌服务 | 免费版限制多 | + +### 11. 注意事项 +- 免费隧道的域名是随机的,每次重启会变化 +- 建议使用付费隧道获取固定域名 +- 内网穿透服务可能有访问速度限制,不建议用于生产环境 +- 确保本地网络稳定,避免断开连接 + ## 接口速查(选摘) - 认证与账户 - `POST /auth/login` 登录 diff --git a/com/example/blog/FixDb.class b/com/example/blog/FixDb.class deleted file mode 100644 index 746589d..0000000 Binary files a/com/example/blog/FixDb.class and /dev/null differ diff --git a/pom.xml b/pom.xml index 99bb51c..fc368d5 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,17 @@ spring-boot-starter-mail + + org.flywaydb + flyway-core + + + + com.h2database + h2 + test + + org.springframework.boot spring-boot-starter-test diff --git a/src/main/java/com/example/blog/BlogApplication.java b/src/main/java/com/example/blog/BlogApplication.java index 522972c..65f19f2 100644 --- a/src/main/java/com/example/blog/BlogApplication.java +++ b/src/main/java/com/example/blog/BlogApplication.java @@ -2,8 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import com.example.blog.config.UploadProperties; @SpringBootApplication +@EnableConfigurationProperties(UploadProperties.class) public class BlogApplication { public static void main(String[] args) { SpringApplication.run(BlogApplication.class, args); diff --git a/src/main/java/com/example/blog/config/SecurityConfig.java b/src/main/java/com/example/blog/config/SecurityConfig.java index 8525d64..6e96773 100644 --- a/src/main/java/com/example/blog/config/SecurityConfig.java +++ b/src/main/java/com/example/blog/config/SecurityConfig.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -27,13 +28,15 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(Customizer.withDefaults()) .authorizeHttpRequests(auth -> auth .requestMatchers( - "/", "/articles", "/articles/search", "/articles/{slug}", "/articles/{slug}/comments", "/auth/**", + "/", "/articles", "/articles/search", "/articles/{slug}", "/articles/{slug}/comments", "/articles/image/**", + "/auth/**", "/error", "/error/**", "/favicon.ico", "/css/**", "/js/**", "/images/**", "/actuator/health", "/actuator/health/**" ).permitAll() - .requestMatchers("/articles/editor/**", "/articles/editor").authenticated() + .requestMatchers("/articles/editor/**", "/articles/editor").hasRole("ADMIN") + .requestMatchers(HttpMethod.POST, "/articles/upload-image", "/articles/*/delete").hasRole("ADMIN") .requestMatchers("/admin/**", "/actuator/**").hasRole("ADMIN") .anyRequest().authenticated() ) diff --git a/src/main/java/com/example/blog/config/UploadProperties.java b/src/main/java/com/example/blog/config/UploadProperties.java new file mode 100644 index 0000000..303ed1f --- /dev/null +++ b/src/main/java/com/example/blog/config/UploadProperties.java @@ -0,0 +1,45 @@ +package com.example.blog.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = "app.upload") +public class UploadProperties { + private String dir = System.getProperty("user.dir") + "/uploads"; + private long maxSize = 5 * 1024 * 1024; + private List allowedExtensions = List.of(".jpg", ".jpeg", ".png", ".gif", ".webp"); + private List allowedContentTypes = List.of("image/jpeg", "image/png", "image/gif", "image/webp"); + + public String getDir() { + return dir; + } + + public void setDir(String dir) { + this.dir = dir; + } + + public long getMaxSize() { + return maxSize; + } + + public void setMaxSize(long maxSize) { + this.maxSize = maxSize; + } + + public List getAllowedExtensions() { + return allowedExtensions; + } + + public void setAllowedExtensions(List allowedExtensions) { + this.allowedExtensions = allowedExtensions; + } + + public List getAllowedContentTypes() { + return allowedContentTypes; + } + + public void setAllowedContentTypes(List allowedContentTypes) { + this.allowedContentTypes = allowedContentTypes; + } +} diff --git a/src/main/java/com/example/blog/controller/ArticleController.java b/src/main/java/com/example/blog/controller/ArticleController.java index 6b55ded..d254abd 100644 --- a/src/main/java/com/example/blog/controller/ArticleController.java +++ b/src/main/java/com/example/blog/controller/ArticleController.java @@ -14,15 +14,11 @@ import org.springframework.web.bind.annotation.*; import org.springframework.http.ResponseEntity; import org.springframework.core.io.Resource; -import org.springframework.core.io.FileSystemResource; import org.springframework.web.multipart.MultipartFile; import java.util.Set; import java.util.Map; -import java.util.UUID; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; +import com.example.blog.service.UploadService; @Controller @RequestMapping("/articles") @@ -33,6 +29,7 @@ public class ArticleController { private final CommentService commentService; private final TaxonomyService taxonomyService; private final com.example.blog.service.UserService userService; + private final UploadService uploadService; @GetMapping public String list(@RequestParam(defaultValue = "0") int page, @@ -150,7 +147,7 @@ public String likeComment(@PathVariable String slug, @PathVariable Long id) { @GetMapping("/editor/new") public String createForm(Model model) { com.example.blog.entity.User currentUser = userService.getCurrentUserOrThrow(); - if (!"admin".equals(currentUser.getUsername())) { + if (!currentUser.isAdmin()) { return "redirect:/?error=no_permission"; } java.util.List
drafts = articleService.listDrafts(currentUser.getId()); @@ -166,7 +163,7 @@ public String createForm(Model model) { @GetMapping("/editor/{id}") public String editForm(@PathVariable Long id, Model model) { com.example.blog.entity.User currentUser = userService.getCurrentUserOrThrow(); - if (!"admin".equals(currentUser.getUsername())) { + if (!currentUser.isAdmin()) { return "redirect:/?error=no_permission"; } Article a = articleService.getById(id); @@ -195,7 +192,7 @@ public String create(@ModelAttribute("form") @Valid ArticleForm form, @RequestParam(required = false) Long id, Model model) { com.example.blog.entity.User currentUser = userService.getCurrentUserOrThrow(); - if (!"admin".equals(currentUser.getUsername())) { + if (!currentUser.isAdmin()) { return "redirect:/?error=no_permission"; } if (br.hasErrors()) { @@ -208,6 +205,10 @@ public String create(@ModelAttribute("form") @Valid ArticleForm form, @PostMapping("/{id}/delete") public String delete(@PathVariable Long id) { + com.example.blog.entity.User currentUser = userService.getCurrentUserOrThrow(); + if (!currentUser.isAdmin()) { + return "redirect:/?error=no_permission"; + } articleService.delete(id); return "redirect:/"; } @@ -215,35 +216,25 @@ public String delete(@PathVariable Long id) { @PostMapping("/upload-image") public ResponseEntity uploadImage(@RequestParam("file") MultipartFile file) throws java.io.IOException { com.example.blog.entity.User currentUser = userService.getCurrentUserOrThrow(); - if (!"admin".equals(currentUser.getUsername())) { + if (!currentUser.isAdmin()) { return ResponseEntity.status(403).body(Map.of("error", "no_permission")); } - if (file.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("error", "empty_file")); - } - String original = file.getOriginalFilename(); - String ext = ""; - if (original != null && original.contains(".")) { - ext = original.substring(original.lastIndexOf(".")).toLowerCase(); - } - String name = UUID.randomUUID().toString().replace("-", "") + ext; - Path dir = Paths.get(System.getProperty("user.dir"), "uploads"); - if (!Files.exists(dir)) { - Files.createDirectories(dir); + try { + String filename = uploadService.storeImage(file); + return ResponseEntity.ok(Map.of("url", "/articles/image/" + filename)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); } - Path target = dir.resolve(name); - Files.write(target, file.getBytes()); - String url = "/articles/image/" + name; - return ResponseEntity.ok(Map.of("url", url)); } @GetMapping("/image/{filename}") public ResponseEntity getImage(@PathVariable String filename) throws java.io.IOException { - Path file = Paths.get(System.getProperty("user.dir"), "uploads", filename); - if (!Files.exists(file)) { + Resource resource = uploadService.loadImage(filename); + if (resource == null) { return ResponseEntity.notFound().build(); } - FileSystemResource resource = new FileSystemResource(file.toFile()); - return ResponseEntity.ok(resource); + return ResponseEntity.ok() + .contentType(uploadService.getMediaType(filename)) + .body(resource); } } diff --git a/src/main/java/com/example/blog/entity/User.java b/src/main/java/com/example/blog/entity/User.java index 22275d1..6c2cbcb 100644 --- a/src/main/java/com/example/blog/entity/User.java +++ b/src/main/java/com/example/blog/entity/User.java @@ -55,4 +55,8 @@ public void prePersist() { if (createdAt == null) createdAt = Instant.now(); if (role == null) role = Role.ROLE_USER; } + + public boolean isAdmin() { + return this.role == Role.ROLE_ADMIN; + } } diff --git a/src/main/java/com/example/blog/service/UploadService.java b/src/main/java/com/example/blog/service/UploadService.java new file mode 100644 index 0000000..03062bf --- /dev/null +++ b/src/main/java/com/example/blog/service/UploadService.java @@ -0,0 +1,11 @@ +package com.example.blog.service; + +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; + +public interface UploadService { + String storeImage(MultipartFile file) throws java.io.IOException; + Resource loadImage(String filename) throws java.io.IOException; + MediaType getMediaType(String filename); +} diff --git a/src/main/java/com/example/blog/service/impl/LocalUploadService.java b/src/main/java/com/example/blog/service/impl/LocalUploadService.java new file mode 100644 index 0000000..d1c909f --- /dev/null +++ b/src/main/java/com/example/blog/service/impl/LocalUploadService.java @@ -0,0 +1,80 @@ +package com.example.blog.service.impl; + +import com.example.blog.config.UploadProperties; +import com.example.blog.service.UploadService; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.UUID; + +@Service +public class LocalUploadService implements UploadService { + private final UploadProperties properties; + + public LocalUploadService(UploadProperties properties) { + this.properties = properties; + } + + @Override + public String storeImage(MultipartFile file) throws IOException { + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("empty_file"); + } + if (file.getSize() > properties.getMaxSize()) { + throw new IllegalArgumentException("file_too_large"); + } + String contentType = file.getContentType(); + if (contentType == null || !properties.getAllowedContentTypes().contains(contentType.toLowerCase())) { + throw new IllegalArgumentException("invalid_content_type"); + } + String original = StringUtils.cleanPath(file.getOriginalFilename() == null ? "" : file.getOriginalFilename()); + String ext = ""; + int idx = original.lastIndexOf('.'); + if (idx >= 0) { + ext = original.substring(idx).toLowerCase(); + } + if (!properties.getAllowedExtensions().contains(ext)) { + throw new IllegalArgumentException("invalid_extension"); + } + String filename = UUID.randomUUID().toString().replace("-", "") + ext; + Path dir = Paths.get(properties.getDir()).normalize(); + Files.createDirectories(dir); + Path target = dir.resolve(filename).normalize(); + if (!target.startsWith(dir)) { + throw new IllegalArgumentException("invalid_path"); + } + Files.copy(file.getInputStream(), target); + return filename; + } + + @Override + public Resource loadImage(String filename) throws IOException { + Path dir = Paths.get(properties.getDir()).normalize(); + Path file = dir.resolve(filename).normalize(); + if (!file.startsWith(dir) || !Files.exists(file) || !Files.isReadable(file)) { + return null; + } + return new FileSystemResource(file.toFile()); + } + + @Override + public MediaType getMediaType(String filename) { + if (filename == null) { + return MediaType.APPLICATION_OCTET_STREAM; + } + String lower = filename.toLowerCase(); + if (lower.endsWith(".png")) return MediaType.IMAGE_PNG; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return MediaType.IMAGE_JPEG; + if (lower.endsWith(".gif")) return MediaType.IMAGE_GIF; + if (lower.endsWith(".webp")) return MediaType.parseMediaType("image/webp"); + return MediaType.APPLICATION_OCTET_STREAM; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..1fe80f6 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,40 @@ +server: + error: + include-message: always + include-stacktrace: always + +spring: + datasource: + url: jdbc:mysql://localhost:3306/blog_db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai + username: root + password: xjliieea691 + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + show-sql: true + thymeleaf: + cache: false + mail: + host: ${MAIL_HOST:smtp.qq.com} + port: ${MAIL_PORT:465} + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + protocol: ${MAIL_PROTOCOL:smtps} + properties: + mail: + smtp: + auth: true + ssl: + enable: true + debug: false + +logging: + level: + org.thymeleaf: DEBUG + + flyway: + enabled: true + baseline-on-migrate: true + baseline-version: 1 + locations: classpath:db/migration diff --git a/src/main/resources/application-example.yml b/src/main/resources/application-example.yml new file mode 100644 index 0000000..2721eec --- /dev/null +++ b/src/main/resources/application-example.yml @@ -0,0 +1,47 @@ +server: + port: 8080 + +spring: + datasource: + url: jdbc:mysql://localhost:3306/blog_db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai + username: your_username + password: your_password + jpa: + hibernate: + ddl-auto: update + open-in-view: false + show-sql: true + properties: + hibernate: + format_sql: true + thymeleaf: + cache: false + mail: + host: smtp.example.com + port: 465 + username: your_email@example.com + password: your_mail_app_password + protocol: smtps + properties: + mail: + smtp: + auth: true + ssl: + enable: true + debug: false + +app: + upload: + dir: ${user.dir}/uploads + max-size: 5242880 + allowed-extensions: + - .jpg + - .jpeg + - .png + - .gif + - .webp + allowed-content-types: + - image/jpeg + - image/png + - image/gif + - image/webp diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..f324421 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,38 @@ +server: + error: + include-message: never + include-stacktrace: never + +spring: + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + flyway: + enabled: true + baseline-on-migrate: true + baseline-version: 1 + locations: classpath:db/migration + jpa: + hibernate: + ddl-auto: validate + show-sql: false + thymeleaf: + cache: true + mail: + host: ${MAIL_HOST} + port: ${MAIL_PORT:465} + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + protocol: ${MAIL_PROTOCOL:smtps} + properties: + mail: + smtp: + auth: true + ssl: + enable: true + debug: false + +logging: + level: + org.thymeleaf: INFO diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d6422ee..e55cc1e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,45 +1,38 @@ server: port: 8080 - error: - include-message: always - include-binding-errors: always - include-stacktrace: always spring: - datasource: - url: jdbc:mysql://localhost:3306/blog_db?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai - username: root - password: xjliieea691 + profiles: + active: dev servlet: multipart: max-file-size: 5MB max-request-size: 10MB jpa: - hibernate: - ddl-auto: update open-in-view: false - show-sql: true properties: hibernate: format_sql: true thymeleaf: cache: false - mail: - host: ${MAIL_HOST:smtp.qq.com} - port: ${MAIL_PORT:465} - username: ${MAIL_USERNAME:2054469475@qq.com} - password: ${MAIL_PASSWORD:yxxkfqdhqmqvcfdd} - protocol: ${MAIL_PROTOCOL:smtps} - properties: - mail: - smtp: - auth: true - ssl: - enable: true - debug: false + +app: + upload: + dir: ${APP_UPLOAD_DIR:${user.dir}/uploads} + max-size: 5242880 + allowed-extensions: + - .jpg + - .jpeg + - .png + - .gif + - .webp + allowed-content-types: + - image/jpeg + - image/png + - image/gif + - image/webp logging: level: - org.thymeleaf: DEBUG org.springframework.web: INFO org.springframework.security: INFO diff --git a/src/main/resources/db/migration/V2__set_admin_role.sql b/src/main/resources/db/migration/V2__set_admin_role.sql new file mode 100644 index 0000000..d4148a7 --- /dev/null +++ b/src/main/resources/db/migration/V2__set_admin_role.sql @@ -0,0 +1,7 @@ +UPDATE users +SET role = 'ROLE_ADMIN' +WHERE username = 'admin' AND (role IS NULL OR role = 'ROLE_USER'); + +UPDATE users +SET role = 'ROLE_USER' +WHERE role IS NULL; diff --git a/src/main/resources/templates/admin/stats.html b/src/main/resources/templates/admin/stats.html index c94a733..b5923b0 100644 --- a/src/main/resources/templates/admin/stats.html +++ b/src/main/resources/templates/admin/stats.html @@ -95,9 +95,67 @@

系统统 -
- - 统计图表已暂时关闭,仅保留数据概览与系统信息。 +
+
+
+
新增趋势
+
+
暂无数据
+ +
+
+
+
+
+
Top5 点赞文章
+
+
暂无点赞数据
+ +
+
+
+
+ +
+
+
+
Top5 评论最多文章
+
+
暂无评论数据
+ +
+
+
+
+
+
最近 10 篇文章浏览量
+
+
暂无浏览数据
+ +
+
+
+
+ +
+
+
+
标签 Top10 使用分布
+
+
暂无标签数据
+ +
+
+
+
+
+
分类文章占比
+
+
暂无分类数据
+ +
+
+
@@ -128,6 +186,86 @@

系统统 返回仪表盘

+ +