Compare commits

..

4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
c79702eba8 Address code review feedback: protect types.js endpoint and improve code readability
Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2025-12-07 05:27:07 +00:00
copilot-swe-agent[bot]
41fc935c09 Fix JsonResult API calls and add documentation
Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2025-12-07 05:24:38 +00:00
copilot-swe-agent[bot]
5fbbe5b240 Add playground loading animation, password auth, and mobile layout support
Co-authored-by: qaiu <29825328+qaiu@users.noreply.github.com>
2025-12-07 05:20:06 +00:00
copilot-swe-agent[bot]
9c121c03f2 Initial plan 2025-12-07 05:11:50 +00:00
9 changed files with 919 additions and 56 deletions

View File

@@ -0,0 +1,166 @@
# Playground 密码保护功能
## 概述
JS解析器演练场现在支持密码保护功能可以通过配置文件控制是否需要密码才能访问。
## 配置说明
`web-service/src/main/resources/app-dev.yml` 文件中添加以下配置:
```yaml
# JS演练场配置
playground:
# 公开模式默认false需要密码访问设为true则无需密码
public: false
# 访问密码,建议修改默认密码!
password: 'nfd_playground_2024'
```
### 配置项说明
- `public`: 布尔值,默认为 `false`
- `false`: 需要输入密码才能访问演练场(推荐)
- `true`: 公开访问,无需密码
- `password`: 字符串,访问密码
- 默认密码:`nfd_playground_2024`
- **强烈建议在生产环境中修改为自定义密码!**
## 功能特点
### 1. 密码保护模式 (public: false)
`public` 设置为 `false` 时:
- 访问 `/playground` 页面时会显示密码输入界面
- 必须输入正确的密码才能使用演练场功能
- 密码验证通过后,会话保持登录状态
- 所有演练场相关的 API 接口都受到保护
### 2. 公开模式 (public: true)
`public` 设置为 `true` 时:
- 无需输入密码即可访问演练场
- 适用于内网环境或开发测试环境
### 3. 加载动画与进度条
页面加载过程会显示进度条,包括以下阶段:
1. 初始化Vue组件 (0-20%)
2. 加载配置和本地数据 (20-40%)
3. 准备TypeScript编译器 (40-50%)
4. 初始化Monaco Editor (50-80%)
5. 加载完成 (80-100%)
### 4. 移动端适配
- 桌面端:左右分栏布局,可拖拽调整宽度
- 移动端(屏幕宽度 ≤ 768px自动切换为上下分栏布局可拖拽调整高度
## 安全建议
⚠️ **重要安全提示:**
1. **修改默认密码**:在生产环境中,务必修改 `playground.password` 为自定义的强密码
2. **使用密码保护**:建议保持 `public: false`,避免未授权访问
3. **定期更换密码**:定期更换访问密码以提高安全性
4. **配置文件保护**:确保配置文件的访问权限受到保护
## 系统启动提示
当系统启动时,会在日志中显示当前配置:
```
INFO - Playground配置已加载: public=false, password=已设置
```
如果使用默认密码,会显示警告:
```
WARN - ⚠️ 警告:您正在使用默认密码,建议修改配置文件中的 playground.password 以确保安全!
```
## API 端点
### 1. 获取状态
```
GET /v2/playground/status
```
返回:
```json
{
"code": 200,
"data": {
"public": false,
"authed": false
}
}
```
### 2. 登录
```
POST /v2/playground/login
Content-Type: application/json
{
"password": "your_password"
}
```
成功响应:
```json
{
"code": 200,
"msg": "登录成功",
"success": true
}
```
失败响应:
```json
{
"code": 500,
"msg": "密码错误",
"success": false
}
```
## 常见问题
### Q: 如何禁用密码保护?
A: 在配置文件中设置 `playground.public: true`
### Q: 忘记密码怎么办?
A: 修改配置文件中的 `playground.password` 为新密码,然后重启服务
### Q: 密码是否加密存储?
A: 当前版本密码以明文形式存储在配置文件中,请确保配置文件的访问权限受到保护
### Q: Session 有效期多久?
A: Session 由 Vert.x 管理,默认在浏览器会话期间有效,关闭浏览器后失效
## 后续版本计划
未来版本可能会添加以下功能:
- [ ] 支持环境变量配置密码
- [ ] 支持加密存储密码
- [ ] 支持多用户账户系统
- [ ] 支持 Token 认证方式
- [ ] 支持 Session 超时配置
## 相关文档
- [Playground 使用指南](PLAYGROUND_GUIDE.md)
- [JavaScript 解析器开发指南](parser/doc/JAVASCRIPT_PARSER_GUIDE.md)
- [TypeScript 实现总结](TYPESCRIPT_IMPLEMENTATION_SUMMARY_CN.md)

View File

@@ -40,6 +40,8 @@ https://nfd-parser.github.io/nfd-preview/preview.html?src=https%3A%2F%2Flz.qaiu.
**JavaScript解析器文档** [JavaScript解析器开发指南](parser/doc/JAVASCRIPT_PARSER_GUIDE.md) | [自定义解析器扩展指南](parser/doc/CUSTOM_PARSER_GUIDE.md) | [快速开始](parser/doc/CUSTOM_PARSER_QUICKSTART.md)
**Playground功能** [JS解析器演练场密码保护说明](PLAYGROUND_PASSWORD_PROTECTION.md)
## 预览地址
[预览地址1](https://lz.qaiu.top)
[预览地址2](https://lzzz.qaiu.top)

View File

@@ -0,0 +1,73 @@
package cn.qaiu.vx.core.verticle.conf;
import io.vertx.core.json.JsonObject;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.impl.JsonUtil;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
/**
* Converter and mapper for {@link cn.qaiu.vx.core.verticle.conf.HttpProxyConf}.
* NOTE: This class has been automatically generated from the {@link cn.qaiu.vx.core.verticle.conf.HttpProxyConf} original class using Vert.x codegen.
*/
public class HttpProxyConfConverter {
private static final Base64.Decoder BASE64_DECODER = JsonUtil.BASE64_DECODER;
private static final Base64.Encoder BASE64_ENCODER = JsonUtil.BASE64_ENCODER;
static void fromJson(Iterable<java.util.Map.Entry<String, Object>> json, HttpProxyConf obj) {
for (java.util.Map.Entry<String, Object> member : json) {
switch (member.getKey()) {
case "password":
if (member.getValue() instanceof String) {
obj.setPassword((String)member.getValue());
}
break;
case "port":
if (member.getValue() instanceof Number) {
obj.setPort(((Number)member.getValue()).intValue());
}
break;
case "preProxyOptions":
if (member.getValue() instanceof JsonObject) {
obj.setPreProxyOptions(new io.vertx.core.net.ProxyOptions((io.vertx.core.json.JsonObject)member.getValue()));
}
break;
case "timeout":
if (member.getValue() instanceof Number) {
obj.setTimeout(((Number)member.getValue()).intValue());
}
break;
case "username":
if (member.getValue() instanceof String) {
obj.setUsername((String)member.getValue());
}
break;
}
}
}
static void toJson(HttpProxyConf obj, JsonObject json) {
toJson(obj, json.getMap());
}
static void toJson(HttpProxyConf obj, java.util.Map<String, Object> json) {
if (obj.getPassword() != null) {
json.put("password", obj.getPassword());
}
if (obj.getPort() != null) {
json.put("port", obj.getPort());
}
if (obj.getPreProxyOptions() != null) {
json.put("preProxyOptions", obj.getPreProxyOptions().toJson());
}
if (obj.getTimeout() != null) {
json.put("timeout", obj.getTimeout());
}
if (obj.getUsername() != null) {
json.put("username", obj.getUsername());
}
}
}

View File

@@ -4,6 +4,33 @@ import axios from 'axios';
* 演练场API服务
*/
export const playgroundApi = {
/**
* 获取Playground状态是否需要认证
* @returns {Promise} 状态信息
*/
async getStatus() {
try {
const response = await axios.get('/v2/playground/status');
return response.data;
} catch (error) {
throw new Error(error.response?.data?.error || error.message || '获取状态失败');
}
},
/**
* Playground登录
* @param {string} password - 访问密码
* @returns {Promise} 登录结果
*/
async login(password) {
try {
const response = await axios.post('/v2/playground/login', { password });
return response.data;
} catch (error) {
throw new Error(error.response?.data?.error || error.message || '登录失败');
}
},
/**
* 测试执行JavaScript代码
* @param {string} jsCode - JavaScript代码

View File

@@ -1,6 +1,58 @@
<template>
<div ref="playgroundContainer" class="playground-container" :class="{ 'dark-theme': isDarkMode, 'fullscreen': isFullscreen }">
<el-card class="playground-card">
<div ref="playgroundContainer" class="playground-container" :class="{ 'dark-theme': isDarkMode, 'fullscreen': isFullscreen, 'is-mobile': isMobile }">
<!-- 加载动画 + 进度条 -->
<div v-if="loading" class="playground-loading-overlay">
<div class="playground-loading-card">
<div class="loading-icon">
<el-icon class="is-loading" :size="40"><Loading /></el-icon>
</div>
<div class="loading-text">正在加载编辑器和编译器...</div>
<div class="loading-bar">
<div class="loading-bar-inner" :style="{ width: loadProgress + '%' }"></div>
</div>
<div class="loading-percent">{{ loadProgress }}%</div>
<div class="loading-details">{{ loadingMessage }}</div>
</div>
</div>
<!-- 密码验证界面 -->
<div v-if="!loading && authChecking" class="playground-auth-loading">
<el-icon class="is-loading" :size="30"><Loading /></el-icon>
<span style="margin-left: 10px;">正在检查访问权限...</span>
</div>
<div v-if="shouldShowAuthUI" class="playground-auth-overlay">
<div class="playground-auth-card">
<div class="auth-icon">
<el-icon :size="50"><Lock /></el-icon>
</div>
<div class="auth-title">JS解析器演练场</div>
<div class="auth-subtitle">请输入访问密码</div>
<el-input
v-model="inputPassword"
type="password"
placeholder="请输入访问密码"
size="large"
@keyup.enter="submitPassword"
class="auth-input"
>
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
</el-input>
<div v-if="authError" class="auth-error">
<el-icon><WarningFilled /></el-icon>
<span>{{ authError }}</span>
</div>
<el-button type="primary" size="large" @click="submitPassword" :loading="authLoading" class="auth-button">
<el-icon v-if="!authLoading"><Unlock /></el-icon>
<span>确认登录</span>
</el-button>
</div>
</div>
<!-- 原有内容 - 只在已认证时显示 -->
<el-card v-if="authed && !loading" class="playground-card">
<template #header>
<div class="card-header">
<div class="header-left">
@@ -67,8 +119,8 @@
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<!-- 代码编辑标签页 -->
<el-tab-pane label="代码编辑" name="editor">
<Splitpanes class="default-theme" @resized="handleResize">
<!-- 左侧代码编辑区 -->
<Splitpanes :class="['default-theme', isMobile ? 'mobile-vertical' : '']" :horizontal="isMobile" @resized="handleResize">
<!-- 编辑器区域 (PC: 左侧, Mobile: 上方) -->
<Pane :size="collapsedPanels.rightPanel ? 100 : splitSizes[0]" min-size="30" class="editor-pane">
<div class="editor-section">
<MonacoEditor
@@ -82,9 +134,9 @@
</div>
</Pane>
<!-- 右侧测试参数和结果 -->
<!-- 测试参数和结果区域 (PC: 右侧, Mobile: 下方) -->
<Pane v-if="!collapsedPanels.rightPanel"
:size="splitSizes[1]" min-size="20" class="test-pane" style="margin-left: 10px;">
:size="splitSizes[1]" min-size="20" class="test-pane" :style="isMobile ? 'margin-top: 10px;' : 'margin-left: 10px;'">
<div class="test-section">
<!-- 优化的折叠按钮 -->
<el-tooltip content="折叠测试面板" placement="left">
@@ -507,6 +559,20 @@ export default {
const codeLanguage = ref(LANGUAGE.JAVASCRIPT); // 新增:代码语言选择
const compiledES5Code = ref(''); // 新增编译后的ES5代码
const compileStatus = ref({ success: true, errors: [] }); // 新增:编译状态
// ===== 加载和认证状态 =====
const loading = ref(true);
const loadProgress = ref(0);
const loadingMessage = ref('初始化...');
const authChecking = ref(true);
const authed = ref(false);
const inputPassword = ref('');
const authError = ref('');
const authLoading = ref(false);
// ===== 移动端检测 =====
const isMobile = ref(false);
const testParams = ref({
shareUrl: 'https://lanzoui.com/i7Aq12ab3cd',
pwd: '',
@@ -721,6 +787,11 @@ async function parseById(
const editorTheme = computed(() => {
return isDarkMode.value ? 'vs-dark' : 'vs';
});
// 计算属性:是否需要显示密码输入界面
const shouldShowAuthUI = computed(() => {
return !loading.value && !authChecking.value && !authed.value;
});
// 编辑器配置
const editorOptions = {
@@ -732,6 +803,136 @@ async function parseById(
formatOnType: true,
tabSize: 2
};
// ===== 移动端检测 =====
const updateIsMobile = () => {
isMobile.value = window.innerWidth <= 768;
};
// ===== 进度设置函数 =====
const setProgress = (progress, message = '') => {
if (progress > loadProgress.value) {
loadProgress.value = progress;
}
if (message) {
loadingMessage.value = message;
}
};
// ===== 认证相关函数 =====
const checkAuthStatus = async () => {
try {
const res = await playgroundApi.getStatus();
if (res.code === 200 && res.data) {
authed.value = res.data.authed || res.data.public;
return res.data.authed || res.data.public;
}
return false;
} catch (error) {
console.error('检查认证状态失败:', error);
ElMessage.error('检查访问权限失败: ' + error.message);
return false;
} finally {
authChecking.value = false;
}
};
const submitPassword = async () => {
if (!inputPassword.value.trim()) {
authError.value = '请输入密码';
return;
}
authError.value = '';
authLoading.value = true;
try {
const res = await playgroundApi.login(inputPassword.value);
if (res.code === 200 || res.success) {
authed.value = true;
ElMessage.success('登录成功');
await initPlayground();
} else {
authError.value = res.msg || res.message || '密码错误';
}
} catch (error) {
authError.value = error.message || '登录失败,请重试';
} finally {
authLoading.value = false;
}
};
// ===== Playground 初始化 =====
const initPlayground = async () => {
loading.value = true;
loadProgress.value = 0;
try {
setProgress(10, '初始化Vue组件...');
await nextTick();
setProgress(20, '加载配置和本地数据...');
// 加载保存的代码
const saved = localStorage.getItem('playground_code');
if (saved) {
jsCode.value = saved;
} else {
jsCode.value = exampleCode;
}
// 加载保存的语言选择
const savedLanguage = localStorage.getItem('playground_language');
if (savedLanguage) {
codeLanguage.value = savedLanguage;
}
setProgress(40, '准备加载TypeScript编译器...');
await new Promise(resolve => setTimeout(resolve, 100));
setProgress(50, '初始化Monaco Editor类型定义...');
await initMonacoTypes();
setProgress(80, '加载完成...');
// 加载保存的主题
const savedTheme = localStorage.getItem('playground_theme');
if (savedTheme) {
currentTheme.value = savedTheme;
const theme = themes.find(t => t.name === savedTheme);
if (theme && document.documentElement && document.body) {
await nextTick();
if (theme.page === 'dark') {
document.documentElement.classList.add('dark');
document.body.classList.add('dark-theme');
document.body.style.backgroundColor = '#0a0a0a';
} else {
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark-theme');
document.body.style.backgroundColor = '#f0f2f5';
}
}
}
// 加载保存的折叠状态
const savedCollapsed = localStorage.getItem('playground_collapsed_panels');
if (savedCollapsed) {
try {
collapsedPanels.value = JSON.parse(savedCollapsed);
} catch (e) {
console.warn('加载折叠状态失败', e);
}
}
setProgress(100, '初始化完成!');
await new Promise(resolve => setTimeout(resolve, 300));
} catch (error) {
console.error('初始化失败:', error);
ElMessage.error('初始化失败: ' + error.message);
} finally {
loading.value = false;
}
};
// 初始化Monaco Editor类型定义
const initMonacoTypes = async () => {
@@ -1368,53 +1569,23 @@ curl "${baseUrl}/json/parser?url=${encodeURIComponent(exampleUrl)}"</pre>
};
onMounted(async () => {
// 初始化移动端检测
updateIsMobile();
window.addEventListener('resize', updateIsMobile);
// 检查认证状态
const isAuthed = await checkAuthStatus();
// 如果已认证初始化playground
if (isAuthed) {
await initPlayground();
} else {
// 未认证,停止加载动画,显示密码输入
loading.value = false;
}
await nextTick();
checkDarkMode();
await initMonacoTypes();
// 加载保存的代码
const saved = localStorage.getItem('playground_code');
if (saved) {
jsCode.value = saved;
} else {
jsCode.value = exampleCode;
}
// 加载保存的主题
await nextTick();
const savedTheme = localStorage.getItem('playground_theme');
if (savedTheme) {
currentTheme.value = savedTheme;
const theme = themes.find(t => t.name === savedTheme);
if (theme && document.documentElement && document.body) {
await nextTick();
if (theme.page === 'dark') {
document.documentElement.classList.add('dark');
document.body.classList.add('dark-theme');
document.body.style.backgroundColor = '#0a0a0a';
} else {
document.documentElement.classList.remove('dark');
document.body.classList.remove('dark-theme');
document.body.style.backgroundColor = '#f0f2f5';
}
}
}
// 加载保存的折叠状态
const savedCollapsed = localStorage.getItem('playground_collapsed_panels');
if (savedCollapsed) {
try {
collapsedPanels.value = JSON.parse(savedCollapsed);
} catch (e) {
console.warn('加载折叠状态失败', e);
}
}
// 加载保存的语言选择
const savedLanguage = localStorage.getItem('playground_language');
if (savedLanguage) {
codeLanguage.value = savedLanguage;
}
// 监听主题变化
if (document.documentElement) {
@@ -1430,6 +1601,10 @@ curl "${baseUrl}/json/parser?url=${encodeURIComponent(exampleUrl)}"</pre>
// 初始化splitpanes样式
updateSplitpanesStyle();
});
onUnmounted(() => {
window.removeEventListener('resize', updateIsMobile);
});
return {
LANGUAGE,
@@ -1443,7 +1618,22 @@ curl "${baseUrl}/json/parser?url=${encodeURIComponent(exampleUrl)}"</pre>
testing,
isDarkMode,
editorTheme,
shouldShowAuthUI,
editorOptions,
// 加载和认证
loading,
loadProgress,
loadingMessage,
authChecking,
authed,
inputPassword,
authError,
authLoading,
checkAuthStatus,
submitPassword,
// 移动端
isMobile,
updateIsMobile,
onCodeChange,
onLanguageChange,
compileTypeScriptCode,
@@ -1550,6 +1740,154 @@ body.dark-theme .splitpanes__splitter:hover,
</style>
<style scoped>
/* ===== 加载动画和进度条 ===== */
.playground-loading-overlay {
position: fixed;
inset: 0;
background: rgba(255, 255, 255, 0.98);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(5px);
}
.dark-theme .playground-loading-overlay {
background: rgba(10, 10, 10, 0.98);
}
.playground-loading-card {
width: 320px;
padding: 30px 40px;
background: #fff;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
border-radius: 12px;
text-align: center;
font-size: 14px;
}
.dark-theme .playground-loading-card {
background: #1f1f1f;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
}
.loading-icon {
margin-bottom: 20px;
color: var(--el-color-primary);
}
.loading-text {
font-size: 15px;
font-weight: 500;
color: var(--el-text-color-primary);
margin-bottom: 16px;
}
.loading-bar {
width: 100%;
height: 6px;
background: var(--el-fill-color-light);
border-radius: 3px;
margin: 12px 0;
overflow: hidden;
}
.loading-bar-inner {
height: 100%;
background: linear-gradient(90deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%);
transition: width 0.3s ease;
border-radius: 3px;
}
.loading-percent {
font-size: 13px;
font-weight: 600;
color: var(--el-color-primary);
margin-bottom: 8px;
}
.loading-details {
font-size: 12px;
color: var(--el-text-color-secondary);
min-height: 20px;
}
/* ===== 认证界面 ===== */
.playground-auth-loading {
position: fixed;
inset: 0;
background: var(--el-bg-color);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--el-text-color-primary);
}
.playground-auth-overlay {
position: fixed;
inset: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.playground-auth-card {
width: 400px;
padding: 40px;
background: #fff;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
border-radius: 16px;
text-align: center;
}
.dark-theme .playground-auth-card {
background: #1f1f1f;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.auth-icon {
margin-bottom: 24px;
color: var(--el-color-primary);
}
.auth-title {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 8px;
}
.auth-subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin-bottom: 24px;
}
.auth-input {
margin-bottom: 16px;
}
.auth-error {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--el-color-danger);
font-size: 13px;
margin-bottom: 16px;
}
.auth-button {
width: 100%;
height: 44px;
font-size: 16px;
font-weight: 500;
}
/* ===== 容器布局 ===== */
.playground-container {
padding: 10px 20px;
@@ -2513,6 +2851,40 @@ html.dark .playground-container .splitpanes__splitter:hover {
}
/* ===== 响应式布局 ===== */
/* 移动端纵向布局 */
.playground-container.is-mobile .splitpanes.mobile-vertical {
flex-direction: column !important;
}
.playground-container.is-mobile .splitpanes--horizontal > .splitpanes__splitter {
height: 6px;
width: 100%;
cursor: row-resize;
margin: 5px 0;
}
.playground-container.is-mobile .editor-pane,
.playground-container.is-mobile .test-pane {
width: 100% !important;
}
.playground-container.is-mobile .test-pane {
margin-left: 0 !important;
margin-top: 10px;
}
.playground-container.is-mobile .playground-loading-card {
width: 90%;
max-width: 320px;
padding: 24px 30px;
}
.playground-container.is-mobile .playground-auth-card {
width: 90%;
max-width: 400px;
padding: 30px 24px;
}
@media screen and (max-width: 1200px) {
.splitpanes {
min-height: 400px;
@@ -2555,6 +2927,10 @@ html.dark .playground-container .splitpanes__splitter:hover {
flex-direction: column;
gap: 8px;
}
.splitpanes {
flex-direction: column;
}
}
/* ===== 改进的滚动条样式 ===== */

View File

@@ -4,6 +4,7 @@ import cn.qaiu.WebClientVertxInit;
import cn.qaiu.db.pool.JDBCPoolInit;
import cn.qaiu.lz.common.cache.CacheConfigLoader;
import cn.qaiu.lz.common.interceptorImpl.RateLimiter;
import cn.qaiu.lz.web.config.PlaygroundConfig;
import cn.qaiu.vx.core.Deploy;
import cn.qaiu.vx.core.util.ConfigConstant;
import cn.qaiu.vx.core.util.VertxHolder;
@@ -88,5 +89,8 @@ public class AppMain {
JsonObject auths = jsonObject.getJsonObject(ConfigConstant.AUTHS);
localMap.put(ConfigConstant.AUTHS, auths);
}
// 演练场配置
PlaygroundConfig.loadFromJson(jsonObject);
}
}

View File

@@ -0,0 +1,73 @@
package cn.qaiu.lz.web.config;
import io.vertx.core.json.JsonObject;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
/**
* JS演练场配置
*
* @author <a href="https://qaiu.top">QAIU</a>
*/
@Data
@Slf4j
public class PlaygroundConfig {
/**
* 单例实例
*/
private static PlaygroundConfig instance;
/**
* 是否公开模式(不需要密码)
* 默认false需要密码访问
*/
private boolean isPublic = false;
/**
* 访问密码
* 默认密码nfd_playground_2024
*/
private String password = "nfd_playground_2024";
/**
* 私有构造函数
*/
private PlaygroundConfig() {
}
/**
* 获取单例实例
*/
public static PlaygroundConfig getInstance() {
if (instance == null) {
synchronized (PlaygroundConfig.class) {
if (instance == null) {
instance = new PlaygroundConfig();
}
}
}
return instance;
}
/**
* 从JsonObject加载配置
*/
public static void loadFromJson(JsonObject config) {
PlaygroundConfig cfg = getInstance();
if (config != null && config.containsKey("playground")) {
JsonObject playgroundConfig = config.getJsonObject("playground");
cfg.isPublic = playgroundConfig.getBoolean("public", false);
cfg.password = playgroundConfig.getString("password", "nfd_playground_2024");
log.info("Playground配置已加载: public={}, password={}",
cfg.isPublic, cfg.isPublic ? "N/A" : "已设置");
if (!cfg.isPublic && "nfd_playground_2024".equals(cfg.password)) {
log.warn("⚠️ 警告:您正在使用默认密码,建议修改配置文件中的 playground.password 以确保安全!");
}
} else {
log.info("未找到playground配置使用默认值: public=false");
}
}
}

View File

@@ -1,6 +1,7 @@
package cn.qaiu.lz.web.controller;
import cn.qaiu.entity.ShareLinkInfo;
import cn.qaiu.lz.web.config.PlaygroundConfig;
import cn.qaiu.lz.web.model.PlaygroundTestResp;
import cn.qaiu.lz.web.service.DbService;
import cn.qaiu.parser.ParserCreate;
@@ -19,6 +20,7 @@ import io.vertx.core.http.HttpServerRequest;
import io.vertx.core.http.HttpServerResponse;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.Session;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
@@ -42,7 +44,92 @@ public class PlaygroundApi {
private static final int MAX_PARSER_COUNT = 100;
private static final int MAX_CODE_LENGTH = 128 * 1024; // 128KB 代码长度限制
private static final String SESSION_AUTH_KEY = "playgroundAuthed";
private final DbService dbService = AsyncServiceUtil.getAsyncServiceInstance(DbService.class);
/**
* 检查Playground访问权限
*/
private boolean checkAuth(RoutingContext ctx) {
PlaygroundConfig config = PlaygroundConfig.getInstance();
// 如果是公开模式,直接允许访问
if (config.isPublic()) {
return true;
}
// 否则检查Session中的认证状态
Session session = ctx.session();
if (session == null) {
return false;
}
Boolean authed = session.get(SESSION_AUTH_KEY);
return authed != null && authed;
}
/**
* 获取Playground状态是否需要认证
*/
@RouteMapping(value = "/status", method = RouteMethod.GET)
public Future<JsonObject> getStatus(RoutingContext ctx) {
PlaygroundConfig config = PlaygroundConfig.getInstance();
boolean authed = checkAuth(ctx);
JsonObject result = new JsonObject()
.put("public", config.isPublic())
.put("authed", authed);
return Future.succeededFuture(JsonResult.data(result).toJsonObject());
}
/**
* Playground登录
*/
@RouteMapping(value = "/login", method = RouteMethod.POST)
public Future<JsonObject> login(RoutingContext ctx) {
Promise<JsonObject> promise = Promise.promise();
try {
PlaygroundConfig config = PlaygroundConfig.getInstance();
// 如果是公开模式,直接成功
if (config.isPublic()) {
Session session = ctx.session();
if (session != null) {
session.put(SESSION_AUTH_KEY, true);
}
promise.complete(JsonResult.success("公开模式,无需密码").toJsonObject());
return promise.future();
}
// 获取密码
JsonObject body = ctx.body().asJsonObject();
String password = body.getString("password");
if (StringUtils.isBlank(password)) {
promise.complete(JsonResult.error("密码不能为空").toJsonObject());
return promise.future();
}
// 验证密码
if (config.getPassword().equals(password)) {
Session session = ctx.session();
if (session != null) {
session.put(SESSION_AUTH_KEY, true);
}
promise.complete(JsonResult.success("登录成功").toJsonObject());
} else {
promise.complete(JsonResult.error("密码错误").toJsonObject());
}
} catch (Exception e) {
log.error("登录失败", e);
promise.complete(JsonResult.error("登录失败: " + e.getMessage()).toJsonObject());
}
return promise.future();
}
/**
* 测试执行JavaScript代码
@@ -52,6 +139,11 @@ public class PlaygroundApi {
*/
@RouteMapping(value = "/test", method = RouteMethod.POST)
public Future<JsonObject> test(RoutingContext ctx) {
// 权限检查
if (!checkAuth(ctx)) {
return Future.succeededFuture(JsonResult.error("未授权访问").toJsonObject());
}
Promise<JsonObject> promise = Promise.promise();
try {
@@ -219,10 +311,17 @@ public class PlaygroundApi {
/**
* 获取types.js文件内容
*
* @param ctx 路由上下文
* @param response HTTP响应
*/
@RouteMapping(value = "/types.js", method = RouteMethod.GET)
public void getTypesJs(HttpServerResponse response) {
public void getTypesJs(RoutingContext ctx, HttpServerResponse response) {
// 权限检查
if (!checkAuth(ctx)) {
ResponseUtil.fireJsonResultResponse(response, JsonResult.error("未授权访问"));
return;
}
try (InputStream inputStream = getClass().getClassLoader()
.getResourceAsStream("custom-parsers/types.js")) {
@@ -248,7 +347,11 @@ public class PlaygroundApi {
* 获取解析器列表
*/
@RouteMapping(value = "/parsers", method = RouteMethod.GET)
public Future<JsonObject> getParserList() {
public Future<JsonObject> getParserList(RoutingContext ctx) {
// 权限检查
if (!checkAuth(ctx)) {
return Future.succeededFuture(JsonResult.error("未授权访问").toJsonObject());
}
return dbService.getPlaygroundParserList();
}
@@ -257,6 +360,11 @@ public class PlaygroundApi {
*/
@RouteMapping(value = "/parsers", method = RouteMethod.POST)
public Future<JsonObject> saveParser(RoutingContext ctx) {
// 权限检查
if (!checkAuth(ctx)) {
return Future.succeededFuture(JsonResult.error("未授权访问").toJsonObject());
}
Promise<JsonObject> promise = Promise.promise();
try {
@@ -356,6 +464,11 @@ public class PlaygroundApi {
*/
@RouteMapping(value = "/parsers/:id", method = RouteMethod.PUT)
public Future<JsonObject> updateParser(RoutingContext ctx, Long id) {
// 权限检查
if (!checkAuth(ctx)) {
return Future.succeededFuture(JsonResult.error("未授权访问").toJsonObject());
}
Promise<JsonObject> promise = Promise.promise();
try {
@@ -410,7 +523,11 @@ public class PlaygroundApi {
* 删除解析器
*/
@RouteMapping(value = "/parsers/:id", method = RouteMethod.DELETE)
public Future<JsonObject> deleteParser(Long id) {
public Future<JsonObject> deleteParser(RoutingContext ctx, Long id) {
// 权限检查
if (!checkAuth(ctx)) {
return Future.succeededFuture(JsonResult.error("未授权访问").toJsonObject());
}
return dbService.deletePlaygroundParser(id);
}
@@ -418,7 +535,11 @@ public class PlaygroundApi {
* 根据ID获取解析器
*/
@RouteMapping(value = "/parsers/:id", method = RouteMethod.GET)
public Future<JsonObject> getParserById(Long id) {
public Future<JsonObject> getParserById(RoutingContext ctx, Long id) {
// 权限检查
if (!checkAuth(ctx)) {
return Future.succeededFuture(JsonResult.error("未授权访问").toJsonObject());
}
return dbService.getPlaygroundParserById(id);
}
@@ -427,6 +548,11 @@ public class PlaygroundApi {
*/
@RouteMapping(value = "/typescript", method = RouteMethod.POST)
public Future<JsonObject> saveTypeScriptCode(RoutingContext ctx) {
// 权限检查
if (!checkAuth(ctx)) {
return Future.succeededFuture(JsonResult.error("未授权访问").toJsonObject());
}
Promise<JsonObject> promise = Promise.promise();
try {
@@ -489,7 +615,11 @@ public class PlaygroundApi {
* 根据parserId获取TypeScript代码
*/
@RouteMapping(value = "/typescript/:parserId", method = RouteMethod.GET)
public Future<JsonObject> getTypeScriptCode(Long parserId) {
public Future<JsonObject> getTypeScriptCode(RoutingContext ctx, Long parserId) {
// 权限检查
if (!checkAuth(ctx)) {
return Future.succeededFuture(JsonResult.error("未授权访问").toJsonObject());
}
return dbService.getTypeScriptCodeByParserId(parserId);
}
@@ -498,6 +628,11 @@ public class PlaygroundApi {
*/
@RouteMapping(value = "/typescript/:parserId", method = RouteMethod.PUT)
public Future<JsonObject> updateTypeScriptCode(RoutingContext ctx, Long parserId) {
// 权限检查
if (!checkAuth(ctx)) {
return Future.succeededFuture(JsonResult.error("未授权访问").toJsonObject());
}
Promise<JsonObject> promise = Promise.promise();
try {

View File

@@ -13,6 +13,13 @@ server:
# 反向代理服务器配置路径(不用加后缀)
proxyConf: server-proxy
# JS演练场配置
playground:
# 公开模式默认false需要密码访问设为true则无需密码
public: false
# 访问密码,建议修改默认密码!
password: 'nfd_playground_2024'
# vertx核心线程配置(一般无需改的), 为0表示eventLoopPoolSize将会采用默认配置(CPU核心*2) workerPoolSize将会采用默认20
vertx:
eventLoopPoolSize: 0