refactor(openflare): migrate console APIs to v1 and centralize routing

Move OpenFlare management endpoints to /api/v1/custom/openflare with
Wavelet response envelopes and Abort* error handling. Keep agent, relay,
and flared protocol routes on /api/* with the legacy compat format.

- Add apiutil helpers and Swagger annotations for console handlers
- Register all OpenFlare routes in internal/router/openflare (not apps)
- Switch frontend services to OpenFlareBaseService and v1 paths
- Remove dead auth/compat code and legacy-base.service.ts
- Update integration tests and changelog
This commit is contained in:
ryan
2026-06-18 20:34:54 +08:00
parent aa348a1b48
commit 399c1bc88d
104 changed files with 28541 additions and 3879 deletions
+9991 -4
View File
File diff suppressed because it is too large Load Diff
+9991 -4
View File
File diff suppressed because it is too large Load Diff
+6208 -4
View File
File diff suppressed because it is too large Load Diff
@@ -40,12 +40,12 @@ export function OriginDetailPageClient() {
const originQuery = useQuery({
queryKey: ["openflare", "origins", originId],
queryFn: () => OriginService.get(parsedId),
queryFn: () => OriginService.getById(parsedId),
enabled,
})
const deleteMutation = useMutation({
mutationFn: () => OriginService.delete(parsedId),
mutationFn: () => OriginService.deleteById(parsedId),
onSuccess: async () => {
toast.success("源站已删除")
await queryClient.invalidateQueries({ queryKey: ["openflare", "origins"] })
+1 -1
View File
@@ -42,7 +42,7 @@ export default function OriginsPage() {
const origins = useMemo(() => originsQuery.data ?? [], [originsQuery.data])
const deleteMutation = useMutation({
mutationFn: (id: number) => OriginService.delete(id),
mutationFn: (id: number) => OriginService.deleteById(id),
onSuccess: async () => {
toast.success("源站已删除")
setDeleteTarget(null)
@@ -83,7 +83,7 @@ export function ProxyRoutesPageClient() {
setDeleting(true);
try {
await ProxyRouteService.delete(deleteTarget.id);
await ProxyRouteService.deleteById(deleteTarget.id);
toast.success('网站已删除');
setDeleteTarget(null);
await fetchRoutes();
@@ -52,7 +52,7 @@ export default function CertificatesPage() {
});
const deleteMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.delete(id),
mutationFn: (id: number) => TlsCertificateService.deleteById(id),
onSuccess: async () => {
toast.success('证书已删除');
setDeleteTarget(null);
@@ -102,7 +102,7 @@ export function WebsiteDetailPageClient() {
}, [routesQuery.data, website]);
const deleteDomainMutation = useMutation({
mutationFn: (id: number) => WebsiteService.delete(id),
mutationFn: (id: number) => WebsiteService.deleteById(id),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
router.push('/websites');
@@ -111,7 +111,7 @@ export function WebsiteDetailPageClient() {
});
const deleteCertificateMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.delete(id),
mutationFn: (id: number) => TlsCertificateService.deleteById(id),
onSuccess: async () => {
toast.success('证书已删除');
setDeleteCertOpen(false);
@@ -40,7 +40,7 @@ export default function DnsAccountsPage() {
});
const deleteMutation = useMutation({
mutationFn: (id: number) => DnsAccountService.delete(id),
mutationFn: (id: number) => DnsAccountService.deleteById(id),
onSuccess: async () => {
toast.success('DNS 账号已删除');
setDeleteTarget(null);
@@ -50,7 +50,7 @@ export default function WebsitesPage() {
});
const deleteMutation = useMutation({
mutationFn: (id: number) => WebsiteService.delete(id),
mutationFn: (id: number) => WebsiteService.deleteById(id),
onSuccess: async () => {
toast.success('网站已删除');
setDeleteTarget(null);
@@ -73,7 +73,7 @@ export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: bool
queryFn: () => AuthService.getAuthSources(),
})
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, false)
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, true)
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true)
const loginMutation = useMutation({
@@ -190,8 +190,8 @@ export function LoginForm({ onOTPStateChange }: { onOTPStateChange?: (show: bool
}
const registrationEnabled =
configBool(publicConfigQuery.data?.registration_enabled, true) &&
configBool(publicConfigQuery.data?.password_register_enabled, true)
configBool(publicConfigQuery.data?.registration_enabled, false) &&
configBool(publicConfigQuery.data?.password_register_enabled, false)
const passwordLoginEnabled = configBool(publicConfigQuery.data?.password_login_enabled, true)
const oidcLoginEnabled = configBool(publicConfigQuery.data?.oidc_login_enabled, true)
@@ -63,12 +63,12 @@ export function RegisterForm() {
)
const registrationEnabled =
configBool(publicConfigQuery.data?.registration_enabled, true) &&
configBool(publicConfigQuery.data?.password_register_enabled, true)
configBool(publicConfigQuery.data?.registration_enabled, false) &&
configBool(publicConfigQuery.data?.password_register_enabled, false)
const emailRegisterEnabled = configBool(publicConfigQuery.data?.email_register_verification_enabled, false)
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, false)
const capEnabled = configBool(publicConfigQuery.data?.cap_login_enabled, true)
const capAutoSolve = configBool(publicConfigQuery.data?.cap_auto_solve, true)
const [capScope, setCapScope] = useState<'send_email_code' | 'register'>('send_email_code')
@@ -242,9 +242,10 @@ export const apiSections: PolicySection[] = [
"error_msg": "",
"data": {
"site_name": "OpenFlare",
"registration_enabled": "true",
"registration_enabled": "false",
"password_login_enabled": "true",
"password_register_enabled": "true",
"password_register_enabled": "false",
"cap_login_enabled": "true",
"oidc_login_enabled": "true"
}
}`}
+1 -2
View File
@@ -184,7 +184,7 @@ export type { PushEvent, PushHistory, PushChannelConfig, ListPushHistoriesReques
export {
AboutService,
LegacyOpenFlareBaseService,
OpenFlareBaseService,
NodeService,
ProxyRouteService,
ConfigVersionService,
@@ -204,7 +204,6 @@ export {
} from './openflare';
export type {
LegacyApiResponse,
NodeItem,
ProxyRouteItem,
ProxyRouteConfigSection,
@@ -1,9 +1,7 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
export class AboutService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api';
import {OpenFlareBaseService} from './base.service';
export class AboutService extends OpenFlareBaseService {
static getAboutContent(): Promise<string> {
return this.legacyGet<string>('/about');
return this.get<string>('/about');
}
}
@@ -1,4 +1,4 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
import type {
AccessLogCleanupPayload,
AccessLogCleanupResult,
@@ -25,17 +25,17 @@ function buildSearchParams(filters: object): Record<string, unknown> {
return params;
}
export class AccessLogService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/access-logs';
export class AccessLogService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/access-logs';
static list(filters: AccessLogFilters = {}): Promise<AccessLogList> {
return this.legacyGet<AccessLogList>('/', buildSearchParams(filters));
return this.get<AccessLogList>('/', buildSearchParams(filters));
}
static listFolds(
filters: FoldedAccessLogFilters,
): Promise<FoldedAccessLogList> {
return this.legacyGet<FoldedAccessLogList>(
return this.get<FoldedAccessLogList>(
'/folds',
buildSearchParams(filters),
);
@@ -44,7 +44,7 @@ export class AccessLogService extends LegacyOpenFlareBaseService {
static listFoldIPs(
filters: FoldedAccessLogIPFilters,
): Promise<FoldedAccessLogIPList> {
return this.legacyGet<FoldedAccessLogIPList>(
return this.get<FoldedAccessLogIPList>(
'/folds/ip-summary',
buildSearchParams(filters),
);
@@ -53,7 +53,7 @@ export class AccessLogService extends LegacyOpenFlareBaseService {
static listIPSummaries(
filters: AccessLogIPSummaryFilters = {},
): Promise<AccessLogIPSummaryList> {
return this.legacyGet<AccessLogIPSummaryList>(
return this.get<AccessLogIPSummaryList>(
'/ip-summary',
buildSearchParams(filters),
);
@@ -62,7 +62,7 @@ export class AccessLogService extends LegacyOpenFlareBaseService {
static getIPTrend(
filters: AccessLogIPTrendFilters,
): Promise<AccessLogIPTrend> {
return this.legacyGet<AccessLogIPTrend>(
return this.get<AccessLogIPTrend>(
'/ip-summary/trend',
buildSearchParams(filters),
);
@@ -71,6 +71,6 @@ export class AccessLogService extends LegacyOpenFlareBaseService {
static cleanup(
payload: AccessLogCleanupPayload,
): Promise<AccessLogCleanupResult> {
return this.legacyPost<AccessLogCleanupResult>('/cleanup', payload);
return this.post<AccessLogCleanupResult>('/cleanup', payload);
}
}
}
@@ -1,8 +1,13 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {ApplyLogCleanupPayload, ApplyLogCleanupResult, ApplyLogList, ApplyLogListQuery,} from './types';
import {OpenFlareBaseService} from './base.service';
import type {
ApplyLogCleanupPayload,
ApplyLogCleanupResult,
ApplyLogList,
ApplyLogListQuery,
} from './types';
export class ApplyLogService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/apply-logs';
export class ApplyLogService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/apply-logs';
static list(query: ApplyLogListQuery = {}): Promise<ApplyLogList> {
const params: Record<string, unknown> = {};
@@ -18,10 +23,10 @@ export class ApplyLogService extends LegacyOpenFlareBaseService {
params.pageSize = query.pageSize;
}
return this.legacyGet<ApplyLogList>('/', params);
return this.get<ApplyLogList>('/', params);
}
static cleanup(payload: ApplyLogCleanupPayload): Promise<ApplyLogCleanupResult> {
return this.legacyPost<ApplyLogCleanupResult>('/cleanup', payload);
return this.post<ApplyLogCleanupResult>('/cleanup', payload);
}
}
}
@@ -0,0 +1,5 @@
import {BaseService} from '@/lib/services/core';
export class OpenFlareBaseService extends BaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare';
}
@@ -1,6 +1,6 @@
import type {InternalAxiosRequestConfig} from 'axios';
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
import type {
ConfigDiffResult,
ConfigPreviewResult,
@@ -10,31 +10,31 @@ import type {
ConfigVersionSummary,
} from './types';
export class ConfigVersionService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/config-versions';
export class ConfigVersionService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/config-versions';
static list(): Promise<ConfigVersionSummary[]> {
return this.legacyGet<ConfigVersionSummary[]>('/');
return this.get<ConfigVersionSummary[]>('/');
}
static getActive(): Promise<ConfigVersionDetail> {
return this.legacyGet<ConfigVersionDetail>('/active');
return this.get<ConfigVersionDetail>('/active');
}
static preview(): Promise<ConfigPreviewResult> {
return this.legacyGet<ConfigPreviewResult>('/preview');
return this.get<ConfigPreviewResult>('/preview');
}
static diff(): Promise<ConfigDiffResult> {
return this.legacyGet<ConfigDiffResult>('/diff');
return this.get<ConfigDiffResult>('/diff');
}
static getById(id: number): Promise<ConfigVersionDetail> {
return this.legacyGet<ConfigVersionDetail>(`/${id}`);
return this.get<ConfigVersionDetail>(`/${id}`);
}
static publish(force?: boolean): Promise<ConfigVersionDetail> {
return this.legacyPost<ConfigVersionDetail>(
return this.post<ConfigVersionDetail>(
'/publish',
undefined,
force
@@ -44,12 +44,12 @@ export class ConfigVersionService extends LegacyOpenFlareBaseService {
}
static activate(id: number): Promise<ConfigVersionDetail> {
return this.legacyPost<ConfigVersionDetail>(`/${id}/activate`);
return this.post<ConfigVersionDetail>(`/${id}/activate`);
}
static cleanup(
payload: ConfigVersionCleanupPayload,
): Promise<ConfigVersionCleanupResult> {
return this.legacyPost<ConfigVersionCleanupResult>('/cleanup', payload);
return this.post<ConfigVersionCleanupResult>('/cleanup', payload);
}
}
}
@@ -1,4 +1,4 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
import type {
CompactCapacityTrendPoint,
CompactDashboardNodeHealth,
@@ -230,11 +230,11 @@ function normalizeDashboardOverview(
};
}
export class DashboardService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/dashboard';
export class DashboardService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/dashboard';
static async getOverview(): Promise<DashboardOverview | null> {
const overview = await this.legacyGet<
const overview = await this.get<
DashboardOverview | DashboardOverviewCompact
>('/overview');
return normalizeDashboardOverview(overview);
@@ -1,27 +1,27 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
import type {DnsAccountItem, DnsAccountMutationPayload} from './types';
export class DnsAccountService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/dns-accounts';
export class DnsAccountService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/dns-accounts';
static async list(): Promise<DnsAccountItem[]> {
return this.legacyGet<DnsAccountItem[]>('/');
return this.get<DnsAccountItem[]>('/');
}
static async create(
payload: DnsAccountMutationPayload,
): Promise<DnsAccountItem> {
return this.legacyPost<DnsAccountItem>('/', payload);
return this.post<DnsAccountItem>('/', payload);
}
static async update(
id: number,
payload: DnsAccountMutationPayload,
): Promise<DnsAccountItem> {
return this.legacyPost<DnsAccountItem>(`/${id}/update`, payload);
return this.post<DnsAccountItem>(`/${id}/update`, payload);
}
static async delete(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
static async deleteById(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
}
@@ -1,6 +1,5 @@
export { AboutService } from './about.service';
export { LegacyOpenFlareBaseService } from './legacy-base.service';
export type { LegacyApiResponse } from './legacy-base.service';
export { OpenFlareBaseService } from './base.service';
export { NodeService } from './node.service';
export { ProxyRouteService } from './proxy-route.service';
@@ -1,58 +0,0 @@
import apiClient from '@/lib/services/core/api-client';
import {ApiErrorBase} from '@/lib/services/core/errors';
import type {InternalAxiosRequestConfig} from 'axios';
export interface LegacyApiResponse<T> {
success: boolean;
message: string;
data: T;
}
/**
* OpenFlare legacy 业务 API 基类
* 解析 { success, message, data } 响应格式
*/
export class LegacyOpenFlareBaseService {
protected static readonly basePath: string = '';
protected static getFullPath(path: string): string {
const full = `${this.basePath}${path}`;
// Avoid Next.js 308 (strip slash) <-> Gin 301 (add slash) redirect loops in dev proxy.
if (full.length > 1 && full.endsWith('/')) {
return full.slice(0, -1);
}
return full;
}
protected static parseLegacyResponse<T>(body: LegacyApiResponse<T>): T {
if (!body.success) {
throw new ApiErrorBase(body.message || '请求失败');
}
return body.data;
}
protected static async legacyGet<T>(
path: string,
params?: Record<string, unknown>,
config?: InternalAxiosRequestConfig,
): Promise<T> {
const response = await apiClient.get<LegacyApiResponse<T>>(
this.getFullPath(path),
{ ...config, params } as InternalAxiosRequestConfig,
);
return this.parseLegacyResponse(response.data);
}
protected static async legacyPost<T>(
path: string,
data?: unknown,
config?: InternalAxiosRequestConfig,
): Promise<T> {
const response = await apiClient.post<LegacyApiResponse<T>>(
this.getFullPath(path),
data,
config,
);
return this.parseLegacyResponse(response.data);
}
}
@@ -1,4 +1,4 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
import type {
NodeAgentReleaseInfo,
NodeAgentUpdatePayload,
@@ -9,60 +9,60 @@ import type {
ReleaseChannel,
} from './types';
export class NodeService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/nodes';
export class NodeService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/nodes';
static async listNodes(): Promise<NodeItem[]> {
return this.legacyGet<NodeItem[]>('/');
return this.get<NodeItem[]>('/');
}
static async createNode(payload: NodeMutationPayload): Promise<NodeItem> {
return this.legacyPost<NodeItem>('/', payload);
return this.post<NodeItem>('/', payload);
}
static async updateNode(id: number, payload: NodeMutationPayload): Promise<NodeItem> {
return this.legacyPost<NodeItem>(`/${id}/update`, payload);
return this.post<NodeItem>(`/${id}/update`, payload);
}
static async deleteNode(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
return this.post<void>(`/${id}/delete`);
}
static async getBootstrapToken(): Promise<NodeBootstrapToken> {
return this.legacyGet<NodeBootstrapToken>('/bootstrap-token');
return this.get<NodeBootstrapToken>('/bootstrap-token');
}
static async rotateBootstrapToken(): Promise<NodeBootstrapToken> {
return this.legacyPost<NodeBootstrapToken>('/bootstrap-token/rotate');
return this.post<NodeBootstrapToken>('/bootstrap-token/rotate');
}
static async requestAgentUpdate(
id: number,
payload?: NodeAgentUpdatePayload,
): Promise<NodeItem> {
return this.legacyPost<NodeItem>(`/${id}/agent-update`, payload ?? {});
return this.post<NodeItem>(`/${id}/agent-update`, payload ?? {});
}
static async requestForceSync(id: number): Promise<NodeItem> {
return this.legacyPost<NodeItem>(`/${id}/force-sync`);
return this.post<NodeItem>(`/${id}/force-sync`);
}
static async requestOpenrestyRestart(id: number): Promise<NodeItem> {
return this.legacyPost<NodeItem>(`/${id}/openresty-restart`);
return this.post<NodeItem>(`/${id}/openresty-restart`);
}
static async getAgentRelease(
id: number,
channel: ReleaseChannel = 'stable',
): Promise<NodeAgentReleaseInfo> {
return this.legacyGet<NodeAgentReleaseInfo>(`/${id}/agent-release`, { channel });
return this.get<NodeAgentReleaseInfo>(`/${id}/agent-release`, { channel });
}
static async getObservability(
id: number,
options?: { hours?: number; limit?: number },
): Promise<NodeObservability> {
return this.legacyGet<NodeObservability>(`/${id}/observability`, {
return this.get<NodeObservability>(`/${id}/observability`, {
hours: options?.hours,
limit: options?.limit,
});
@@ -71,8 +71,8 @@ export class NodeService extends LegacyOpenFlareBaseService {
static async cleanupHealthEvents(
id: number,
): Promise<{ node_id: string; deleted_count: number }> {
return this.legacyPost<{ node_id: string; deleted_count: number }>(
return this.post<{ node_id: string; deleted_count: number }>(
`/${id}/observability/cleanup`,
);
}
}
}
@@ -1,4 +1,4 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
import type {
DatabaseCleanupPayload,
DatabaseCleanupResult,
@@ -7,29 +7,29 @@ import type {
OptionItem,
} from './types';
export class OptionService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/option';
export class OptionService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/option';
static list(): Promise<OptionItem[]> {
return this.legacyGet<OptionItem[]>('/');
return this.get<OptionItem[]>('/');
}
static update(key: string, value: string): Promise<void> {
return this.legacyPost<void>('/update', { key, value });
return this.post<void>('/update', { key, value });
}
static updateBatch(options: OptionItem[]): Promise<void> {
const payload: OptionBatchPayload = { options };
return this.legacyPost<void>('/update-batch', payload);
return this.post<void>('/update-batch', payload);
}
static lookupGeoIP(provider: string, ip: string): Promise<GeoIPLookupResult> {
return this.legacyPost<GeoIPLookupResult>('/geoip/lookup', { provider, ip });
return this.post<GeoIPLookupResult>('/geoip/lookup', { provider, ip });
}
static cleanupDatabase(
payload: DatabaseCleanupPayload,
): Promise<DatabaseCleanupResult> {
return this.legacyPost<DatabaseCleanupResult>('/database/cleanup', payload);
return this.post<DatabaseCleanupResult>('/database/cleanup', payload);
}
}
}
@@ -1,29 +1,29 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {OriginDetail, OriginItem, OriginMutationPayload,} from './types';
import {OpenFlareBaseService} from './base.service';
import type {OriginDetail, OriginItem, OriginMutationPayload} from './types';
export class OriginService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/origins';
export class OriginService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/origins';
static list(): Promise<OriginItem[]> {
return this.legacyGet<OriginItem[]>('/');
return this.get<OriginItem[]>('/');
}
static get(id: number): Promise<OriginDetail> {
return this.legacyGet<OriginDetail>(`/${id}`);
static getById(id: number): Promise<OriginDetail> {
return this.get<OriginDetail>(`/${id}`);
}
static create(payload: OriginMutationPayload): Promise<OriginItem> {
return this.legacyPost<OriginItem>('/', payload);
return this.post<OriginItem>('/', payload);
}
static update(
id: number,
payload: OriginMutationPayload,
): Promise<OriginItem> {
return this.legacyPost<OriginItem>(`/${id}/update`, payload);
return this.post<OriginItem>(`/${id}/update`, payload);
}
static delete(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
static deleteById(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
}
}
@@ -1,11 +1,10 @@
import type {AxiosProgressEvent, InternalAxiosRequestConfig} from 'axios';
import apiClient from '@/lib/services/core/api-client';
import {ApiErrorBase} from '@/lib/services/core/errors';
import type {ApiResponse} from '@/lib/services/core';
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
import type {
LegacyApiResponse,
PagesDeployment,
PagesDeploymentFile,
PagesDeploymentUploadPayload,
@@ -13,41 +12,41 @@ import type {
PagesProjectPayload,
} from './types';
export class PagesService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/pages';
export class PagesService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/pages';
static listProjects(): Promise<PagesProject[]> {
return this.legacyGet<PagesProject[]>('/');
return this.get<PagesProject[]>('/');
}
static getProject(id: number): Promise<PagesProject> {
return this.legacyGet<PagesProject>(`/${id}`);
return this.get<PagesProject>(`/${id}`);
}
static createProject(payload: PagesProjectPayload): Promise<PagesProject> {
return this.legacyPost<PagesProject>('/', payload);
return this.post<PagesProject>('/', payload);
}
static updateProject(
id: number,
payload: PagesProjectPayload,
): Promise<PagesProject> {
return this.legacyPost<PagesProject>(`/${id}/update`, payload);
return this.post<PagesProject>(`/${id}/update`, payload);
}
static deleteProject(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
return this.post<void>(`/${id}/delete`);
}
static listDeployments(projectId: number): Promise<PagesDeployment[]> {
return this.legacyGet<PagesDeployment[]>(`/${projectId}/deployments`);
return this.get<PagesDeployment[]>(`/${projectId}/deployments`);
}
static listDeploymentFiles(
projectId: number,
deploymentId: number,
): Promise<PagesDeploymentFile[]> {
return this.legacyGet<PagesDeploymentFile[]>(
return this.get<PagesDeploymentFile[]>(
`/${projectId}/deployments/${deploymentId}/files`,
);
}
@@ -72,7 +71,7 @@ export class PagesService extends LegacyOpenFlareBaseService {
projectId: number,
deploymentId: number,
): Promise<PagesProject> {
return this.legacyPost<PagesProject>(
return this.post<PagesProject>(
`/${projectId}/deployments/${deploymentId}/activate`,
);
}
@@ -81,7 +80,7 @@ export class PagesService extends LegacyOpenFlareBaseService {
projectId: number,
deploymentId: number,
): Promise<void> {
return this.legacyPost<void>(
return this.post<void>(
`/${projectId}/deployments/${deploymentId}/delete`,
);
}
@@ -91,7 +90,7 @@ export class PagesService extends LegacyOpenFlareBaseService {
formData: FormData,
onProgress?: (percent: number) => void,
): Promise<T> {
const response = await apiClient.post<LegacyApiResponse<T>>(
const response = await apiClient.post<ApiResponse<T>>(
this.getFullPath(path),
formData,
{
@@ -104,10 +103,6 @@ export class PagesService extends LegacyOpenFlareBaseService {
} as InternalAxiosRequestConfig,
);
if (!response.data.success) {
throw new ApiErrorBase(response.data.message || '请求失败');
}
return response.data.data;
}
}
}
@@ -1,29 +1,29 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
import type {ProxyRouteItem, ProxyRouteMutationPayload} from './types';
export class ProxyRouteService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/proxy-routes';
export class ProxyRouteService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/proxy-routes';
static async list(): Promise<ProxyRouteItem[]> {
return this.legacyGet<ProxyRouteItem[]>('/');
return this.get<ProxyRouteItem[]>('/');
}
static async getById(id: number): Promise<ProxyRouteItem> {
return this.legacyGet<ProxyRouteItem>(`/${id}`);
return this.get<ProxyRouteItem>(`/${id}`);
}
static async create(payload: ProxyRouteMutationPayload): Promise<ProxyRouteItem> {
return this.legacyPost<ProxyRouteItem>('/', payload);
return this.post<ProxyRouteItem>('/', payload);
}
static async update(
id: number,
payload: ProxyRouteMutationPayload,
): Promise<ProxyRouteItem> {
return this.legacyPost<ProxyRouteItem>(`/${id}/update`, payload);
return this.post<ProxyRouteItem>(`/${id}/update`, payload);
}
static async delete(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
static async deleteById(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
}
}
@@ -1,10 +1,8 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
import type {OpenFlarePublicStatus} from './types';
export class StatusService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api';
export class StatusService extends OpenFlareBaseService {
static getPublicStatus(): Promise<OpenFlarePublicStatus> {
return this.legacyGet<OpenFlarePublicStatus>('/status');
return this.get<OpenFlarePublicStatus>('/status');
}
}
}
@@ -1,4 +1,4 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
import type {
AcmeAccountItem,
TlsCertificateApplyPayload,
@@ -9,68 +9,68 @@ import type {
TlsCertificateMutationPayload,
} from './types';
class AcmeAccountApi extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/acme-accounts';
class AcmeAccountApi extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/acme-accounts';
static getDefault(): Promise<AcmeAccountItem> {
return this.legacyGet<AcmeAccountItem>('/default');
return this.get<AcmeAccountItem>('/default');
}
}
export class TlsCertificateService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/tls-certificates';
export class TlsCertificateService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/tls-certificates';
static async list(): Promise<TlsCertificateItem[]> {
return this.legacyGet<TlsCertificateItem[]>('/');
return this.get<TlsCertificateItem[]>('/');
}
static async getById(id: number): Promise<TlsCertificateDetailItem> {
return this.legacyGet<TlsCertificateDetailItem>(`/${id}`);
return this.get<TlsCertificateDetailItem>(`/${id}`);
}
static async getContent(id: number): Promise<TlsCertificateContentItem> {
return this.legacyGet<TlsCertificateContentItem>(`/${id}/content`);
return this.get<TlsCertificateContentItem>(`/${id}/content`);
}
static async create(
payload: TlsCertificateMutationPayload,
): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>('/', payload);
return this.post<TlsCertificateItem>('/', payload);
}
static async update(
id: number,
payload: TlsCertificateMutationPayload,
): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>(`/${id}/update`, payload);
return this.post<TlsCertificateItem>(`/${id}/update`, payload);
}
static async delete(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
static async deleteById(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
static async apply(
payload: TlsCertificateApplyPayload,
): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>('/apply', payload);
return this.post<TlsCertificateItem>('/apply', payload);
}
static async renew(id: number): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>(`/${id}/renew`);
return this.post<TlsCertificateItem>(`/${id}/renew`);
}
static async updateAcme(
id: number,
payload: TlsCertificateApplyPayload,
): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>(`/${id}/update-acme`, payload);
return this.post<TlsCertificateItem>(`/${id}/update-acme`, payload);
}
static async convertToAcme(
id: number,
payload: TlsCertificateApplyPayload,
): Promise<TlsCertificateItem> {
return this.legacyPost<TlsCertificateItem>(`/${id}/convert-acme`, payload);
return this.post<TlsCertificateItem>(`/${id}/convert-acme`, payload);
}
static async importFile(
@@ -82,7 +82,7 @@ export class TlsCertificateService extends LegacyOpenFlareBaseService {
formData.append('cert_file', payload.certFile);
formData.append('key_file', payload.keyFile);
return this.legacyPost<TlsCertificateItem>('/import-file', formData);
return this.post<TlsCertificateItem>('/import-file', formData);
}
static getDefaultAcmeAccount(): Promise<AcmeAccountItem> {
@@ -1,13 +1,3 @@
/**
* OpenFlare 遗留业务 API 响应信封
* 阶段一 `/api/*` 端点使用此格式,与 Wavelet `/api/v1/*` 的 `{error_msg,data}` 不同
*/
export interface LegacyApiResponse<T = unknown> {
success: boolean;
message: string;
data: T;
}
export type ReleaseChannel = 'stable' | 'preview';
export type NodeType = 'edge_node' | 'tunnel_relay' | 'tunnel_client';
@@ -2,18 +2,18 @@ import type {InternalAxiosRequestConfig} from 'axios';
import {getApiBaseUrl} from '@/lib/services/core/config';
import {ApiErrorBase} from '@/lib/services/core/errors';
import type {ApiResponse} from '@/lib/services/core';
import type {LegacyApiResponse} from './legacy-base.service';
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {LatestReleaseInfo, ReleaseChannel, UpgradeStreamSnapshot, UploadedServerBinaryInfo,} from './types';
import {OpenFlareBaseService} from './base.service';
import type {LatestReleaseInfo, ReleaseChannel, UpgradeStreamSnapshot, UploadedServerBinaryInfo} from './types';
/**
* OpenFlare 服务端升级 API(遗留 `/api/update/*`)。
* OpenFlare 服务端升级 API(`/api/v1/custom/openflare/update/*`)。
*/
export class UpdateService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/update';
export class UpdateService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/update';
private static getLegacyApiUrl(path: string): string {
private static getApiUrl(path: string): string {
const baseURL = getApiBaseUrl();
const fullPath = this.getFullPath(path);
if (!baseURL) {
@@ -23,11 +23,11 @@ export class UpdateService extends LegacyOpenFlareBaseService {
}
static getLatestRelease(channel: ReleaseChannel = 'stable'): Promise<LatestReleaseInfo> {
return this.legacyGet<LatestReleaseInfo>('/latest-release', { channel });
return this.get<LatestReleaseInfo>('/latest-release', { channel });
}
static upgradeServer(channel: ReleaseChannel = 'stable'): Promise<LatestReleaseInfo> {
return this.legacyPost<LatestReleaseInfo>('/upgrade', { channel });
return this.post<LatestReleaseInfo>('/upgrade', { channel });
}
static uploadServerBinary(
@@ -38,14 +38,14 @@ export class UpdateService extends LegacyOpenFlareBaseService {
formData.append('binary', binary);
if (!onProgress) {
return this.legacyPost<UploadedServerBinaryInfo>('/manual-upload', formData, {
return this.post<UploadedServerBinaryInfo>('/manual-upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
} as InternalAxiosRequestConfig);
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', this.getLegacyApiUrl('/manual-upload'));
xhr.open('POST', this.getApiUrl('/manual-upload'));
xhr.withCredentials = true;
xhr.upload.addEventListener('progress', (event) => {
@@ -55,11 +55,11 @@ export class UpdateService extends LegacyOpenFlareBaseService {
});
xhr.addEventListener('load', () => {
let payload: LegacyApiResponse<UploadedServerBinaryInfo> | null = null;
let payload: ApiResponse<UploadedServerBinaryInfo> | null = null;
try {
payload = JSON.parse(
xhr.responseText,
) as LegacyApiResponse<UploadedServerBinaryInfo>;
) as ApiResponse<UploadedServerBinaryInfo>;
} catch {
payload = null;
}
@@ -77,10 +77,6 @@ export class UpdateService extends LegacyOpenFlareBaseService {
reject(new ApiErrorBase('响应格式无效', undefined, xhr.status));
return;
}
if (!payload.success) {
reject(new ApiErrorBase(payload.message || '请求失败', undefined, xhr.status));
return;
}
resolve(payload.data);
});
@@ -93,7 +89,7 @@ export class UpdateService extends LegacyOpenFlareBaseService {
}
static confirmManualServerUpgrade(uploadToken: string): Promise<UploadedServerBinaryInfo> {
return this.legacyPost<UploadedServerBinaryInfo>('/manual-upgrade', {
return this.post<UploadedServerBinaryInfo>('/manual-upgrade', {
upload_token: uploadToken,
});
}
@@ -103,7 +99,7 @@ export class UpdateService extends LegacyOpenFlareBaseService {
return null;
}
const apiUrl = this.getLegacyApiUrl('/logs/ws');
const apiUrl = this.getApiUrl('/logs/ws');
const resolvedUrl = apiUrl.startsWith('http://')
? `ws://${apiUrl.slice('http://'.length)}`
: apiUrl.startsWith('https://')
@@ -132,4 +128,4 @@ export class UpdateService extends LegacyOpenFlareBaseService {
return null;
}
}
}
}
@@ -1,9 +1,9 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
export class UptimeKumaService extends LegacyOpenFlareBaseService {
protected static override readonly basePath = '/api/uptimekuma';
export class UptimeKumaService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/uptimekuma';
static sync(): Promise<void> {
return this.legacyPost<void>('/sync');
return this.post<void>('/sync');
}
}
}
@@ -1,4 +1,4 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import {OpenFlareBaseService} from './base.service';
import type {
WAFIPGroup,
WAFIPGroupAutoTestPayload,
@@ -10,76 +10,76 @@ import type {
WAFSiteRuleGroups,
} from './types';
export class WafService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/waf';
export class WafService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/waf';
static async listRuleGroups(): Promise<WAFRuleGroup[]> {
return this.legacyGet<WAFRuleGroup[]>('/rule-groups');
return this.get<WAFRuleGroup[]>('/rule-groups');
}
static async getRuleGroup(id: number): Promise<WAFRuleGroup> {
return this.legacyGet<WAFRuleGroup>(`/rule-groups/${id}`);
return this.get<WAFRuleGroup>(`/rule-groups/${id}`);
}
static async createRuleGroup(payload: WAFRuleGroupPayload): Promise<WAFRuleGroup> {
return this.legacyPost<WAFRuleGroup>('/rule-groups', payload);
return this.post<WAFRuleGroup>('/rule-groups', payload);
}
static async updateRuleGroup(
id: number,
payload: WAFRuleGroupPayload,
): Promise<WAFRuleGroup> {
return this.legacyPost<WAFRuleGroup>(`/rule-groups/${id}/update`, payload);
return this.post<WAFRuleGroup>(`/rule-groups/${id}/update`, payload);
}
static async deleteRuleGroup(id: number): Promise<void> {
return this.legacyPost<void>(`/rule-groups/${id}/delete`);
return this.post<void>(`/rule-groups/${id}/delete`);
}
static async updateRuleGroupSites(id: number, ids: number[]): Promise<WAFRuleGroup> {
return this.legacyPost<WAFRuleGroup>(`/rule-groups/${id}/sites`, { ids });
return this.post<WAFRuleGroup>(`/rule-groups/${id}/sites`, { ids });
}
static async listSiteRuleGroups(routeId: number): Promise<WAFSiteRuleGroups> {
return this.legacyGet<WAFSiteRuleGroups>(`/sites/${routeId}/rule-groups`);
return this.get<WAFSiteRuleGroups>(`/sites/${routeId}/rule-groups`);
}
static async updateSiteRuleGroups(
routeId: number,
ids: number[],
): Promise<WAFSiteRuleGroups> {
return this.legacyPost<WAFSiteRuleGroups>(`/sites/${routeId}/rule-groups`, {
return this.post<WAFSiteRuleGroups>(`/sites/${routeId}/rule-groups`, {
ids,
});
}
static async listIPGroups(): Promise<WAFIPGroup[]> {
return this.legacyGet<WAFIPGroup[]>('/ip-groups');
return this.get<WAFIPGroup[]>('/ip-groups');
}
static async getIPGroup(id: number): Promise<WAFIPGroup> {
return this.legacyGet<WAFIPGroup>(`/ip-groups/${id}`);
return this.get<WAFIPGroup>(`/ip-groups/${id}`);
}
static async createIPGroup(payload: WAFIPGroupPayload): Promise<WAFIPGroup> {
return this.legacyPost<WAFIPGroup>('/ip-groups', payload);
return this.post<WAFIPGroup>('/ip-groups', payload);
}
static async updateIPGroup(id: number, payload: WAFIPGroupPayload): Promise<WAFIPGroup> {
return this.legacyPost<WAFIPGroup>(`/ip-groups/${id}/update`, payload);
return this.post<WAFIPGroup>(`/ip-groups/${id}/update`, payload);
}
static async deleteIPGroup(id: number): Promise<void> {
return this.legacyPost<void>(`/ip-groups/${id}/delete`);
return this.post<void>(`/ip-groups/${id}/delete`);
}
static async testIPGroup(
payload: WAFIPGroupAutoTestPayload,
): Promise<WAFIPGroupAutoTestResult> {
return this.legacyPost<WAFIPGroupAutoTestResult>('/ip-groups/test', payload);
return this.post<WAFIPGroupAutoTestResult>('/ip-groups/test', payload);
}
static async syncIPGroup(id: number): Promise<WAFIPGroupSyncResult> {
return this.legacyPost<WAFIPGroupSyncResult>(`/ip-groups/${id}/sync`);
return this.post<WAFIPGroupSyncResult>(`/ip-groups/${id}/sync`);
}
}
}
@@ -1,31 +1,35 @@
import {LegacyOpenFlareBaseService} from './legacy-base.service';
import type {ManagedDomainItem, ManagedDomainMatchResult, ManagedDomainMutationPayload,} from './types';
import {OpenFlareBaseService} from './base.service';
import type {
ManagedDomainItem,
ManagedDomainMatchResult,
ManagedDomainMutationPayload,
} from './types';
export class WebsiteService extends LegacyOpenFlareBaseService {
protected static readonly basePath = '/api/managed-domains';
export class WebsiteService extends OpenFlareBaseService {
protected static override readonly basePath: string = '/api/v1/custom/openflare/managed-domains';
static async list(): Promise<ManagedDomainItem[]> {
return this.legacyGet<ManagedDomainItem[]>('/');
return this.get<ManagedDomainItem[]>('/');
}
static async create(
payload: ManagedDomainMutationPayload,
): Promise<ManagedDomainItem> {
return this.legacyPost<ManagedDomainItem>('/', payload);
return this.post<ManagedDomainItem>('/', payload);
}
static async update(
id: number,
payload: ManagedDomainMutationPayload,
): Promise<ManagedDomainItem> {
return this.legacyPost<ManagedDomainItem>(`/${id}/update`, payload);
return this.post<ManagedDomainItem>(`/${id}/update`, payload);
}
static async delete(id: number): Promise<void> {
return this.legacyPost<void>(`/${id}/delete`);
static async deleteById(id: number): Promise<void> {
return this.post<void>(`/${id}/delete`);
}
static async match(domain: string): Promise<ManagedDomainMatchResult> {
return this.legacyGet<ManagedDomainMatchResult>('/match', {domain});
return this.get<ManagedDomainMatchResult>('/match', {domain});
}
}
}
@@ -56,13 +56,13 @@ func TestProtectionEnabledReflectsLoginSwitch(t *testing.T) {
ResetRuntimeSettingsForTest()
if ProtectionEnabled(ctx) {
t.Fatal("ProtectionEnabled() = true, want false from seed defaults")
if !ProtectionEnabled(ctx) {
t.Fatal("ProtectionEnabled() = false, want true from seed defaults")
}
if err := db.DB(ctx).Model(&model.SystemConfig{}).
Where("key = ?", model.ConfigKeyCapLoginEnabled).
Update("value", "true").Error; err != nil {
Update("value", "false").Error; err != nil {
t.Fatalf("Update(cap_login_enabled) error = %v", err)
}
if err := repository.InvalidateSystemConfigCache(ctx, model.ConfigKeyCapLoginEnabled); err != nil {
@@ -70,8 +70,8 @@ func TestProtectionEnabledReflectsLoginSwitch(t *testing.T) {
}
InvalidateRuntimeSettings()
if !ProtectionEnabled(ctx) {
t.Fatal("ProtectionEnabled() = false, want true after config update")
if ProtectionEnabled(ctx) {
t.Fatal("ProtectionEnabled() = true, want false after config update")
}
}
@@ -13,28 +13,6 @@ import (
"github.com/gin-gonic/gin"
)
// RegisterRoutes mounts agent API routes under /agent.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
agentRoute := apiGroup.Group("/agent")
{
discoveryRoute := agentRoute.Group("/")
discoveryRoute.Use(AgentRegisterAuth())
{
discoveryRoute.POST("/nodes/register", RegisterHandler)
}
authorizedRoute := agentRoute.Group("/")
authorizedRoute.Use(AgentAuth())
{
authorizedRoute.GET("/ws", AgentWebSocketHandler)
authorizedRoute.POST("/nodes/heartbeat", HeartbeatHandler)
authorizedRoute.GET("/config-versions/active", GetActiveConfigHandler)
authorizedRoute.GET("/pages/deployments/:deployment_id/package", DownloadPagesPackageHandler)
authorizedRoute.POST("/waf/ip-groups/sync", SyncWAFIPGroupsHandler)
authorizedRoute.POST("/apply-logs", ReportApplyLogHandler)
}
}
}
// RegisterHandler registers or discovers an agent node.
func RegisterHandler(c *gin.Context) {
@@ -0,0 +1,39 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package apiutil provides HTTP helpers for OpenFlare v1 custom API handlers.
package apiutil
import (
"strconv"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
)
const errInvalidParams = "参数错误"
const errInvalidID = "无效的 ID"
// BindJSON binds JSON body; returns false after aborting with 400.
func BindJSON(c *gin.Context, dst any) bool {
if err := c.ShouldBindJSON(dst); err != nil {
response.AbortBadRequest(c, errInvalidParams)
return false
}
return true
}
// IDParam parses :id from the URL path.
func IDParam(c *gin.Context) (uint, bool) {
raw := c.Param("id")
if raw == "" {
response.AbortBadRequest(c, errInvalidID)
return 0, false
}
id64, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id64 == 0 {
response.AbortBadRequest(c, errInvalidID)
return 0, false
}
return uint(id64), true
}
@@ -0,0 +1,34 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package apiutil
import (
"errors"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AbortNotFoundIfMissing maps gorm.ErrRecordNotFound to 404; other errors to 400.
func AbortNotFoundIfMissing(c *gin.Context, err error, notFoundMsg string) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
response.AbortNotFound(c, notFoundMsg)
return true
}
response.AbortBadRequest(c, err.Error())
return true
}
// AbortBadRequestOnError writes a 400 for any non-nil error.
func AbortBadRequestOnError(c *gin.Context, err error) bool {
if err == nil {
return false
}
response.AbortBadRequest(c, err.Error())
return true
}
@@ -0,0 +1,37 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package apiutil
import (
"github.com/Rain-kl/Wavelet/internal/apps/admin"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/common"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
)
// AdminRequired ensures the caller is logged in as a Wavelet administrator.
func AdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
user, err := oauth.GetUserFromRequest(c)
if err != nil {
response.AbortUnauthorized(c, common.UnAuthorized)
return
}
oauth.SetToContext(c, oauth.UserObjKey, user)
if tokenAuth, _ := oauth.GetFromContext[bool](c, oauth.TokenAuthKey); tokenAuth {
tokenAdmin, _ := oauth.GetFromContext[bool](c, oauth.TokenAdminKey)
if !tokenAdmin {
response.AbortNotFound(c, admin.TokenAdminRequired)
return
}
}
if !user.IsAdmin {
response.AbortNotFound(c, admin.AdminRequired)
return
}
c.Next()
}
}
@@ -1,7 +1,7 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package compat
package apiutil
import (
"strings"
@@ -10,14 +10,10 @@ import (
)
// RegisterCollection registers a collection endpoint on both "" and "/" so requests
// work with or without a trailing slash. This avoids 301/308 redirect loops between
// Next.js dev proxy and Gin when paths disagree on trailing slashes.
//
// When the group base already ends with "/" (e.g. userGroup.Group("/")), only "/" is
// registered to avoid duplicate route panic.
// work with or without a trailing slash.
func RegisterCollection(route *gin.RouterGroup, method string, handlers ...gin.HandlerFunc) {
route.Handle(method, "/", handlers...)
if !strings.HasSuffix(route.BasePath(), "/") {
route.Handle(method, "", handlers...)
}
}
}
@@ -4,39 +4,67 @@
package apply_log
import (
"net/http"
"strconv"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
)
// GetApplyLogs lists apply logs with pagination and optional node_id filter.
// @Summary 获取配置下发日志
// @Description 分页返回节点配置下发记录,支持按节点 ID 筛选,需要管理员权限
// @Tags openflare-apply-log
// @Produce json
// @Security SessionCookie
// @Param node_id query string false "节点 ID 筛选"
// @Param pageNo query int false "页码"
// @Param page_no query int false "页码(别名)"
// @Param pageSize query int false "每页数量"
// @Param page_size query int false "每页数量(别名)"
// @Success 200 {object} response.Any{data=apply_log.ListResult} "下发日志列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/apply-logs [get]
func GetApplyLogs(c *gin.Context) {
result, err := ListPage(c.Request.Context(), ListQuery{
NodeID: c.Query("node_id"),
PageNo: readIntQuery(c, "pageNo", "page_no"),
PageSize: readIntQuery(c, "pageSize", "page_size"),
})
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, result)
c.JSON(http.StatusOK, response.OK(result))
}
// CleanupApplyLogs removes old apply logs or deletes all records.
// @Summary 清理配置下发日志
// @Description 按保留天数清理历史下发记录,或删除全部记录,需要管理员权限
// @Tags openflare-apply-log
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param body body apply_log.CleanupInput true "清理参数"
// @Success 200 {object} response.Any{data=apply_log.CleanupResult} "清理结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/apply-logs/cleanup [post]
func CleanupApplyLogs(c *gin.Context) {
var input CleanupInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
result, err := Cleanup(c.Request.Context(), input)
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, result)
c.JSON(http.StatusOK, response.OK(result))
}
func readIntQuery(c *gin.Context, primary, secondary string) int {
@@ -46,4 +74,4 @@ func readIntQuery(c *gin.Context, primary, secondary string) int {
}
parsed, _ := strconv.Atoi(value)
return parsed
}
}
@@ -1,34 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
const (
errInvalidParams = "无效的参数"
errUnauthorized = "无权进行此操作,未登录或 token 无效"
errPasswordLoginDisabled = "管理员关闭了密码登录"
errUsernameOrPasswordWrong = "用户名或密码错误"
errBannedAccount = "用户已被封禁"
errSaveSessionFailed = "无法保存会话信息,请重试"
errRegistrationDisabled = "管理员关闭了注册"
errPasswordTooShort = "密码长度不能少于 8 位"
errEmailRequired = "邮箱地址不能为空"
errEmailAlreadyRegistered = "邮箱地址已被占用"
errEmailNotRegistered = "该邮箱地址未注册"
errEmailCodeInvalid = "验证码错误或已过期"
errResetLinkInvalid = "重置链接非法或已过期"
errUserNotFound = "用户不存在"
errGenerateTokenFailed = "生成 Token 失败"
errInsufficientPermission = "无权进行此操作,权限不足"
errCannotDisableRoot = "无法禁用超级管理员用户"
errCannotDeleteRoot = "无法删除超级管理员用户"
errCannotPromoteAdmin = "普通管理员用户无法提升其他用户为管理员"
errAlreadyAdmin = "该用户已经是管理员"
errAlreadyCommonUser = "该用户已经是普通用户"
errAuthSourceDisabled = "认证源未启用"
errInvalidAuthSourceID = "认证源 ID 无效"
errPendingOAuthExpired = "待绑定第三方账号已失效,请重新登录"
errPendingOAuthInvalid = "待绑定第三方账号无效,请重新登录"
errCapTokenMissing = "缺少人机验证凭证"
errCapTokenInvalid = "人机验证凭证无效或已过期"
)
@@ -1,82 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package auth provides OpenFlare legacy auth business logic.
package auth
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/model"
)
// LegacyUser mirrors the old OpenFlare frontend user shape.
type LegacyUser struct {
ID int `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role int `json:"role"`
Status int `json:"status"`
Token string `json:"token,omitempty"`
Email string `json:"email,omitempty"`
}
const (
legacyUserStatusEnabled = 1
legacyUserStatusDisabled = 2
)
// RoleFromUser maps a Wavelet user to the legacy role value.
func RoleFromUser(user *model.User) int {
if user == nil {
return 0
}
if user.IsAdmin {
return compat.RoleRootUser
}
return compat.RoleCommonUser
}
// StatusFromUser maps is_active to legacy status.
func StatusFromUser(user *model.User) int {
if user == nil || !user.IsActive {
return legacyUserStatusDisabled
}
return legacyUserStatusEnabled
}
// ToLegacyUser converts a Wavelet user to the legacy response shape.
func ToLegacyUser(user *model.User, token string) LegacyUser {
if user == nil {
return LegacyUser{}
}
return LegacyUser{
ID: int(user.ID),
Username: user.Username,
DisplayName: displayName(user),
Role: RoleFromUser(user),
Status: StatusFromUser(user),
Token: token,
Email: user.Email,
}
}
// ToLegacyUsers converts a slice of users.
func ToLegacyUsers(users []model.User) []LegacyUser {
result := make([]LegacyUser, 0, len(users))
for i := range users {
result = append(result, ToLegacyUser(&users[i], ""))
}
return result
}
func displayName(user *model.User) string {
if user.Nickname != "" {
return user.Nickname
}
return user.Username
}
// IsAdminRole reports whether a legacy role has admin privileges.
func IsAdminRole(role int) bool {
return role >= compat.RoleAdminUser
}
@@ -1,801 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"math/big"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/buildinfo"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/db/idgen"
"github.com/Rain-kl/Wavelet/internal/listener"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/task"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
legacyTokenName = "openflare-legacy"
minPasswordLength = 8
legacyItemsPerPage = 10
passwordResetKeyFmt = "of_password_reset:%s"
passwordResetExpiry = 15 * time.Minute
verificationCodeRange = 900000
verificationOffset = 100000
)
// LoginInput holds legacy login credentials.
type LoginInput struct {
Username string
Password string
Code string
}
// RegisterInput holds legacy registration fields.
type RegisterInput struct {
Username string
Password string
Nickname string
DisplayName string
Email string
Code string
}
// ManageUserInput holds legacy user management actions.
type ManageUserInput struct {
Username string
Action string
}
// UpdateUserInput holds legacy admin user update fields.
type UpdateUserInput struct {
ID int
Username string
Password string
DisplayName string
Role int
Email string
}
// UpdateSelfInput holds legacy self-update fields.
type UpdateSelfInput struct {
Username string
Password string
DisplayName string
Email string
}
// CreateUserInput holds legacy admin create-user fields.
type CreateUserInput struct {
Username string
Password string
DisplayName string
Role int
Email string
}
// PasswordResetInput holds password reset confirmation.
type PasswordResetInput struct {
Email string
Token string
}
// LinkExistingInput binds a pending OAuth account to an existing user.
type LinkExistingInput struct {
Username string
Password string
}
// Login authenticates a user and returns the legacy user shape with an access token.
func Login(ctx context.Context, c *gin.Context, input LoginInput) (LegacyUser, error) {
if !isPasswordLoginEnabled(ctx) {
return LegacyUser{}, errors.New(errPasswordLoginDisabled)
}
input.Username = strings.TrimSpace(input.Username)
if input.Username == "" || input.Password == "" {
return LegacyUser{}, errors.New(errInvalidParams)
}
var user model.User
if err := db.DB(ctx).Where("username = ? OR email = ?", input.Username, input.Username).First(&user).Error; err != nil {
logger.WarnF(ctx, "[LoginAudit] failed login attempt (username not found) for input: %s, IP: %s", input.Username, c.ClientIP())
return LegacyUser{}, errors.New(errUsernameOrPasswordWrong)
}
if !user.IsActive {
return LegacyUser{}, errors.New(errBannedAccount)
}
if !user.CheckPassword(input.Password) {
logger.WarnF(ctx, "[LoginAudit] failed login attempt (incorrect password) for username: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP())
return LegacyUser{}, errors.New(errUsernameOrPasswordWrong)
}
if isEmailLoginVerificationEnabled(ctx) {
if err := verifyLoginEmailCode(ctx, user.Email, input.Code); err != nil {
return LegacyUser{}, err
}
}
user.LastLoginAt = time.Now()
if err := db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error; err != nil {
return LegacyUser{}, err
}
if err := setLoginSession(ctx, c, &user); err != nil {
return LegacyUser{}, errors.New(errSaveSessionFailed)
}
token, err := issueLegacyAccessToken(ctx, &user)
if err != nil {
return LegacyUser{}, err
}
logger.InfoF(ctx, "[LoginAudit] successful legacy login for user: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP())
listener.EmitAdminLoggedIn(ctx, &user, c.ClientIP())
return ToLegacyUser(&user, token), nil
}
// Register creates a new user and logs them in.
func Register(ctx context.Context, c *gin.Context, input RegisterInput) (LegacyUser, error) {
if !isRegistrationEnabled(ctx) || !isPasswordRegisterEnabled(ctx) {
return LegacyUser{}, errors.New(errRegistrationDisabled)
}
input.Username = strings.TrimSpace(input.Username)
input.Password = strings.TrimSpace(input.Password)
input.Nickname = strings.TrimSpace(input.Nickname)
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.Email = strings.TrimSpace(input.Email)
input.Code = strings.TrimSpace(input.Code)
if input.Username == "" || input.Password == "" {
return LegacyUser{}, errors.New(errInvalidParams)
}
if len(input.Password) < minPasswordLength {
return LegacyUser{}, errors.New(errPasswordTooShort)
}
if input.Email == "" {
return LegacyUser{}, errors.New(errEmailRequired)
}
if isEmailRegisterVerificationEnabled(ctx) {
if input.Code == "" || !verifyEmailCode(ctx, input.Email, "register", input.Code) {
return LegacyUser{}, errors.New(errEmailCodeInvalid)
}
}
user := model.User{
ID: idgen.NextUint64ID(),
Username: input.Username,
Nickname: input.Nickname,
Email: input.Email,
IsActive: true,
IsAdmin: false,
LastLoginAt: time.Now(),
}
if user.Nickname == "" {
user.Nickname = input.DisplayName
}
if user.Nickname == "" {
user.Nickname = input.Username
}
if err := user.SetEncryptedPassword(input.Password); err != nil {
return LegacyUser{}, err
}
if err := user.RegisterUser(ctx, db.DB(ctx)); err != nil {
return LegacyUser{}, err
}
if err := setLoginSession(ctx, c, &user); err != nil {
return LegacyUser{}, errors.New(errSaveSessionFailed)
}
token, err := issueLegacyAccessToken(ctx, &user)
if err != nil {
return LegacyUser{}, err
}
return ToLegacyUser(&user, token), nil
}
// Logout clears session and revokes the legacy access token when provided.
func Logout(ctx context.Context, c *gin.Context) error {
token := strings.TrimSpace(c.GetHeader(compat.OpenFlareTokenHeader()))
if token == "" {
token = strings.TrimSpace(c.GetHeader("X-Access-Token"))
}
if token != "" {
tokenHash := model.HashToken(token)
_ = db.DB(ctx).Where("token_hash = ? AND name = ?", tokenHash, legacyTokenName).Delete(&model.AccessToken{}).Error
}
session := sessions.Default(c)
session.Options(oauth.GetSessionOptions(-1))
session.Clear()
return session.Save()
}
// GetSelf returns the current user's legacy profile.
func GetSelf(ctx context.Context, userID uint64) (LegacyUser, error) {
user, err := repository.GetUserByID(ctx, userID)
if err != nil {
return LegacyUser{}, errors.New(errUserNotFound)
}
return ToLegacyUser(&user, ""), nil
}
// GenerateUserToken issues a fresh legacy access token for the user.
func GenerateUserToken(ctx context.Context, userID uint64) (string, error) {
user, err := repository.GetUserByID(ctx, userID)
if err != nil {
return "", errors.New(errUserNotFound)
}
return issueLegacyAccessToken(ctx, &user)
}
// UpdateSelf updates the logged-in user's profile.
func UpdateSelf(ctx context.Context, userID uint64, input UpdateSelfInput) error {
user, err := repository.GetUserByID(ctx, userID)
if err != nil {
return errors.New(errUserNotFound)
}
if input.DisplayName != "" {
user.Nickname = strings.TrimSpace(input.DisplayName)
}
if input.Username != "" {
user.Username = strings.TrimSpace(input.Username)
}
if input.Email != "" {
user.Email = strings.TrimSpace(input.Email)
}
if input.Password != "" {
if len(input.Password) < minPasswordLength {
return errors.New(errPasswordTooShort)
}
if err := user.SetEncryptedPassword(input.Password); err != nil {
return err
}
}
return db.DB(ctx).Save(&user).Error
}
// DeleteSelf removes the logged-in user.
func DeleteSelf(ctx context.Context, userID uint64) error {
return repository.DeleteUserWithRelations(ctx, userID)
}
// ListUsers returns a paginated legacy user list.
func ListUsers(ctx context.Context, page int) ([]LegacyUser, error) {
if page < 0 {
page = 0
}
_, users, err := repository.ListAdminUsers(ctx, repository.AdminUserListFilter{
Page: page + 1,
PageSize: legacyItemsPerPage,
})
if err != nil {
return nil, err
}
return ToLegacyUsers(users), nil
}
// SearchUsers searches users by keyword.
func SearchUsers(ctx context.Context, keyword string) ([]LegacyUser, error) {
keyword = strings.TrimSpace(keyword)
var users []model.User
query := db.DB(ctx).Model(&model.User{}).
Select("id, username, nickname, email, is_active, is_admin")
if keyword != "" {
like := keyword + "%"
query = query.Where(
"CAST(id AS TEXT) = ? OR username LIKE ? OR email LIKE ? OR nickname LIKE ?",
keyword, like, like, like,
)
}
if err := query.Order("id DESC").Find(&users).Error; err != nil {
return nil, err
}
return ToLegacyUsers(users), nil
}
// GetUserByID returns a legacy user if the caller has sufficient role.
func GetUserByID(ctx context.Context, callerRole int, id uint64) (LegacyUser, error) {
user, err := repository.GetAdminUserDetail(ctx, id)
if err != nil {
return LegacyUser{}, errors.New(errUserNotFound)
}
targetRole := RoleFromUser(&user)
if callerRole <= targetRole {
return LegacyUser{}, errors.New(errInsufficientPermission)
}
return ToLegacyUser(&user, ""), nil
}
// CreateUser creates a user from legacy admin input.
func CreateUser(ctx context.Context, callerRole int, input CreateUserInput) error {
input.Username = strings.TrimSpace(input.Username)
input.Password = strings.TrimSpace(input.Password)
input.DisplayName = strings.TrimSpace(input.DisplayName)
input.Email = strings.TrimSpace(input.Email)
if input.Username == "" || input.Password == "" {
return errors.New(errInvalidParams)
}
if input.Role >= callerRole {
return errors.New(errInsufficientPermission)
}
if input.Email == "" {
input.Email = input.Username + "@openflare.local"
}
newUser := model.User{
ID: idgen.NextUint64ID(),
Username: input.Username,
Nickname: input.DisplayName,
Email: input.Email,
IsActive: true,
IsAdmin: input.Role >= compat.RoleAdminUser,
}
if newUser.Nickname == "" {
newUser.Nickname = input.Username
}
if err := newUser.SetEncryptedPassword(input.Password); err != nil {
return err
}
return repository.CreateUser(ctx, &newUser)
}
// UpdateUser updates another user with legacy role checks.
func UpdateUser(ctx context.Context, callerRole int, input UpdateUserInput) error {
if input.ID == 0 {
return errors.New(errInvalidParams)
}
origin, err := repository.GetAdminUserDetail(ctx, uint64(input.ID))
if err != nil {
return errors.New(errUserNotFound)
}
originRole := RoleFromUser(&origin)
if callerRole <= originRole {
return errors.New(errInsufficientPermission)
}
if input.Role > 0 && callerRole <= input.Role {
return errors.New(errInsufficientPermission)
}
if trimmed := strings.TrimSpace(input.Username); trimmed != "" {
origin.Username = trimmed
}
if input.DisplayName != "" {
origin.Nickname = strings.TrimSpace(input.DisplayName)
}
if input.Email != "" {
origin.Email = strings.TrimSpace(input.Email)
}
if input.Role > 0 {
origin.IsAdmin = input.Role >= compat.RoleAdminUser
}
if input.Password != "" {
if len(input.Password) < minPasswordLength {
return errors.New(errPasswordTooShort)
}
if err := origin.SetEncryptedPassword(input.Password); err != nil {
return err
}
}
return db.DB(ctx).Save(&origin).Error
}
// DeleteUserByID deletes a user when the caller has sufficient role.
func DeleteUserByID(ctx context.Context, callerRole int, id uint64) error {
origin, err := repository.GetAdminUserDetail(ctx, id)
if err != nil {
return errors.New(errUserNotFound)
}
if callerRole <= RoleFromUser(&origin) {
return errors.New(errInsufficientPermission)
}
if RoleFromUser(&origin) >= compat.RoleRootUser {
return errors.New(errCannotDeleteRoot)
}
return repository.DeleteUserWithRelations(ctx, id)
}
// ManageUser performs enable/disable/delete/promote/demote actions.
func ManageUser(ctx context.Context, callerRole int, input ManageUserInput) (LegacyUser, error) {
input.Username = strings.TrimSpace(input.Username)
input.Action = strings.TrimSpace(input.Action)
if input.Username == "" || input.Action == "" {
return LegacyUser{}, errors.New(errInvalidParams)
}
user, err := repository.GetUserByUsername(ctx, input.Username)
if err != nil {
return LegacyUser{}, errors.New(errUserNotFound)
}
targetRole := RoleFromUser(&user)
if callerRole <= targetRole && callerRole != compat.RoleRootUser {
return LegacyUser{}, errors.New(errInsufficientPermission)
}
switch input.Action {
case "disable":
if targetRole >= compat.RoleRootUser {
return LegacyUser{}, errors.New(errCannotDisableRoot)
}
user.IsActive = false
case "enable":
user.IsActive = true
case "delete":
if targetRole >= compat.RoleRootUser {
return LegacyUser{}, errors.New(errCannotDeleteRoot)
}
if err := repository.DeleteUserWithRelations(ctx, user.ID); err != nil {
return LegacyUser{}, err
}
return LegacyUser{Role: compat.RoleCommonUser, Status: legacyUserStatusDisabled}, nil
case "promote":
if callerRole != compat.RoleRootUser {
return LegacyUser{}, errors.New(errCannotPromoteAdmin)
}
if user.IsAdmin {
return LegacyUser{}, errors.New(errAlreadyAdmin)
}
user.IsAdmin = true
case "demote":
if targetRole >= compat.RoleRootUser {
return LegacyUser{}, errors.New(errCannotDisableRoot)
}
if !user.IsAdmin {
return LegacyUser{}, errors.New(errAlreadyCommonUser)
}
user.IsAdmin = false
default:
return LegacyUser{}, errors.New(errInvalidParams)
}
if err := db.DB(ctx).Save(&user).Error; err != nil {
return LegacyUser{}, err
}
return LegacyUser{
Role: RoleFromUser(&user),
Status: StatusFromUser(&user),
}, nil
}
// SendRegisterVerificationEmail sends a registration verification code.
func SendRegisterVerificationEmail(ctx context.Context, email string) error {
email = strings.TrimSpace(email)
if email == "" || !strings.Contains(email, "@") {
return errors.New(errInvalidParams)
}
var count int64
if err := db.DB(ctx).Model(&model.User{}).Where("email = ?", email).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New(errEmailAlreadyRegistered)
}
return sendEmailVerificationCode(ctx, email, "register", "register_email")
}
// SendPasswordResetEmail stores a reset token and emails the user.
func SendPasswordResetEmail(ctx context.Context, email string) error {
email = strings.TrimSpace(email)
if email == "" || !strings.Contains(email, "@") {
return errors.New(errInvalidParams)
}
var user model.User
if err := db.DB(ctx).Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errEmailNotRegistered)
}
return err
}
token, err := generateResetToken()
if err != nil {
return err
}
key := fmt.Sprintf(passwordResetKeyFmt, email)
if err := db.SetJSON(ctx, key, token, passwordResetExpiry); err != nil {
return err
}
serverAddr, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress)
base := strings.TrimRight(serverAddr.Value, "/")
link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", base, email, token)
body := fmt.Sprintf("<p>您好,你正在进行密码重置。</p><p>点击<a href='%s'>此处</a>进行密码重置。</p>", link)
return dispatchEmail(ctx, email, "密码重置", body)
}
// ResetPassword validates a reset token and returns a new random password.
func ResetPassword(ctx context.Context, input PasswordResetInput) (string, error) {
input.Email = strings.TrimSpace(input.Email)
input.Token = strings.TrimSpace(input.Token)
if input.Email == "" || input.Token == "" {
return "", errors.New(errInvalidParams)
}
key := fmt.Sprintf(passwordResetKeyFmt, input.Email)
var stored string
if err := db.GetJSON(ctx, key, &stored); err != nil || stored != input.Token {
return "", errors.New(errResetLinkInvalid)
}
password, err := generateResetToken()
if err != nil {
return "", err
}
if len(password) < minPasswordLength {
password = password + "Aa1!"
}
var user model.User
if err := db.DB(ctx).Where("email = ?", input.Email).First(&user).Error; err != nil {
return "", errors.New(errEmailNotRegistered)
}
if err := user.SetEncryptedPassword(password); err != nil {
return "", err
}
if err := db.DB(ctx).Model(&user).Update("password", user.Password).Error; err != nil {
return "", err
}
_ = db.Redis.Del(ctx, db.PrefixedKey(key)).Err()
return password, nil
}
// BuildPublicStatus assembles the legacy /api/status payload.
func BuildPublicStatus(ctx context.Context) (map[string]any, error) {
authSources, err := publicAuthSources(ctx, "/api")
if err != nil {
authSources = []map[string]any{}
}
siteName, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeySiteName)
serverAddr, _ := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress)
return map[string]any{
"version": buildVersion(),
"start_time": appStartUnix(),
"email_verification": isEmailRegisterVerificationEnabled(ctx),
"github_oauth": false,
"github_client_id": "",
"system_name": siteName.Value,
"home_page_link": "",
"footer_html": "",
"wechat_qrcode": "",
"wechat_login": false,
"server_address": serverAddr.Value,
"password_register_enabled": isPasswordRegisterEnabled(ctx),
"cap_login_enabled": capLoginEnabled(ctx),
"auth_sources": authSources,
}, nil
}
// GetNotice returns the legacy notice option value.
func GetNotice(ctx context.Context) string {
return getOptionValue(ctx, "notice")
}
// GetAbout returns the legacy about option value.
func GetAbout(ctx context.Context) string {
return getOptionValue(ctx, "about")
}
func issueLegacyAccessToken(ctx context.Context, user *model.User) (string, error) {
tokenStr, err := model.GenerateTokenString()
if err != nil {
return "", errors.New(errGenerateTokenFailed)
}
record := model.AccessToken{
UserID: user.ID,
Name: legacyTokenName,
TokenHash: model.HashToken(tokenStr),
MaskedToken: model.MaskTokenString(tokenStr),
IsAdmin: user.IsAdmin,
}
if err := db.DB(ctx).Create(&record).Error; err != nil {
return "", err
}
return tokenStr, nil
}
func setLoginSession(ctx context.Context, c *gin.Context, user *model.User) error {
session := sessions.Default(c)
session.Set(oauth.UserIDKey, user.ID)
session.Set(oauth.UserNameKey, user.Username)
session.Set(oauth.PasswordHashKey, user.Password)
maxAge := config.Config.App.SessionAge
isSessionCookie := false
ttlHours, err := repository.GetIntByKey(ctx, model.ConfigKeyLoginSessionTTLHours)
if err == nil {
switch {
case ttlHours == -1:
maxAge = 10 * 365 * 24 * 3600
case ttlHours > 0:
maxAge = ttlHours * 3600
case ttlHours == 0:
isSessionCookie = true
}
}
session.Options(oauth.GetSessionOptions(maxAge))
if err := session.Save(); err != nil {
return err
}
if isSessionCookie {
oauth.StripCookieMaxAgeAndExpires(c.Writer.Header(), config.Config.App.SessionCookieName)
}
return nil
}
func isPasswordLoginEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyPasswordLoginEnabled)
return err != nil || enabled
}
func isPasswordRegisterEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyPasswordRegisterEnabled)
return err != nil || enabled
}
func isRegistrationEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled)
return err != nil || enabled
}
func isEmailLoginVerificationEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyEmailLoginVerificationEnabled)
return err == nil && enabled
}
func isEmailRegisterVerificationEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyEmailRegisterVerificationEnabled)
return err == nil && enabled
}
func capLoginEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyCapLoginEnabled)
return err == nil && enabled
}
func verifyLoginEmailCode(ctx context.Context, email, code string) error {
if code == "" {
return errors.New("need_email_code:" + email)
}
if !verifyEmailCode(ctx, email, "login", code) {
return errors.New(errEmailCodeInvalid)
}
return nil
}
func verifyEmailCode(ctx context.Context, email, scene, code string) bool {
key := fmt.Sprintf("email_code:%s:%s", scene, email)
var stored string
if err := db.GetJSON(ctx, key, &stored); err != nil {
return false
}
if stored != code {
return false
}
_ = db.Redis.Del(ctx, db.PrefixedKey(key)).Err()
return true
}
func sendEmailVerificationCode(ctx context.Context, email, scene, templateName string) error {
scHost, errHost := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPHost)
scPort, errPort := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPort)
scUser, errUser := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPUsername)
scPass, errPass := repository.GetSystemConfigByKey(ctx, model.ConfigKeySMTPPassword)
if errHost != nil || errPort != nil || errUser != nil || errPass != nil ||
scHost.Value == "" || scPort.Value == "" || scUser.Value == "" || scPass.Value == "" {
return errors.New("系统 SMTP 邮件服务配置不完整")
}
code, err := generateVerificationCode()
if err != nil {
return err
}
codeKey := fmt.Sprintf("email_code:%s:%s", scene, email)
if err := db.SetJSON(ctx, codeKey, code, 5*time.Minute); err != nil {
return err
}
tmpl, err := repository.GetTemplateByKey(ctx, templateName)
if err != nil {
body := fmt.Sprintf("<p>您的验证码为: <strong>%s</strong></p>", code)
return dispatchEmail(ctx, email, "邮箱验证", body)
}
subject, body, err := tmpl.Render(map[string]any{"Code": code})
if err != nil {
return err
}
return dispatchEmail(ctx, email, subject, body)
}
type sendEmailPayload struct {
To string `json:"to"`
Subject string `json:"subject"`
Body string `json:"body"`
}
func dispatchEmail(ctx context.Context, to, subject, body string) error {
payload := sendEmailPayload{To: to, Subject: subject, Body: body}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = task.DispatchTask(ctx, "mail:send", payloadBytes, "system")
return err
}
func generateVerificationCode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(verificationCodeRange))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()+verificationOffset), nil
}
func generateResetToken() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1<<62))
if err != nil {
return "", err
}
return fmt.Sprintf("%x", n.Int64()), nil
}
func publicAuthSources(ctx context.Context, baseAPIPath string) ([]map[string]any, error) {
sources, err := model.GetActiveAuthSources(ctx)
if err != nil {
return nil, err
}
result := make([]map[string]any, 0, len(sources))
base := strings.TrimRight(baseAPIPath, "/")
for _, source := range sources {
result = append(result, map[string]any{
"id": source.ID,
"name": source.Name,
"type": source.Type,
"display_name": source.DisplayName,
"authorize_url": fmt.Sprintf("%s/oauth/%s/authorize", base, source.Name),
"icon_url": source.IconURL,
})
}
return result, nil
}
func getOptionValue(ctx context.Context, key string) string {
sc, err := repository.GetSystemConfigByKey(ctx, key)
if err != nil {
return ""
}
return sc.Value
}
var appStart = time.Now()
func appStartUnix() int64 {
return appStart.Unix()
}
func buildVersion() string {
if buildinfo.Version != "" {
return buildinfo.Version
}
return "dev"
}
@@ -1,509 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/listener"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
const pendingExternalAccountSessionKey = "pending_external_account"
// OAuthCallbackResult is the legacy OAuth callback payload.
type OAuthCallbackResult struct {
Status string `json:"status"`
User *LegacyUser `json:"user,omitempty"`
}
// PendingExternalAccount stores OAuth bind-pending state in session.
type PendingExternalAccount struct {
AuthSourceID uint64 `json:"auth_source_id"`
ExternalID string `json:"external_id"`
ExternalUsername string `json:"external_username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
}
// OAuthAuthorize builds an authorize URL for a legacy auth source route param.
func OAuthAuthorize(ctx context.Context, c *gin.Context, sourceKey string) (string, error) {
source, err := resolveAuthSourceByRoute(ctx, sourceKey)
if err != nil {
return "", err
}
if !source.IsActive {
return "", errors.New(errAuthSourceDisabled)
}
if err := source.Validate(); err != nil {
return "", err
}
state := uuid.NewString()
session := sessions.Default(c)
session.Set(oauthStateSessionKey(source.ID), state)
if err := session.Save(); err != nil {
return "", errors.New(errSaveSessionFailed)
}
redirectURL := legacyOAuthCallbackURL(c, source)
return buildLegacyAuthorizeURL(ctx, source, redirectURL, state)
}
// OAuthCallback handles GET /oauth/:source/callback for the legacy frontend.
func OAuthCallback(ctx context.Context, c *gin.Context, sourceKey string) (OAuthCallbackResult, error) {
source, err := resolveAuthSourceByRoute(ctx, sourceKey)
if err != nil {
return OAuthCallbackResult{}, err
}
if !source.IsActive {
return OAuthCallbackResult{}, errors.New(errAuthSourceDisabled)
}
session := sessions.Default(c)
expectedState, _ := session.Get(oauthStateSessionKey(source.ID)).(string)
state := c.Query("state")
if expectedState == "" || state == "" || state != expectedState {
return OAuthCallbackResult{}, errors.New("授权状态无效,请重新登录")
}
session.Delete(oauthStateSessionKey(source.ID))
if err := session.Save(); err != nil {
return OAuthCallbackResult{}, errors.New(errSaveSessionFailed)
}
if oauthError := c.Query("error"); oauthError != "" {
description := c.Query("error_description")
if description == "" {
description = oauthError
}
return OAuthCallbackResult{}, errors.New(description)
}
redirectURL := legacyOAuthCallbackURL(c, source)
userInfo, err := exchangeLegacyOAuthProfile(ctx, source, c.Query("code"), state, redirectURL)
if err != nil {
return OAuthCallbackResult{}, err
}
var currentUserID *uint64
if current := currentUserFromLegacyToken(ctx, c); current != nil {
currentUserID = &current.ID
}
result, pending, err := completeLegacyOAuthLogin(ctx, source, userInfo, currentUserID)
if err != nil {
return OAuthCallbackResult{}, err
}
if pending != nil {
raw, marshalErr := json.Marshal(pending)
if marshalErr != nil {
return OAuthCallbackResult{}, marshalErr
}
session.Set(pendingExternalAccountSessionKey, string(raw))
if err := session.Save(); err != nil {
return OAuthCallbackResult{}, errors.New(errSaveSessionFailed)
}
return result, nil
}
if result.User != nil {
var dbUser model.User
if err := db.DB(ctx).Where("id = ?", result.User.ID).First(&dbUser).Error; err != nil {
return OAuthCallbackResult{}, err
}
if err := setLoginSession(ctx, c, &dbUser); err != nil {
return OAuthCallbackResult{}, errors.New(errSaveSessionFailed)
}
token, tokenErr := issueLegacyAccessToken(ctx, &dbUser)
if tokenErr != nil {
return OAuthCallbackResult{}, tokenErr
}
legacy := ToLegacyUser(&dbUser, token)
result.User = &legacy
listener.EmitAdminLoggedIn(ctx, &dbUser, c.ClientIP())
}
return result, nil
}
// LinkExistingOAuthAccount binds a pending external account to an existing user.
func LinkExistingOAuthAccount(ctx context.Context, c *gin.Context, input LinkExistingInput) (OAuthCallbackResult, error) {
session := sessions.Default(c)
raw, _ := session.Get(pendingExternalAccountSessionKey).(string)
if raw == "" {
return OAuthCallbackResult{}, errors.New(errPendingOAuthExpired)
}
var pending PendingExternalAccount
if err := json.Unmarshal([]byte(raw), &pending); err != nil {
return OAuthCallbackResult{}, errors.New(errPendingOAuthInvalid)
}
user, err := linkPendingExternalAccount(ctx, &pending, input)
if err != nil {
return OAuthCallbackResult{}, err
}
session.Delete(pendingExternalAccountSessionKey)
if err := session.Save(); err != nil {
return OAuthCallbackResult{}, errors.New(errSaveSessionFailed)
}
if err := setLoginSession(ctx, c, user); err != nil {
return OAuthCallbackResult{}, errors.New(errSaveSessionFailed)
}
token, err := issueLegacyAccessToken(ctx, user)
if err != nil {
return OAuthCallbackResult{}, err
}
legacy := ToLegacyUser(user, token)
return OAuthCallbackResult{Status: "linked", User: &legacy}, nil
}
func resolveAuthSourceByRoute(ctx context.Context, raw string) (*model.AuthSource, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, errors.New("认证源不能为空")
}
if parsed, err := parseUint64(raw); err == nil && parsed > 0 {
return model.GetAuthSourceByID(ctx, parsed)
}
return model.GetAuthSourceByName(ctx, raw)
}
func oauthStateSessionKey(sourceID uint64) string {
return fmt.Sprintf("oauth_state_%d", sourceID)
}
func legacyOAuthCallbackURL(c *gin.Context, source *model.AuthSource) string {
ctx := c.Request.Context()
base := ""
if sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress); err == nil {
base = strings.TrimRight(sc.Value, "/")
}
if base == "" {
scheme := "http"
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
scheme = "https"
}
host := c.Request.Host
if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" {
host = forwardedHost
}
base = scheme + "://" + host
}
sourceName := source.Name
if sourceName == "" {
sourceName = fmt.Sprintf("%d", source.ID)
}
callback, _ := url.JoinPath(base, "oauth", sourceName)
return callback
}
func buildLegacyAuthorizeURL(ctx context.Context, source *model.AuthSource, redirectURL, state string) (string, error) {
payloadValue, err := encodeLegacyOAuthState(source.Name, state)
if err != nil {
return "", err
}
stateKey := fmt.Sprintf("of_oauth_state:%s", state)
if err := db.Redis.Set(ctx, db.PrefixedKey(stateKey), payloadValue, 10*time.Minute).Err(); err != nil {
return "", err
}
return oauthBuildAuthorizeURL(ctx, source, redirectURL, state)
}
func encodeLegacyOAuthState(sourceName, state string) (string, error) {
payload := map[string]string{
"source_name": sourceName,
"state": state,
}
raw, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(raw), nil
}
func legacyFrontendLoginRedirectURL(ctx context.Context, source *model.AuthSource) (string, error) {
sc, err := repository.GetSystemConfigByKey(ctx, model.ConfigKeyServerAddress)
if err != nil || strings.TrimSpace(sc.Value) == "" {
return "", errors.New("server_address 未配置")
}
base := strings.TrimRight(sc.Value, "/")
name := source.Name
if name == "" {
name = fmt.Sprintf("%d", source.ID)
}
return base + "/oauth/" + url.PathEscape(name), nil
}
func exchangeLegacyOAuthProfile(ctx context.Context, source *model.AuthSource, code, state, redirectURL string) (*model.OAuthUserInfo, error) {
if strings.TrimSpace(code) == "" {
return nil, errors.New("授权 code 不能为空")
}
// Validate state from Redis cache written during authorize.
stateKey := fmt.Sprintf("of_oauth_state:%s", state)
payloadRaw, err := db.Redis.Get(ctx, db.PrefixedKey(stateKey)).Result()
if err != nil {
return nil, errors.New("授权状态无效,请重新登录")
}
_ = db.Redis.Del(ctx, db.PrefixedKey(stateKey)).Err()
var payload map[string]string
if err := json.Unmarshal([]byte(payloadRaw), &payload); err != nil {
return nil, err
}
if payload["source_name"] != source.Name {
return nil, errors.New("授权状态无效,请重新登录")
}
userInfo, err := buildOAuthUserInfo(ctx, source, code, state, redirectURL)
if err != nil {
return nil, err
}
if err := normalizeOAuthUserInfo(userInfo); err != nil {
return nil, err
}
if userInfo.Sub == "" {
userInfo.Sub = userInfo.Username
}
return userInfo, nil
}
func completeLegacyOAuthLogin(ctx context.Context, source *model.AuthSource, profile *model.OAuthUserInfo, currentUserID *uint64) (OAuthCallbackResult, *PendingExternalAccount, error) {
if source == nil || profile == nil || strings.TrimSpace(profile.Sub) == "" {
return OAuthCallbackResult{}, nil, errors.New("第三方账号资料不完整")
}
account, err := model.FindExternalAccount(ctx, source.ID, profile.Sub)
if err == nil {
var user model.User
if err := db.DB(ctx).Where("id = ?", account.UserID).First(&user).Error; err != nil {
return OAuthCallbackResult{}, nil, err
}
if !user.IsActive {
return OAuthCallbackResult{}, nil, errors.New(errBannedAccount)
}
legacy := ToLegacyUser(&user, "")
return OAuthCallbackResult{Status: "logged_in", User: &legacy}, nil, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return OAuthCallbackResult{}, nil, err
}
if currentUserID != nil && *currentUserID > 0 {
var user model.User
if err := db.DB(ctx).Where("id = ?", *currentUserID).First(&user).Error; err != nil {
return OAuthCallbackResult{}, nil, err
}
if !user.IsActive {
return OAuthCallbackResult{}, nil, errors.New(errBannedAccount)
}
if err := model.BindExternalAccount(ctx, &model.ExternalAccount{
AuthSourceID: source.ID,
UserID: user.ID,
ExternalID: profile.Sub,
ExternalUsername: profile.Username,
Email: profile.Email,
}); err != nil {
return OAuthCallbackResult{}, nil, err
}
legacy := ToLegacyUser(&user, "")
return OAuthCallbackResult{Status: "linked", User: &legacy}, nil, nil
}
registrationEnabled, regErr := repository.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled)
if regErr != nil {
registrationEnabled = true
}
if !registrationEnabled {
pending := &PendingExternalAccount{
AuthSourceID: source.ID,
ExternalID: profile.Sub,
ExternalUsername: profile.Username,
DisplayName: profile.Name,
Email: profile.Email,
}
return OAuthCallbackResult{Status: "link_required"}, pending, nil
}
user, err := createUserFromOAuthProfile(ctx, source, profile)
if err != nil {
return OAuthCallbackResult{}, nil, err
}
legacy := ToLegacyUser(&user, "")
return OAuthCallbackResult{Status: "logged_in", User: &legacy}, nil, nil
}
func createUserFromOAuthProfile(ctx context.Context, source *model.AuthSource, profile *model.OAuthUserInfo) (model.User, error) {
username, err := uniqueLegacyUsername(ctx, profile.Username)
if err != nil {
return model.User{}, err
}
profile.Username = username
var user model.User
if err := user.CreateUser(ctx, db.DB(ctx), profile); err != nil {
return model.User{}, err
}
if err := model.BindExternalAccount(ctx, &model.ExternalAccount{
AuthSourceID: source.ID,
UserID: user.ID,
ExternalID: profile.Sub,
ExternalUsername: profile.Username,
Email: profile.Email,
}); err != nil {
return model.User{}, err
}
logger.InfoF(ctx, "[LoginAudit] successful legacy OAuth registration via source: %s, user: %s, ID: %d", source.Name, user.Username, user.ID)
return user, nil
}
func linkPendingExternalAccount(ctx context.Context, pending *PendingExternalAccount, input LinkExistingInput) (*model.User, error) {
if pending == nil || pending.AuthSourceID == 0 || pending.ExternalID == "" {
return nil, errors.New(errPendingOAuthExpired)
}
input.Username = strings.TrimSpace(input.Username)
if input.Username == "" || input.Password == "" {
return nil, errors.New(errInvalidParams)
}
var user model.User
if err := db.DB(ctx).Where("username = ? OR email = ?", input.Username, input.Username).First(&user).Error; err != nil {
return nil, errors.New(errUsernameOrPasswordWrong)
}
if !user.IsActive {
return nil, errors.New(errBannedAccount)
}
if !user.CheckPassword(input.Password) {
return nil, errors.New(errUsernameOrPasswordWrong)
}
if existing, err := model.FindExternalAccount(ctx, pending.AuthSourceID, pending.ExternalID); err == nil {
if existing.UserID != user.ID {
return nil, errors.New("该第三方账号已绑定其他用户")
}
return &user, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err := model.BindExternalAccount(ctx, &model.ExternalAccount{
AuthSourceID: pending.AuthSourceID,
UserID: user.ID,
ExternalID: pending.ExternalID,
ExternalUsername: pending.ExternalUsername,
Email: pending.Email,
}); err != nil {
return nil, err
}
return &user, nil
}
func currentUserFromLegacyToken(ctx context.Context, c *gin.Context) *model.User {
token := strings.TrimSpace(c.GetHeader(compat.OpenFlareTokenHeader()))
if token == "" {
return nil
}
tokenHash := model.HashToken(token)
var record model.AccessToken
if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&record).Error; err != nil {
return nil
}
var user model.User
if err := db.DB(ctx).Where("id = ? AND is_active = ?", record.UserID, true).First(&user).Error; err != nil {
return nil
}
return &user
}
func uniqueLegacyUsername(ctx context.Context, base string) (string, error) {
base = strings.TrimSpace(base)
if base == "" {
base = "user"
}
candidate := base
for i := 0; i <= 1000; i++ {
if i > 0 {
candidate = fmt.Sprintf("%s-%d", base, i)
}
count, err := repository.CountUsersByUsername(ctx, candidate)
if err != nil {
return "", err
}
if count == 0 {
return candidate, nil
}
}
return "", errors.New("无法生成唯一用户名")
}
func parseUint64(raw string) (uint64, error) {
var id uint64
_, err := fmt.Sscanf(raw, "%d", &id)
return id, err
}
func isOIDCLoginEnabled(ctx context.Context) bool {
enabled, err := repository.GetBoolByKey(ctx, model.ConfigKeyOIDCLoginEnabled)
return err != nil || enabled
}
// The following functions mirror oauth package internals for legacy GET callback support.
// They intentionally duplicate minimal logic to avoid modifying the core oauth module.
func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code, nonce, redirectURL string) (*model.OAuthUserInfo, error) {
authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL)
if err != nil {
return nil, err
}
token, err := authConfig.Exchange(ctx, code)
if err != nil {
return nil, err
}
userInfo := &model.OAuthUserInfo{Active: true}
if verifier != nil {
if verifyErr := verifyIDToken(ctx, verifier, token, nonce, userInfo); verifyErr != nil {
return nil, verifyErr
}
}
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
userInfo.Username = userInfo.PreferredUsername
}
if userInfo.Username == "" && userInfo.Email != "" {
userInfo.Username = strings.Split(userInfo.Email, "@")[0]
}
if userInfo.Username == "" && userInfo.Sub != "" {
userInfo.Username = userInfo.Sub
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
return userInfo, nil
}
func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error {
userInfo.Username = strings.TrimSpace(userInfo.Username)
userInfo.Email = strings.TrimSpace(userInfo.Email)
userInfo.Name = strings.TrimSpace(userInfo.Name)
if userInfo.Username == "" {
return errors.New("无法从认证源获取用户名")
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
if !userInfo.Active {
userInfo.Active = true
}
return nil
}
@@ -1,134 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"golang.org/x/sync/singleflight"
)
type legacyOIDCProviderCacheType struct {
mu sync.RWMutex
entries map[string]*oidc.Provider
sfGroup singleflight.Group
}
var legacyOIDCProviderCache = &legacyOIDCProviderCacheType{
entries: make(map[string]*oidc.Provider),
}
func (c *legacyOIDCProviderCacheType) get(ctx context.Context, issuer string) (*oidc.Provider, error) {
c.mu.RLock()
if p, ok := c.entries[issuer]; ok {
c.mu.RUnlock()
return p, nil
}
c.mu.RUnlock()
bg := context.Background()
if client, ok := ctx.Value(oauth2.HTTPClient).(*http.Client); ok && client != nil {
bg = oidc.ClientContext(bg, client)
}
v, err, _ := c.sfGroup.Do(issuer, func() (any, error) {
c.mu.RLock()
if p, ok := c.entries[issuer]; ok {
c.mu.RUnlock()
return p, nil
}
c.mu.RUnlock()
p, err := oidc.NewProvider(bg, issuer)
if err != nil {
return nil, err
}
c.mu.Lock()
c.entries[issuer] = p
c.mu.Unlock()
return p, nil
})
if err != nil {
return nil, err
}
return v.(*oidc.Provider), nil //nolint:forcetypeassert // singleflight value type is fixed
}
// oauthBuildAuthorizeURL builds an OAuth authorize URL using the same rules as apps/oauth.
func oauthBuildAuthorizeURL(ctx context.Context, source *model.AuthSource, redirectURL, state string) (string, error) {
authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL)
if err != nil {
return "", err
}
if verifier != nil {
return authConfig.AuthCodeURL(state, oidc.Nonce(state)), nil
}
return authConfig.AuthCodeURL(state), nil
}
func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
if source == nil {
return nil, nil, errors.New("认证源不能为空")
}
if source.OpenIDDiscoveryURL == "" {
return nil, nil, errors.New("认证源未配置 OpenID Discovery URL")
}
issuer := strings.TrimSuffix(strings.TrimSpace(source.OpenIDDiscoveryURL), "/")
issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration")
issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server")
provider, err := legacyOIDCProviderCache.get(ctx, issuer)
if err != nil {
return nil, nil, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: source.ClientID})
scopes := strings.Fields(source.Scopes)
if len(scopes) == 0 {
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
}
if !containsScope(scopes, oidc.ScopeOpenID) {
scopes = append([]string{oidc.ScopeOpenID}, scopes...)
}
return &oauth2.Config{
ClientID: source.ClientID,
ClientSecret: source.ClientSecret,
RedirectURL: redirectURL,
Scopes: scopes,
Endpoint: provider.Endpoint(),
}, verifier, nil
}
func containsScope(scopes []string, scope string) bool {
for _, item := range scopes {
if item == scope {
return true
}
}
return false
}
func verifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token, nonce string, userInfo *model.OAuthUserInfo) error {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil
}
idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
if verifyErr != nil {
return fmt.Errorf("ID Token 验证失败: %w", verifyErr)
}
if nonce != "" && idToken.Nonce != nonce {
return errors.New("nonce 不匹配")
}
return idToken.Claims(userInfo)
}
@@ -1,353 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/listener"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/pkg/logger"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const (
errGitHubOAuthDisabled = "管理员未开启通过 GitHub 登录以及注册"
errWeChatOAuthDisabled = "管理员未开启通过微信登录以及注册"
errRegistrationClosed = "管理员关闭了新用户注册"
errGitHubAlreadyBound = "该 GitHub 账户已被绑定"
errWeChatAlreadyBound = "该微信账号已被绑定"
)
type githubOAuthResponse struct {
AccessToken string `json:"access_token"`
}
type githubUser struct {
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
}
type wechatLoginResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data string `json:"data"`
}
// GitHubOAuth handles the legacy GET /oauth/github shortcut.
func GitHubOAuth(ctx context.Context, c *gin.Context, code string) (LegacyUser, error) {
if current := currentUserFromLegacyToken(ctx, c); current != nil {
if err := GitHubBind(ctx, c, current, code); err != nil {
return LegacyUser{}, err
}
return LegacyUser{}, nil
}
if !model.GitHubOAuthEnabled {
return LegacyUser{}, errors.New(errGitHubOAuthDisabled)
}
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
return LegacyUser{}, err
}
user, err := findUserByShortcutBinding(ctx, githubUser.Login, "github", "GitHub")
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return LegacyUser{}, errors.New(errRegistrationClosed)
}
return LegacyUser{}, err
}
if !user.IsActive {
return LegacyUser{}, errors.New(errBannedAccount)
}
return finishLegacyLogin(ctx, c, user)
}
// GitHubBind binds a GitHub account to the current user.
func GitHubBind(ctx context.Context, c *gin.Context, current *model.User, code string) error {
if current == nil {
return errors.New(errUnauthorized)
}
if !model.GitHubOAuthEnabled {
return errors.New(errGitHubOAuthDisabled)
}
githubUser, err := getGitHubUserInfoByCode(code)
if err != nil {
return err
}
if _, err := findUserByShortcutBinding(ctx, githubUser.Login, "github", "GitHub"); err == nil {
return errors.New(errGitHubAlreadyBound)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return bindShortcutExternalAccount(ctx, current.ID, githubUser.Login, githubUser.Login, githubUser.Email, "github", "GitHub")
}
// WeChatOAuth handles the legacy GET /oauth/wechat shortcut.
func WeChatOAuth(ctx context.Context, c *gin.Context, code string) (LegacyUser, error) {
if !model.WeChatAuthEnabled {
return LegacyUser{}, errors.New(errWeChatOAuthDisabled)
}
wechatID, err := getWeChatIDByCode(code)
if err != nil {
return LegacyUser{}, err
}
user, err := findUserByShortcutBinding(ctx, wechatID, "wechat", "WeChat")
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return LegacyUser{}, errors.New(errRegistrationClosed)
}
return LegacyUser{}, err
}
if !user.IsActive {
return LegacyUser{}, errors.New(errBannedAccount)
}
return finishLegacyLogin(ctx, c, user)
}
// WeChatBind binds a WeChat account to the current user.
func WeChatBind(ctx context.Context, userID uint64, code string) error {
if userID == 0 {
return errors.New(errUnauthorized)
}
if !model.WeChatAuthEnabled {
return errors.New(errWeChatOAuthDisabled)
}
wechatID, err := getWeChatIDByCode(code)
if err != nil {
return err
}
if _, err := findUserByShortcutBinding(ctx, wechatID, "wechat", "WeChat"); err == nil {
return errors.New(errWeChatAlreadyBound)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return bindShortcutExternalAccount(ctx, userID, wechatID, wechatID, "", "wechat", "WeChat")
}
// EmailBind binds a verified email address to the current user.
func EmailBind(ctx context.Context, userID uint64, email, code string) error {
email = strings.TrimSpace(email)
code = strings.TrimSpace(code)
if userID == 0 {
return errors.New(errUnauthorized)
}
if email == "" || code == "" {
return errors.New(errInvalidParams)
}
if !verifyEmailCode(ctx, email, "register", code) {
return errors.New(errEmailCodeInvalid)
}
var user model.User
if err := db.DB(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
return errors.New(errUserNotFound)
}
user.Email = email
return db.DB(ctx).Model(&user).Update("email", email).Error
}
func finishLegacyLogin(ctx context.Context, c *gin.Context, user *model.User) (LegacyUser, error) {
if user == nil {
return LegacyUser{}, errors.New(errUserNotFound)
}
user.LastLoginAt = time.Now()
if err := db.DB(ctx).Model(user).Update("last_login_at", user.LastLoginAt).Error; err != nil {
return LegacyUser{}, err
}
if err := setLoginSession(ctx, c, user); err != nil {
return LegacyUser{}, errors.New(errSaveSessionFailed)
}
token, err := issueLegacyAccessToken(ctx, user)
if err != nil {
return LegacyUser{}, err
}
logger.InfoF(ctx, "[LoginAudit] successful legacy shortcut login for user: %s, ID: %d, IP: %s", user.Username, user.ID, c.ClientIP())
listener.EmitAdminLoggedIn(ctx, user, c.ClientIP())
return ToLegacyUser(user, token), nil
}
func getGitHubUserInfoByCode(code string) (*githubUser, error) {
code = strings.TrimSpace(code)
if code == "" {
return nil, errors.New(errInvalidParams)
}
values := map[string]string{
"client_id": model.GitHubClientId,
"client_secret": model.GitHubClientSecret,
"code": code,
}
jsonData, err := json.Marshal(values)
if err != nil {
return nil, err
}
client := http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
res, err := client.Do(req)
if err != nil {
slog.Error("github oauth access token request failed", "error", err)
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
}
defer res.Body.Close()
var oauthResponse githubOAuthResponse
if err := json.NewDecoder(res.Body).Decode(&oauthResponse); err != nil {
return nil, err
}
if strings.TrimSpace(oauthResponse.AccessToken) == "" {
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
}
req, err = http.NewRequest(http.MethodGet, "https://api.github.com/user", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oauthResponse.AccessToken))
res2, err := client.Do(req)
if err != nil {
slog.Error("github user info request failed", "error", err)
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!")
}
defer res2.Body.Close()
var ghUser githubUser
if err := json.NewDecoder(res2.Body).Decode(&ghUser); err != nil {
return nil, err
}
if strings.TrimSpace(ghUser.Login) == "" {
return nil, errors.New("返回值非法,用户字段为空,请稍后重试!")
}
return &ghUser, nil
}
func getWeChatIDByCode(code string) (string, error) {
code = strings.TrimSpace(code)
if code == "" {
return "", errors.New(errInvalidParams)
}
serverAddress := strings.TrimRight(strings.TrimSpace(model.WeChatServerAddress), "/")
if serverAddress == "" {
return "", errors.New(errWeChatOAuthDisabled)
}
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/wechat/user?code=%s", serverAddress, code), nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", model.WeChatServerToken)
client := http.Client{Timeout: 5 * time.Second}
httpResponse, err := client.Do(req)
if err != nil {
return "", err
}
defer func(body io.ReadCloser) {
if closeErr := body.Close(); closeErr != nil {
slog.Error("failed to close wechat response body", "error", closeErr)
}
}(httpResponse.Body)
var res wechatLoginResponse
if err := json.NewDecoder(httpResponse.Body).Decode(&res); err != nil {
return "", err
}
if !res.Success {
if strings.TrimSpace(res.Message) == "" {
return "", errors.New(errInvalidParams)
}
return "", errors.New(res.Message)
}
if strings.TrimSpace(res.Data) == "" {
return "", errors.New(errEmailCodeInvalid)
}
return strings.TrimSpace(res.Data), nil
}
func findUserByShortcutBinding(ctx context.Context, externalID string, sourceNames ...string) (*model.User, error) {
externalID = strings.TrimSpace(externalID)
if externalID == "" {
return nil, gorm.ErrRecordNotFound
}
query := db.DB(ctx).
Table("w_external_accounts AS ea").
Select("u.*").
Joins("JOIN w_users u ON u.id = ea.user_id").
Where("ea.external_id = ?", externalID)
if len(sourceNames) > 0 {
lowered := make([]string, 0, len(sourceNames))
for _, name := range sourceNames {
trimmed := strings.ToLower(strings.TrimSpace(name))
if trimmed != "" {
lowered = append(lowered, trimmed)
}
}
if len(lowered) > 0 {
query = query.
Joins("JOIN w_auth_sources s ON s.id = ea.auth_source_id").
Where("LOWER(s.name) IN ?", lowered)
}
}
var user model.User
if err := query.First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func bindShortcutExternalAccount(ctx context.Context, userID uint64, externalID, externalUsername, email string, sourceNames ...string) error {
source, err := resolveShortcutAuthSource(ctx, sourceNames...)
if err != nil {
return err
}
return model.BindExternalAccount(ctx, &model.ExternalAccount{
AuthSourceID: source.ID,
UserID: userID,
ExternalID: strings.TrimSpace(externalID),
ExternalUsername: strings.TrimSpace(externalUsername),
Email: strings.TrimSpace(email),
})
}
func resolveShortcutAuthSource(ctx context.Context, sourceNames ...string) (*model.AuthSource, error) {
for _, name := range sourceNames {
source, err := model.GetAuthSourceByName(ctx, name)
if err == nil {
return source, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
}
return nil, errors.New("认证源不存在")
}
@@ -1,81 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package compat
import (
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
const openFlareTokenHeader = "OpenFlare-Token"
// Role constants mirror legacy OpenFlare role values.
const (
RoleCommonUser = 1
RoleAdminUser = 10
RoleRootUser = 100
)
// RequireRole ensures the caller is authenticated with at least minRole.
// Phase 1: supports Wavelet session/access-token via oauth.GetUserFromRequest.
func RequireRole(minRole int) gin.HandlerFunc {
return func(c *gin.Context) {
user, err := oauth.GetUserFromRequest(c)
if err != nil || user == nil {
Unauthorized(c, "无权进行此操作,未登录或 token 无效")
c.Abort()
return
}
role := resolveRole(c, user)
if role < minRole {
Fail(c, "无权进行此操作,权限不足")
c.Abort()
return
}
c.Set("of_user_id", user.ID)
c.Set("of_role", role)
c.Set("of_is_admin", user.IsAdmin)
c.Next()
}
}
// UserAuth requires role >= CommonUser.
func UserAuth() gin.HandlerFunc { return RequireRole(RoleCommonUser) }
// AdminAuth requires role >= AdminUser.
func AdminAuth() gin.HandlerFunc { return RequireRole(RoleAdminUser) }
// RootAuth requires role >= RootUser.
func RootAuth() gin.HandlerFunc { return RequireRole(RoleRootUser) }
func resolveRole(c *gin.Context, user *model.User) int {
if user == nil {
return 0
}
if tokenAdmin, ok := oauth.GetFromContext[bool](c, oauth.TokenAdminKey); ok && tokenAdmin {
return RoleRootUser
}
if user.IsAdmin {
return RoleRootUser
}
return RoleCommonUser
}
// OpenFlareTokenHeader returns the legacy auth header name.
func OpenFlareTokenHeader() string {
return openFlareTokenHeader
}
// BridgeOpenFlareToken maps OpenFlare-Token to X-Access-Token for legacy clients.
func BridgeOpenFlareToken() gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetHeader("X-Access-Token") == "" {
if token := c.GetHeader(openFlareTokenHeader); token != "" {
c.Request.Header.Set("X-Access-Token", token)
}
}
c.Next()
}
}
@@ -4,37 +4,54 @@
package config_version
import (
"errors"
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, "记录不存在")
return true
}
compat.Fail(c, err.Error())
return true
return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在")
}
// ListConfigVersionsHandler lists config versions.
// @Summary 获取配置版本列表
// @Description 返回所有已发布的 OpenResty 配置版本摘要,需要管理员权限
// @Tags openflare-config-version
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.ConfigVersionSummary} "配置版本列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/config-versions [get]
func ListConfigVersionsHandler(c *gin.Context) {
versions, err := ListConfigVersions(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, versions)
c.JSON(http.StatusOK, response.OK(versions))
}
// GetConfigVersionHandler returns a config version by id.
// @Summary 获取配置版本详情
// @Description 返回指定配置版本的完整快照与渲染内容,需要管理员权限
// @Tags openflare-config-version
// @Produce json
// @Security SessionCookie
// @Param id path int true "配置版本 ID"
// @Success 200 {object} response.Any{data=model.ConfigVersion} "配置版本详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或版本不存在"
// @Router /api/v1/custom/openflare/config-versions/{id} [get]
func GetConfigVersionHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -42,42 +59,77 @@ func GetConfigVersionHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, version)
c.JSON(http.StatusOK, response.OK(version))
}
// GetActiveConfigVersionHandler returns the active config version.
// @Summary 获取当前活跃配置版本
// @Description 返回当前正在使用的配置版本,需要管理员权限
// @Tags openflare-config-version
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=model.ConfigVersion} "活跃配置版本"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限、不存在或无活跃版本"
// @Router /api/v1/custom/openflare/config-versions/active [get]
func GetActiveConfigVersionHandler(c *gin.Context) {
version, err := GetActiveConfigVersion(c.Request.Context())
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, errNoActiveVersion)
return
}
handleLogicError(c, err)
if apiutil.AbortNotFoundIfMissing(c, err, errNoActiveVersion) {
return
}
compat.OK(c, version)
c.JSON(http.StatusOK, response.OK(version))
}
// PreviewConfigVersionHandler previews the current draft configuration.
// @Summary 预览当前草稿配置
// @Description 渲染并返回当前草稿配置的预览结果,需要管理员权限
// @Tags openflare-config-version
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=config_version.ConfigPreviewResult} "配置预览"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/config-versions/preview [get]
func PreviewConfigVersionHandler(c *gin.Context) {
preview, err := PreviewConfigVersion(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, preview)
c.JSON(http.StatusOK, response.OK(preview))
}
// DiffConfigVersionHandler diffs the current draft against the active version.
// @Summary 对比草稿与活跃配置
// @Description 对比当前草稿配置与活跃版本之间的差异,需要管理员权限
// @Tags openflare-config-version
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=config_version.ConfigDiffResult} "配置差异"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/config-versions/diff [get]
func DiffConfigVersionHandler(c *gin.Context) {
diff, err := DiffConfigVersion(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, diff)
c.JSON(http.StatusOK, response.OK(diff))
}
// PublishConfigVersionHandler publishes a new config version.
// @Summary 发布配置版本
// @Description 将当前草稿配置发布为新版本,需要管理员权限
// @Tags openflare-config-version
// @Produce json
// @Security SessionCookie
// @Param force query bool false "是否强制发布"
// @Success 200 {object} response.Any{data=model.ConfigVersion} "发布成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/config-versions/publish [post]
func PublishConfigVersionHandler(c *gin.Context) {
username := c.GetString("username")
force := c.Query("force") == "true"
@@ -85,12 +137,23 @@ func PublishConfigVersionHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, version)
c.JSON(http.StatusOK, response.OK(version))
}
// ActivateConfigVersionHandler activates an existing config version.
// @Summary 激活配置版本
// @Description 将指定历史版本设为当前活跃配置,需要管理员权限
// @Tags openflare-config-version
// @Produce json
// @Security SessionCookie
// @Param id path int true "配置版本 ID"
// @Success 200 {object} response.Any{data=model.ConfigVersion} "激活成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或版本不存在"
// @Router /api/v1/custom/openflare/config-versions/{id}/activate [post]
func ActivateConfigVersionHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -98,18 +161,30 @@ func ActivateConfigVersionHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, version)
c.JSON(http.StatusOK, response.OK(version))
}
// CleanupConfigVersionsHandler removes old inactive config versions.
// @Summary 清理历史配置版本
// @Description 删除超出保留数量的非活跃配置版本,需要管理员权限
// @Tags openflare-config-version
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param body body config_version.CleanupInput true "清理参数"
// @Success 200 {object} response.Any{data=config_version.CleanupResult} "清理结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/config-versions/cleanup [post]
func CleanupConfigVersionsHandler(c *gin.Context) {
var input CleanupInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
result, err := CleanupConfigVersions(c.Request.Context(), input.KeepCount)
if handleLogicError(c, err) {
return
}
compat.OK(c, result)
}
c.JSON(http.StatusOK, response.OK(result))
}
@@ -4,24 +4,29 @@
package dashboard
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
)
// RegisterRoutes mounts legacy OpenFlare dashboard routes.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
dashboardRoute := apiGroup.Group("/dashboard")
dashboardRoute.Use(compat.AdminAuth())
{
dashboardRoute.GET("/overview", getOverviewHandler)
}
}
func getOverviewHandler(c *gin.Context) {
// GetOverviewHandler 获取仪表盘概览数据。
// @Summary 获取仪表盘概览
// @Description 聚合节点与可观测性数据,返回 OpenFlare 控制台仪表盘概览,需要管理员权限
// @Tags openflare-dashboard
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=dashboard.OverviewPayload} "仪表盘概览"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/dashboard/overview [get]
func GetOverviewHandler(c *gin.Context) {
overview, err := GetOverview(c.Request.Context())
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, overview)
}
c.JSON(http.StatusOK, response.OK(overview))
}
@@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
)
// PostHeartbeat handles POST /flared/heartbeat.
func PostHeartbeat(c *gin.Context) {
var payload HeartbeatPayload
@@ -10,11 +10,11 @@ import (
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
oflegacy "github.com/Rain-kl/Wavelet/internal/apps/openflare/legacy"
ofnode "github.com/Rain-kl/Wavelet/internal/apps/openflare/node"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
@@ -58,10 +58,8 @@ func setupProtocolTestEnv(t *testing.T) (*gin.Engine, func()) {
option.ResetInitializationForTest()
agent.ResetAuthCacheForTest()
gin.SetMode(gin.TestMode)
engine := gin.New()
apiGroup := engine.Group("/api")
oflegacy.RegisterRoutes(apiGroup)
engine := testhelper.NewTestGinEngine()
mountOpenFlareTestRoutes(engine)
cleanup := func() {
db.SetDB(nil)
@@ -8,13 +8,14 @@ import (
"net/http"
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/admin"
"github.com/Rain-kl/Wavelet/internal/apps/cap"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
oflegacy "github.com/Rain-kl/Wavelet/internal/apps/openflare/legacy"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db/idgen"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/repository"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
@@ -28,11 +29,6 @@ type statusPayload struct {
SystemName string `json:"system_name"`
}
type legacyUserPayload struct {
Username string `json:"username"`
Token string `json:"token"`
}
func setupAuthOptionIntegration(t *testing.T) (*gorm.DB, *gin.Engine) {
t.Helper()
@@ -43,6 +39,12 @@ func setupAuthOptionIntegration(t *testing.T) (*gorm.DB, *gin.Engine) {
option.ResetInitializationForTest()
t.Cleanup(option.ResetInitializationForTest)
require.NoError(t, dbConn.Model(&model.SystemConfig{}).
Where("key = ?", model.ConfigKeyCapLoginEnabled).
Update("value", "false").Error)
require.NoError(t, repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyCapLoginEnabled))
cap.InvalidateRuntimeSettings()
oldCookieName := config.Config.App.SessionCookieName
oldSecret := config.Config.App.SessionSecret
oldDomain := config.Config.App.SessionDomain
@@ -65,9 +67,7 @@ func setupAuthOptionIntegration(t *testing.T) (*gorm.DB, *gin.Engine) {
store := cookie.NewStore([]byte(config.Config.App.SessionSecret))
store.Options(oauth.GetSessionOptions(3600))
r := testhelper.NewTestGinEngine(sessions.Sessions(config.Config.App.SessionCookieName, store))
api := r.Group("/api")
oflegacy.RegisterRoutes(api)
mountOpenFlareTestRoutes(r)
return dbConn, r
}
@@ -88,134 +88,88 @@ func seedUser(t *testing.T, dbConn *gorm.DB, username, password string, isAdmin
return user
}
func seedUserWithAccessToken(t *testing.T, dbConn *gorm.DB, username, password string, isAdmin bool) string {
t.Helper()
user := seedUser(t, dbConn, username, password, isAdmin)
token, err := model.GenerateTokenString()
require.NoError(t, err)
tokenRecord := model.AccessToken{
UserID: user.ID,
Name: username + "-integration-token",
TokenHash: model.HashToken(token),
MaskedToken: model.MaskTokenString(token),
IsAdmin: isAdmin,
}
require.NoError(t, dbConn.Create(&tokenRecord).Error)
return token
}
func TestGETStatusReturnsSuccessEnvelope(t *testing.T) {
_, r := setupAuthOptionIntegration(t)
w := performJSONRequest(t, r, http.MethodGet, "/api/status", nil, nil)
w := performJSONRequest(t, r, http.MethodGet, apiPath("/status"), nil, nil)
assert.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
assert.True(t, env.Success, "message=%s", env.Message)
resp := requireAPIOK(t, w)
var status statusPayload
unmarshalEnvelopeData(t, env.Data, &status)
unmarshalAPIData(t, resp.Data, &status)
assert.NotEmpty(t, status.SystemName)
}
func TestPOSTUserLoginWithSeededUser(t *testing.T) {
dbConn, r := setupAuthOptionIntegration(t)
seedUser(t, dbConn, "testuser", "password123", false)
w := performJSONRequest(t, r, http.MethodPost, "/api/user/login", map[string]string{
"username": "testuser",
"password": "password123",
}, nil)
assert.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
assert.True(t, env.Success, "message=%s", env.Message)
var user legacyUserPayload
unmarshalEnvelopeData(t, env.Data, &user)
assert.Equal(t, "testuser", user.Username)
assert.NotEmpty(t, user.Token)
}
func TestGETUserSelfWithToken(t *testing.T) {
dbConn, r := setupAuthOptionIntegration(t)
seedUser(t, dbConn, "selfuser", "password123", false)
loginResp := performJSONRequest(t, r, http.MethodPost, "/api/user/login", map[string]string{
"username": "selfuser",
"password": "password123",
}, nil)
loginEnv := decodeEnvelope(t, loginResp)
require.True(t, loginEnv.Success, "login failed: %s", loginEnv.Message)
var loginUser legacyUserPayload
unmarshalEnvelopeData(t, loginEnv.Data, &loginUser)
require.NotEmpty(t, loginUser.Token)
w := performJSONRequest(t, r, http.MethodGet, "/api/user/self", nil, map[string]string{
compat.OpenFlareTokenHeader(): loginUser.Token,
})
assert.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
assert.True(t, env.Success, "message=%s", env.Message)
var self legacyUserPayload
unmarshalEnvelopeData(t, env.Data, &self)
assert.Equal(t, "selfuser", self.Username)
}
func TestGETOptionRequiresRootAuth(t *testing.T) {
dbConn, r := setupAuthOptionIntegration(t)
seedUser(t, dbConn, "commonuser", "password123", false)
seedUser(t, dbConn, "rootuser", "password123", true)
commonToken := loginAndGetToken(t, r, "commonuser", "password123")
rootToken := loginAndGetToken(t, r, "rootuser", "password123")
commonToken := seedUserWithAccessToken(t, dbConn, "commonuser", "password123", false)
rootToken := seedUserWithAccessToken(t, dbConn, "rootuser", "password123", true)
t.Run("unauthenticated", func(t *testing.T) {
w := performJSONRequest(t, r, http.MethodGet, "/api/option/", nil, nil)
w := performJSONRequest(t, r, http.MethodGet, apiPath("/option/"), nil, nil)
assert.Equal(t, http.StatusUnauthorized, w.Code)
env := decodeEnvelope(t, w)
assert.False(t, env.Success)
resp := decodeAPIResponse(t, w)
assert.NotEmpty(t, resp.ErrorMsg)
})
t.Run("common user forbidden", func(t *testing.T) {
w := performJSONRequest(t, r, http.MethodGet, "/api/option/", nil, map[string]string{
compat.OpenFlareTokenHeader(): commonToken,
})
assert.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
assert.False(t, env.Success)
assert.Contains(t, env.Message, "权限不足")
w := performJSONRequest(t, r, http.MethodGet, apiPath("/option/"), nil, adminAuthHeaders(commonToken))
assert.Equal(t, http.StatusNotFound, w.Code)
resp := decodeAPIResponse(t, w)
assert.Equal(t, admin.TokenAdminRequired, resp.ErrorMsg)
})
t.Run("root user allowed", func(t *testing.T) {
w := performJSONRequest(t, r, http.MethodGet, "/api/option/", nil, map[string]string{
compat.OpenFlareTokenHeader(): rootToken,
})
w := performJSONRequest(t, r, http.MethodGet, apiPath("/option/"), nil, adminAuthHeaders(rootToken))
assert.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
assert.True(t, env.Success, "message=%s", env.Message)
requireAPIOK(t, w)
})
}
func TestGETNodesWithOpenFlareToken(t *testing.T) {
func TestGETNodesWithAccessToken(t *testing.T) {
dbConn, r := setupAuthOptionIntegration(t)
require.NoError(t, dbConn.AutoMigrate(&model.OpenFlareNode{}))
seedUser(t, dbConn, "admin", "password123", true)
rootToken := loginAndGetToken(t, r, "admin", "password123")
rootToken := seedUserWithAccessToken(t, dbConn, "admin", "password123", true)
w := performJSONRequest(t, r, http.MethodGet, "/api/nodes/", nil, map[string]string{
compat.OpenFlareTokenHeader(): rootToken,
})
w := performJSONRequest(t, r, http.MethodGet, apiPath("/nodes/"), nil, adminAuthHeaders(rootToken))
assert.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
assert.True(t, env.Success, "message=%s", env.Message)
requireAPIOK(t, w)
}
func TestOptionHotReloadAfterUpdate(t *testing.T) {
dbConn, r := setupAuthOptionIntegration(t)
seedUser(t, dbConn, "admin", "password123", true)
rootToken := loginAndGetToken(t, r, "admin", "password123")
rootToken := seedUserWithAccessToken(t, dbConn, "admin", "password123", true)
statusBefore := getStatusSystemName(t, r, nil)
assert.NotEmpty(t, statusBefore)
updateResp := performJSONRequest(t, r, http.MethodPost, "/api/option/update", map[string]string{
updateResp := performJSONRequest(t, r, http.MethodPost, apiPath("/option/update"), map[string]string{
"key": "SystemName",
"value": "HotReloadIntegration",
}, map[string]string{
compat.OpenFlareTokenHeader(): rootToken,
})
}, adminAuthHeaders(rootToken))
assert.Equal(t, http.StatusOK, updateResp.Code)
updateEnv := decodeEnvelope(t, updateResp)
assert.True(t, updateEnv.Success, "message=%s", updateEnv.Message)
requireAPIOK(t, updateResp)
statusAfter := getStatusSystemName(t, r, nil)
assert.Equal(t, "HotReloadIntegration", statusAfter)
@@ -226,31 +180,14 @@ func TestOptionHotReloadAfterUpdate(t *testing.T) {
assert.Equal(t, "HotReloadIntegration", model.OptionValue("SystemName"))
}
func loginAndGetToken(t *testing.T, r http.Handler, username, password string) string {
t.Helper()
w := performJSONRequest(t, r, http.MethodPost, "/api/user/login", map[string]string{
"username": username,
"password": password,
}, nil)
env := decodeEnvelope(t, w)
require.True(t, env.Success, "login failed: %s", env.Message)
var user legacyUserPayload
unmarshalEnvelopeData(t, env.Data, &user)
require.NotEmpty(t, user.Token)
return user.Token
}
func getStatusSystemName(t *testing.T, r http.Handler, headers map[string]string) string {
t.Helper()
w := performJSONRequest(t, r, http.MethodGet, "/api/status", nil, headers)
w := performJSONRequest(t, r, http.MethodGet, apiPath("/status"), nil, headers)
require.Equal(t, http.StatusOK, w.Code)
env := decodeEnvelope(t, w)
require.True(t, env.Success, "message=%s", env.Message)
resp := requireAPIOK(t, w)
var status statusPayload
unmarshalEnvelopeData(t, env.Data, &status)
unmarshalAPIData(t, resp.Data, &status)
return status.SystemName
}
}
@@ -9,10 +9,10 @@ import (
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
oflegacy "github.com/Rain-kl/Wavelet/internal/apps/openflare/legacy"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/Rain-kl/Wavelet/internal/testhelper"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
@@ -59,10 +59,8 @@ func setupCoreChainTest(t *testing.T) (*gin.Engine, adminSeed, func()) {
seed, err := seedAdminWithAccessToken(sqliteDB)
require.NoError(t, err)
gin.SetMode(gin.TestMode)
engine := gin.New()
apiGroup := engine.Group("/api")
oflegacy.RegisterRoutes(apiGroup)
engine := testhelper.NewTestGinEngine()
mountOpenFlareTestRoutes(engine)
cleanup := func() {
db.SetDB(nil)
@@ -125,7 +123,7 @@ func TestCoreChainMigrationFlow(t *testing.T) {
)
t.Run("create origin", func(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, "/api/origins/", map[string]any{
rec := performJSONRequest(t, engine, http.MethodPost, apiPath("/origins/"), map[string]any{
"name": "Primary Origin",
"address": "origin.core-chain.internal",
"remark": "integration upstream",
@@ -134,10 +132,8 @@ func TestCoreChainMigrationFlow(t *testing.T) {
})
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
originID = uint(data["id"].(float64))
assert.NotZero(t, originID)
assert.Equal(t, "Primary Origin", data["name"])
@@ -145,7 +141,7 @@ func TestCoreChainMigrationFlow(t *testing.T) {
})
t.Run("create proxy route linked to origin", func(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, "/api/proxy-routes/", map[string]any{
rec := performJSONRequest(t, engine, http.MethodPost, apiPath("/proxy-routes/"), map[string]any{
"site_name": "core-chain-site",
"domain": "core-chain.example.com",
"origin_id": originID,
@@ -157,10 +153,8 @@ func TestCoreChainMigrationFlow(t *testing.T) {
})
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
proxyRouteID = uint(data["id"].(float64))
assert.NotZero(t, proxyRouteID)
assert.Equal(t, "core-chain-site", data["site_name"])
@@ -170,35 +164,32 @@ func TestCoreChainMigrationFlow(t *testing.T) {
})
t.Run("publish config version", func(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, "/api/config-versions/publish", nil, map[string]string{
rec := performJSONRequest(t, engine, http.MethodPost, apiPath("/config-versions/publish"), nil, map[string]string{
"X-Access-Token": seed.Token,
})
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
configVersion, _ = data["version"].(string)
configChecksum, _ = data["checksum"].(string)
assert.NotEmpty(t, configVersion)
assert.NotEmpty(t, configChecksum)
assert.Equal(t, true, data["is_active"])
activeRec := performJSONRequest(t, engine, http.MethodGet, "/api/config-versions/active", nil, map[string]string{
activeRec := performJSONRequest(t, engine, http.MethodGet, apiPath("/config-versions/active"), nil, map[string]string{
"X-Access-Token": seed.Token,
})
require.Equal(t, http.StatusOK, activeRec.Code)
activeEnvelope := decodeEnvelope(t, activeRec)
require.True(t, activeEnvelope.Success, activeEnvelope.Message)
activeResp := requireAPIOK(t, activeRec)
activeData := unmarshalEnvelopeMap(t, activeEnvelope.Data)
activeData := unmarshalAPIMap(t, activeResp.Data)
assert.Equal(t, configVersion, activeData["version"])
assert.Equal(t, configChecksum, activeData["checksum"])
})
t.Run("create node", func(t *testing.T) {
rec := performJSONRequest(t, engine, http.MethodPost, "/api/nodes/", map[string]any{
rec := performJSONRequest(t, engine, http.MethodPost, apiPath("/nodes/"), map[string]any{
"name": "edge-core-chain",
"ip": "10.10.0.1",
"auto_update_enabled": true,
@@ -207,10 +198,8 @@ func TestCoreChainMigrationFlow(t *testing.T) {
})
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
nodeID = uint(data["id"].(float64))
nodePublicID, _ = data["node_id"].(string)
agentToken, _ = data["access_token"].(string)
@@ -248,7 +237,7 @@ func TestCoreChainMigrationFlow(t *testing.T) {
t,
engine,
http.MethodGet,
"/api/apply-logs/?node_id="+nodePublicID+"&pageNo=1&pageSize=10",
apiPath("/apply-logs/?node_id="+nodePublicID+"&pageNo=1&pageSize=10"),
nil,
map[string]string{
"X-Access-Token": seed.Token,
@@ -256,10 +245,8 @@ func TestCoreChainMigrationFlow(t *testing.T) {
)
require.Equal(t, http.StatusOK, listRec.Code)
listEnvelope := decodeEnvelope(t, listRec)
require.True(t, listEnvelope.Success, listEnvelope.Message)
listData := unmarshalEnvelopeMap(t, listEnvelope.Data)
listResp := requireAPIOK(t, listRec)
listData := unmarshalAPIMap(t, listResp.Data)
assert.Equal(t, float64(1), listData["total"])
rows, ok := listData["rows"].([]any)
@@ -271,14 +258,13 @@ func TestCoreChainMigrationFlow(t *testing.T) {
assert.Equal(t, configVersion, row["version"])
assert.Equal(t, "success", row["result"])
nodeRec := performJSONRequest(t, engine, http.MethodGet, "/api/nodes/", nil, map[string]string{
nodeRec := performJSONRequest(t, engine, http.MethodGet, apiPath("/nodes/"), nil, map[string]string{
"X-Access-Token": seed.Token,
})
require.Equal(t, http.StatusOK, nodeRec.Code)
nodeEnvelope := decodeEnvelope(t, nodeRec)
require.True(t, nodeEnvelope.Success, nodeEnvelope.Message)
nodeResp := requireAPIOK(t, nodeRec)
nodes := unmarshalEnvelopeSlice(t, nodeEnvelope.Data)
nodes := unmarshalAPISlice(t, nodeResp.Data)
require.Len(t, nodes, 1)
nodeView, ok := nodes[0].(map[string]any)
require.True(t, ok)
@@ -288,4 +274,4 @@ func TestCoreChainMigrationFlow(t *testing.T) {
assert.Equal(t, configChecksum, nodeView["latest_apply_checksum"])
assert.Equal(t, float64(2), nodeView["latest_support_file_count"])
})
}
}
@@ -11,9 +11,29 @@ import (
"testing"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/common/response"
v1 "github.com/Rain-kl/Wavelet/internal/router/v1"
ofrouter "github.com/Rain-kl/Wavelet/internal/router/v1/openflare"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func decodeAPIResponse(t *testing.T, rec *httptest.ResponseRecorder) response.Any {
t.Helper()
var resp response.Any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
return resp
}
func requireAPIOK(t *testing.T, rec *httptest.ResponseRecorder) response.Any {
t.Helper()
resp := decodeAPIResponse(t, rec)
require.Empty(t, resp.ErrorMsg, "unexpected API error: %s", resp.ErrorMsg)
return resp
}
func decodeEnvelope(t *testing.T, rec *httptest.ResponseRecorder) compat.Envelope {
t.Helper()
@@ -22,7 +42,7 @@ func decodeEnvelope(t *testing.T, rec *httptest.ResponseRecorder) compat.Envelop
return envelope
}
func unmarshalEnvelopeData(t *testing.T, data any, target any) {
func unmarshalAPIData(t *testing.T, data any, target any) {
t.Helper()
payload, err := json.Marshal(data)
@@ -30,20 +50,47 @@ func unmarshalEnvelopeData(t *testing.T, data any, target any) {
require.NoError(t, json.Unmarshal(payload, target))
}
func unmarshalEnvelopeMap(t *testing.T, data any) map[string]any {
func unmarshalEnvelopeData(t *testing.T, data any, target any) {
t.Helper()
unmarshalAPIData(t, data, target)
}
func unmarshalAPIMap(t *testing.T, data any) map[string]any {
t.Helper()
var result map[string]any
unmarshalEnvelopeData(t, data, &result)
unmarshalAPIData(t, data, &result)
return result
}
func unmarshalEnvelopeMap(t *testing.T, data any) map[string]any {
t.Helper()
return unmarshalAPIMap(t, data)
}
func unmarshalAPISlice(t *testing.T, data any) []any {
t.Helper()
var result []any
unmarshalAPIData(t, data, &result)
return result
}
func unmarshalEnvelopeSlice(t *testing.T, data any) []any {
t.Helper()
return unmarshalAPISlice(t, data)
}
var result []any
unmarshalEnvelopeData(t, data, &result)
return result
func mountOpenFlareTestRoutes(engine *gin.Engine) {
api := engine.Group("/api")
ofrouter.RegisterRoutes(api)
apiV1 := api.Group("/v1")
v1.RegisterV1Routes(apiV1, api)
}
func apiPath(subpath string) string {
return ofrouter.V1BasePath + subpath
}
func performJSONRequest(
@@ -15,7 +15,6 @@ import (
"testing"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/legacy"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db"
@@ -59,8 +58,7 @@ func setupSecurityTest(t *testing.T) (*gin.Engine, adminSeed, func()) {
config.Config.App.SessionSecret = "test_session_secret_for_security_integration"
engine := testhelper.NewTestGinEngine()
apiGroup := engine.Group("/api")
legacy.RegisterRoutes(apiGroup)
mountOpenFlareTestRoutes(engine)
cleanup := func() {
config.Config.App.SessionSecret = oldSecret
@@ -110,7 +108,7 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
)
t.Run("WAF rule group create", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/waf/rule-groups", map[string]any{
rec := performLegacyRequest(t, engine, http.MethodPost, apiPath("/waf/rule-groups"), map[string]any{
"name": "edge-security",
"enabled": true,
"block_status_code": 403,
@@ -121,10 +119,8 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
ruleGroupID = uint(data["id"].(float64))
assert.NotZero(t, ruleGroupID)
assert.Equal(t, "edge-security", data["name"])
@@ -133,13 +129,11 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
})
t.Run("WAF rule group list includes global and custom groups", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodGet, "/api/waf/rule-groups", nil, adminAuthHeaders(seed.Token))
rec := performLegacyRequest(t, engine, http.MethodGet, apiPath("/waf/rule-groups"), nil, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
groups := unmarshalEnvelopeSlice(t, envelope.Data)
resp := requireAPIOK(t, rec)
groups := unmarshalAPISlice(t, resp.Data)
require.GreaterOrEqual(t, len(groups), 2)
foundCustom := false
@@ -164,16 +158,14 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
t,
engine,
http.MethodGet,
fmt.Sprintf("/api/waf/rule-groups/%d", ruleGroupID),
fmt.Sprintf("%s/waf/rule-groups/%d", apiPath(""), ruleGroupID),
nil,
adminAuthHeaders(seed.Token),
)
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
assert.Equal(t, float64(ruleGroupID), data["id"])
assert.Equal(t, "edge-security", data["name"])
})
@@ -183,7 +175,7 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
t,
engine,
http.MethodPost,
fmt.Sprintf("/api/waf/rule-groups/%d/update", ruleGroupID),
fmt.Sprintf("%s/waf/rule-groups/%d/update", apiPath(""), ruleGroupID),
map[string]any{
"name": "edge-security-updated",
"enabled": true,
@@ -194,16 +186,14 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
)
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
assert.Equal(t, "edge-security-updated", data["name"])
assert.Equal(t, float64(451), data["block_status_code"])
})
t.Run("WAF IP group create", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/waf/ip-groups", map[string]any{
rec := performLegacyRequest(t, engine, http.MethodPost, apiPath("/waf/ip-groups"), map[string]any{
"name": "blocked-ips",
"type": "manual",
"enabled": true,
@@ -212,10 +202,8 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
ipGroupID = uint(data["id"].(float64))
assert.NotZero(t, ipGroupID)
assert.Equal(t, "blocked-ips", data["name"])
@@ -223,7 +211,7 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
})
t.Run("create proxy route for WAF binding", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/proxy-routes/", map[string]any{
rec := performLegacyRequest(t, engine, http.MethodPost, apiPath("/proxy-routes/"), map[string]any{
"site_name": "security-site",
"domain": "security.example.com",
"origin_url": "http://origin.security.internal:8080",
@@ -231,10 +219,8 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
proxyRouteID = uint(data["id"].(float64))
assert.NotZero(t, proxyRouteID)
assert.Equal(t, "security.example.com", data["domain"])
@@ -245,7 +231,7 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
t,
engine,
http.MethodPost,
fmt.Sprintf("/api/waf/sites/%d/rule-groups", proxyRouteID),
fmt.Sprintf("%s/waf/sites/%d/rule-groups", apiPath(""), proxyRouteID),
map[string]any{
"ids": []uint{ruleGroupID},
},
@@ -253,10 +239,8 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
)
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
assert.Equal(t, float64(proxyRouteID), data["route_id"])
appliedIDs, ok := data["applied_ids"].([]any)
@@ -270,16 +254,14 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
t,
engine,
http.MethodGet,
fmt.Sprintf("/api/waf/sites/%d/rule-groups", proxyRouteID),
fmt.Sprintf("%s/waf/sites/%d/rule-groups", apiPath(""), proxyRouteID),
nil,
adminAuthHeaders(seed.Token),
)
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
assert.NotNil(t, data["global_rule_group"])
appliedGroups, ok := data["applied_rule_groups"].([]any)
@@ -293,7 +275,7 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
t.Run("create TLS certificate with PEM", func(t *testing.T) {
certPEM, keyPEM := generateSelfSignedCertificatePair(t, []string{"security.example.com"})
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/tls-certificates/", map[string]any{
rec := performLegacyRequest(t, engine, http.MethodPost, apiPath("/tls-certificates/"), map[string]any{
"name": "security-cert",
"cert_pem": certPEM,
"key_pem": keyPEM,
@@ -301,10 +283,8 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
certID = uint(data["id"].(float64))
assert.NotZero(t, certID)
assert.Equal(t, "security-cert", data["name"])
@@ -312,7 +292,7 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
})
t.Run("create managed domain", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/managed-domains/", map[string]any{
rec := performLegacyRequest(t, engine, http.MethodPost, apiPath("/managed-domains/"), map[string]any{
"domain": "security.example.com",
"cert_id": certID,
"enabled": true,
@@ -320,10 +300,8 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
domainID = uint(data["id"].(float64))
assert.NotZero(t, domainID)
assert.Equal(t, "security.example.com", data["domain"])
@@ -332,17 +310,15 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
})
t.Run("create DNS account", func(t *testing.T) {
rec := performLegacyRequest(t, engine, http.MethodPost, "/api/dns-accounts/", map[string]any{
rec := performLegacyRequest(t, engine, http.MethodPost, apiPath("/dns-accounts/"), map[string]any{
"name": "cloudflare-dns",
"type": "cloudflare",
"authorization": "test-api-token-value",
}, adminAuthHeaders(seed.Token))
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
data := unmarshalEnvelopeMap(t, envelope.Data)
resp := requireAPIOK(t, rec)
data := unmarshalAPIMap(t, resp.Data)
dnsAccountID = uint(data["id"].(float64))
assert.NotZero(t, dnsAccountID)
assert.Equal(t, "cloudflare-dns", data["name"])
@@ -358,29 +334,27 @@ func TestSecurityWAFTLSMigrationFlow(t *testing.T) {
t,
engine,
http.MethodPost,
fmt.Sprintf("/api/waf/rule-groups/%d/delete", ruleGroupID),
fmt.Sprintf("%s/waf/rule-groups/%d/delete", apiPath(""), ruleGroupID),
nil,
adminAuthHeaders(seed.Token),
)
require.Equal(t, http.StatusOK, rec.Code)
envelope := decodeEnvelope(t, rec)
require.True(t, envelope.Success, envelope.Message)
requireAPIOK(t, rec)
detailRec := performLegacyRequest(
t,
engine,
http.MethodGet,
fmt.Sprintf("/api/waf/rule-groups/%d", ruleGroupID),
fmt.Sprintf("%s/waf/rule-groups/%d", apiPath(""), ruleGroupID),
nil,
adminAuthHeaders(seed.Token),
)
require.Equal(t, http.StatusOK, detailRec.Code)
detailEnvelope := decodeEnvelope(t, detailRec)
assert.False(t, detailEnvelope.Success)
require.Equal(t, http.StatusNotFound, detailRec.Code)
detailResp := decodeAPIResponse(t, detailRec)
assert.NotEmpty(t, detailResp.ErrorMsg)
})
_ = ipGroupID
_ = domainID
_ = dnsAccountID
}
}
@@ -1,251 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"fmt"
"strconv"
"strings"
ofauth "github.com/Rain-kl/Wavelet/internal/apps/openflare/auth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
type legacyUserPayload struct {
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Role int `json:"role"`
Email string `json:"email"`
}
type manageUserRequest struct {
Username string `json:"username"`
Action string `json:"action"`
}
type authSourcePayload struct {
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
OpenIDDiscoveryURL string `json:"openid_discovery_url"`
Scopes string `json:"scopes"`
IconURL string `json:"icon_url"`
}
type authSourceTogglePayload struct {
IsActive bool `json:"is_active"`
}
func GetAllUsers(c *gin.Context) {
page, _ := strconv.Atoi(c.Query("p"))
users, err := ofauth.ListUsers(c.Request.Context(), page)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, users)
}
func SearchUsers(c *gin.Context) {
users, err := ofauth.SearchUsers(c.Request.Context(), c.Query("keyword"))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, users)
}
func GetUser(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
user, err := ofauth.GetUserByID(c.Request.Context(), callerRole(c), uint64(id))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, user)
}
func CreateUser(c *gin.Context) {
var req legacyUserPayload
if !compat.BindJSON(c, &req) {
return
}
if err := ofauth.CreateUser(c.Request.Context(), callerRole(c), ofauth.CreateUserInput{
Username: req.Username,
Password: req.Password,
DisplayName: req.DisplayName,
Role: req.Role,
Email: req.Email,
}); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func UpdateUser(c *gin.Context) {
var req legacyUserPayload
if !compat.BindJSON(c, &req) {
return
}
if err := ofauth.UpdateUser(c.Request.Context(), callerRole(c), ofauth.UpdateUserInput{
ID: req.ID,
Username: req.Username,
Password: req.Password,
DisplayName: req.DisplayName,
Role: req.Role,
Email: req.Email,
}); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func DeleteUser(c *gin.Context) {
id, ok := compat.IDParam(c)
if !ok {
return
}
if err := ofauth.DeleteUserByID(c.Request.Context(), callerRole(c), uint64(id)); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func ManageUser(c *gin.Context) {
var req manageUserRequest
if !compat.BindJSON(c, &req) {
return
}
user, err := ofauth.ManageUser(c.Request.Context(), callerRole(c), ofauth.ManageUserInput{
Username: req.Username,
Action: req.Action,
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, user)
}
func ListAuthSources(c *gin.Context) {
sources, err := model.GetAuthSources(c.Request.Context())
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, sources)
}
func CreateAuthSource(c *gin.Context) {
var payload authSourcePayload
if !compat.BindJSON(c, &payload) {
return
}
source := payload.toModel()
if err := model.CreateAuthSource(c.Request.Context(), &source); err != nil {
compat.Fail(c, err.Error())
return
}
source.Sanitize()
compat.OK(c, source)
}
func UpdateAuthSource(c *gin.Context) {
id, err := parseAuthSourceID(c)
if err != nil {
compat.Fail(c, err.Error())
return
}
var payload authSourcePayload
if !compat.BindJSON(c, &payload) {
return
}
source := payload.toModel()
source.ID = id
keepSecret := strings.TrimSpace(source.ClientSecret) == ""
if err := model.UpdateAuthSource(c.Request.Context(), &source, keepSecret); err != nil {
compat.Fail(c, err.Error())
return
}
updated, err := model.GetAuthSourceByID(c.Request.Context(), id)
if err != nil {
compat.Fail(c, err.Error())
return
}
updated.Sanitize()
compat.OK(c, updated)
}
func DeleteAuthSource(c *gin.Context) {
id, err := parseAuthSourceID(c)
if err != nil {
compat.Fail(c, err.Error())
return
}
if err := model.DeleteAuthSource(c.Request.Context(), id); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func ToggleAuthSource(c *gin.Context) {
id, err := parseAuthSourceID(c)
if err != nil {
compat.Fail(c, err.Error())
return
}
var payload authSourceTogglePayload
if !compat.BindJSON(c, &payload) {
return
}
if err := model.ToggleAuthSource(c.Request.Context(), id, payload.IsActive); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func (payload authSourcePayload) toModel() model.AuthSource {
return model.AuthSource{
Name: payload.Name,
Type: payload.Type,
DisplayName: payload.DisplayName,
IsActive: payload.IsActive,
ClientID: payload.ClientID,
ClientSecret: payload.ClientSecret,
OpenIDDiscoveryURL: payload.OpenIDDiscoveryURL,
Scopes: payload.Scopes,
IconURL: payload.IconURL,
}
}
func parseAuthSourceID(c *gin.Context) (uint64, error) {
raw := strings.TrimSpace(c.Param("id"))
if raw == "" {
return 0, fmt.Errorf("认证源 ID 无效")
}
source, err := model.GetAuthSourceByName(c.Request.Context(), raw)
if err == nil {
return source.ID, nil
}
parsed, err := strconv.ParseUint(raw, 10, 64)
if err != nil || parsed == 0 {
return 0, fmt.Errorf("认证源 ID 无效")
}
return parsed, nil
}
@@ -1,87 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"strconv"
"strings"
ofauth "github.com/Rain-kl/Wavelet/internal/apps/openflare/auth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
type linkExistingRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// OAuthAuthorize starts OAuth authorization for a legacy auth source.
func OAuthAuthorize(c *gin.Context) {
url, err := ofauth.OAuthAuthorize(c.Request.Context(), c, c.Param("source"))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, gin.H{"authorize_url": url})
}
// OAuthCallback handles the legacy GET OAuth callback.
func OAuthCallback(c *gin.Context) {
result, err := ofauth.OAuthCallback(c.Request.Context(), c, c.Param("source"))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
// LinkExistingOAuthAccount binds a pending OAuth account to an existing user.
func LinkExistingOAuthAccount(c *gin.Context) {
var req linkExistingRequest
if !compat.BindJSON(c, &req) {
return
}
result, err := ofauth.LinkExistingOAuthAccount(c.Request.Context(), c, ofauth.LinkExistingInput{
Username: req.Username,
Password: req.Password,
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, result)
}
// ListExternalAccounts returns external account bindings for the current user.
func ListExternalAccounts(c *gin.Context) {
userID := callerUserID(c)
accounts, err := model.ListExternalAccountsByUserID(c.Request.Context(), userID)
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, accounts)
}
// DeleteExternalAccount removes an external account binding.
func DeleteExternalAccount(c *gin.Context) {
userID := callerUserID(c)
if userID == 0 {
compat.Unauthorized(c, "无权进行此操作,未登录或 token 无效")
return
}
rawID := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseUint(rawID, 10, 64)
if err != nil || id == 0 {
compat.Fail(c, "绑定记录 ID 无效")
return
}
if err := model.DeleteExternalAccountForUser(c.Request.Context(), id, userID); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
@@ -1,52 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
ofauth "github.com/Rain-kl/Wavelet/internal/apps/openflare/auth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
// GitHubOAuth handles GET /oauth/github for legacy GitHub login or bind.
func GitHubOAuth(c *gin.Context) {
user, err := ofauth.GitHubOAuth(c.Request.Context(), c, c.Query("code"))
if err != nil {
compat.Fail(c, err.Error())
return
}
if user.ID == 0 {
compat.OKMessage(c, "bind")
return
}
compat.OK(c, user)
}
// WeChatOAuth handles GET /oauth/wechat for legacy WeChat login.
func WeChatOAuth(c *gin.Context) {
user, err := ofauth.WeChatOAuth(c.Request.Context(), c, c.Query("code"))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, user)
}
// WeChatBind handles GET /oauth/wechat/bind for legacy WeChat account binding.
func WeChatBind(c *gin.Context) {
if err := ofauth.WeChatBind(c.Request.Context(), callerUserID(c), c.Query("code")); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
// EmailBind handles GET /oauth/email/bind for legacy email binding.
func EmailBind(c *gin.Context) {
if err := ofauth.EmailBind(c.Request.Context(), callerUserID(c), c.Query("email"), c.Query("code")); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
@@ -1,150 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
ofauth "github.com/Rain-kl/Wavelet/internal/apps/openflare/auth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Code string `json:"code"`
}
type registerRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Nickname string `json:"nickname"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Code string `json:"code"`
}
type updateSelfRequest struct {
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
}
type passwordResetRequest struct {
Email string `json:"email"`
Token string `json:"token"`
}
func Login(c *gin.Context) {
var req loginRequest
if !compat.BindJSON(c, &req) {
return
}
user, err := ofauth.Login(c.Request.Context(), c, ofauth.LoginInput{
Username: req.Username,
Password: req.Password,
Code: req.Code,
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, user)
}
func Logout(c *gin.Context) {
if err := ofauth.Logout(c.Request.Context(), c); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func GetSelf(c *gin.Context) {
user, err := ofauth.GetSelf(c.Request.Context(), callerUserID(c))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, user)
}
func UpdateSelf(c *gin.Context) {
var req updateSelfRequest
if !compat.BindJSON(c, &req) {
return
}
if err := ofauth.UpdateSelf(c.Request.Context(), callerUserID(c), ofauth.UpdateSelfInput{
Username: req.Username,
Password: req.Password,
DisplayName: req.DisplayName,
Email: req.Email,
}); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func DeleteSelf(c *gin.Context) {
if err := ofauth.DeleteSelf(c.Request.Context(), callerUserID(c)); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func GenerateToken(c *gin.Context) {
token, err := ofauth.GenerateUserToken(c.Request.Context(), callerUserID(c))
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, token)
}
func Register(c *gin.Context) {
var req registerRequest
if !compat.BindJSON(c, &req) {
return
}
user, err := ofauth.Register(c.Request.Context(), c, ofauth.RegisterInput{
Username: req.Username,
Password: req.Password,
Nickname: req.Nickname,
DisplayName: req.DisplayName,
Email: req.Email,
Code: req.Code,
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, user)
}
func SendEmailVerification(c *gin.Context) {
email := c.Query("email")
if err := ofauth.SendRegisterVerificationEmail(c.Request.Context(), email); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
func ResetPassword(c *gin.Context) {
var req passwordResetRequest
if !compat.BindJSON(c, &req) {
return
}
password, err := ofauth.ResetPassword(c.Request.Context(), ofauth.PasswordResetInput{
Email: req.Email,
Token: req.Token,
})
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, password)
}
@@ -1,73 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/cap"
"github.com/gin-gonic/gin"
)
type capRedeemRequest struct {
Token string `json:"token" binding:"required"`
Solutions []int `json:"solutions" binding:"required"`
}
// GetCapChallenge generates a CAP challenge for the legacy frontend.
func GetCapChallenge(c *gin.Context) {
scope := c.Param("scope")
if scope == "" {
scope = c.Query("scope")
}
if scope == "" {
scope = "login"
}
mgr := cap.GetDefaultManager()
resp, err := mgr.Generate(c.Request.Context(), scope)
if err != nil {
c.JSON(http.StatusInternalServerError, cap.RedeemResponse{
Success: false,
Error: err.Error(),
})
return
}
c.JSON(http.StatusOK, resp)
}
// RedeemCapChallenge redeems a CAP challenge for the legacy frontend.
func RedeemCapChallenge(c *gin.Context) {
scope := c.Param("scope")
if scope == "" {
scope = c.Query("scope")
}
if scope == "" {
scope = "login"
}
var req capRedeemRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, cap.RedeemResponse{
Success: false,
Error: "无效的参数",
})
return
}
mgr := cap.GetDefaultManager()
resp, err := mgr.Redeem(c.Request.Context(), req.Token, req.Solutions, scope)
if err != nil {
c.JSON(http.StatusInternalServerError, cap.RedeemResponse{
Success: false,
Error: err.Error(),
})
return
}
if !resp.Success {
c.JSON(http.StatusBadRequest, resp)
return
}
c.JSON(http.StatusOK, resp)
}
@@ -1,52 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/cap"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
// legacyCapAuth verifies PoW CAPTCHA for legacy login using OpenFlare response format.
func legacyCapAuth(scope string) gin.HandlerFunc {
mgr := cap.GetDefaultManager()
return func(c *gin.Context) {
if !cap.ProtectionEnabled(c.Request.Context()) {
c.Next()
return
}
token := c.GetHeader("X-Cap-Token")
if token == "" {
compat.Fail(c, "缺少人机验证凭证")
c.Abort()
return
}
valid, err := mgr.VerifyToken(c.Request.Context(), token, scope)
if err != nil || !valid {
compat.Fail(c, "人机验证凭证无效或已过期")
c.Abort()
return
}
c.Next()
}
}
func callerRole(c *gin.Context) int {
if role, ok := c.Get("of_role"); ok {
if v, ok := role.(int); ok {
return v
}
}
return 0
}
func callerUserID(c *gin.Context) uint64 {
if id, ok := c.Get("of_user_id"); ok {
if v, ok := id.(uint64); ok {
return v
}
}
return 0
}
@@ -1,20 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
ofauth "github.com/Rain-kl/Wavelet/internal/apps/openflare/auth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
// SendPasswordResetEmail sends a password reset email for the legacy frontend.
func SendPasswordResetEmail(c *gin.Context) {
email := c.Query("email")
if err := ofauth.SendPasswordResetEmail(c.Request.Context(), email); err != nil {
compat.Fail(c, err.Error())
return
}
compat.OKMessage(c, "")
}
@@ -1,29 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package legacy registers OpenFlare /api/* compatibility routes for the old frontend.
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
// RegisterRoutes mounts all OpenFlare legacy API routes under the /api group.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
apiGroup.Use(compat.BridgeOpenFlareToken())
registerAuthRoutes(apiGroup)
registerOptionRoutes(apiGroup)
registerOriginRoutes(apiGroup)
registerApplyLogRoutes(apiGroup)
registerProxyRouteRoutes(apiGroup)
registerNodeRoutes(apiGroup)
registerWAFRoutes(apiGroup)
registerTLSRoutes(apiGroup)
registerConfigVersionRoutes(apiGroup)
registerAgentRoutes(apiGroup)
registerPagesRoutes(apiGroup)
registerRelayFlaredRoutes(apiGroup)
registerDashboardObsRoutes(apiGroup)
registerMiscRoutes(apiGroup)
}
@@ -1,13 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
"github.com/gin-gonic/gin"
)
func registerAgentRoutes(apiGroup *gin.RouterGroup) {
agent.RegisterRoutes(apiGroup)
}
@@ -1,78 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
func registerAuthRoutes(apiGroup *gin.RouterGroup) {
// /status, /notice, /about are registered by T-OPTION (option.RegisterRoutes).
apiGroup.GET("/verification", SendEmailVerification)
apiGroup.GET("/reset_password", SendPasswordResetEmail)
apiGroup.POST("/user/reset", ResetPassword)
oauthGroup := apiGroup.Group("/oauth")
{
oauthGroup.GET("/github", GitHubOAuth)
oauthGroup.GET("/wechat", WeChatOAuth)
oauthGroup.GET("/wechat/bind", compat.BridgeOpenFlareToken(), compat.UserAuth(), WeChatBind)
oauthGroup.GET("/email/bind", compat.BridgeOpenFlareToken(), compat.UserAuth(), EmailBind)
oauthGroup.GET("/:source/authorize", OAuthAuthorize)
oauthGroup.GET("/:source/callback", OAuthCallback)
oauthGroup.POST("/link-existing", LinkExistingOAuthAccount)
externalAccounts := oauthGroup.Group("/external-accounts")
externalAccounts.Use(compat.UserAuth())
{
compat.RegisterCollection(externalAccounts, "GET", ListExternalAccounts)
externalAccounts.POST("/:id/delete", DeleteExternalAccount)
}
}
capGroup := apiGroup.Group("/cap")
{
capGroup.POST("/:scope/challenge", GetCapChallenge)
capGroup.POST("/:scope/redeem", RedeemCapChallenge)
}
userGroup := apiGroup.Group("/user")
{
userGroup.POST("/register", Register)
userGroup.POST("/login", legacyCapAuth("login"), Login)
userGroup.GET("/logout", Logout)
selfGroup := userGroup.Group("/")
selfGroup.Use(compat.UserAuth())
{
selfGroup.GET("/self", GetSelf)
selfGroup.POST("/self/update", UpdateSelf)
selfGroup.POST("/self/delete", DeleteSelf)
selfGroup.GET("/token", GenerateToken)
}
adminGroup := userGroup.Group("/")
adminGroup.Use(compat.AdminAuth())
{
compat.RegisterCollection(adminGroup, "GET", GetAllUsers)
adminGroup.GET("/search", SearchUsers)
adminGroup.GET("/:id", GetUser)
compat.RegisterCollection(adminGroup, "POST", CreateUser)
adminGroup.POST("/manage", ManageUser)
adminGroup.POST("/update", UpdateUser)
adminGroup.POST("/:id/delete", DeleteUser)
}
}
authSourceGroup := apiGroup.Group("/auth-sources")
authSourceGroup.Use(compat.RootAuth())
{
compat.RegisterCollection(authSourceGroup, "GET", ListAuthSources)
compat.RegisterCollection(authSourceGroup, "POST", CreateAuthSource)
authSourceGroup.POST("/:id/update", UpdateAuthSource)
authSourceGroup.POST("/:id/delete", DeleteAuthSource)
authSourceGroup.POST("/:id/toggle", ToggleAuthSource)
}
}
@@ -1,15 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/dashboard"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/observability"
"github.com/gin-gonic/gin"
)
func registerDashboardObsRoutes(apiGroup *gin.RouterGroup) {
dashboard.RegisterRoutes(apiGroup)
observability.RegisterRoutes(apiGroup)
}
@@ -1,13 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/gin-gonic/gin"
)
func registerOptionRoutes(apiGroup *gin.RouterGroup) {
option.RegisterRoutes(apiGroup)
}
@@ -1,30 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
import (
ofauth "github.com/Rain-kl/Wavelet/internal/apps/openflare/auth"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
// GetStatus returns public server status for the legacy frontend.
func GetStatus(c *gin.Context) {
data, err := ofauth.BuildPublicStatus(c.Request.Context())
if err != nil {
compat.Fail(c, err.Error())
return
}
compat.OK(c, data)
}
// GetNotice returns the legacy notice content.
func GetNotice(c *gin.Context) {
compat.OK(c, ofauth.GetNotice(c.Request.Context()))
}
// GetAbout returns the legacy about content.
func GetAbout(c *gin.Context) {
compat.OK(c, ofauth.GetAbout(c.Request.Context()))
}
+165 -33
View File
@@ -7,96 +7,169 @@ import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, errNodeNotFound)
return true
}
compat.Fail(c, err.Error())
return true
return apiutil.AbortNotFoundIfMissing(c, err, errNodeNotFound)
}
// ListNodesHandler lists all nodes.
// @Summary 获取节点列表
// @Description 返回所有节点及最新配置下发记录,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]node.View} "节点列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/nodes [get]
func ListNodesHandler(c *gin.Context) {
nodes, err := ListNodes(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, nodes)
c.JSON(http.StatusOK, response.OK(nodes))
}
// CreateNodeHandler creates a node.
// @Summary 创建节点
// @Description 创建新的边缘节点记录,需要管理员权限
// @Tags openflare-node
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param body body node.Input true "节点参数"
// @Success 200 {object} response.Any{data=node.View} "创建成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/nodes [post]
func CreateNodeHandler(c *gin.Context) {
var input Input
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
view, err := CreateNode(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
// UpdateNodeHandler updates a node.
// @Summary 更新节点
// @Description 更新指定节点的配置信息,需要管理员权限
// @Tags openflare-node
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Param body body node.Input true "节点参数"
// @Success 200 {object} response.Any{data=node.View} "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/custom/openflare/nodes/{id}/update [post]
func UpdateNodeHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input Input
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
view, err := UpdateNode(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
// DeleteNodeHandler deletes a node.
// @Summary 删除节点
// @Description 删除指定节点记录,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Success 200 {object} response.Any{data=string} "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/custom/openflare/nodes/{id}/delete [post]
func DeleteNodeHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteNode(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OKMessage(c, "")
c.JSON(http.StatusOK, response.OKNil())
}
// GetBootstrapTokenHandler returns the global discovery token.
// @Summary 获取引导令牌
// @Description 返回全局节点发现引导令牌,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=node.BootstrapView} "引导令牌"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/nodes/bootstrap-token [get]
func GetBootstrapTokenHandler(c *gin.Context) {
view, err := GetBootstrapToken(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
// RotateBootstrapTokenHandler rotates the global discovery token.
// @Summary 轮换引导令牌
// @Description 重新生成全局节点发现引导令牌,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=node.BootstrapView} "新引导令牌"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/nodes/bootstrap-token/rotate [post]
func RotateBootstrapTokenHandler(c *gin.Context) {
view, err := RotateBootstrapToken(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
// GetAgentReleaseHandler returns the latest agent release for a node.
// @Summary 获取 Agent 发布信息
// @Description 返回指定节点可用的最新 Agent 版本信息,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Param channel query string false "发布渠道"
// @Success 200 {object} response.Any{data=node.AgentReleaseInfo} "Agent 发布信息"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/custom/openflare/nodes/{id}/agent-release [get]
func GetAgentReleaseHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -104,19 +177,32 @@ func GetAgentReleaseHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, release)
c.JSON(http.StatusOK, response.OK(release))
}
// RequestAgentUpdateHandler requests agent self-update on a node.
// @Summary 请求 Agent 更新
// @Description 向指定节点下发 Agent 自更新指令,需要管理员权限
// @Tags openflare-node
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Param body body node.AgentUpdateInput false "更新参数(可选)"
// @Success 200 {object} response.Any{data=node.View} "更新请求已下发"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/custom/openflare/nodes/{id}/agent-update [post]
func RequestAgentUpdateHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var request AgentUpdateInput
if c.Request.ContentLength > 0 {
if err := bindOptionalJSON(c.Request.Body, &request); err != nil {
compat.Fail(c, "参数错误")
response.AbortBadRequest(c, "参数错误")
return
}
}
@@ -124,12 +210,23 @@ func RequestAgentUpdateHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
// RequestOpenrestyRestartHandler requests openresty restart on a node.
// @Summary 请求重启 OpenResty
// @Description 向指定节点下发 OpenResty 重启指令,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Success 200 {object} response.Any{data=node.View} "重启请求已下发"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/custom/openflare/nodes/{id}/openresty-restart [post]
func RequestOpenrestyRestartHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -137,12 +234,23 @@ func RequestOpenrestyRestartHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
// RequestForceSyncHandler requests force sync on a node.
// @Summary 请求强制同步配置
// @Description 向指定节点下发强制同步当前活跃配置的指令,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Success 200 {object} response.Any{data=node.View} "同步请求已下发"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/custom/openflare/nodes/{id}/force-sync [post]
func RequestForceSyncHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -150,30 +258,54 @@ func RequestForceSyncHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
// GetObservabilityHandler returns node observability details.
// @Summary 获取节点可观测性数据
// @Description 返回指定节点的指标、健康事件与流量分析数据,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Param hours query int false "统计时间范围(小时)"
// @Param limit query int false "返回记录数量上限"
// @Success 200 {object} response.Any{data=node.ObservabilityView} "可观测性数据"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/custom/openflare/nodes/{id}/observability [get]
func GetObservabilityHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var query ObservabilityQuery
if err := c.ShouldBindQuery(&query); err != nil {
compat.Fail(c, "参数错误")
response.AbortBadRequest(c, "参数错误")
return
}
view, err := GetObservability(c.Request.Context(), id, query)
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
// CleanupHealthEventsHandler cleans up node health events.
// @Summary 清理节点健康事件
// @Description 清理指定节点的历史健康事件记录,需要管理员权限
// @Tags openflare-node
// @Produce json
// @Security SessionCookie
// @Param id path int true "节点 ID"
// @Success 200 {object} response.Any{data=node.HealthEventCleanupResult} "清理结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或节点不存在"
// @Router /api/v1/custom/openflare/nodes/{id}/observability/cleanup [post]
func CleanupHealthEventsHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -181,7 +313,7 @@ func CleanupHealthEventsHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, result)
c.JSON(http.StatusOK, response.OK(result))
}
func bindOptionalJSON(body io.Reader, target any) error {
@@ -189,4 +321,4 @@ func bindOptionalJSON(body io.Reader, target any) error {
return err
}
return nil
}
}
@@ -4,47 +4,96 @@
package observability
import (
"net/http"
"strconv"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
)
// RegisterRoutes mounts legacy OpenFlare access log routes.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
accessLogRoute := apiGroup.Group("/access-logs")
accessLogRoute.Use(compat.AdminAuth())
{
compat.RegisterCollection(accessLogRoute, "GET", getAccessLogsHandler)
accessLogRoute.GET("/folds", getFoldedAccessLogsHandler)
accessLogRoute.GET("/folds/ip-summary", getFoldedAccessLogIPsHandler)
accessLogRoute.GET("/ip-summary", getAccessLogIPSummariesHandler)
accessLogRoute.GET("/ip-summary/trend", getAccessLogIPTrendHandler)
accessLogRoute.POST("/cleanup", cleanupAccessLogsHandler)
}
}
func getAccessLogsHandler(c *gin.Context) {
// GetAccessLogsHandler 分页列出访问日志。
// @Summary 列出访问日志
// @Description 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限
// @Tags openflare-observability
// @Produce json
// @Security SessionCookie
// @Param node_id query string false "节点 ID"
// @Param remote_addr query string false "客户端 IP"
// @Param host query string false "请求 Host"
// @Param path query string false "请求路径"
// @Param p query int false "页码"
// @Param page_size query int false "每页条数"
// @Param sort_by query string false "排序字段"
// @Param sort_order query string false "排序方向"
// @Success 200 {object} response.Any{data=observability.AccessLogList} "访问日志列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/access-logs [get]
func GetAccessLogsHandler(c *gin.Context) {
logs, err := ListAccessLogs(c.Request.Context(), readAccessLogQuery(c))
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, logs)
c.JSON(http.StatusOK, response.OK(logs))
}
func getFoldedAccessLogsHandler(c *gin.Context) {
// getFoldedAccessLogsHandler 分页列出折叠访问日志。
// @Summary 列出折叠访问日志
// @Description 按时间桶聚合访问日志并分页返回,需要管理员权限
// @Tags openflare-observability
// @Produce json
// @Security SessionCookie
// @Param node_id query string false "节点 ID"
// @Param remote_addr query string false "客户端 IP"
// @Param host query string false "请求 Host"
// @Param path query string false "请求路径"
// @Param fold_minutes query int false "折叠时间窗口(分钟)"
// @Param p query int false "页码"
// @Param page_size query int false "每页条数"
// @Param sort_by query string false "排序字段"
// @Param sort_order query string false "排序方向"
// @Success 200 {object} response.Any{data=observability.FoldedAccessLogList} "折叠访问日志列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/access-logs/folds [get]
func GetFoldedAccessLogsHandler(c *gin.Context) {
query := readAccessLogQuery(c)
query.FoldMinutes = readQueryInt(c, "fold_minutes")
logs, err := ListFoldedAccessLogs(c.Request.Context(), query)
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, logs)
c.JSON(http.StatusOK, response.OK(logs))
}
func getFoldedAccessLogIPsHandler(c *gin.Context) {
// getFoldedAccessLogIPsHandler 列出折叠桶内的 IP 汇总。
// @Summary 列出折叠访问日志 IP 汇总
// @Description 在指定时间桶内按 IP 聚合访问统计,需要管理员权限
// @Tags openflare-observability
// @Produce json
// @Security SessionCookie
// @Param node_id query string false "节点 ID"
// @Param remote_addr query string false "客户端 IP"
// @Param host query string false "请求 Host"
// @Param path query string false "请求路径"
// @Param bucket_started_at query string false "时间桶起始时间"
// @Param fold_minutes query int false "折叠时间窗口(分钟)"
// @Param p query int false "页码"
// @Param page_size query int false "每页条数"
// @Param sort_by query string false "排序字段"
// @Param sort_order query string false "排序方向"
// @Success 200 {object} response.Any{data=observability.FoldedAccessLogIPList} "折叠 IP 汇总列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/access-logs/folds/ip-summary [get]
func GetFoldedAccessLogIPsHandler(c *gin.Context) {
result, err := ListFoldedAccessLogIPs(c.Request.Context(), FoldedAccessLogIPQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
@@ -57,14 +106,32 @@ func getFoldedAccessLogIPsHandler(c *gin.Context) {
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
})
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, result)
c.JSON(http.StatusOK, response.OK(result))
}
func getAccessLogIPSummariesHandler(c *gin.Context) {
// getAccessLogIPSummariesHandler 列出访问日志 IP 汇总。
// @Summary 列出访问日志 IP 汇总
// @Description 按 IP 聚合访问日志统计并分页返回,需要管理员权限
// @Tags openflare-observability
// @Produce json
// @Security SessionCookie
// @Param node_id query string false "节点 ID"
// @Param remote_addr query string false "客户端 IP"
// @Param host query string false "请求 Host"
// @Param p query int false "页码"
// @Param page_size query int false "每页条数"
// @Param sort_by query string false "排序字段"
// @Param sort_order query string false "排序方向"
// @Success 200 {object} response.Any{data=observability.AccessLogIPSummaryList} "IP 汇总列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/access-logs/ip-summary [get]
func GetAccessLogIPSummariesHandler(c *gin.Context) {
result, err := ListAccessLogIPSummaries(c.Request.Context(), AccessLogIPSummaryQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
@@ -74,14 +141,30 @@ func getAccessLogIPSummariesHandler(c *gin.Context) {
SortBy: c.Query("sort_by"),
SortOrder: c.Query("sort_order"),
})
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, result)
c.JSON(http.StatusOK, response.OK(result))
}
func getAccessLogIPTrendHandler(c *gin.Context) {
// getAccessLogIPTrendHandler 获取 IP 访问趋势。
// @Summary 获取访问日志 IP 趋势
// @Description 返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限
// @Tags openflare-observability
// @Produce json
// @Security SessionCookie
// @Param node_id query string false "节点 ID"
// @Param remote_addr query string false "客户端 IP"
// @Param host query string false "请求 Host"
// @Param hours query int false "统计时间范围(小时)"
// @Param bucket_minutes query int false "时间桶粒度(分钟)"
// @Success 200 {object} response.Any{data=observability.AccessLogIPTrendView} "IP 访问趋势"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/access-logs/ip-summary/trend [get]
func GetAccessLogIPTrendHandler(c *gin.Context) {
result, err := GetAccessLogIPTrend(c.Request.Context(), AccessLogIPTrendQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
@@ -89,24 +172,36 @@ func getAccessLogIPTrendHandler(c *gin.Context) {
Hours: readQueryInt(c, "hours"),
BucketMinutes: readQueryInt(c, "bucket_minutes"),
})
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, result)
c.JSON(http.StatusOK, response.OK(result))
}
func cleanupAccessLogsHandler(c *gin.Context) {
// cleanupAccessLogsHandler 清理过期访问日志。
// @Summary 清理访问日志
// @Description 按保留天数清理过期访问日志记录,需要管理员权限
// @Tags openflare-observability
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body observability.AccessLogCleanupInput true "清理参数"
// @Success 200 {object} response.Any{data=observability.AccessLogCleanupResult} "清理结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/access-logs/cleanup [post]
func CleanupAccessLogsHandler(c *gin.Context) {
var input AccessLogCleanupInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
result, err := CleanupAccessLogs(c.Request.Context(), input)
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, result)
c.JSON(http.StatusOK, response.OK(result))
}
func readAccessLogQuery(c *gin.Context) AccessLogQuery {
@@ -125,4 +220,4 @@ func readAccessLogQuery(c *gin.Context) AccessLogQuery {
func readQueryInt(c *gin.Context, key string) int {
value, _ := strconv.Atoi(c.DefaultQuery(key, "0"))
return value
}
}
+152 -66
View File
@@ -7,128 +7,214 @@ import (
"encoding/json"
"errors"
"io"
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/gin-gonic/gin"
)
// RegisterRoutes mounts legacy OpenFlare option and public status routes.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
apiGroup.GET("/status", getStatusHandler)
apiGroup.GET("/notice", getNoticeHandler)
apiGroup.GET("/about", getAboutHandler)
optionRoute := apiGroup.Group("/option")
optionRoute.Use(compat.RootAuth())
{
compat.RegisterCollection(optionRoute, "GET", listOptionsHandler)
optionRoute.POST("/update", updateOptionHandler)
optionRoute.POST("/update-batch", updateOptionsBatchHandler)
optionRoute.POST("/geoip/lookup", lookupGeoIPHandler)
optionRoute.POST("/database/cleanup", cleanupDatabaseHandler)
}
uptimeKumaRoute := apiGroup.Group("/uptimekuma")
uptimeKumaRoute.Use(compat.RootAuth())
{
uptimeKumaRoute.POST("/sync", syncUptimeKumaHandler)
}
}
func getStatusHandler(c *gin.Context) {
view, err := getStatus(c.Request.Context(), "/api")
if err != nil {
compat.Fail(c, errOptionInitFailed)
// GetStatusHandler 获取公开运行状态。
// @Summary 获取 OpenFlare 公开状态
// @Description 返回版本、认证源与系统公开配置,无需登录
// @Tags openflare-option
// @Produce json
// @Success 200 {object} response.Any{data=option.statusView} "公开状态"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/status [get]
func GetStatusHandler(c *gin.Context) {
view, err := getStatus(c.Request.Context(), "/api/v1/custom/openflare")
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
func getNoticeHandler(c *gin.Context) {
// getNoticeHandler 获取系统公告。
// @Summary 获取系统公告
// @Description 返回 OpenFlare 控制台公告文本,无需登录
// @Tags openflare-option
// @Produce json
// @Success 200 {object} response.Any{data=string} "系统公告"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/notice [get]
// GetNoticeHandler returns the notice content.
func GetNoticeHandler(c *gin.Context) {
notice, err := getNotice(c.Request.Context())
if err != nil {
compat.Fail(c, errOptionInitFailed)
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, notice)
c.JSON(http.StatusOK, response.OK(notice))
}
func getAboutHandler(c *gin.Context) {
// getAboutHandler 获取关于信息。
// @Summary 获取关于信息
// @Description 返回 OpenFlare 关于页面文本,无需登录
// @Tags openflare-option
// @Produce json
// @Success 200 {object} response.Any{data=string} "关于信息"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/about [get]
// GetAboutHandler returns the about content.
func GetAboutHandler(c *gin.Context) {
about, err := getAbout(c.Request.Context())
if err != nil {
compat.Fail(c, errOptionInitFailed)
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, about)
c.JSON(http.StatusOK, response.OK(about))
}
func listOptionsHandler(c *gin.Context) {
// listOptionsHandler 列出全部配置项。
// @Summary 列出 OpenFlare 配置项
// @Description 返回全部非敏感 OpenFlare 配置项,需要管理员权限
// @Tags openflare-option
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.OpenFlareOption} "配置项列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/option [get]
// ListOptionsHandler lists OpenFlare options.
func ListOptionsHandler(c *gin.Context) {
options, err := listOptions(c.Request.Context())
if err != nil {
compat.Fail(c, errOptionInitFailed)
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, options)
c.JSON(http.StatusOK, response.OK(options))
}
func updateOptionHandler(c *gin.Context) {
// updateOptionHandler 更新单个配置项。
// @Summary 更新 OpenFlare 配置项
// @Description 更新单个 OpenFlare 配置项,需要管理员权限
// @Tags openflare-option
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body model.OpenFlareOption true "配置项"
// @Success 200 {object} response.Any "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/option/update [post]
// UpdateOptionHandler updates a single option.
func UpdateOptionHandler(c *gin.Context) {
var option model.OpenFlareOption
if !compat.BindJSON(c, &option) {
if !apiutil.BindJSON(c, &option) {
return
}
if err := updateOption(c.Request.Context(), option); err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, updateOption(c.Request.Context(), option)) {
return
}
compat.OKMessage(c, "")
c.JSON(http.StatusOK, response.OKNil())
}
func updateOptionsBatchHandler(c *gin.Context) {
// updateOptionsBatchHandler 批量更新配置项。
// @Summary 批量更新 OpenFlare 配置项
// @Description 批量更新多个 OpenFlare 配置项,需要管理员权限
// @Tags openflare-option
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body option.optionBatchPayload true "批量配置项"
// @Success 200 {object} response.Any "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/option/update-batch [post]
// UpdateOptionsBatchHandler updates options in batch.
func UpdateOptionsBatchHandler(c *gin.Context) {
var payload optionBatchPayload
if !compat.BindJSON(c, &payload) {
if !apiutil.BindJSON(c, &payload) {
return
}
if err := updateOptionsBatch(c.Request.Context(), payload); err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, updateOptionsBatch(c.Request.Context(), payload)) {
return
}
compat.OKMessage(c, "")
c.JSON(http.StatusOK, response.OKNil())
}
func lookupGeoIPHandler(c *gin.Context) {
// lookupGeoIPHandler 查询 GeoIP 信息。
// @Summary GeoIP 地址查询
// @Description 按提供商与 IP 查询地理位置信息,需要管理员权限
// @Tags openflare-option
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body option.geoIPLookupRequest true "查询参数"
// @Success 200 {object} response.Any{data=option.geoIPLookupView} "GeoIP 查询结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/option/geoip/lookup [post]
// LookupGeoIPHandler performs a GeoIP lookup.
func LookupGeoIPHandler(c *gin.Context) {
var request geoIPLookupRequest
if !compat.BindJSON(c, &request) {
if !apiutil.BindJSON(c, &request) {
return
}
view, err := lookupGeoIP(c.Request.Context(), request.Provider, request.IP)
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
func cleanupDatabaseHandler(c *gin.Context) {
// cleanupDatabaseHandler 清理可观测性数据库数据。
// @Summary 清理可观测性数据库
// @Description 按目标与保留天数清理可观测性相关数据表,需要管理员权限
// @Tags openflare-option
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body option.databaseCleanupInput false "清理参数"
// @Success 200 {object} response.Any{data=option.databaseCleanupResult} "清理结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/option/database/cleanup [post]
// CleanupDatabaseHandler cleans up observability data.
func CleanupDatabaseHandler(c *gin.Context) {
var input databaseCleanupInput
if err := bindOptionalJSON(c.Request.Body, &input); err != nil {
compat.Fail(c, errInvalidParams)
response.AbortBadRequest(c, errInvalidParams)
return
}
result, err := cleanupDatabaseObservability(c.Request.Context(), input)
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, result)
c.JSON(http.StatusOK, response.OK(result))
}
func syncUptimeKumaHandler(c *gin.Context) {
if err := syncUptimeKuma(c.Request.Context()); err != nil {
compat.Fail(c, err.Error())
// syncUptimeKumaHandler 同步 Uptime Kuma 监控。
// @Summary 同步 Uptime Kuma
// @Description 将 OpenFlare 节点同步到 Uptime Kuma,需要管理员权限
// @Tags openflare-option
// @Accept json
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=string} "同步成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/uptimekuma/sync [post]
// SyncUptimeKumaHandler triggers UptimeKuma sync.
func SyncUptimeKumaHandler(c *gin.Context) {
if apiutil.AbortBadRequestOnError(c, syncUptimeKuma(c.Request.Context())) {
return
}
compat.OKMessage(c, "同步成功")
c.JSON(http.StatusOK, response.OK("同步成功"))
}
func bindOptionalJSON(body io.Reader, target any) error {
@@ -136,4 +222,4 @@ func bindOptionalJSON(body io.Reader, target any) error {
return err
}
return nil
}
}
@@ -4,37 +4,54 @@
package origin
import (
"errors"
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, errOriginNotFound)
return true
}
compat.Fail(c, err.Error())
return true
return apiutil.AbortNotFoundIfMissing(c, err, errOriginNotFound)
}
// GetOrigins 列出全部源站。
// @Summary 获取源站列表
// @Description 返回所有源站及关联代理规则数量,需要管理员权限
// @Tags openflare-origin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]origin.View} "源站列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/origins [get]
func GetOrigins(c *gin.Context) {
origins, err := ListOrigins(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, origins)
c.JSON(http.StatusOK, response.OK(origins))
}
// GetOrigin 获取源站详情。
// @Summary 获取源站详情
// @Description 返回指定源站信息及关联代理规则摘要,需要管理员权限
// @Tags openflare-origin
// @Produce json
// @Security SessionCookie
// @Param id path int true "源站 ID"
// @Success 200 {object} response.Any{data=origin.DetailView} "源站详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或源站不存在"
// @Router /api/v1/custom/openflare/origins/{id} [get]
func GetOrigin(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -42,47 +59,83 @@ func GetOrigin(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, detail)
c.JSON(http.StatusOK, response.OK(detail))
}
// CreateOriginHandler 创建源站。
// @Summary 创建源站
// @Description 创建新的上游源站记录,需要管理员权限
// @Tags openflare-origin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param body body origin.Input true "源站参数"
// @Success 200 {object} response.Any{data=origin.View} "创建成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/origins [post]
func CreateOriginHandler(c *gin.Context) {
var input Input
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
origin, err := CreateOrigin(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, origin)
c.JSON(http.StatusOK, response.OK(origin))
}
// UpdateOriginHandler 更新源站。
// @Summary 更新源站
// @Description 更新指定源站的配置信息,需要管理员权限
// @Tags openflare-origin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "源站 ID"
// @Param body body origin.Input true "源站参数"
// @Success 200 {object} response.Any{data=origin.View} "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或源站不存在"
// @Router /api/v1/custom/openflare/origins/{id}/update [post]
func UpdateOriginHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input Input
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
origin, err := UpdateOrigin(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, origin)
c.JSON(http.StatusOK, response.OK(origin))
}
// DeleteOriginHandler 删除源站。
// @Summary 删除源站
// @Description 删除指定源站记录,需要管理员权限
// @Tags openflare-origin
// @Produce json
// @Security SessionCookie
// @Param id path int true "源站 ID"
// @Success 200 {object} response.Any{data=string} "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或源站不存在"
// @Router /api/v1/custom/openflare/origins/{id}/delete [post]
func DeleteOriginHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteOrigin(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OK(c, nil)
}
c.JSON(http.StatusOK, response.OKNil())
}
+162 -32
View File
@@ -4,52 +4,72 @@
package pages
import (
"errors"
"net/http"
"strconv"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, errPagesProjectNotFound)
return true
}
compat.Fail(c, err.Error())
return true
return apiutil.AbortNotFoundIfMissing(c, err, errPagesProjectNotFound)
}
func deploymentIDParam(c *gin.Context) (uint, bool) {
raw := c.Param("deployment_id")
if raw == "" {
compat.Fail(c, "无效的 ID")
response.AbortBadRequest(c, "无效的 ID")
return 0, false
}
id64, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id64 == 0 {
compat.Fail(c, "无效的 ID")
response.AbortBadRequest(c, "无效的 ID")
return 0, false
}
return uint(id64), true
}
// ListProjectsHandler 列出全部 Pages 项目。
// @Summary 列出 Pages 项目
// @Description 返回全部 OpenFlare Pages 项目,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]pages.View} "Pages 项目列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/pages [get]
func ListProjectsHandler(c *gin.Context) {
projects, err := ListProjects(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, projects)
c.JSON(http.StatusOK, response.OK(projects))
}
// GetProjectHandler 获取 Pages 项目详情。
// @Summary 获取 Pages 项目详情
// @Description 按 ID 返回 Pages 项目详情,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Success 200 {object} response.Any{data=pages.View} "Pages 项目详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "项目不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/pages/{id} [get]
func GetProjectHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -57,54 +77,108 @@ func GetProjectHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, project)
c.JSON(http.StatusOK, response.OK(project))
}
// CreateProjectHandler 创建 Pages 项目。
// @Summary 创建 Pages 项目
// @Description 创建新的 OpenFlare Pages 项目,需要管理员权限
// @Tags openflare-pages
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body pages.Input true "项目参数"
// @Success 200 {object} response.Any{data=pages.View} "创建成功的项目"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/pages [post]
func CreateProjectHandler(c *gin.Context) {
var input Input
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
project, err := CreateProject(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, project)
c.JSON(http.StatusOK, response.OK(project))
}
// UpdateProjectHandler 更新 Pages 项目。
// @Summary 更新 Pages 项目
// @Description 按 ID 更新 OpenFlare Pages 项目,需要管理员权限
// @Tags openflare-pages
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Param request body pages.Input true "项目参数"
// @Success 200 {object} response.Any{data=pages.View} "更新后的项目"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "项目不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/pages/{id}/update [post]
func UpdateProjectHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input Input
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
project, err := UpdateProject(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, project)
c.JSON(http.StatusOK, response.OK(project))
}
// DeleteProjectHandler 删除 Pages 项目。
// @Summary 删除 Pages 项目
// @Description 按 ID 删除 OpenFlare Pages 项目,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "项目不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/pages/{id}/delete [post]
func DeleteProjectHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteProject(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OK(c, nil)
c.JSON(http.StatusOK, response.OKNil())
}
// ListDeploymentsHandler 列出项目的全部部署。
// @Summary 列出 Pages 部署
// @Description 返回指定项目的全部部署记录,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Success 200 {object} response.Any{data=[]pages.DeploymentView} "部署列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "项目不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/pages/{id}/deployments [get]
func ListDeploymentsHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -112,30 +186,59 @@ func ListDeploymentsHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, deployments)
c.JSON(http.StatusOK, response.OK(deployments))
}
// UploadDeploymentHandler 上传 Pages 部署包。
// @Summary 上传 Pages 部署包
// @Description 为指定项目上传 ZIP 部署包,需要管理员权限
// @Tags openflare-pages
// @Accept multipart/form-data
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Param package formData file true "部署包 ZIP 文件"
// @Success 200 {object} response.Any{data=pages.DeploymentView} "部署记录"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "项目不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/pages/{id}/deployments/upload [post]
func UploadDeploymentHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
file, err := c.FormFile("package")
if err != nil {
compat.Fail(c, errPagesPackageMissing)
response.AbortBadRequest(c, errPagesPackageMissing)
return
}
deployment, err := UploadDeployment(c.Request.Context(), id, file, "")
if handleLogicError(c, err) {
return
}
compat.OK(c, deployment)
c.JSON(http.StatusOK, response.OK(deployment))
}
// ActivateDeploymentHandler 激活 Pages 部署。
// @Summary 激活 Pages 部署
// @Description 将指定部署设为项目当前生效版本,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Param deployment_id path int true "部署 ID"
// @Success 200 {object} response.Any{data=pages.View} "激活后的项目"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "项目或部署不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/pages/{id}/deployments/{deployment_id}/activate [post]
func ActivateDeploymentHandler(c *gin.Context) {
projectID, ok := compat.IDParam(c)
projectID, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -147,12 +250,26 @@ func ActivateDeploymentHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, project)
c.JSON(http.StatusOK, response.OK(project))
}
// DeleteDeploymentHandler 删除 Pages 部署。
// @Summary 删除 Pages 部署
// @Description 删除指定项目的部署记录,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param id path int true "项目 ID"
// @Param deployment_id path int true "部署 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "项目或部署不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/pages/{id}/deployments/{deployment_id}/delete [post]
func DeleteDeploymentHandler(c *gin.Context) {
projectID, ok := compat.IDParam(c)
projectID, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -163,10 +280,23 @@ func DeleteDeploymentHandler(c *gin.Context) {
if err := DeleteDeployment(c.Request.Context(), projectID, deploymentID); handleLogicError(c, err) {
return
}
compat.OK(c, nil)
c.JSON(http.StatusOK, response.OKNil())
}
// ListDeploymentFilesHandler 列出部署文件清单。
// @Summary 列出 Pages 部署文件
// @Description 返回指定部署包含的文件清单,需要管理员权限
// @Tags openflare-pages
// @Produce json
// @Security SessionCookie
// @Param deployment_id path int true "部署 ID"
// @Success 200 {object} response.Any{data=[]pages.DeploymentFileView} "部署文件列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "部署不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/pages/deployments/{deployment_id}/files [get]
func ListDeploymentFilesHandler(c *gin.Context) {
deploymentID, ok := deploymentIDParam(c)
if !ok {
@@ -176,5 +306,5 @@ func ListDeploymentFilesHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, files)
}
c.JSON(http.StatusOK, response.OK(files))
}
@@ -4,37 +4,54 @@
package proxy_route
import (
"errors"
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, errProxyRouteNotFound)
return true
}
compat.Fail(c, err.Error())
return true
return apiutil.AbortNotFoundIfMissing(c, err, errProxyRouteNotFound)
}
// GetProxyRoutes 列出全部代理规则。
// @Summary 获取代理规则列表
// @Description 返回所有代理规则配置,需要管理员权限
// @Tags openflare-proxy-route
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]proxy_route.View} "代理规则列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/proxy-routes [get]
func GetProxyRoutes(c *gin.Context) {
routes, err := ListProxyRoutes(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, routes)
c.JSON(http.StatusOK, response.OK(routes))
}
// GetProxyRouteHandler 获取代理规则详情。
// @Summary 获取代理规则详情
// @Description 返回指定代理规则的完整配置,需要管理员权限
// @Tags openflare-proxy-route
// @Produce json
// @Security SessionCookie
// @Param id path int true "代理规则 ID"
// @Success 200 {object} response.Any{data=proxy_route.View} "代理规则详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或规则不存在"
// @Router /api/v1/custom/openflare/proxy-routes/{id} [get]
func GetProxyRouteHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -42,47 +59,83 @@ func GetProxyRouteHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, route)
c.JSON(http.StatusOK, response.OK(route))
}
// CreateProxyRouteHandler 创建代理规则。
// @Summary 创建代理规则
// @Description 创建新的反向代理规则,需要管理员权限
// @Tags openflare-proxy-route
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param body body proxy_route.Input true "代理规则参数"
// @Success 200 {object} response.Any{data=proxy_route.View} "创建成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/custom/openflare/proxy-routes [post]
func CreateProxyRouteHandler(c *gin.Context) {
var input Input
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
route, err := CreateProxyRoute(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, route)
c.JSON(http.StatusOK, response.OK(route))
}
// UpdateProxyRouteHandler 更新代理规则。
// @Summary 更新代理规则
// @Description 更新指定代理规则的配置,需要管理员权限
// @Tags openflare-proxy-route
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "代理规则 ID"
// @Param body body proxy_route.Input true "代理规则参数"
// @Success 200 {object} response.Any{data=proxy_route.View} "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或规则不存在"
// @Router /api/v1/custom/openflare/proxy-routes/{id}/update [post]
func UpdateProxyRouteHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input Input
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
route, err := UpdateProxyRoute(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, route)
c.JSON(http.StatusOK, response.OK(route))
}
// DeleteProxyRouteHandler 删除代理规则。
// @Summary 删除代理规则
// @Description 删除指定代理规则,需要管理员权限
// @Tags openflare-proxy-route
// @Produce json
// @Security SessionCookie
// @Param id path int true "代理规则 ID"
// @Success 200 {object} response.Any{data=string} "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或规则不存在"
// @Router /api/v1/custom/openflare/proxy-routes/{id}/delete [post]
func DeleteProxyRouteHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteProxyRoute(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OK(c, nil)
}
c.JSON(http.StatusOK, response.OKNil())
}
@@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
)
// PostHeartbeat handles POST /relay/heartbeat.
func PostHeartbeat(c *gin.Context) {
var payload HeartbeatPayload
+327 -53
View File
@@ -4,38 +4,58 @@
package tls
import (
"errors"
"net/http"
"strings"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, "记录不存在")
return true
}
compat.Fail(c, err.Error())
return true
return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在")
}
// GetCertificates 列出 TLS 证书。
// @Summary 列出 TLS 证书
// @Description 返回全部 TLS 证书(不含 PEM),需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.TLSCertificate} "证书列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/tls-certificates [get]
func GetCertificates(c *gin.Context) {
certificates, err := ListCertificates(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, certificates)
c.JSON(http.StatusOK, response.OK(certificates))
}
// GetCertificateDetail 获取 TLS 证书详情。
// @Summary 获取 TLS 证书详情
// @Description 按 ID 返回 TLS 证书详情(不含 PEM),需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "证书详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/tls-certificates/{id} [get]
func GetCertificateDetail(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -43,12 +63,25 @@ func GetCertificateDetail(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, certificate)
c.JSON(http.StatusOK, response.OK(certificate))
}
// GetCertificateContentHandler 获取 TLS 证书 PEM 内容。
// @Summary 获取 TLS 证书 PEM 内容
// @Description 按 ID 返回证书与私钥 PEM 内容,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Success 200 {object} response.Any{data=tls.CertificateContent} "证书 PEM 内容"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/tls-certificates/{id}/content [get]
func GetCertificateContentHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -56,122 +89,235 @@ func GetCertificateContentHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, content)
c.JSON(http.StatusOK, response.OK(content))
}
// CreateCertificateHandler 从 PEM 创建证书。
// @Summary 创建 TLS 证书
// @Description 从 PEM 文本创建 TLS 证书,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body tls.CertificateInput true "证书参数"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "创建成功的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/tls-certificates [post]
func CreateCertificateHandler(c *gin.Context) {
var input CertificateInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
certificate, err := CreateCertificate(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, certificate)
c.JSON(http.StatusOK, response.OK(certificate))
}
// UpdateCertificateHandler 更新证书。
// @Summary 更新 TLS 证书
// @Description 按 ID 更新 TLS 证书 PEM 信息,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Param request body tls.CertificateInput true "证书参数"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "更新后的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/tls-certificates/{id}/update [post]
func UpdateCertificateHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input CertificateInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
certificate, err := UpdateCertificate(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, certificate)
c.JSON(http.StatusOK, response.OK(certificate))
}
// ImportCertificateFile 从文件导入证书。
// @Summary 从文件导入 TLS 证书
// @Description 上传证书与私钥文件创建 TLS 证书,需要管理员权限
// @Tags openflare-tls
// @Accept multipart/form-data
// @Produce json
// @Security SessionCookie
// @Param name formData string false "证书名称"
// @Param remark formData string false "备注"
// @Param cert_file formData file true "证书文件"
// @Param key_file formData file true "私钥文件"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "导入成功的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/tls-certificates/import-file [post]
func ImportCertificateFile(c *gin.Context) {
name := c.PostForm("name")
remark := c.PostForm("remark")
certFile, err := c.FormFile("cert_file")
if err != nil {
compat.Fail(c, "缺少证书文件")
response.AbortBadRequest(c, "缺少证书文件")
return
}
keyFile, err := c.FormFile("key_file")
if err != nil {
compat.Fail(c, "缺少私钥文件")
response.AbortBadRequest(c, "缺少私钥文件")
return
}
certificate, err := CreateCertificateFromFiles(c.Request.Context(), name, certFile, keyFile, remark)
if handleLogicError(c, err) {
return
}
compat.OK(c, certificate)
c.JSON(http.StatusOK, response.OK(certificate))
}
// DeleteCertificateHandler 删除证书。
// @Summary 删除 TLS 证书
// @Description 按 ID 删除 TLS 证书,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/tls-certificates/{id}/delete [post]
func DeleteCertificateHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteCertificate(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OK(c, nil)
c.JSON(http.StatusOK, response.OKNil())
}
// ApplyCertificateHandler 申请 ACME 证书。
// @Summary 申请 ACME 证书
// @Description 通过 ACME 申请新的 TLS 证书,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body tls.ApplyInput true "ACME 申请参数"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "申请中的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/tls-certificates/apply [post]
func ApplyCertificateHandler(c *gin.Context) {
var input ApplyInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
certificate, err := ApplyCertificate(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, certificate)
c.JSON(http.StatusOK, response.OK(certificate))
}
// UpdateACMECertificateHandler 更新 ACME 证书配置。
// @Summary 更新 ACME 证书配置
// @Description 按 ID 更新 ACME 证书申请配置,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Param request body tls.ApplyInput true "ACME 申请参数"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "更新后的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/tls-certificates/{id}/update-acme [post]
func UpdateACMECertificateHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input ApplyInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
certificate, err := UpdateACMECertificate(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, certificate)
c.JSON(http.StatusOK, response.OK(certificate))
}
// ConvertCertificateToACMEHandler 将上传证书转为 ACME。
// @Summary 将证书转为 ACME 管理
// @Description 将已上传证书转换为 ACME 自动续期模式,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Param request body tls.ApplyInput true "ACME 申请参数"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "转换后的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/tls-certificates/{id}/convert-acme [post]
func ConvertCertificateToACMEHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input ApplyInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
certificate, err := ConvertCertificateToACME(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, certificate)
c.JSON(http.StatusOK, response.OK(certificate))
}
// RenewCertificateHandler 续期 ACME 证书。
// @Summary 续期 ACME 证书
// @Description 手动触发 ACME 证书续期,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "证书 ID"
// @Success 200 {object} response.Any{data=model.TLSCertificate} "续期后的证书"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/tls-certificates/{id}/renew [post]
func RenewCertificateHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -179,126 +325,254 @@ func RenewCertificateHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, certificate)
c.JSON(http.StatusOK, response.OK(certificate))
}
// GetManagedDomains 列出托管域名。
// @Summary 列出托管域名
// @Description 返回全部托管域名及关联证书,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.ManagedDomain} "托管域名列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/managed-domains [get]
func GetManagedDomains(c *gin.Context) {
domains, err := ListManagedDomains(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, domains)
c.JSON(http.StatusOK, response.OK(domains))
}
// CreateManagedDomainHandler 创建托管域名。
// @Summary 创建托管域名
// @Description 创建新的托管域名记录,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body tls.ManagedDomainInput true "托管域名参数"
// @Success 200 {object} response.Any{data=model.ManagedDomain} "创建成功的托管域名"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/managed-domains [post]
func CreateManagedDomainHandler(c *gin.Context) {
var input ManagedDomainInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
domain, err := CreateManagedDomain(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, domain)
c.JSON(http.StatusOK, response.OK(domain))
}
// UpdateManagedDomainHandler 更新托管域名。
// @Summary 更新托管域名
// @Description 按 ID 更新托管域名,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "托管域名 ID"
// @Param request body tls.ManagedDomainInput true "托管域名参数"
// @Success 200 {object} response.Any{data=model.ManagedDomain} "更新后的托管域名"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/managed-domains/{id}/update [post]
func UpdateManagedDomainHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input ManagedDomainInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
domain, err := UpdateManagedDomain(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, domain)
c.JSON(http.StatusOK, response.OK(domain))
}
// DeleteManagedDomainHandler 删除托管域名。
// @Summary 删除托管域名
// @Description 按 ID 删除托管域名,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "托管域名 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/managed-domains/{id}/delete [post]
func DeleteManagedDomainHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteManagedDomain(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OK(c, nil)
c.JSON(http.StatusOK, response.OKNil())
}
// MatchManagedDomainCertificateHandler 匹配域名证书。
// @Summary 匹配托管域名证书
// @Description 按域名查询可用的证书匹配候选,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param domain query string true "域名"
// @Success 200 {object} response.Any{data=tls.ManagedDomainMatchResult} "证书匹配结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/managed-domains/match [get]
func MatchManagedDomainCertificateHandler(c *gin.Context) {
domain := strings.TrimSpace(c.Query("domain"))
result, err := MatchManagedDomainCertificate(c.Request.Context(), domain)
if handleLogicError(c, err) {
return
}
compat.OK(c, result)
c.JSON(http.StatusOK, response.OK(result))
}
// GetDNSAccounts 列出 DNS 账号。
// @Summary 列出 DNS 账号
// @Description 返回全部 DNS 提供商账号,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.DNSAccount} "DNS 账号列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/dns-accounts [get]
func GetDNSAccounts(c *gin.Context) {
accounts, err := ListDNSAccounts(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, accounts)
c.JSON(http.StatusOK, response.OK(accounts))
}
// CreateDNSAccountHandler 创建 DNS 账号。
// @Summary 创建 DNS 账号
// @Description 创建新的 DNS 提供商账号,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body tls.DNSAccountInput true "DNS 账号参数"
// @Success 200 {object} response.Any{data=model.DNSAccount} "创建成功的 DNS 账号"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/dns-accounts [post]
func CreateDNSAccountHandler(c *gin.Context) {
var input DNSAccountInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
account, err := CreateDNSAccount(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, account)
c.JSON(http.StatusOK, response.OK(account))
}
// UpdateDNSAccountHandler 更新 DNS 账号。
// @Summary 更新 DNS 账号
// @Description 按 ID 更新 DNS 提供商账号,需要管理员权限
// @Tags openflare-tls
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "DNS 账号 ID"
// @Param request body tls.DNSAccountInput true "DNS 账号参数"
// @Success 200 {object} response.Any{data=model.DNSAccount} "更新后的 DNS 账号"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/dns-accounts/{id}/update [post]
func UpdateDNSAccountHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input DNSAccountInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
account, err := UpdateDNSAccount(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, account)
c.JSON(http.StatusOK, response.OK(account))
}
// DeleteDNSAccountHandler 删除 DNS 账号。
// @Summary 删除 DNS 账号
// @Description 按 ID 删除 DNS 提供商账号,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Param id path int true "DNS 账号 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/dns-accounts/{id}/delete [post]
func DeleteDNSAccountHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteDNSAccount(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OK(c, nil)
c.JSON(http.StatusOK, response.OKNil())
}
// GetDefaultAcmeAccountHandler 获取默认 ACME 账号。
// @Summary 获取默认 ACME 账号
// @Description 返回系统默认 ACME 账号配置,需要管理员权限
// @Tags openflare-tls
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=model.AcmeAccount} "默认 ACME 账号"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/acme-accounts/default [get]
func GetDefaultAcmeAccountHandler(c *gin.Context) {
account, err := GetDefaultAcmeAccount(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, account)
}
c.JSON(http.StatusOK, response.OK(account))
}
@@ -10,7 +10,8 @@ import (
"net/http"
"time"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
@@ -19,50 +20,101 @@ var upgradeLogsUpgrader = websocket.Upgrader{
CheckOrigin: func(_ *http.Request) bool { return true },
}
// GetLatestReleaseHandler returns the newest GitHub release for the legacy update UI.
// GetLatestReleaseHandler 获取最新 GitHub 发布版本。
// @Summary 获取最新服务端发布版本
// @Description 查询 OpenFlare 服务端最新 GitHub Release 及升级状态,需要管理员权限
// @Tags openflare-update
// @Produce json
// @Security SessionCookie
// @Param channel query string false "发布渠道(stable/preview)"
// @Success 200 {object} response.Any{data=update.LatestReleaseView} "最新发布信息"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/update/latest-release [get]
func GetLatestReleaseHandler(c *gin.Context) {
release, err := GetLatestRelease(c.Request.Context(), c.Query("channel"))
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
compat.OK(c, release)
c.JSON(http.StatusOK, response.OK(release))
}
// UpgradeServerHandler schedules an automatic upgrade from the latest release.
// UpgradeServerHandler 调度自动升级任务。
// @Summary 触发服务端自动升级
// @Description 从最新 Release 调度 OpenFlare 服务端自动升级,需要管理员权限
// @Tags openflare-update
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body update.upgradeRequest false "升级参数"
// @Success 200 {object} response.Any{data=update.LatestReleaseView} "升级任务已调度"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/update/upgrade [post]
func UpgradeServerHandler(c *gin.Context) {
var request upgradeRequest
if err := bindOptionalJSON(c.Request.Body, &request); err != nil {
compat.Fail(c, "无效的参数")
response.AbortBadRequest(c, "无效的参数")
return
}
release, err := ScheduleUpgrade(c.Request.Context(), request.Channel)
if err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, err) {
return
}
okWithMessage(c, release, "服务升级任务已启动,下载完成后将自动重启。")
c.JSON(http.StatusOK, response.OK(release))
}
// UploadManualServerBinaryHandler rejects manual uploads (feature disabled upstream).
// UploadManualServerBinaryHandler 上传手动升级二进制(已禁用)。
// @Summary 上传手动升级二进制
// @Description 上传服务端二进制以进行手动升级(当前功能已禁用),需要管理员权限
// @Tags openflare-update
// @Accept multipart/form-data
// @Produce json
// @Security SessionCookie
// @Param binary formData file true "服务端二进制文件"
// @Failure 400 {object} response.Any "功能已禁用或参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/custom/openflare/update/manual-upload [post]
func UploadManualServerBinaryHandler(c *gin.Context) {
if err := UploadManualBinary(); err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, UploadManualBinary()) {
return
}
}
// ConfirmManualServerUpgradeHandler rejects manual upgrades (feature disabled upstream).
// ConfirmManualServerUpgradeHandler 确认手动升级(已禁用)。
// @Summary 确认手动服务端升级
// @Description 确认并执行手动上传的服务端升级(当前功能已禁用),需要管理员权限
// @Tags openflare-update
// @Accept json
// @Produce json
// @Security SessionCookie
// @Failure 400 {object} response.Any "功能已禁用或参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/custom/openflare/update/manual-upgrade [post]
func ConfirmManualServerUpgradeHandler(c *gin.Context) {
if err := ConfirmManualUpgrade(); err != nil {
compat.Fail(c, err.Error())
if apiutil.AbortBadRequestOnError(c, ConfirmManualUpgrade()) {
return
}
}
// StreamServerUpgradeLogsHandler streams upgrade progress snapshots over WebSocket.
// StreamServerUpgradeLogsHandler 通过 WebSocket 推送升级日志。
// @Summary 流式获取服务端升级日志
// @Description 通过 WebSocket 推送升级进度快照,需要管理员权限
// @Tags openflare-update
// @Security SessionCookie
// @Success 101 {object} update.StreamSnapshot "WebSocket 升级日志流"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Router /api/v1/custom/openflare/update/logs/ws [get]
func StreamServerUpgradeLogsHandler(c *gin.Context) {
conn, err := upgradeLogsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
@@ -97,17 +149,9 @@ func StreamServerUpgradeLogsHandler(c *gin.Context) {
}
}
func okWithMessage(c *gin.Context, data any, message string) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": message,
"data": data,
})
}
func bindOptionalJSON(body io.Reader, target any) error {
if err := json.NewDecoder(body).Decode(target); err != nil && !errors.Is(err, io.EOF) {
return err
}
return nil
}
}
+252 -57
View File
@@ -4,52 +4,72 @@
package waf
import (
"errors"
"net/http"
"strconv"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
compat.Fail(c, "记录不存在")
return true
}
compat.Fail(c, err.Error())
return true
return apiutil.AbortNotFoundIfMissing(c, err, "记录不存在")
}
func routeIDParam(c *gin.Context) (uint, bool) {
raw := c.Param("route_id")
if raw == "" {
compat.Fail(c, "invalid id")
response.AbortBadRequest(c, "invalid id")
return 0, false
}
id64, err := strconv.ParseUint(raw, 10, 64)
if err != nil || id64 == 0 {
compat.Fail(c, "invalid id")
response.AbortBadRequest(c, "invalid id")
return 0, false
}
return uint(id64), true
}
// ListRuleGroupsHandler lists all WAF rule groups.
// ListRuleGroupsHandler 列出全部 WAF 规则组。
// @Summary 列出 WAF 规则组
// @Description 返回全部 WAF 规则组,需要管理员权限
// @Tags openflare-waf
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]waf.RuleGroupView} "规则组列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/rule-groups [get]
func ListRuleGroupsHandler(c *gin.Context) {
groups, err := ListRuleGroups(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, groups)
c.JSON(http.StatusOK, response.OK(groups))
}
// GetRuleGroupHandler returns a WAF rule group by id.
// GetRuleGroupHandler 获取 WAF 规则组详情。
// @Summary 获取 WAF 规则组详情
// @Description 按 ID 返回 WAF 规则组详情,需要管理员权限
// @Tags openflare-waf
// @Produce json
// @Security SessionCookie
// @Param id path int true "规则组 ID"
// @Success 200 {object} response.Any{data=waf.RuleGroupView} "规则组详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/rule-groups/{id} [get]
func GetRuleGroupHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -57,69 +77,138 @@ func GetRuleGroupHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, group)
c.JSON(http.StatusOK, response.OK(group))
}
// CreateRuleGroupHandler creates a WAF rule group.
// CreateRuleGroupHandler 创建 WAF 规则组。
// @Summary 创建 WAF 规则组
// @Description 创建新的 WAF 规则组,需要管理员权限
// @Tags openflare-waf
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body waf.RuleGroupInput true "规则组参数"
// @Success 200 {object} response.Any{data=waf.RuleGroupView} "创建成功的规则组"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/rule-groups [post]
func CreateRuleGroupHandler(c *gin.Context) {
var input RuleGroupInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
group, err := CreateRuleGroup(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, group)
c.JSON(http.StatusOK, response.OK(group))
}
// UpdateRuleGroupHandler updates a WAF rule group.
// UpdateRuleGroupHandler 更新 WAF 规则组。
// @Summary 更新 WAF 规则组
// @Description 按 ID 更新 WAF 规则组,需要管理员权限
// @Tags openflare-waf
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "规则组 ID"
// @Param request body waf.RuleGroupInput true "规则组参数"
// @Success 200 {object} response.Any{data=waf.RuleGroupView} "更新后的规则组"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/rule-groups/{id}/update [post]
func UpdateRuleGroupHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input RuleGroupInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
group, err := UpdateRuleGroup(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, group)
c.JSON(http.StatusOK, response.OK(group))
}
// DeleteRuleGroupHandler deletes a WAF rule group.
// DeleteRuleGroupHandler 删除 WAF 规则组。
// @Summary 删除 WAF 规则组
// @Description 按 ID 删除 WAF 规则组,需要管理员权限
// @Tags openflare-waf
// @Produce json
// @Security SessionCookie
// @Param id path int true "规则组 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/rule-groups/{id}/delete [post]
func DeleteRuleGroupHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteRuleGroup(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OKMessage(c, "")
c.JSON(http.StatusOK, response.OKNil())
}
// ReplaceRuleGroupSitesHandler replaces site bindings for a rule group.
// ReplaceRuleGroupSitesHandler 替换规则组绑定的站点。
// @Summary 替换规则组站点绑定
// @Description 替换 WAF 规则组关联的代理站点列表,需要管理员权限
// @Tags openflare-waf
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "规则组 ID"
// @Param request body waf.IDsRequest true "站点 ID 列表"
// @Success 200 {object} response.Any{data=waf.RuleGroupView} "更新后的规则组"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/rule-groups/{id}/sites [post]
func ReplaceRuleGroupSitesHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var request IDsRequest
if !compat.BindJSON(c, &request) {
if !apiutil.BindJSON(c, &request) {
return
}
group, err := ReplaceRuleGroupSites(c.Request.Context(), id, request.IDs)
if handleLogicError(c, err) {
return
}
compat.OK(c, group)
c.JSON(http.StatusOK, response.OK(group))
}
// GetSiteRuleGroupsHandler returns WAF rule groups for a proxy route.
// GetSiteRuleGroupsHandler 获取站点的 WAF 规则组绑定。
// @Summary 获取站点 WAF 规则组
// @Description 返回代理站点关联的 WAF 规则组绑定,需要管理员权限
// @Tags openflare-waf
// @Produce json
// @Security SessionCookie
// @Param route_id path int true "代理路由 ID"
// @Success 200 {object} response.Any{data=waf.SiteRuleGroupsView} "站点规则组绑定"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/sites/{route_id}/rule-groups [get]
func GetSiteRuleGroupsHandler(c *gin.Context) {
routeID, ok := routeIDParam(c)
if !ok {
@@ -129,38 +218,77 @@ func GetSiteRuleGroupsHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
// ReplaceSiteRuleGroupsHandler replaces rule group bindings for a proxy route.
// ReplaceSiteRuleGroupsHandler 替换站点的 WAF 规则组绑定。
// @Summary 替换站点 WAF 规则组
// @Description 替换代理站点关联的 WAF 规则组列表,需要管理员权限
// @Tags openflare-waf
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param route_id path int true "代理路由 ID"
// @Param request body waf.IDsRequest true "规则组 ID 列表"
// @Success 200 {object} response.Any{data=waf.SiteRuleGroupsView} "更新后的站点规则组绑定"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/sites/{route_id}/rule-groups [post]
func ReplaceSiteRuleGroupsHandler(c *gin.Context) {
routeID, ok := routeIDParam(c)
if !ok {
return
}
var request IDsRequest
if !compat.BindJSON(c, &request) {
if !apiutil.BindJSON(c, &request) {
return
}
view, err := ReplaceSiteRuleGroups(c.Request.Context(), routeID, request.IDs)
if handleLogicError(c, err) {
return
}
compat.OK(c, view)
c.JSON(http.StatusOK, response.OK(view))
}
// ListIPGroupsHandler lists all WAF IP groups.
// ListIPGroupsHandler 列出全部 WAF IP 组。
// @Summary 列出 WAF IP 组
// @Description 返回全部 WAF IP 组,需要管理员权限
// @Tags openflare-waf
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]waf.IPGroupView} "IP 组列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/ip-groups [get]
func ListIPGroupsHandler(c *gin.Context) {
groups, err := ListIPGroups(c.Request.Context())
if handleLogicError(c, err) {
return
}
compat.OK(c, groups)
c.JSON(http.StatusOK, response.OK(groups))
}
// GetIPGroupHandler returns a WAF IP group by id.
// GetIPGroupHandler 获取 WAF IP 组详情。
// @Summary 获取 WAF IP 组详情
// @Description 按 ID 返回 WAF IP 组详情,需要管理员权限
// @Tags openflare-waf
// @Produce json
// @Security SessionCookie
// @Param id path int true "IP 组 ID"
// @Success 200 {object} response.Any{data=waf.IPGroupView} "IP 组详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/ip-groups/{id} [get]
func GetIPGroupHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -168,54 +296,108 @@ func GetIPGroupHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, group)
c.JSON(http.StatusOK, response.OK(group))
}
// CreateIPGroupHandler creates a WAF IP group.
// CreateIPGroupHandler 创建 WAF IP 组。
// @Summary 创建 WAF IP 组
// @Description 创建新的 WAF IP 组,需要管理员权限
// @Tags openflare-waf
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body waf.IPGroupInput true "IP 组参数"
// @Success 200 {object} response.Any{data=waf.IPGroupView} "创建成功的 IP 组"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/ip-groups [post]
func CreateIPGroupHandler(c *gin.Context) {
var input IPGroupInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
group, err := CreateIPGroup(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, group)
c.JSON(http.StatusOK, response.OK(group))
}
// UpdateIPGroupHandler updates a WAF IP group.
// UpdateIPGroupHandler 更新 WAF IP 组。
// @Summary 更新 WAF IP 组
// @Description 按 ID 更新 WAF IP 组,需要管理员权限
// @Tags openflare-waf
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "IP 组 ID"
// @Param request body waf.IPGroupInput true "IP 组参数"
// @Success 200 {object} response.Any{data=waf.IPGroupView} "更新后的 IP 组"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/ip-groups/{id}/update [post]
func UpdateIPGroupHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input IPGroupInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
group, err := UpdateIPGroup(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
compat.OK(c, group)
c.JSON(http.StatusOK, response.OK(group))
}
// DeleteIPGroupHandler deletes a WAF IP group.
// DeleteIPGroupHandler 删除 WAF IP 组。
// @Summary 删除 WAF IP 组
// @Description 按 ID 删除 WAF IP 组,需要管理员权限
// @Tags openflare-waf
// @Produce json
// @Security SessionCookie
// @Param id path int true "IP 组 ID"
// @Success 200 {object} response.Any "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/ip-groups/{id}/delete [post]
func DeleteIPGroupHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteIPGroup(c.Request.Context(), id); handleLogicError(c, err) {
return
}
compat.OKMessage(c, "")
c.JSON(http.StatusOK, response.OKNil())
}
// SyncIPGroupHandler triggers a stub sync for a WAF IP group.
// SyncIPGroupHandler 触发 WAF IP 组同步。
// @Summary 同步 WAF IP 组
// @Description 手动触发 WAF IP 组外部 IP 同步,需要管理员权限
// @Tags openflare-waf
// @Produce json
// @Security SessionCookie
// @Param id path int true "IP 组 ID"
// @Success 200 {object} response.Any{data=waf.IPGroupSyncResult} "同步结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 404 {object} response.Any "记录不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/ip-groups/{id}/sync [post]
func SyncIPGroupHandler(c *gin.Context) {
id, ok := compat.IDParam(c)
id, ok := apiutil.IDParam(c)
if !ok {
return
}
@@ -223,18 +405,31 @@ func SyncIPGroupHandler(c *gin.Context) {
if handleLogicError(c, err) {
return
}
compat.OK(c, result)
c.JSON(http.StatusOK, response.OK(result))
}
// TestIPGroupAutoConfigHandler tests automatic IP group configuration (stub).
// TestIPGroupAutoConfigHandler 测试 WAF IP 组自动配置。
// @Summary 测试 WAF IP 组自动配置
// @Description 根据自动配置规则测试 IP 匹配结果(桩实现),需要管理员权限
// @Tags openflare-waf
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param request body waf.IPGroupAutoTestInput true "自动配置参数"
// @Success 200 {object} response.Any{data=waf.IPGroupAutoTestResult} "测试结果"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 403 {object} response.Any "无管理员权限"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/custom/openflare/waf/ip-groups/test [post]
func TestIPGroupAutoConfigHandler(c *gin.Context) {
var input IPGroupAutoTestInput
if !compat.BindJSON(c, &input) {
if !apiutil.BindJSON(c, &input) {
return
}
result, err := TestIPGroupAutoConfig(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
compat.OK(c, result)
}
c.JSON(http.StatusOK, response.OK(result))
}
@@ -137,7 +137,7 @@ CREATE INDEX IF NOT EXISTS idx_templates_created_at ON templates (created_at);
CREATE INDEX IF NOT EXISTS idx_templates_updated_at ON templates (updated_at);
INSERT INTO system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES
('cap_login_enabled', 'false', 'system', 1, '是否启用登录人机验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_login_enabled', 'true', 'system', 1, '是否启用登录人机验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_auto_solve', 'true', 'system', 1, '打开页面后是否自动开始计算,关闭则需用户手动点击触发', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_count', '1', 'system', 0, '客户端需求解的 PoW 难题总数,默认 1,推荐 1~5', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_size', '32', 'system', 0, '人机验证盐值长度', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
@@ -152,8 +152,8 @@ INSERT INTO system_configs (key, value, type, visibility, description, created_a
('upload_allowed_extensions', 'jpg,png,webp', 'system', 1, '允许上传的图片扩展名(逗号分隔)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('site_name', 'OpenFlare', 'system', 1, '系统平台的展示名称', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_login_enabled', 'true', 'system', 1, '是否允许使用账号密码登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('registration_enabled', 'true', 'system', 1, '控制普通用户是否可以自主注册(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_register_enabled', 'true', 'system', 1, '是否允许通过密码创建本地账号', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('registration_enabled', 'false', 'system', 1, '控制普通用户是否可以自主注册(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_register_enabled', 'false', 'system', 1, '是否允许通过密码创建本地账号', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('oidc_login_enabled', 'true', 'system', 1, '是否允许使用第三方 OIDC 认证源登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('max_api_keys_per_user', '5', 'business', 1, '限制每个普通用户可以创建的 API Key 最大数量', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('email_login_verification_enabled', 'false', 'system', 1, '是否开启邮箱登录验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
@@ -0,0 +1,25 @@
-- +goose Up
UPDATE w_system_configs
SET value = 'false', updated_at = CURRENT_TIMESTAMP
WHERE key = 'registration_enabled' AND value = 'true';
UPDATE w_system_configs
SET value = 'false', updated_at = CURRENT_TIMESTAMP
WHERE key = 'password_register_enabled' AND value = 'true';
UPDATE w_system_configs
SET value = 'true', updated_at = CURRENT_TIMESTAMP
WHERE key = 'cap_login_enabled' AND value = 'false';
-- +goose Down
UPDATE w_system_configs
SET value = 'true', updated_at = CURRENT_TIMESTAMP
WHERE key = 'registration_enabled' AND value = 'false';
UPDATE w_system_configs
SET value = 'true', updated_at = CURRENT_TIMESTAMP
WHERE key = 'password_register_enabled' AND value = 'false';
UPDATE w_system_configs
SET value = 'false', updated_at = CURRENT_TIMESTAMP
WHERE key = 'cap_login_enabled' AND value = 'true';
@@ -137,7 +137,7 @@ CREATE INDEX IF NOT EXISTS idx_templates_created_at ON templates (created_at);
CREATE INDEX IF NOT EXISTS idx_templates_updated_at ON templates (updated_at);
INSERT INTO system_configs (key, value, type, visibility, description, created_at, updated_at) VALUES
('cap_login_enabled', 'false', 'system', 1, '是否启用登录人机验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_login_enabled', 'true', 'system', 1, '是否启用登录人机验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_auto_solve', 'true', 'system', 1, '打开页面后是否自动开始计算,关闭则需用户手动点击触发', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_count', '1', 'system', 0, '客户端需求解的 PoW 难题总数,默认 1,推荐 1~5', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('cap_challenge_size', '32', 'system', 0, '人机验证盐值长度', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
@@ -152,8 +152,8 @@ INSERT INTO system_configs (key, value, type, visibility, description, created_a
('upload_allowed_extensions', 'jpg,png,webp', 'system', 1, '允许上传的图片扩展名(逗号分隔)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('site_name', 'OpenFlare', 'system', 1, '系统平台的展示名称', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_login_enabled', 'true', 'system', 1, '是否允许使用账号密码登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('registration_enabled', 'true', 'system', 1, '控制普通用户是否可以自主注册(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_register_enabled', 'true', 'system', 1, '是否允许通过密码创建本地账号', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('registration_enabled', 'false', 'system', 1, '控制普通用户是否可以自主注册(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('password_register_enabled', 'false', 'system', 1, '是否允许通过密码创建本地账号', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('oidc_login_enabled', 'true', 'system', 1, '是否允许使用第三方 OIDC 认证源登录', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('max_api_keys_per_user', '5', 'business', 1, '限制每个普通用户可以创建的 API Key 最大数量', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
('email_login_verification_enabled', 'false', 'system', 1, '是否开启邮箱登录验证(true/false)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP),
@@ -0,0 +1,25 @@
-- +goose Up
UPDATE w_system_configs
SET value = 'false', updated_at = CURRENT_TIMESTAMP
WHERE key = 'registration_enabled' AND value = 'true';
UPDATE w_system_configs
SET value = 'false', updated_at = CURRENT_TIMESTAMP
WHERE key = 'password_register_enabled' AND value = 'true';
UPDATE w_system_configs
SET value = 'true', updated_at = CURRENT_TIMESTAMP
WHERE key = 'cap_login_enabled' AND value = 'false';
-- +goose Down
UPDATE w_system_configs
SET value = 'true', updated_at = CURRENT_TIMESTAMP
WHERE key = 'registration_enabled' AND value = 'false';
UPDATE w_system_configs
SET value = 'true', updated_at = CURRENT_TIMESTAMP
WHERE key = 'password_register_enabled' AND value = 'false';
UPDATE w_system_configs
SET value = 'false', updated_at = CURRENT_TIMESTAMP
WHERE key = 'cap_login_enabled' AND value = 'true';
+3 -3
View File
@@ -15,10 +15,10 @@ import (
"syscall"
"time"
oflegacy "github.com/Rain-kl/Wavelet/internal/apps/openflare/legacy"
"github.com/Rain-kl/Wavelet/internal/apps/risk_control"
router_root "github.com/Rain-kl/Wavelet/internal/router/root"
v1 "github.com/Rain-kl/Wavelet/internal/router/v1"
ofrouter "github.com/Rain-kl/Wavelet/internal/router/v1/openflare"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
"github.com/Rain-kl/Wavelet/internal/config"
@@ -116,8 +116,8 @@ func registerRoutes(r *gin.Engine) {
apiGroup := r.Group(config.Config.App.APIPrefix)
{
// OpenFlare legacy /api/* routes (old frontend compatibility)
oflegacy.RegisterRoutes(apiGroup)
// OpenFlare Agent/Relay/Flared protocol routes under /api/*
ofrouter.RegisterRoutes(apiGroup)
// API V1
apiV1Router := apiGroup.Group("/v1")
+2
View File
@@ -6,6 +6,7 @@ package v1
import (
"github.com/Rain-kl/Wavelet/internal/apps/custom"
ofrouter "github.com/Rain-kl/Wavelet/internal/router/v1/openflare"
"github.com/gin-gonic/gin"
)
@@ -14,5 +15,6 @@ func RegisterCustomRoutes(apiV1Router *gin.RouterGroup) {
customRouter := apiV1Router.Group("/custom")
{
customRouter.GET("/hello", custom.Hello)
ofrouter.RegisterV1Routes(customRouter)
}
}
@@ -0,0 +1,16 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package openflare registers OpenFlare HTTP routes.
// Management console APIs are mounted via RegisterV1Routes under /api/v1/custom/openflare.
// Agent/Relay/Flared protocol routes are mounted via RegisterRoutes under /api.
package openflare
import "github.com/gin-gonic/gin"
// RegisterRoutes mounts Agent/Relay/Flared protocol routes under the /api group.
func RegisterRoutes(apiGroup *gin.RouterGroup) {
registerAgentRoutes(apiGroup)
registerRelayRoutes(apiGroup)
registerFlaredRoutes(apiGroup)
}
@@ -0,0 +1,31 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/agent"
"github.com/gin-gonic/gin"
)
func registerAgentRoutes(apiGroup *gin.RouterGroup) {
agentRoute := apiGroup.Group("/agent")
{
discoveryRoute := agentRoute.Group("/")
discoveryRoute.Use(agent.AgentRegisterAuth())
{
discoveryRoute.POST("/nodes/register", agent.RegisterHandler)
}
authorizedRoute := agentRoute.Group("/")
authorizedRoute.Use(agent.AgentAuth())
{
authorizedRoute.GET("/ws", agent.AgentWebSocketHandler)
authorizedRoute.POST("/nodes/heartbeat", agent.HeartbeatHandler)
authorizedRoute.GET("/config-versions/active", agent.GetActiveConfigHandler)
authorizedRoute.GET("/pages/deployments/:deployment_id/package", agent.DownloadPagesPackageHandler)
authorizedRoute.POST("/waf/ip-groups/sync", agent.SyncWAFIPGroupsHandler)
authorizedRoute.POST("/apply-logs", agent.ReportApplyLogHandler)
}
}
}
@@ -1,19 +1,19 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apply_log"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/gin-gonic/gin"
)
func registerApplyLogRoutes(apiGroup *gin.RouterGroup) {
applyLogRoute := apiGroup.Group("/apply-logs")
applyLogRoute.Use(compat.AdminAuth())
applyLogRoute.Use(apiutil.AdminRequired())
{
compat.RegisterCollection(applyLogRoute, "GET", apply_log.GetApplyLogs)
apiutil.RegisterCollection(applyLogRoute, "GET", apply_log.GetApplyLogs)
applyLogRoute.POST("/cleanup", apply_log.CleanupApplyLogs)
}
}
@@ -1,19 +1,19 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/config_version"
"github.com/gin-gonic/gin"
)
func registerConfigVersionRoutes(apiGroup *gin.RouterGroup) {
configVersionGroup := apiGroup.Group("/config-versions")
configVersionGroup.Use(compat.AdminAuth())
configVersionGroup.Use(apiutil.AdminRequired())
{
compat.RegisterCollection(configVersionGroup, "GET", config_version.ListConfigVersionsHandler)
apiutil.RegisterCollection(configVersionGroup, "GET", config_version.ListConfigVersionsHandler)
configVersionGroup.GET("/active", config_version.GetActiveConfigVersionHandler)
configVersionGroup.GET("/preview", config_version.PreviewConfigVersionHandler)
configVersionGroup.GET("/diff", config_version.DiffConfigVersionHandler)
@@ -0,0 +1,18 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/dashboard"
"github.com/gin-gonic/gin"
)
func registerDashboardRoutes(apiGroup *gin.RouterGroup) {
dashboardRoute := apiGroup.Group("/dashboard")
dashboardRoute.Use(apiutil.AdminRequired())
{
dashboardRoute.GET("/overview", dashboard.GetOverviewHandler)
}
}
@@ -1,22 +1,22 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/node"
"github.com/gin-gonic/gin"
)
func registerNodeRoutes(apiGroup *gin.RouterGroup) {
nodeRoute := apiGroup.Group("/nodes")
nodeRoute.Use(compat.AdminAuth())
nodeRoute.Use(apiutil.AdminRequired())
{
nodeRoute.GET("/bootstrap-token", node.GetBootstrapTokenHandler)
nodeRoute.POST("/bootstrap-token/rotate", node.RotateBootstrapTokenHandler)
compat.RegisterCollection(nodeRoute, "GET", node.ListNodesHandler)
compat.RegisterCollection(nodeRoute, "POST", node.CreateNodeHandler)
apiutil.RegisterCollection(nodeRoute, "GET", node.ListNodesHandler)
apiutil.RegisterCollection(nodeRoute, "POST", node.CreateNodeHandler)
nodeRoute.GET("/:id/agent-release", node.GetAgentReleaseHandler)
nodeRoute.POST("/:id/update", node.UpdateNodeHandler)
nodeRoute.POST("/:id/delete", node.DeleteNodeHandler)
@@ -0,0 +1,23 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/observability"
"github.com/gin-gonic/gin"
)
func registerObservabilityRoutes(apiGroup *gin.RouterGroup) {
accessLogRoute := apiGroup.Group("/access-logs")
accessLogRoute.Use(apiutil.AdminRequired())
{
apiutil.RegisterCollection(accessLogRoute, "GET", observability.GetAccessLogsHandler)
accessLogRoute.GET("/folds", observability.GetFoldedAccessLogsHandler)
accessLogRoute.GET("/folds/ip-summary", observability.GetFoldedAccessLogIPsHandler)
accessLogRoute.GET("/ip-summary", observability.GetAccessLogIPSummariesHandler)
accessLogRoute.GET("/ip-summary/trend", observability.GetAccessLogIPTrendHandler)
accessLogRoute.POST("/cleanup", observability.CleanupAccessLogsHandler)
}
}
@@ -0,0 +1,32 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/option"
"github.com/gin-gonic/gin"
)
func registerOptionRoutes(apiGroup *gin.RouterGroup) {
apiGroup.GET("/status", option.GetStatusHandler)
apiGroup.GET("/notice", option.GetNoticeHandler)
apiGroup.GET("/about", option.GetAboutHandler)
optionRoute := apiGroup.Group("/option")
optionRoute.Use(apiutil.AdminRequired())
{
apiutil.RegisterCollection(optionRoute, "GET", option.ListOptionsHandler)
optionRoute.POST("/update", option.UpdateOptionHandler)
optionRoute.POST("/update-batch", option.UpdateOptionsBatchHandler)
optionRoute.POST("/geoip/lookup", option.LookupGeoIPHandler)
optionRoute.POST("/database/cleanup", option.CleanupDatabaseHandler)
}
uptimeKumaRoute := apiGroup.Group("/uptimekuma")
uptimeKumaRoute.Use(apiutil.AdminRequired())
{
uptimeKumaRoute.POST("/sync", option.SyncUptimeKumaHandler)
}
}
@@ -1,21 +1,21 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/origin"
"github.com/gin-gonic/gin"
)
func registerOriginRoutes(apiGroup *gin.RouterGroup) {
originRoute := apiGroup.Group("/origins")
originRoute.Use(compat.AdminAuth())
originRoute.Use(apiutil.AdminRequired())
{
compat.RegisterCollection(originRoute, "GET", origin.GetOrigins)
apiutil.RegisterCollection(originRoute, "GET", origin.GetOrigins)
originRoute.GET("/:id", origin.GetOrigin)
compat.RegisterCollection(originRoute, "POST", origin.CreateOriginHandler)
apiutil.RegisterCollection(originRoute, "POST", origin.CreateOriginHandler)
originRoute.POST("/:id/update", origin.UpdateOriginHandler)
originRoute.POST("/:id/delete", origin.DeleteOriginHandler)
}
@@ -1,21 +1,21 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/pages"
"github.com/gin-gonic/gin"
)
func registerPagesRoutes(apiGroup *gin.RouterGroup) {
pagesRoute := apiGroup.Group("/pages")
pagesRoute.Use(compat.AdminAuth())
pagesRoute.Use(apiutil.AdminRequired())
{
compat.RegisterCollection(pagesRoute, "GET", pages.ListProjectsHandler)
apiutil.RegisterCollection(pagesRoute, "GET", pages.ListProjectsHandler)
pagesRoute.GET("/:id", pages.GetProjectHandler)
compat.RegisterCollection(pagesRoute, "POST", pages.CreateProjectHandler)
apiutil.RegisterCollection(pagesRoute, "POST", pages.CreateProjectHandler)
pagesRoute.POST("/:id/update", pages.UpdateProjectHandler)
pagesRoute.POST("/:id/delete", pages.DeleteProjectHandler)
pagesRoute.GET("/:id/deployments", pages.ListDeploymentsHandler)
@@ -1,21 +1,21 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/proxy_route"
"github.com/gin-gonic/gin"
)
func registerProxyRouteRoutes(apiGroup *gin.RouterGroup) {
proxyRouteGroup := apiGroup.Group("/proxy-routes")
proxyRouteGroup.Use(compat.AdminAuth())
proxyRouteGroup.Use(apiutil.AdminRequired())
{
compat.RegisterCollection(proxyRouteGroup, "GET", proxy_route.GetProxyRoutes)
apiutil.RegisterCollection(proxyRouteGroup, "GET", proxy_route.GetProxyRoutes)
proxyRouteGroup.GET("/:id", proxy_route.GetProxyRouteHandler)
compat.RegisterCollection(proxyRouteGroup, "POST", proxy_route.CreateProxyRouteHandler)
apiutil.RegisterCollection(proxyRouteGroup, "POST", proxy_route.CreateProxyRouteHandler)
proxyRouteGroup.POST("/:id/update", proxy_route.UpdateProxyRouteHandler)
proxyRouteGroup.POST("/:id/delete", proxy_route.DeleteProxyRouteHandler)
}
@@ -1,7 +1,7 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/flared"
@@ -9,14 +9,16 @@ import (
"github.com/gin-gonic/gin"
)
func registerRelayFlaredRoutes(apiGroup *gin.RouterGroup) {
func registerRelayRoutes(apiGroup *gin.RouterGroup) {
relayRoute := apiGroup.Group("/relay")
relayRoute.Use(relay.RelayAuth())
{
relayRoute.POST("/heartbeat", relay.PostHeartbeat)
relayRoute.GET("/ws", relay.GetWebSocket)
}
}
func registerFlaredRoutes(apiGroup *gin.RouterGroup) {
flaredRoute := apiGroup.Group("/flared")
flaredRoute.Use(flared.TunnelAuth())
{
@@ -1,32 +1,32 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/tls"
"github.com/gin-gonic/gin"
)
func registerTLSRoutes(apiGroup *gin.RouterGroup) {
managedDomainRoute := apiGroup.Group("/managed-domains")
managedDomainRoute.Use(compat.AdminAuth())
managedDomainRoute.Use(apiutil.AdminRequired())
{
compat.RegisterCollection(managedDomainRoute, "GET", tls.GetManagedDomains)
apiutil.RegisterCollection(managedDomainRoute, "GET", tls.GetManagedDomains)
managedDomainRoute.GET("/match", tls.MatchManagedDomainCertificateHandler)
compat.RegisterCollection(managedDomainRoute, "POST", tls.CreateManagedDomainHandler)
apiutil.RegisterCollection(managedDomainRoute, "POST", tls.CreateManagedDomainHandler)
managedDomainRoute.POST("/:id/update", tls.UpdateManagedDomainHandler)
managedDomainRoute.POST("/:id/delete", tls.DeleteManagedDomainHandler)
}
tlsCertificateRoute := apiGroup.Group("/tls-certificates")
tlsCertificateRoute.Use(compat.AdminAuth())
tlsCertificateRoute.Use(apiutil.AdminRequired())
{
compat.RegisterCollection(tlsCertificateRoute, "GET", tls.GetCertificates)
apiutil.RegisterCollection(tlsCertificateRoute, "GET", tls.GetCertificates)
tlsCertificateRoute.GET("/:id", tls.GetCertificateDetail)
tlsCertificateRoute.GET("/:id/content", tls.GetCertificateContentHandler)
compat.RegisterCollection(tlsCertificateRoute, "POST", tls.CreateCertificateHandler)
apiutil.RegisterCollection(tlsCertificateRoute, "POST", tls.CreateCertificateHandler)
tlsCertificateRoute.POST("/:id/update", tls.UpdateCertificateHandler)
tlsCertificateRoute.POST("/:id/update-acme", tls.UpdateACMECertificateHandler)
tlsCertificateRoute.POST("/:id/convert-acme", tls.ConvertCertificateToACMEHandler)
@@ -37,16 +37,16 @@ func registerTLSRoutes(apiGroup *gin.RouterGroup) {
}
acmeAccountRoute := apiGroup.Group("/acme-accounts")
acmeAccountRoute.Use(compat.AdminAuth())
acmeAccountRoute.Use(apiutil.AdminRequired())
{
acmeAccountRoute.GET("/default", tls.GetDefaultAcmeAccountHandler)
}
dnsAccountRoute := apiGroup.Group("/dns-accounts")
dnsAccountRoute.Use(compat.AdminAuth())
dnsAccountRoute.Use(apiutil.AdminRequired())
{
compat.RegisterCollection(dnsAccountRoute, "GET", tls.GetDNSAccounts)
compat.RegisterCollection(dnsAccountRoute, "POST", tls.CreateDNSAccountHandler)
apiutil.RegisterCollection(dnsAccountRoute, "GET", tls.GetDNSAccounts)
apiutil.RegisterCollection(dnsAccountRoute, "POST", tls.CreateDNSAccountHandler)
dnsAccountRoute.POST("/:id/update", tls.UpdateDNSAccountHandler)
dnsAccountRoute.POST("/:id/delete", tls.DeleteDNSAccountHandler)
}
@@ -1,17 +1,17 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package legacy
package openflare
import (
"github.com/Rain-kl/Wavelet/internal/apps/openflare/compat"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/update"
"github.com/gin-gonic/gin"
)
func registerMiscRoutes(apiGroup *gin.RouterGroup) {
func registerUpdateRoutes(apiGroup *gin.RouterGroup) {
updateRoute := apiGroup.Group("/update")
updateRoute.Use(compat.RootAuth())
updateRoute.Use(apiutil.AdminRequired())
{
updateRoute.GET("/latest-release", update.GetLatestReleaseHandler)
updateRoute.GET("/logs/ws", update.StreamServerUpgradeLogsHandler)

Some files were not shown because too many files have changed in this diff Show More