mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
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:
+9991
-4
File diff suppressed because it is too large
Load Diff
+9991
-4
File diff suppressed because it is too large
Load Diff
+6208
-4
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"] })
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}`}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+3
-7
@@ -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 = ¤t.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()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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)
|
||||
}
|
||||
+5
-5
@@ -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)
|
||||
+5
-5
@@ -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)
|
||||
}
|
||||
+4
-2
@@ -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())
|
||||
{
|
||||
+12
-12
@@ -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)
|
||||
}
|
||||
+4
-4
@@ -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
Reference in New Issue
Block a user