feat: add isolated hn site profile and EZ theme adapter

This commit is contained in:
hellcat 2026-10-13 20:35:45 +08:00
parent 9880081af7
commit 54438581f9
35 changed files with 8064 additions and 8 deletions

View File

@ -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
View 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
View File

@ -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
View File

@ -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

View File

@ -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',

View File

@ -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).
}
}

View File

@ -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
View 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'] ?? [])];
}
}

View File

@ -123,9 +123,9 @@ class Setting
}
}
$this->loadedSettings = $settings;
$this->loadedSettings = array_replace($settings, SiteProfile::publicSettings());
} catch (\Throwable) {
$this->loadedSettings = [];
$this->loadedSettings = SiteProfile::publicSettings();
}
}

View 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_* 部署配置管理。']);
}
}
}
}

View File

@ -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')),
];

View File

@ -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' => [

View File

@ -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:')
),
/*

View File

@ -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
View 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 · 探索全球网络'),
];

View File

@ -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 和运行资源;参数不等于自动隔离错误复用的环境。
## 主要业务入口
| 范围 | 入口 |

View File

@ -15,6 +15,7 @@
| 文件 | 用途 |
| --- | --- |
| SITE_PROFILES.md | dpl / hn 独立部署参数、EZ 主题、开发与发布隔离 |
| ARCHITECTURE.md | 服务入口、前后端边界、配置来源、开发与检查 |
| BUSINESS_RULES.md | 邮件、提醒、账号与支付等不可随意改变的约束 |
| DEPLOYMENT.md | Git 发布、备份、运行依赖、恢复及故障排查 |

View 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)。不将本地主题验证当作服务器上线、真实付款或客户端节点连接验收。

View File

@ -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
View File

@ -0,0 +1 @@
/node_modules/

1
frontends/ez-theme Submodule

@ -0,0 +1 @@
Subproject commit 2c0581386d86006315e6eba2f5970c923892caba

1
frontends/hn/index.html Normal file
View 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
View File

@ -0,0 +1 @@
import '../ez-theme/src/main.js';

View 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);
}

View 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');
});

View 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

File diff suppressed because it is too large Load Diff

65
frontends/package.json Normal file
View 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
View 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

View 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";

View 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
View File

@ -2,3 +2,5 @@
!v2board
!Xboard
!.gitignore
!EZ

1
theme/EZ/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/assets/

6
theme/EZ/config.json Normal file
View File

@ -0,0 +1,6 @@
{
"name": "EZ",
"description": "EZ THEME \u00b7 hardnerd adapter",
"version": "0.4.0-hn.1",
"configs": []
}

View 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>