feat: add isolated hn site profile and EZ theme adapter
This commit is contained in:
parent
9880081af7
commit
54438581f9
@ -1,7 +1,8 @@
|
||||
SITE_PROFILE=dpl
|
||||
APP_NAME=XBoard
|
||||
APP_FULL_NAME="Deep Link"
|
||||
APP_ENV=production
|
||||
APP_KEY=base64:PZXk5vTuTinfeEVG5FpYv2l6WEhLsyvGpiWK7IgJJ60=
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
|
||||
23
.env.hn.example
Normal file
23
.env.hn.example
Normal file
@ -0,0 +1,23 @@
|
||||
SITE_PROFILE=hn
|
||||
APP_NAME=hardnerd
|
||||
APP_FULL_NAME=hardnerd.net
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://hardnerd.net
|
||||
APP_KEY=
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=hn
|
||||
DB_USERNAME=hn
|
||||
DB_PASSWORD=
|
||||
CACHE_DRIVER=redis
|
||||
QUEUE_CONNECTION=redis
|
||||
SESSION_DRIVER=file
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
CACHE_PREFIX=hn_cache
|
||||
REDIS_PREFIX=hn_database_
|
||||
HORIZON_PREFIX=hn_horizon:
|
||||
SESSION_COOKIE=hn_session
|
||||
LOG_CHANNEL=stack
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -48,3 +48,6 @@ compose.split.yaml
|
||||
|
||||
/macos-client/.swiftpm/
|
||||
/storage/app/private/
|
||||
|
||||
# hn loopback development marker
|
||||
/storage/framework/ez-vite.hot*
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -1,3 +1,6 @@
|
||||
[submodule "public/assets/admin"]
|
||||
path = public/assets/admin
|
||||
url = https://github.com/cedar2025/xboard-admin-dist.git
|
||||
[submodule "ez-theme"]
|
||||
path = frontends/ez-theme
|
||||
url = https://github.com/EZTHEME/EZ_THEME.git
|
||||
|
||||
@ -217,6 +217,7 @@ class ConfigController extends Controller
|
||||
public function save(ConfigSave $request)
|
||||
{
|
||||
$data = $request->validated();
|
||||
\App\Support\SiteProfile::assertSettingsWritable($data);
|
||||
|
||||
$templateKeys = [
|
||||
'subscribe_template_singbox' => 'singbox',
|
||||
|
||||
@ -28,6 +28,10 @@ class SettingServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
// dpl keeps its existing environment and database settings.
|
||||
if (\App\Support\SiteProfile::isHn()) {
|
||||
config(['app.name' => config('site.name'), 'app.full_name' => config('site.full_name')]);
|
||||
}
|
||||
// App URL is forced per-request via middleware (Octane-safe).
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ class ThemeService
|
||||
private const USER_THEME_DIR = '/storage/theme/';
|
||||
private const CONFIG_FILE = 'config.json';
|
||||
private const SETTING_PREFIX = 'theme_';
|
||||
private const SYSTEM_THEMES = ['Xboard', 'v2board'];
|
||||
private const SYSTEM_THEMES = ['Xboard', 'v2board', 'EZ'];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@ -190,6 +190,7 @@ class ThemeService
|
||||
return true;
|
||||
}
|
||||
|
||||
\App\Support\SiteProfile::assertSettingsWritable(['frontend_theme' => $theme]);
|
||||
$currentTheme = admin_setting('current_theme');
|
||||
|
||||
try {
|
||||
|
||||
30
app/Support/EzAssets.php
Normal file
30
app/Support/EzAssets.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
final class EzAssets
|
||||
{
|
||||
public static function scripts(Request $request): array
|
||||
{
|
||||
$origin = 'http://127.0.0.1:5174';
|
||||
$marker = storage_path('framework/ez-vite.hot');
|
||||
if (in_array($request->getHost(), ['127.0.0.1', 'localhost', '::1', 'hn.loc'], true)
|
||||
&& is_file($marker) && trim(file_get_contents($marker)) === $origin) {
|
||||
$socket = @fsockopen('127.0.0.1', 5174, $error, $message, 0.1);
|
||||
if ($socket) {
|
||||
fclose($socket);
|
||||
return ['scripts' => [$origin.'/@vite/client', $origin.'/main.js'], 'styles' => []];
|
||||
}
|
||||
}
|
||||
$manifest = base_path('theme/EZ/assets/.vite/manifest.json');
|
||||
if (!is_file($manifest)) {
|
||||
throw new RuntimeException('EZ assets missing: run npm ci && npm run build:hn in frontends/.');
|
||||
}
|
||||
$entries = json_decode(file_get_contents($manifest), true, flags: JSON_THROW_ON_ERROR);
|
||||
$entry = $entries['index.html'];
|
||||
return ['scripts' => ['/theme/EZ/assets/'.$entry['file']], 'styles' => array_map(fn ($file) => '/theme/EZ/assets/'.$file, $entry['css'] ?? [])];
|
||||
}
|
||||
}
|
||||
@ -123,9 +123,9 @@ class Setting
|
||||
}
|
||||
}
|
||||
|
||||
$this->loadedSettings = $settings;
|
||||
$this->loadedSettings = array_replace($settings, SiteProfile::publicSettings());
|
||||
} catch (\Throwable) {
|
||||
$this->loadedSettings = [];
|
||||
$this->loadedSettings = SiteProfile::publicSettings();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
32
app/Support/SiteProfile.php
Normal file
32
app/Support/SiteProfile.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class SiteProfile
|
||||
{
|
||||
public static function isHn(): bool
|
||||
{
|
||||
return config('site.profile', 'dpl') === 'hn';
|
||||
}
|
||||
|
||||
public static function publicSettings(): array
|
||||
{
|
||||
return self::isHn() ? [
|
||||
'frontend_theme' => 'EZ',
|
||||
'app_name' => config('site.name'),
|
||||
'logo' => config('site.logo'),
|
||||
'app_description' => config('site.description'),
|
||||
] : [];
|
||||
}
|
||||
|
||||
public static function assertSettingsWritable(array $settings): void
|
||||
{
|
||||
foreach (self::publicSettings() as $key => $value) {
|
||||
if (array_key_exists($key, $settings) && $settings[$key] !== $value) {
|
||||
throw ValidationException::withMessages([$key => 'hn 品牌与主题由 SITE_PROFILE / SITE_* 部署配置管理。']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -102,6 +102,6 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache'),
|
||||
'prefix' => env('CACHE_PREFIX', (env('SITE_PROFILE', 'dpl') === 'hn' ? 'hn_cache' : Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache')),
|
||||
|
||||
];
|
||||
|
||||
@ -132,7 +132,7 @@ return [
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'),
|
||||
'prefix' => env('REDIS_PREFIX', (env('SITE_PROFILE', 'dpl') === 'hn' ? 'hn_database_' : Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_')),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
|
||||
@ -60,7 +60,7 @@ return [
|
||||
|
||||
'prefix' => env(
|
||||
'HORIZON_PREFIX',
|
||||
Str::slug(env('APP_NAME', 'laravel'), '_') . '_horizon:'
|
||||
(env('SITE_PROFILE', 'dpl') === 'hn' ? 'hn_horizon:' : Str::slug(env('APP_NAME', 'laravel'), '_') . '_horizon:')
|
||||
),
|
||||
|
||||
/*
|
||||
|
||||
@ -126,7 +126,7 @@ return [
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug(env('APP_NAME', 'laravel'), '_') . '_session'
|
||||
(env('SITE_PROFILE', 'dpl') === 'hn' ? 'hn_session' : Str::slug(env('APP_NAME', 'laravel'), '_') . '_session')
|
||||
),
|
||||
|
||||
/*
|
||||
|
||||
14
config/site.php
Normal file
14
config/site.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
$profile = env('SITE_PROFILE', 'dpl');
|
||||
if (!in_array($profile, ['dpl', 'hn'], true)) {
|
||||
throw new InvalidArgumentException('SITE_PROFILE must be dpl or hn.');
|
||||
}
|
||||
|
||||
return [
|
||||
'profile' => $profile,
|
||||
'name' => env('SITE_NAME', 'hardnerd'),
|
||||
'full_name' => env('SITE_FULL_NAME', 'hardnerd.net'),
|
||||
'logo' => env('SITE_LOGO', '/brand/hn/logo.svg'),
|
||||
'description' => env('SITE_DESCRIPTION', 'hardnerd · 探索全球网络'),
|
||||
];
|
||||
@ -10,6 +10,10 @@
|
||||
- 后台 Blade:resources/views/admin.blade.php;自定义后台扩展的源文件 resources/js/admin/ 与运行副本 public/admin-custom/ 必须一致。
|
||||
- 当前后台入口配置 secure_path=dove,通过 routes/web.php 使用,不是硬编码固定路由。后台 API 也使用这个配置。后台路径不是身份认证的替代。
|
||||
|
||||
## 多实例站点配置
|
||||
|
||||
`SITE_PROFILE` 默认 dpl 保留原有运行行为;hn 固定 EZ 主题、独立品牌与缓存/会话命名空间。实现、子模块、开发和发布边界见 [SITE_PROFILES](SITE_PROFILES.md)。部署实例须各用各的数据库、密钥、storage 和运行资源;参数不等于自动隔离错误复用的环境。
|
||||
|
||||
## 主要业务入口
|
||||
|
||||
| 范围 | 入口 |
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
|
||||
| 文件 | 用途 |
|
||||
| --- | --- |
|
||||
| SITE_PROFILES.md | dpl / hn 独立部署参数、EZ 主题、开发与发布隔离 |
|
||||
| ARCHITECTURE.md | 服务入口、前后端边界、配置来源、开发与检查 |
|
||||
| BUSINESS_RULES.md | 邮件、提醒、账号与支付等不可随意改变的约束 |
|
||||
| DEPLOYMENT.md | Git 发布、备份、运行依赖、恢复及故障排查 |
|
||||
|
||||
68
docs/maintenance/SITE_PROFILES.md
Normal file
68
docs/maintenance/SITE_PROFILES.md
Normal file
@ -0,0 +1,68 @@
|
||||
# dpl / hn 独立实例与 EZ 主题
|
||||
|
||||
## 配置与边界
|
||||
|
||||
同一后端仓库、同一业务逻辑,分别部署到各自服务器/目录。`SITE_PROFILE` 为启动参数,不按访问域名切换租户,不共享数据库。
|
||||
|
||||
| 参数 | 行为 |
|
||||
| --- | --- |
|
||||
| 未设置或 `SITE_PROFILE=dpl` | 保持既有 APP_NAME、APP_FULL_NAME、数据库品牌设置与 frontend_theme(包括 Stellar),缓存/Redis/Horizon/session 默认命名规则不变 |
|
||||
| `SITE_PROFILE=hn` | 前台固定为 EZ,默认 hardnerd / hardnerd.net;独立命名空间 hn_cache / hn_database_ / hn_horizon: / hn_session |
|
||||
| 其他值 | 启动报错,防止拼写错误误选 dpl |
|
||||
|
||||
hn 可用 `SITE_NAME`、`SITE_FULL_NAME`、`SITE_DESCRIPTION`、`SITE_LOGO` 覆盖品牌;后台读到的主题/品牌与部署配置一致,不能在后台切换成另一站的主题。读取覆盖在缓存取出后、请求内合并,不写回原有设置,也不污染 Redis 中的配置。dpl 后台设置流程保持原状。
|
||||
|
||||
**选择 hn 不会自动创建数据库或隔离错误复制的私密环境。** 使用 `.env.hn.example` 建立 hn 自己的 `.env`,独立创建 hn 数据库和最小权限用户、APP_KEY、storage、日志及 worker/scheduler。不得复用 dpl 的实际 `.env`、业务数据库、storage 挂载或加密配置。显式 CACHE_PREFIX / REDIS_PREFIX / HORIZON_PREFIX / SESSION_COOKIE 优先于默认值,复制环境时必须核对。
|
||||
|
||||
邮件、支付商户、订阅域名、节点和通知配置由 hn 自己设置。共用功能不等于已有外部资源授权共用;新实例未配置这些服务时,不宣称能发信、收款或提供真实节点。
|
||||
|
||||
## EZ 来源与适配
|
||||
|
||||
原始源码:[EZTHEME/EZ_THEME](https://github.com/EZTHEME/EZ_THEME),package v0.4.0;Git 子模块 `frontends/ez-theme` 锁定 `2c0581386d86006315e6eba2f5970c923892caba`。上游源码不直接改写,hn 适配层在 `frontends/hn`,依赖和锁文件在 `frontends/`。后台资源与 Stellar 各自既有仓库边界保持不变。
|
||||
|
||||
上游 package.json 标注 ISC,当前锁定版本未包含单独 LICENSE 文件;保留作者/上游出处,公开再分发前复核授权声明,不能自行称作 MIT。
|
||||
|
||||
适配明确使用 Xboard API、当前站点的 `/api/v1`,删除演示 API、中间件地址和占位客户端链接。默认深色、绿色主题和 hn logo;禁用上游的随机外部背景、工单第三方图片托管、工单外部 IP 定位与 Crisp 用户数据传送。登录验证码仍由实例配置决定,不改变后端验证逻辑。客户端导入等 EZ 自带能力保留,下载卡片因未指定正式链接而隐藏。EZ 使用 hash 路由,无需修改 dpl Stellar history fallback。
|
||||
|
||||
后端服务、认证、用户、套餐、订单、支付、邮件与订阅仍使用同一现有实现;只接入站点配置和主题。浏览器令牌保存于本站 origin,部署应使用两个独立域名。
|
||||
|
||||
## 本地开发
|
||||
|
||||
在独立 hn checkout 使用自己的 `.env`、空数据库和独立 Redis。不要把 dpl 的本地运行目录改成 hn。
|
||||
|
||||
```bash
|
||||
git submodule update --init frontends/ez-theme
|
||||
cd frontends
|
||||
npm ci --ignore-scripts
|
||||
npm run dev:hn
|
||||
```
|
||||
|
||||
Vite 只监听 `127.0.0.1:5174`,标记文件 `storage/framework/ez-vite.hot`。后端只在明确的本地 hostname 与运行中的该端口匹配时加载开发资源,公共 hostname 或失效标记回退到正式资源。未将 Laravel .env 注入前端。通过后端页面打开可获得实例品牌;直接打开 Vite 仅用于开发。
|
||||
|
||||
当前隔离预览:`http://127.0.0.1:8591`,工作树内部 `database/hn-preview.sqlite`,临时 Redis `127.0.0.1:6387`;这些不是正式部署地址,也不是 dpl 数据。
|
||||
|
||||
## 发布与恢复
|
||||
|
||||
本次未推送、未部署到服务器。以后发布按 Git:提交并推送共享版本,在指定 hn 服务器 checkout 更新明确提交,初始化锁定子模块、安装依赖并构建。版本升级前备份 hn 自己的数据库/配置;dpl 发布单独授权、单独验收。
|
||||
|
||||
```bash
|
||||
git submodule update --init frontends/ez-theme
|
||||
cd frontends
|
||||
npm ci --ignore-scripts
|
||||
npm run build:hn
|
||||
cd ..
|
||||
php scripts/hn/publish-assets.php
|
||||
php artisan config:cache
|
||||
```
|
||||
|
||||
构建输出 `theme/EZ/assets/` 默认忽略,不把下载依赖/私密环境入 Git;发布脚本仅允许 hn 配置,将 EZ 资源复制至当前实例 `public/theme/EZ`,不切换或删除其他主题。脚本需要 PHP >=8.2、Laravel dependencies 与 hn 自身运行资源。修改参数前需清理旧配置缓存,发布后重启 hn 自己的长驻 worker/Octane。首次实例初始化按已有安装流程执行迁移、创建独立管理员;不要通过拷贝 dpl 用户和订单完成初始化。
|
||||
|
||||
回滚恢复 hn 的原 Git 版本、原 lock/submodule 和相应构建资源,清配置缓存、重启 hn 进程;本次没有新增业务迁移。不要把已运行 hn 的参数切成 dpl 代替版本回滚。现有 dpl 部署无需安装或构建 EZ;保留原主题和环境即可。
|
||||
|
||||
## 验证
|
||||
|
||||
新增站点测试:`php vendor/bin/phpunit --bootstrap vendor/autoload.php tests/Feature/SiteProfile`。前台配置测试:在 frontends 运行 `npm run test:hn`。构建检查:`npm run build:hn`。
|
||||
|
||||
支付/邮件既有隔离套件和订单并发套件应在独立 PHP 进程运行:前者使用精简 user schema,Eloquent 的静态 guardable columns 缓存会影响后者,合并执行可产生缺 password 的测试夹具错误。不要据此修改业务模型。
|
||||
|
||||
当次实际验证与尚未验收事项见 [STATUS](STATUS.md)。不将本地主题验证当作服务器上线、真实付款或客户端节点连接验收。
|
||||
@ -2,6 +2,16 @@
|
||||
|
||||
文档核对日期:2026-10-11。以下区分本会话检查结果与用户验收确认,不承诺服务器一直保持此状态。
|
||||
|
||||
## hn / EZ 本地实现(2026-10-13)
|
||||
|
||||
独立工作树 `/Users/hellcat/Documents/Codex/2026-10-12/ji-cha/work/dpl-hn`,分支 `codex/hn-site-profile`,基于后端 `99f9b9ec927d924ce48f4bf6835a7edf604dcf46`。共享代码由 SITE_PROFILE 区分 dpl / hn,hn 使用锁定 EZ 源码和自有品牌,部署边界见 [SITE_PROFILES](SITE_PROFILES.md)。运行中的 dpl 后端、Stellar、实际 .env、数据库、Redis、队列、代理和生产服务器均未修改;主工作区既有修改保留。
|
||||
|
||||
本地隔离环境使用工作树 SQLite 和专用 Redis 6387,虚构用户、管理员和套餐。浏览器已验证 EZ 首页、登录、用户中心、套餐列表 ¥19/月及结算摘要;后台显示 EZ 为当前主题,切换 Xboard 返回部署配置限制,EZ 保持当前。已验证 dev 资源和正式构建资源加载;未提交真实订单、未付款、未发信、未连接实际节点。
|
||||
|
||||
验证:新增站点/权限/参数/命名空间 10 测试 / 26 断言通过;EZ 配置测试通过,npm build:hn 构建通过;既有支付/邮件 65 测试 / 386 断言,订单并发单独进程 4 测试 / 13 断言通过。最初合并运行订单套件遭遇精简 user schema 的 Eloquent 静态字段缓存干扰,分进程复核通过,业务模型未为测试改写。PHP 语法和 git diff --check 通过。
|
||||
|
||||
实现与文档当前仅在本地分支;未推送、未部署、未验证正式域名/白色机反代。hn 生产服务器、独立数据库、管理员、邮件、支付、订阅和节点资源尚未指定或验收,不能把本地完成解释为上线完成。无业务迁移新增,dpl 发布需单独授权和验收。
|
||||
|
||||
## 最新代码基线
|
||||
|
||||
- 邮件页签实现版本 13117587c86aa49dafad3467b04d90469f4de2ab;本地提交推送、生产 Git fast-forward 完成。
|
||||
|
||||
1
frontends/.gitignore
vendored
Normal file
1
frontends/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/node_modules/
|
||||
1
frontends/ez-theme
Submodule
1
frontends/ez-theme
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 2c0581386d86006315e6eba2f5970c923892caba
|
||||
1
frontends/hn/index.html
Normal file
1
frontends/hn/index.html
Normal file
@ -0,0 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>hardnerd</title><link rel="icon" href="/brand/hn/logo.svg"></head><body><div id="app"></div><script type="module" src="/main.js"></script></body></html>
|
||||
1
frontends/hn/main.js
vendored
Normal file
1
frontends/hn/main.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
import '../ez-theme/src/main.js';
|
||||
31
frontends/hn/runtime-config.mjs
Normal file
31
frontends/hn/runtime-config.mjs
Normal file
@ -0,0 +1,31 @@
|
||||
export function adaptConfig(original, settings, location) {
|
||||
const config = structuredClone(original);
|
||||
config.PANEL_TYPE = 'Xboard';
|
||||
config.API_CONFIG = {urlMode: 'auto', showCheckBackend: false, staticBaseUrl: [], autoConfig: {useSameProtocol: true, appendApiPath: true, apiPath: '/api/v1'}};
|
||||
config.API_MIDDLEWARE_ENABLED = false;
|
||||
config.API_MIDDLEWARE_URL = '';
|
||||
config.API_MIDDLEWARE_KEY = '';
|
||||
config.SITE_CONFIG.siteName = settings.title || 'hardnerd';
|
||||
config.SITE_CONFIG.siteDescription = settings.description || 'hardnerd · 探索全球网络';
|
||||
config.SITE_CONFIG.copyright = `© ${new Date().getFullYear()} ${settings.full_name || 'hardnerd.net'}`;
|
||||
config.SITE_CONFIG.customLandingPage = '';
|
||||
config.DEFAULT_CONFIG.defaultTheme = 'dark';
|
||||
config.DEFAULT_CONFIG.primaryColor = '#8bcf36';
|
||||
config.AUTH_LAYOUT_CONFIG.splitLayout.leftContent.backgroundImage = '';
|
||||
config.AUTHORIZED_DOMAINS = [location.hostname];
|
||||
config.CLIENT_CONFIG.showDownloadCard = false;
|
||||
config.CLIENT_CONFIG.clientLinks = {};
|
||||
config.PROFILE_CONFIG.showRecentDevices = false;
|
||||
config.CUSTOMER_SERVICE_CONFIG.enabled = false;
|
||||
config.TICKET_CONFIG.isImageHosting = false;
|
||||
config.TICKET_CONFIG.imggbKey = '';
|
||||
config.MORE_PAGE_CONFIG.enableCustomCards = false;
|
||||
config.MORE_PAGE_CONFIG.customCards = [];
|
||||
const scrub = value => {
|
||||
if (typeof value === 'string' && /ezdemo\.xyz|loliapi\.com|t\.me\/your_|github\.com\/xxx|store\/apps\/xxx|\/app\/xxx/.test(value)) return '';
|
||||
if (Array.isArray(value)) return value.map(scrub);
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrub(item)]));
|
||||
return value;
|
||||
};
|
||||
return scrub(config);
|
||||
}
|
||||
21
frontends/hn/tests/config.test.mjs
Normal file
21
frontends/hn/tests/config.test.mjs
Normal file
@ -0,0 +1,21 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
import {adaptConfig} from '../runtime-config.mjs';
|
||||
const source=fs.readFileSync(new URL('../../ez-theme/src/config/index.js',import.meta.url),'utf8').replace('export const config','const config');
|
||||
const window={}; vm.runInNewContext(source,{window});
|
||||
|
||||
test('HN uses same-origin Xboard API and never inherits EZ demo services',()=>{
|
||||
const config=adaptConfig(window.EZ_CONFIG,{title:'hardnerd',full_name:'hardnerd.net'},{hostname:'127.0.0.1'});
|
||||
assert.equal(config.PANEL_TYPE,'Xboard');
|
||||
assert.equal(config.API_CONFIG.urlMode,'auto');
|
||||
assert.deepEqual(config.API_CONFIG.staticBaseUrl,[]);
|
||||
assert.equal(config.API_CONFIG.autoConfig.apiPath,'/api/v1');
|
||||
assert.equal(config.SITE_CONFIG.siteName,'hardnerd');
|
||||
assert.equal(config.CUSTOMER_SERVICE_CONFIG.enabled,false);
|
||||
assert.equal(config.TICKET_CONFIG.isImageHosting,false);
|
||||
assert.equal(config.CLIENT_CONFIG.showDownloadCard,false);
|
||||
assert.doesNotMatch(JSON.stringify(config),/ezdemo\.xyz|loliapi\.com|t\.me\/your_|github\.com\/xxx/);
|
||||
assert.equal(window.EZ_CONFIG.PANEL_TYPE,'Xiao-V2board');
|
||||
});
|
||||
36
frontends/hn/vite.config.mjs
Normal file
36
frontends/hn/vite.config.mjs
Normal file
@ -0,0 +1,36 @@
|
||||
import {defineConfig} from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
const backend = path.resolve(root, '../..');
|
||||
const upstream = path.resolve(root, '../ez-theme');
|
||||
const upstreamConfig = path.join(upstream, 'src/config/index.js');
|
||||
const marker = path.join(backend, 'storage/framework/ez-vite.hot');
|
||||
export default defineConfig(({mode}) => ({
|
||||
root, envDir: false, base: mode === 'production' ? '/theme/EZ/assets/' : '/', publicDir: false,
|
||||
plugins: [{name:'hn-ez-adapter', enforce:'pre',
|
||||
load(id) {
|
||||
if (id === upstreamConfig) return `import {config as original} from ${JSON.stringify(upstreamConfig+'?hn-base')}; import {adaptConfig} from ${JSON.stringify(path.join(root,'runtime-config.mjs'))}; export const config=adaptConfig(original,window.settings||{},window.location);`;
|
||||
},
|
||||
transform(code, id) {
|
||||
if (!id.startsWith(upstream + '/src/')) return;
|
||||
code = code.replaceAll('src="/images/logo.png"', ':src="hnLogo"').replaceAll('/images/logo.png', '/brand/hn/logo.svg');
|
||||
if (id === path.join(upstream,'src/appInit.js')) code = code.replace("app.mount('#app');", "app.config.globalProperties.hnLogo = window.settings?.logo || '/brand/hn/logo.svg'; app.mount('#app');");
|
||||
if (id === path.join(upstream,'src/api/user.js')) code = code.replace(/export function getIpLocationInfo\(\)\s*\{[\s\S]*?\n\}/, 'export function getIpLocationInfo() { return Promise.resolve({data: null}); }');
|
||||
return code;
|
||||
},
|
||||
configureServer(server) {
|
||||
server.httpServer?.once('listening', () => {fs.mkdirSync(path.dirname(marker), {recursive:true}); fs.writeFileSync(marker,'http://127.0.0.1:5174');});
|
||||
server.httpServer?.once('close', () => {if(fs.existsSync(marker))fs.unlinkSync(marker);});
|
||||
}
|
||||
}, vue({template:{transformAssetUrls:{includeAbsolute:false}}})],
|
||||
resolve:{alias:{'@':path.join(upstream,'src')}, dedupe:['vue']},
|
||||
define:{'process.env':{NODE_ENV:mode,VUE_APP_CONFIGJS:'false',VUE_APP_DEBUGGING:'false'},__VUE_OPTIONS_API__:true,__VUE_PROD_DEVTOOLS__:false,__VUE_PROD_HYDRATION_MISMATCH_DETAILS__:false},
|
||||
css:{preprocessorOptions:{scss:{additionalData:'@use "@/assets/styles/base/variables.scss" as *;'}}},
|
||||
optimizeDeps:{include:['axios','vue','vue-router','pinia','vue-i18n']},
|
||||
build:{outDir:path.join(backend,'theme/EZ/assets'),assetsDir:'chunks',manifest:true,emptyOutDir:true,sourcemap:false},
|
||||
server:{host:'127.0.0.1',port:5174,strictPort:true,origin:'http://127.0.0.1:5174',cors:{origin:/^http:\/\/(127\.0\.0\.1|localhost|hn\.loc)(:\d+)?$/},
|
||||
fs:{allow:[path.resolve(root,'..')],deny:['**/.env','**/.env.*','**/.git','**/.git/**','**/.npmrc']},proxy:{'/api':'http://127.0.0.1:8591','/brand':'http://127.0.0.1:8591'}}
|
||||
}));
|
||||
7492
frontends/package-lock.json
generated
Normal file
7492
frontends/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
65
frontends/package.json
Normal file
65
frontends/package.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "dpl-shared-frontends",
|
||||
"version": "v0.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:hn": "vite --config hn/vite.config.mjs",
|
||||
"build:hn": "vite build --config hn/vite.config.mjs",
|
||||
"test:hn": "node --test hn/tests/*.test.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@originjs/crypto-js-wasm": "^1.1.1",
|
||||
"@tabler/icons-vue": "^3.30.0",
|
||||
"@vue/compat": "^3.5.13",
|
||||
"@vue/runtime-core": "^3.5.13",
|
||||
"@vue/runtime-dom": "^3.5.13",
|
||||
"@vue/shared": "^3.5.13",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"@vueuse/motion": "^3.0.3",
|
||||
"aieditor": "^1.4.1",
|
||||
"axios": "^0.27.2",
|
||||
"chart.js": "^3.9.1",
|
||||
"core-js": "^3.25.0",
|
||||
"crisp": "^1.0.0",
|
||||
"crisp-sdk-web": "^1.0.25",
|
||||
"crypto-js": "^4.2.0",
|
||||
"disable-devtool": "^0.3.9",
|
||||
"dompurify": "^3.2.4",
|
||||
"echarts": "^5.6.0",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"markdown-it": "^14.1.0",
|
||||
"marked": "^15.0.7",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.3",
|
||||
"postscribe": "^2.0.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"shepherd.js": "^14.5.0",
|
||||
"vue-confetti": "^2.3.0",
|
||||
"vue-confetti-explosion": "^1.0.2",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-markdown-render": "^2.2.1",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-shepherd": "^5.0.1",
|
||||
"vue3-qrcode": "^0.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"javascript-obfuscator": "^4.1.1",
|
||||
"postcss": "^8.5.3",
|
||||
"postcss-import": "^16.1.0",
|
||||
"sass": "^1.69.5",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"terser": "^5.47.1",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.1.4",
|
||||
"vue": "^3.5.18"
|
||||
}
|
||||
}
|
||||
8
public/brand/hn/logo.svg
Normal file
8
public/brand/hn/logo.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216 216" role="img"><title>hardnerd</title> <g >
|
||||
<rect width="216" height="216" rx="48" fill="#171B1A"/>
|
||||
<!-- The two vertical stems form H; the glasses are its crossbar. -->
|
||||
<path d="M48 48H76V168H48ZM140 48H168V168H140Z" fill="#F4F3EE"/>
|
||||
<path d="M36 86H91V129H49L36 114ZM125 86H180V114L167 129H125Z" fill="#B4F047" stroke="#171B1A" stroke-width="8" stroke-linejoin="round"/>
|
||||
<path d="M91 99H125" fill="none" stroke="#B4F047" stroke-width="12"/>
|
||||
<path d="M55 99H70M144 99H159" stroke="#171B1A" stroke-width="7" stroke-linecap="square"/>
|
||||
</g></svg>
|
||||
|
After Width: | Height: | Size: 630 B |
16
scripts/hn/publish-assets.php
Normal file
16
scripts/hn/publish-assets.php
Normal file
@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
// Run only after building EZ, within the hn deployment checkout.
|
||||
require __DIR__.'/../../vendor/autoload.php';
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
if (!App\Support\SiteProfile::isHn()) {
|
||||
throw new RuntimeException('EZ asset publication requires SITE_PROFILE=hn.');
|
||||
}
|
||||
if (!is_file(base_path('theme/EZ/assets/.vite/manifest.json'))) {
|
||||
throw new RuntimeException('Build EZ first: cd frontends && npm ci && npm run build:hn');
|
||||
}
|
||||
if (!Illuminate\Support\Facades\File::copyDirectory(base_path('theme/EZ'), public_path('theme/EZ'))) {
|
||||
throw new RuntimeException('Failed to publish EZ assets.');
|
||||
}
|
||||
echo "EZ assets published for hn.\n";
|
||||
166
tests/Feature/SiteProfile/SiteProfileTest.php
Normal file
166
tests/Feature/SiteProfile/SiteProfileTest.php
Normal file
@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\SiteProfile;
|
||||
|
||||
use App\Providers\SettingServiceProvider;
|
||||
use App\Support\Setting;
|
||||
use App\Support\SiteProfile;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Foundation\Bootstrap\RegisterProviders;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SiteProfileTest extends TestCase
|
||||
{
|
||||
private array $environmentSnapshot;
|
||||
|
||||
public function createApplication()
|
||||
{
|
||||
$app = require dirname(__DIR__, 3).'/bootstrap/app.php';
|
||||
$app->beforeBootstrapping(RegisterProviders::class, function ($app) {
|
||||
$app['config']->set(['database.default'=>'sqlite', 'database.connections.sqlite.database'=>':memory:',
|
||||
'cache.default'=>'array', 'cache.stores.redis'=>['driver'=>'array'], 'queue.default'=>'sync',
|
||||
'app.key'=>'base64:'.base64_encode(str_repeat('t',32)), 'site.profile'=>'dpl']);
|
||||
});
|
||||
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->environmentSnapshot = [$_ENV, $_SERVER];
|
||||
Schema::create('v2_plugins', fn (Blueprint $t) => [$t->id(), $t->boolean('is_enabled')->default(false)]);
|
||||
Schema::create('v2_settings', fn (Blueprint $t) => [$t->id(),$t->string('name'),$t->text('value')->nullable(),$t->timestamps()]);
|
||||
Cache::store('redis')->flush();
|
||||
$this->app->forgetInstance(Setting::class);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
[$_ENV, $_SERVER] = $this->environmentSnapshot;
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_dpl_can_render_its_existing_bundled_frontend(): void
|
||||
{
|
||||
admin_setting(['frontend_theme'=>'Xboard']);
|
||||
$this->get('/')->assertOk()->assertSee('/theme/Xboard', false);
|
||||
}
|
||||
|
||||
public function test_public_hostname_never_loads_local_dev_scripts(): void
|
||||
{
|
||||
try {
|
||||
$assets = \App\Support\EzAssets::scripts(\Illuminate\Http\Request::create('https://hardnerd.net/'));
|
||||
foreach ($assets['scripts'] as $script) $this->assertStringStartsWith('/theme/EZ/assets/', $script);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->assertStringContainsString('EZ assets missing', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_default_dpl_keeps_database_brand_and_theme(): void
|
||||
{
|
||||
admin_setting(['frontend_theme'=>'Stellar','app_name'=>'Deep Link','logo'=>'/dpl.svg','app_description'=>'original']);
|
||||
$before = DB::table('v2_settings')->get()->toJson();
|
||||
$this->assertSame([], SiteProfile::publicSettings());
|
||||
$this->assertSame('Stellar',admin_setting('frontend_theme'));
|
||||
SiteProfile::assertSettingsWritable(['frontend_theme'=>'Xboard']);
|
||||
$this->assertSame($before, DB::table('v2_settings')->get()->toJson());
|
||||
}
|
||||
|
||||
public function test_hn_uses_ez_without_rewriting_stored_settings_or_cached_values(): void
|
||||
{
|
||||
admin_setting(['frontend_theme'=>'Stellar','app_name'=>'Deep Link','logo'=>'/dpl.svg','app_description'=>'original']);
|
||||
$before = DB::table('v2_settings')->get()->toJson();
|
||||
config(['site.profile'=>'hn']);
|
||||
$this->app->forgetInstance(Setting::class);
|
||||
(new SettingServiceProvider($this->app))->boot();
|
||||
$this->assertSame('EZ',admin_setting('frontend_theme'));
|
||||
$this->assertSame('hardnerd',admin_setting('app_name'));
|
||||
$this->assertSame('hardnerd',config('app.name'));
|
||||
$this->assertSame('hardnerd.net',config('app.full_name'));
|
||||
$this->assertSame('/brand/hn/logo.svg',admin_setting('logo'));
|
||||
$this->assertSame('Stellar', Cache::store('redis')->get(Setting::CACHE_KEY)['frontend_theme']);
|
||||
$this->assertSame($before, DB::table('v2_settings')->get()->toJson());
|
||||
$this->withoutExceptionHandling();
|
||||
$this->getJson('/api/v1/guest/comm/config')->assertOk()->assertJsonPath('data.app_name','hardnerd');
|
||||
config(['site.profile'=>'dpl']);
|
||||
$this->app->forgetInstance(Setting::class);
|
||||
$this->assertSame('Stellar',admin_setting('frontend_theme'));
|
||||
}
|
||||
|
||||
public function test_hn_rejects_an_admin_theme_switch_before_writing(): void
|
||||
{
|
||||
config(['site.profile'=>'hn']);
|
||||
$this->expectException(ValidationException::class);
|
||||
(new \App\Services\ThemeService)->switch('Xboard');
|
||||
}
|
||||
|
||||
public function test_admin_save_rejects_profile_changes_before_any_setting_write(): void
|
||||
{
|
||||
config(['site.profile'=>'hn']);
|
||||
admin_setting(['currency'=>'CNY']);
|
||||
$request = \Mockery::mock(\App\Http\Requests\Admin\ConfigSave::class);
|
||||
$request->shouldReceive('validated')->once()->andReturn(['currency'=>'USD','frontend_theme'=>'Stellar']);
|
||||
try {
|
||||
(new \App\Http\Controllers\V2\Admin\ConfigController)->save($request);
|
||||
$this->fail('hn must reject another profile theme');
|
||||
} catch (ValidationException $exception) {
|
||||
$this->assertArrayHasKey('frontend_theme', $exception->errors());
|
||||
$this->assertSame('CNY',admin_setting('currency'));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_missing_selector_defaults_to_dpl(): void
|
||||
{
|
||||
$old = getenv('SITE_PROFILE');
|
||||
unset($_ENV['SITE_PROFILE'],$_SERVER['SITE_PROFILE']);
|
||||
putenv('SITE_PROFILE');
|
||||
try {
|
||||
$this->assertSame('dpl',(require base_path('config/site.php'))['profile']);
|
||||
} finally {
|
||||
if ($old === false) putenv('SITE_PROFILE'); else putenv('SITE_PROFILE='.$old);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_hn_admin_api_still_rejects_guests_and_regular_users(): void
|
||||
{
|
||||
config(['site.profile'=>'hn']);
|
||||
$url = '/api/v2/'.hash('crc32b', config('app.key')).'/config/fetch';
|
||||
$this->getJson($url)->assertForbidden();
|
||||
\Laravel\Sanctum\Sanctum::actingAs(new \App\Models\User(['is_admin'=>false]));
|
||||
$this->getJson($url)->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_invalid_selector_fails_instead_of_silently_selecting_dpl(): void
|
||||
{
|
||||
$old = $_ENV['SITE_PROFILE'] ?? null;
|
||||
$_SERVER['SITE_PROFILE']=$_ENV['SITE_PROFILE']='typo';
|
||||
try {
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
require base_path('config/site.php');
|
||||
} finally {
|
||||
if ($old === null) unset($_SERVER['SITE_PROFILE'],$_ENV['SITE_PROFILE']); else $_SERVER['SITE_PROFILE']=$_ENV['SITE_PROFILE']=$old;
|
||||
}
|
||||
}
|
||||
|
||||
public function test_hn_namespace_defaults_do_not_reuse_dpl_defaults(): void
|
||||
{
|
||||
$_SERVER['APP_NAME']=$_ENV['APP_NAME']='Shared';
|
||||
foreach (['dpl','hn'] as $profile) {
|
||||
$_SERVER['SITE_PROFILE']=$_ENV['SITE_PROFILE']=$profile;
|
||||
$values[$profile] = [
|
||||
(require base_path('config/cache.php'))['prefix'],
|
||||
(require base_path('config/database.php'))['redis']['options']['prefix'],
|
||||
(require base_path('config/session.php'))['cookie'],
|
||||
(require base_path('config/horizon.php'))['prefix'],
|
||||
];
|
||||
}
|
||||
$this->assertSame(['shared_cache','shared_database_','shared_session','shared_horizon:'],$values['dpl']);
|
||||
$this->assertSame(['hn_cache','hn_database_','hn_session','hn_horizon:'],$values['hn']);
|
||||
unset($_SERVER['APP_NAME'],$_SERVER['SITE_PROFILE'],$_ENV['APP_NAME'],$_ENV['SITE_PROFILE']);
|
||||
}
|
||||
}
|
||||
2
theme/.gitignore
vendored
2
theme/.gitignore
vendored
@ -2,3 +2,5 @@
|
||||
!v2board
|
||||
!Xboard
|
||||
!.gitignore
|
||||
|
||||
!EZ
|
||||
|
||||
1
theme/EZ/.gitignore
vendored
Normal file
1
theme/EZ/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/assets/
|
||||
6
theme/EZ/config.json
Normal file
6
theme/EZ/config.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "EZ",
|
||||
"description": "EZ THEME \u00b7 hardnerd adapter",
|
||||
"version": "0.4.0-hn.1",
|
||||
"configs": []
|
||||
}
|
||||
13
theme/EZ/dashboard.blade.php
Normal file
13
theme/EZ/dashboard.blade.php
Normal file
@ -0,0 +1,13 @@
|
||||
@php($assets = \App\Support\EzAssets::scripts(request()))
|
||||
<!doctype html><html lang="zh-CN"><head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ $title }}</title><meta name="description" content="{{ $description }}"><link rel="icon" href="{{ $logo }}">
|
||||
<script>window.settings={!! json_encode(['title'=>$title,'description'=>$description,'logo'=>$logo],JSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOT) !!};</script>
|
||||
@foreach ($assets['styles'] as $style)
|
||||
<link rel="stylesheet" href="{{ $style }}">
|
||||
@endforeach
|
||||
</head><body><div id="app"></div>
|
||||
@foreach ($assets['scripts'] as $script)
|
||||
<script type="module" src="{{ $script }}"></script>
|
||||
@endforeach
|
||||
</body></html>
|
||||
Loading…
Reference in New Issue
Block a user