refactor: optimize security baseline, task logging, frontend maven packaging, and redirect root page to v2
This commit is contained in:
+3
-1
@@ -44,9 +44,11 @@ COPY src ./src
|
||||
# vite.config.js 中 outDir 为相对 __dirname 的路径,容器内 __dirname=/frontend
|
||||
COPY --from=frontend-builder /src/main/resources/static/v2 /app/src/main/resources/static/v2
|
||||
|
||||
# 前端产物已由 frontend-builder 阶段构建并 COPY 进来;
|
||||
# 此阶段不含 frontend-vue/,且离线模式无法下载 Node,必须跳过前端构建。
|
||||
# -T 1C: 按 CPU 核数并行; -o: 离线模式(依赖已缓存,跳过元数据检查)
|
||||
RUN --mount=type=cache,target=/root/.m2 \
|
||||
mvn -B -DskipTests -T 1C -o clean package
|
||||
mvn -B -DskipTests -T 1C -o clean package -Dskip.frontend.build=true
|
||||
|
||||
# ============================================================
|
||||
# Stage 3: 运行镜像(最小化 JRE)
|
||||
|
||||
@@ -30,13 +30,18 @@ mvn spring-boot:run -Dspring-boot.run.mainClass=com.svnlog.web.WebApplication
|
||||
|
||||
## DeepSeek API Key 读取优先级
|
||||
|
||||
1. 请求中的 `apiKey`
|
||||
1. 请求中的 `apiKey`(临时传入,优先级最高)
|
||||
2. 设置页保存的运行时 `apiKey`
|
||||
3. 环境变量 `DEEPSEEK_API_KEY`
|
||||
|
||||
> **首次启动未配置 API Key 是正常状态。**
|
||||
> `/api/settings` 会返回 `apiKeyConfigured: false`,
|
||||
> 请前往「系统设置」页面填写 API Key 后保存,或通过环境变量注入。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 不要在源码和日志中写入真实密钥
|
||||
- 使用环境变量注入密钥是更安全的做法(尤其在 Docker/CI 环境中)
|
||||
- DeepSeek 模式需要可访问 DeepSeek API 的网络环境
|
||||
- OpenAI 兼容模式要求兼容服务提供 `/chat/completions` 流式接口
|
||||
- 接口调用可能产生费用,建议控制调用频率
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# 前端构建说明
|
||||
|
||||
## 目录职责
|
||||
|
||||
| 目录 | 说明 |
|
||||
|------|------|
|
||||
| `frontend-vue/` | 前端源码(Vue 3 + Vite) |
|
||||
| `src/main/resources/static/v2/` | **前端构建产物**,由 Vite 生成,不要手工修改 |
|
||||
|
||||
> ⚠️ `static/v2/` 是自动生成目录,请勿直接手工编辑其中的文件,
|
||||
> 应始终通过构建前端源码来更新。
|
||||
|
||||
---
|
||||
|
||||
## 本地开发
|
||||
|
||||
启动前端开发服务器(热重载,代理到后端 :8080):
|
||||
|
||||
```bash
|
||||
cd frontend-vue
|
||||
npm install # 首次或依赖变更时
|
||||
npm run dev # 启动 Vite dev server,访问 http://localhost:5173/v2/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Maven 打包(含前端构建)
|
||||
|
||||
项目使用 `frontend-maven-plugin` 绑定在 **`prepare-package`** 阶段,
|
||||
只有 `mvn package`(及后续阶段)才会触发前端构建:
|
||||
|
||||
| 命令 | 前端构建 |
|
||||
|------|---------|
|
||||
| `mvn compile` | ❌ 不触发 |
|
||||
| `mvn test` | ❌ 不触发 |
|
||||
| `mvn package` | ✅ 自动执行 `install-node(v20 LTS) → npm ci → npm run build` |
|
||||
```bash
|
||||
# 完整打包(含前端构建,首次默认自动下载 Node)
|
||||
mvn clean package -DskipTests
|
||||
|
||||
# 如果 nodejs.org 下载极慢或报错(如 Premature end of Content-Length),可指定国内镜像源:
|
||||
mvn clean package -DskipTests -Dnode.download.root=https://npmmirror.com/mirrors/node/
|
||||
|
||||
# 跳过前端构建(前提:static/v2/ 中已存有最新的构建产物)
|
||||
mvn clean package -DskipTests -Dskip.frontend.build=true
|
||||
```
|
||||
|
||||
### 离线与缓存方案 (CI / 弱网环境)
|
||||
|
||||
由于默认全量打包依赖外网 Node.js 下载,当本地/CI环境无法直接访问外网或网络极其不稳定时,建议采用以下优化方案:
|
||||
|
||||
1. **手动预缓存 Node.js 包(最稳妥的离线打包方案)**
|
||||
您可以直接手动下载对应平台的 Node 压缩包,并放置到本地 Maven 缓存目录中。以 Linux x64 平台为例:
|
||||
- 下载对应版本的 Node 包:[node-v20.11.0-linux-x64.tar.gz](https://nodejs.org/dist/v20.11.0/node-v20.11.0-linux-x64.tar.gz)
|
||||
- 保存到本地 Maven 仓库缓存路径下:
|
||||
`~/.m2/repository/com/github/eirslett/node/20.11.0/node-20.11.0-linux-x64.tar.gz`
|
||||
- 再次运行 `mvn package` 时,插件检测到该本地缓存文件存在,将**直接读取该文件进行解压**,不会发起任何外网下载请求。
|
||||
|
||||
2. **切换镜像源**
|
||||
如果网络通畅但仅访问官方 nodejs.org 受限,可以使用 `-Dnode.download.root` 参数切换至其他镜像源:
|
||||
- 阿里云镜像:`-Dnode.download.root=https://npmmirror.com/mirrors/node/`
|
||||
- 华为云镜像:`-Dnode.download.root=https://mirrors.huaweicloud.com/nodejs/`
|
||||
|
||||
3. **跳过前端构建**
|
||||
如果仅变更后端 Java 代码,不需要重新编译前端,可直接通过 `-Dskip.frontend.build=true` 跳过前端:
|
||||
`mvn clean package -DskipTests -Dskip.frontend.build=true`
|
||||
|
||||
---
|
||||
|
||||
## Docker 构建(推荐生产方式)
|
||||
|
||||
```bash
|
||||
make up
|
||||
```
|
||||
|
||||
Dockerfile 使用多阶段构建:
|
||||
1. Stage 1(`frontend-builder`):`node:22-alpine` 执行 `npm ci && npm run build`
|
||||
2. Stage 2(`builder`):Maven 将前端产物 COPY 进来并打包 jar
|
||||
3. Stage 3(`runner`):最小 JRE 运行
|
||||
|
||||
---
|
||||
|
||||
## 首次配置 API Key 说明
|
||||
|
||||
服务启动后,如果未配置 API Key,`/api/settings` 返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"apiKeyConfigured": false,
|
||||
"apiKeySource": "none"
|
||||
}
|
||||
```
|
||||
|
||||
这是**正常状态**,请前往设置页配置 DeepSeek API Key 或 OpenAI 兼容 API Key。
|
||||
|
||||
也可通过环境变量配置 DeepSeek API Key:
|
||||
|
||||
```bash
|
||||
export DEEPSEEK_API_KEY=sk-xxxxxxxx
|
||||
```
|
||||
@@ -18,6 +18,11 @@
|
||||
<maven.compiler.source>1.8</maven.compiler.source>
|
||||
<maven.compiler.target>1.8</maven.compiler.target>
|
||||
<spring-boot.version>2.7.18</spring-boot.version>
|
||||
<!-- 本地跳过前端构建:mvn clean package -DskipTests -Dskip.frontend.build=true -->
|
||||
<skip.frontend.build>false</skip.frontend.build>
|
||||
<!-- Node.js 下载源地址,默认官方。因网络下载失败时,可配置镜像源,如:-->
|
||||
<!-- -Dnode.download.root=https://npmmirror.com/mirrors/node/ -->
|
||||
<node.download.root>https://nodejs.org/dist/</node.download.root>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@@ -77,6 +82,58 @@
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<!--
|
||||
frontend-maven-plugin: 绑定到 prepare-package 阶段。
|
||||
- mvn compile / mvn test:不触发前端构建。
|
||||
- mvn package(含 make up Docker 构建的 Java 侧):
|
||||
Docker 后端阶段已用 -Dskip.frontend.build=true,
|
||||
本地全量打包时自动执行 install-node + npm ci + npm run build。
|
||||
- 跳过:mvn clean package -DskipTests -Dskip.frontend.build=true
|
||||
详见 docs/frontend-build.md。
|
||||
-->
|
||||
<plugin>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<version>1.15.0</version>
|
||||
<configuration>
|
||||
<workingDirectory>frontend-vue</workingDirectory>
|
||||
<skip>${skip.frontend.build}</skip>
|
||||
<nodeDownloadRoot>${node.download.root}</nodeDownloadRoot>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>install-node-and-npm</id>
|
||||
<goals>
|
||||
<goal>install-node-and-npm</goal>
|
||||
</goals>
|
||||
<phase>prepare-package</phase>
|
||||
<configuration>
|
||||
<!-- Node 20 LTS,与 Vite 5.x 兼容 -->
|
||||
<nodeVersion>v20.11.0</nodeVersion>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm-install</id>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
</goals>
|
||||
<phase>prepare-package</phase>
|
||||
<configuration>
|
||||
<arguments>ci</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>npm-build</id>
|
||||
<goals>
|
||||
<goal>npm</goal>
|
||||
</goals>
|
||||
<phase>prepare-package</phase>
|
||||
<configuration>
|
||||
<arguments>run build</arguments>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
|
||||
@@ -13,8 +13,8 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
public class IndexController {
|
||||
|
||||
@GetMapping(value = {"/", "/index.html"})
|
||||
public ResponseEntity<Resource> index() {
|
||||
return htmlResponse("static/index.html");
|
||||
public String index() {
|
||||
return "redirect:/v2/";
|
||||
}
|
||||
|
||||
@GetMapping("/v2")
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -31,6 +32,18 @@ public class AiApiService {
|
||||
private static final int DEEPSEEK_REASONER_MAX_TOKENS_RETRY = 64000;
|
||||
private static final int STREAM_PERSIST_INTERVAL = 8;
|
||||
|
||||
// 脱敏正则:JSON 中常见敏感字段(api_key / token / password / secret 等)
|
||||
private static final Pattern SENSITIVE_JSON_FIELD_PATTERN = Pattern.compile(
|
||||
"(\"(?:api[_\\-]?key|apikey|token|authorization|password|secret"
|
||||
+ "|access[_\\-]?key|auth[_\\-]?token)\"\\s*:\\s*\")([^\"]*)(\")",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
// 脱敏正则:HTTP Authorization 头(含 Bearer Token)— 匹配到实际凭证值
|
||||
private static final Pattern SENSITIVE_BEARER_PATTERN = Pattern.compile(
|
||||
"((?:Authorization[:\\s]+)?Bearer[:\\s]+)(\\S+)",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
|
||||
private final OkHttpClient httpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
@@ -133,7 +146,9 @@ public class AiApiService {
|
||||
if (response.body() != null) {
|
||||
errorBody = response.body().string();
|
||||
}
|
||||
String detail = providerContext.displayName + " API 调用失败: " + response.code() + " " + errorBody;
|
||||
// 先脱敏、再截断:不将完整错误体透传到日志/任务错误
|
||||
String detail = providerContext.displayName + " API 调用失败: "
|
||||
+ response.code() + " " + truncateErrorBody(errorBody);
|
||||
if (response.code() == 429 || response.code() >= 500) {
|
||||
throw new RetrySupport.RetryableException(detail);
|
||||
}
|
||||
@@ -367,6 +382,32 @@ public class AiApiService {
|
||||
return object.get(key).getAsString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏并截断错误体:先替换敏感字段的值为 ***,再截断到 500 字符以内。
|
||||
* package-private 以支持单测。
|
||||
*/
|
||||
String truncateErrorBody(String errorBody) {
|
||||
if (errorBody == null || errorBody.trim().isEmpty()) {
|
||||
return "(empty)";
|
||||
}
|
||||
// 先脱敏,再截断
|
||||
final String redacted = redactSensitiveFields(errorBody.trim());
|
||||
final int maxLen = 500;
|
||||
if (redacted.length() <= maxLen) {
|
||||
return redacted;
|
||||
}
|
||||
return redacted.substring(0, maxLen) + "... [已截断, 长度=" + redacted.length() + "]";
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换常见敏感字段的值为 ***。
|
||||
* 覆盖:JSON 敏感字段(api_key、token、password 等)和 HTTP Authorization/Bearer Token。
|
||||
*/
|
||||
private String redactSensitiveFields(String text) {
|
||||
final String step1 = SENSITIVE_JSON_FIELD_PATTERN.matcher(text).replaceAll("$1***$3");
|
||||
return SENSITIVE_BEARER_PATTERN.matcher(step1).replaceAll("$1***");
|
||||
}
|
||||
|
||||
static final class AiProviderContext {
|
||||
private final String provider;
|
||||
private final String displayName;
|
||||
|
||||
@@ -21,10 +21,8 @@ public class SettingsService {
|
||||
public static final String PROVIDER_OPENAI_COMPATIBLE = "openai-compatible";
|
||||
private static final String SETTINGS_FILE_NAME = "settings.json";
|
||||
|
||||
// 启动默认 API Key(仅作为本地默认值,可在设置页覆盖)
|
||||
private static final String BOOTSTRAP_API_KEY = "sk-48c59012c93b43a08fecbaf3e74799e7";
|
||||
// 默认值:API Key 不再内置,必须通过环境变量或设置页配置
|
||||
private static final String DEFAULT_OPENAI_BASE_URL = "http://127.0.0.1:5001/v1";
|
||||
private static final String DEFAULT_OPENAI_API_KEY = "sk-f8b3f43e1fdd4f50b287050f08a6c7ed";
|
||||
private static final String DEFAULT_OPENAI_STAGE_ONE_MODEL = "deepseek-v4-flash";
|
||||
private static final String DEFAULT_OPENAI_STAGE_TWO_MODEL = "deepseek-v4-pro";
|
||||
private static final String ENV_SVN_USERNAME = "SVN_USERNAME";
|
||||
@@ -80,7 +78,7 @@ public class SettingsService {
|
||||
this.runtimeApiKey = initStartupApiKey();
|
||||
this.runtimeProvider = PROVIDER_DEEPSEEK;
|
||||
this.runtimeOpenaiBaseUrl = DEFAULT_OPENAI_BASE_URL;
|
||||
this.runtimeOpenaiApiKey = DEFAULT_OPENAI_API_KEY;
|
||||
this.runtimeOpenaiApiKey = null;
|
||||
this.runtimeOpenaiStageOneModel = DEFAULT_OPENAI_STAGE_ONE_MODEL;
|
||||
this.runtimeOpenaiStageTwoModel = DEFAULT_OPENAI_STAGE_TWO_MODEL;
|
||||
this.runtimeSvnUsername = trim(System.getenv(ENV_SVN_USERNAME));
|
||||
@@ -230,14 +228,12 @@ public class SettingsService {
|
||||
}
|
||||
|
||||
private String initStartupApiKey() {
|
||||
// 仅从环境变量读取,不内置默认值
|
||||
final String envKey = System.getenv("DEEPSEEK_API_KEY");
|
||||
if (envKey != null && !envKey.trim().isEmpty()) {
|
||||
LOGGER.info("DeepSeek API Key loaded from environment variable DEEPSEEK_API_KEY");
|
||||
return envKey.trim();
|
||||
}
|
||||
if (BOOTSTRAP_API_KEY != null && !BOOTSTRAP_API_KEY.trim().isEmpty()
|
||||
&& !BOOTSTRAP_API_KEY.startsWith("REPLACE_WITH_")) {
|
||||
return BOOTSTRAP_API_KEY.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -363,7 +359,7 @@ public class SettingsService {
|
||||
}
|
||||
|
||||
public String getOpenaiApiKey() {
|
||||
return trimOrDefault(runtimeOpenaiApiKey, DEFAULT_OPENAI_API_KEY);
|
||||
return trim(runtimeOpenaiApiKey);
|
||||
}
|
||||
|
||||
public String getSvnUsername() {
|
||||
|
||||
@@ -243,16 +243,18 @@ public class TaskService {
|
||||
if (!loaded.isEmpty()) {
|
||||
persistSafely();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// ignore persistence loading failures to keep service available
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to load task history; service continues without history. Reason: {}",
|
||||
e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void persistSafely() {
|
||||
try {
|
||||
persistenceService.save(buildStorePath(), tasks.values());
|
||||
} catch (Exception ignored) {
|
||||
// ignore persistence saving failures to avoid interrupting running tasks
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to persist task history; in-memory state intact. Reason: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.svnlog.web.service;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* AiApiService 中 truncateErrorBody / redactSensitiveFields 的单元测试。
|
||||
* truncateErrorBody 为 package-private,可直接测试。
|
||||
*/
|
||||
class AiApiServiceTest {
|
||||
|
||||
// settingsService 未用到 truncateErrorBody,传 null 安全
|
||||
private final AiApiService service = new AiApiService(null);
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyPlaceholderWhenBodyIsNull() {
|
||||
Assertions.assertEquals("(empty)", service.truncateErrorBody(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnEmptyPlaceholderWhenBodyIsBlank() {
|
||||
Assertions.assertEquals("(empty)", service.truncateErrorBody(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnShortBodyWithoutSensitiveDataAsIs() {
|
||||
final String body = "{\"error\": \"model not found\", \"code\": 404}";
|
||||
Assertions.assertEquals(body, service.truncateErrorBody(body));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTruncateLongBodyAndAppendMarker() {
|
||||
final StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 600; i++) {
|
||||
sb.append('x');
|
||||
}
|
||||
final String result = service.truncateErrorBody(sb.toString());
|
||||
Assertions.assertTrue(result.contains("[已截断"), "should contain truncation marker");
|
||||
Assertions.assertTrue(result.length() < 600, "result should be shorter than original");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRedactApiKeyFieldInJsonBody() {
|
||||
final String body = "{\"error\": \"invalid\", \"api_key\": \"sk-secret-123456\"}";
|
||||
final String result = service.truncateErrorBody(body);
|
||||
Assertions.assertFalse(result.contains("sk-secret-123456"), "api_key value must be redacted");
|
||||
Assertions.assertTrue(result.contains("***"), "redaction marker must be present");
|
||||
Assertions.assertTrue(result.contains("api_key"), "field name must be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRedactTokenFieldInJsonBody() {
|
||||
final String body = "{\"token\": \"eyJhbGciOiJIUzI1NiJ9.payload\", \"status\": 401}";
|
||||
final String result = service.truncateErrorBody(body);
|
||||
Assertions.assertFalse(result.contains("eyJhbGciOiJIUzI1NiJ9.payload"), "token value must be redacted");
|
||||
Assertions.assertTrue(result.contains("***"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRedactPasswordFieldInJsonBody() {
|
||||
final String body = "{\"password\": \"my-secret-pw\", \"code\": 401}";
|
||||
final String result = service.truncateErrorBody(body);
|
||||
Assertions.assertFalse(result.contains("my-secret-pw"), "password value must be redacted");
|
||||
Assertions.assertTrue(result.contains("***"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRedactAuthorizationBearerToken() {
|
||||
final String body = "Authorization: Bearer sk-real-token-abc\nError: unauthorized";
|
||||
final String result = service.truncateErrorBody(body);
|
||||
Assertions.assertFalse(result.contains("sk-real-token-abc"), "bearer token must be redacted");
|
||||
Assertions.assertTrue(result.contains("***"), "redaction marker must be present");
|
||||
Assertions.assertTrue(result.contains("Authorization"), "header name must be preserved");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreserveNonSensitiveContentAfterRedaction() {
|
||||
final String body = "{\"error\": \"rate limit exceeded\", \"code\": 429}";
|
||||
final String result = service.truncateErrorBody(body);
|
||||
Assertions.assertTrue(result.contains("rate limit exceeded"), "non-sensitive content must be preserved");
|
||||
Assertions.assertTrue(result.contains("429"), "non-sensitive content must be preserved");
|
||||
}
|
||||
}
|
||||
@@ -30,14 +30,15 @@ class AiWorkflowServiceTest {
|
||||
final OutputFileService outputFileService = buildOutputFileService();
|
||||
final SettingsService settingsService = buildSettingsService(outputFileService);
|
||||
final AiApiService aiApiService = buildAiApiService(settingsService);
|
||||
final AiWorkflowService service = new AiWorkflowService(
|
||||
new AiWorkflowService(
|
||||
outputFileService,
|
||||
new AiInputValidator(),
|
||||
aiApiService,
|
||||
buildExcelExportService()
|
||||
);
|
||||
|
||||
final AiApiService.AiProviderContext context = aiApiService.resolveProviderContext(null);
|
||||
// DeepSeek provider:通过请求临时传入 key 来解析 context(不依赖内置默认 key)
|
||||
final AiApiService.AiProviderContext context = aiApiService.resolveProviderContext("sk-test-key");
|
||||
|
||||
Assertions.assertEquals(SettingsService.PROVIDER_DEEPSEEK, context.getProvider());
|
||||
Assertions.assertEquals("deepseek-chat", context.getStageOneModel());
|
||||
|
||||
Reference in New Issue
Block a user