🎉 first commit

This commit is contained in:
LIlGG
2025-09-24 13:06:25 +08:00
commit 1f4fb103e9
409 changed files with 61222 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
import { deleteNetlifyConnectionSettings, saveNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.netlify.auth');
export type HandleAuthArgs = {
request: Request;
userId: string;
};
export async function handleNetlifyAuth({ request, userId }: HandleAuthArgs) {
try {
const { token } = await request.json();
if (!token) {
return errorResponse(400, '缺少令牌参数');
}
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
await deleteNetlifyConnectionSettings(userId);
return errorResponse(401, '无效的令牌或未经授权');
}
const userData = await response.json();
await saveNetlifyConnectionSettings(userId, token);
logger.info(`用户 ${userId} 成功验证并保存了 Netlify 令牌`);
return successResponse(
{
isConnect: !!userData,
},
'Netlify 令牌验证成功',
);
} catch (error) {
logger.error('验证 Netlify 令牌失败:', error);
return errorResponse(500, '验证 Netlify 令牌失败');
}
}

View File

@@ -0,0 +1,68 @@
import { getNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { deleteDeploymentById, getDeploymentById } from '~/lib/.server/deployment';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.netlify.delete');
export type DeletePageArgs = {
userId: string;
request: Request;
};
export async function deleteNetlifySite(token: string, siteId: string): Promise<void> {
try {
const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const errorData = (await response.json()) as { message: string };
logger.error(`删除站点失败: ${response.status} ${errorData.message}`);
throw new Error(`${errorData.message}`);
}
} catch (error) {
logger.error(`删除站点失败:`, error);
throw new Error(`${error}`);
}
}
export async function deletePage({ userId, request }: DeletePageArgs) {
const { id } = await request.json();
try {
// 查找部署记录
const deployment = await getDeploymentById(id);
if (!deployment) {
return errorResponse(404, '未找到部署记录');
}
// 获取Netlify连接设置
const connectionSettings = await getNetlifyConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到Netlify');
}
const siteId = (deployment.metadata as Record<string, any>)?.siteId;
if (!siteId) {
return errorResponse(400, '部署记录缺少必要信息');
}
// 删除站点
await deleteNetlifySite(connectionSettings.token, siteId);
// 删除部署记录
await deleteDeploymentById(id);
logger.info(`用户 ${userId} 已删除 Netlify 部署 ${id}`);
return successResponse(id, '页面已删除');
} catch (error) {
logger.error(`删除 Netlify 部署 ${id} 失败:`, error);
return errorResponse(500, error instanceof Error ? error.message : '删除失败');
}
}

View File

@@ -0,0 +1,319 @@
import { getNetlifyConnectionSettings, saveNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { createOrUpdateDeployment, getLatestDeployment } from '~/lib/.server/deployment';
import { createScopedLogger } from '~/lib/.server/logger';
import { DeploymentPlatformEnum, DeploymentStatusEnum } from '~/types/deployment';
import type { NetlifySiteInfo } from '~/types/netlify';
import { errorResponse, successResponse } from '~/utils/api-response';
import { binaryStringToUint8Array, isBinaryString } from '~/utils/file-utils';
export type HandleDeployArgs = {
request: Request;
userId: string;
};
interface DeployRequestBody {
siteId?: string;
files: Record<string, string>;
chatId: string;
token?: string;
}
/**
* 计算字符串或二进制数据的 SHA1 哈希值
*
* @param message 要计算哈希的字符串或二进制数据
* @returns SHA1 哈希值
*/
async function sha1(message: string) {
// 检查是否为二进制字符串
let msgBuffer;
if (isBinaryString(message)) {
// 对于二进制字符串,使用 Buffer.from 处理
const buffer = Buffer.from(message, 'binary');
msgBuffer = new Uint8Array(buffer);
} else {
// 对于普通字符串,使用 TextEncoder
msgBuffer = new TextEncoder().encode(message);
}
const hashBuffer = await crypto.subtle.digest('SHA-1', msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
const logger = createScopedLogger('api.netlify.deploy');
export async function handleDeploy({ request, userId }: HandleDeployArgs) {
try {
const { siteId, files, token: requestToken, chatId } = (await request.json()) as DeployRequestBody;
let connectionSettings = await getNetlifyConnectionSettings(userId);
if (requestToken) {
connectionSettings = {
token: requestToken,
};
await saveNetlifyConnectionSettings(userId, requestToken);
}
if (!connectionSettings) {
logger.warn('未连接到Netlify');
return errorResponse(401, '未连接到Netlify请先设置访问令牌');
}
const { token } = connectionSettings;
const existingDeployment = await getLatestDeployment(userId, chatId, DeploymentPlatformEnum.NETLIFY);
let targetSiteId = siteId ? siteId : existingDeployment?.deploymentId ? existingDeployment.deploymentId : undefined;
let siteInfo: NetlifySiteInfo | undefined;
if (targetSiteId) {
const siteResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (siteResponse.ok) {
const existingSite = (await siteResponse.json()) as any;
siteInfo = {
id: existingSite.id,
name: existingSite.name,
url: existingSite.url,
chatId,
};
}
}
if (!siteInfo) {
const siteName = `upage-${chatId}-${Date.now()}`;
const createSiteResponse = await fetch('https://api.netlify.com/api/v1/sites', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: siteName,
custom_domain: null,
}),
});
if (!createSiteResponse.ok) {
return errorResponse(400, 'Failed to create site');
}
const newSite = (await createSiteResponse.json()) as any;
targetSiteId = newSite.id;
siteInfo = {
id: newSite.id,
name: newSite.name,
url: newSite.url,
chatId,
};
}
// Create file digests
const fileDigests: Record<string, string> = {};
const filePathsAndHashes: Record<string, string> = {};
for (const [filePath, content] of Object.entries(files)) {
// Ensure file path starts with a forward slash
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
const hash = await sha1(content);
fileDigests[normalizedPath] = hash;
filePathsAndHashes[normalizedPath] = hash;
}
// Create a new deploy with digests
const deployResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: fileDigests,
async: true,
skip_processing: false,
draft: false,
function_schedules: [],
required: Object.keys(fileDigests),
framework: null,
}),
});
if (!deployResponse.ok) {
const errorText = await deployResponse.text();
logger.error(`创建部署失败: ${deployResponse.status} - ${errorText}`);
return errorResponse(400, `Failed to create deployment: ${errorText}`);
}
const deploy = (await deployResponse.json()) as any;
let retryCount = 0;
const maxRetries = 60;
let deploymentStatus;
while (retryCount < maxRetries) {
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!statusResponse.ok) {
if (statusResponse.status === 401) {
return errorResponse(401, '链接已过期,请重新设置访问令牌。');
}
return errorResponse(400, '获取部署状态失败');
}
deploymentStatus = (await statusResponse.json()) as any;
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
logger.info('部署完成,状态:', deploymentStatus.state);
break;
}
if (deploymentStatus.state === 'prepared' || deploymentStatus.state === 'uploaded') {
// Upload all files regardless of required array
for (const [filePath, content] of Object.entries(files)) {
const normalizedPath = filePath.startsWith('/') ? filePath : '/' + filePath;
logger.info(`准备上传文件: ${normalizedPath}, 是否二进制: ${isBinaryString(content)}`);
let uploadSuccess = false;
let uploadRetries = 0;
while (!uploadSuccess && uploadRetries < 3) {
try {
let uploadBody: string | Uint8Array | ArrayBuffer;
if (isBinaryString(content)) {
uploadBody = binaryStringToUint8Array(content);
} else {
uploadBody = content;
}
const uploadResponse = await fetch(
`https://api.netlify.com/api/v1/deploys/${deploy.id}/files${normalizedPath}`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/octet-stream',
},
body: uploadBody as BodyInit,
},
);
uploadSuccess = uploadResponse.ok;
if (!uploadSuccess) {
if (uploadResponse.status === 422) {
logger.warn(
`Upload failed for ${normalizedPath} (${uploadResponse.status}, But it may be uploaded successfully`,
);
uploadSuccess = true;
} else {
const errorText = await uploadResponse.text();
logger.error(`Upload failed for ${normalizedPath} (${uploadResponse.status}): ${errorText}`);
uploadRetries++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
} else {
logger.info(`Successfully uploaded ${normalizedPath}`);
}
} catch (error) {
logger.error('Upload error:', error);
uploadRetries++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
if (!uploadSuccess) {
return errorResponse(500, `上传文件失败: ${filePath}`);
}
}
break;
}
if (deploymentStatus.state === 'error') {
return errorResponse(500, deploymentStatus.error_message || 'Deploy preparation failed');
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (retryCount >= maxRetries) {
return errorResponse(500, 'Deploy preparation timed out');
}
// 第二阶段:轮询直到部署完成
logger.info('文件上传完成,等待部署完成...');
retryCount = 0;
const maxDeploymentRetries = 60; // 60秒超时
while (retryCount < maxDeploymentRetries) {
const statusResponse = await fetch(`https://api.netlify.com/api/v1/sites/${targetSiteId}/deploys/${deploy.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
deploymentStatus = (await statusResponse.json()) as any;
if (deploymentStatus.state === 'ready' || deploymentStatus.state === 'uploaded') {
logger.info('部署完成,状态:', deploymentStatus.state);
break;
}
if (deploymentStatus.state === 'error') {
return errorResponse(500, deploymentStatus.error_message || 'Deployment failed');
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (retryCount >= maxDeploymentRetries) {
return errorResponse(500, 'Deployment timed out');
}
try {
await createOrUpdateDeployment({
userId,
chatId,
platform: DeploymentPlatformEnum.NETLIFY,
deploymentId: deploymentStatus.id,
url: deploymentStatus.ssl_url || deploymentStatus.url,
status: DeploymentStatusEnum.SUCCESS,
metadata: {
siteId: siteInfo.id,
siteName: siteInfo.name,
},
});
logger.info(`为用户 ${userId} 创建或更新了 Netlify 部署记录`);
} catch (error) {
logger.error('创建部署记录失败:', error);
}
return successResponse(
{
deploy: {
id: deploymentStatus.id,
state: deploymentStatus.state,
url: deploymentStatus.ssl_url || deploymentStatus.url,
},
site: siteInfo,
},
'部署成功',
);
} catch (error) {
logger.error('Deploy error:', error);
return errorResponse(500, 'Deployment failed');
}
}

View File

@@ -0,0 +1,60 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { createScopedLogger } from '~/lib/.server/logger';
import { errorResponse } from '~/utils/api-response';
import { handleNetlifyAuth } from './auth.server';
import { deletePage } from './delete.server';
import { handleDeploy } from './deploy.server';
import { getNetlifyStats } from './stats.server';
import { toggleAccess } from './toggle-access.server';
const logger = createScopedLogger('api.netlify.route');
export async function loader(args: LoaderFunctionArgs) {
const { request, params } = args;
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
switch (params.action) {
case 'stats':
return getNetlifyStats({ userId });
default:
return errorResponse(404, `未知的 API 操作: ${params.action}`);
}
}
export async function action(args: ActionFunctionArgs) {
const { request, params } = args;
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
logger.debug('处理 Netlify API 请求', { action: params.action });
switch (params.action) {
case 'deploy':
return handleDeploy({ ...args, userId });
case 'auth':
return handleNetlifyAuth({ request, userId });
case 'toggle-access':
return toggleAccess({ ...args, userId });
case 'delete':
return deletePage({ ...args, userId });
default:
logger.warn('未知的 API 操作', { action: params.action });
return errorResponse(404, `未知的 API 操作: ${params.action}`);
}
}

View File

@@ -0,0 +1,80 @@
import { getNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.netlify.stats');
export type GetNetlifyStatsArgs = {
userId: string;
};
export async function getNetlifyStats({ userId }: GetNetlifyStatsArgs) {
try {
const connectionSettings = await getNetlifyConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到Netlify请先设置访问令牌');
}
const { token } = connectionSettings;
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!sitesResponse.ok) {
return errorResponse(sitesResponse.status, '获取站点列表失败');
}
const sitesData = await sitesResponse.json();
let deploysData = [];
let buildsData = [];
let lastDeployTime = '';
if (sitesData && sitesData.length > 0) {
const firstSite = sitesData[0];
const deploysResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/deploys`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (deploysResponse.ok) {
deploysData = await deploysResponse.json();
if (deploysData.length > 0) {
lastDeployTime = deploysData[0].created_at;
const buildsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/builds`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (buildsResponse.ok) {
buildsData = await buildsResponse.json();
}
}
}
}
return successResponse(
{
sites: sitesData,
deploys: deploysData,
builds: buildsData,
lastDeployTime,
totalSites: sitesData.length,
},
'获取 Netlify 站点统计信息成功',
);
} catch (error) {
logger.error('获取 Netlify 站点统计信息失败:', error);
return errorResponse(500, '获取 Netlify 站点统计信息失败');
}
}

View File

@@ -0,0 +1,89 @@
import { getNetlifyConnectionSettings } from '~/lib/.server/connectionSettings';
import { getDeploymentById, updateDeploymentStatus } from '~/lib/.server/deployment';
import { request } from '~/lib/fetch';
import type { NetlifySite } from '~/types/netlify';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
import { generateUUID } from '~/utils/uuid';
const logger = createScopedLogger('api.netlify.toggle-access');
export type ToggleAccessArgs = {
userId: string;
request: Request;
};
export async function setNetlifySiteName(token: string, siteId: string, name: string): Promise<NetlifySite> {
try {
const response = await request(`https://api.netlify.com/api/v1/sites/${siteId}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name }),
});
if (!response.ok) {
const errorData = (await response.json()) as {
message: string;
};
throw new Error(errorData.message);
}
const result = (await response.json()) as NetlifySite;
return result;
} catch (error) {
logger.error(`设置 Netlify 站点 ${siteId} 名称失败:`, error);
throw new Error(`${error}`);
}
}
export async function toggleAccess({ userId, request }: ToggleAccessArgs) {
const { id } = await request.json();
try {
const deployment = await getDeploymentById(id);
if (!deployment) {
return errorResponse(404, '未找到部署记录');
}
// 获取Netlify连接设置
const connectionSettings = await getNetlifyConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到Netlify');
}
const metadata = deployment.metadata as Record<string, any> | null;
const siteId = metadata?.siteId;
if (!siteId) {
return errorResponse(400, '部署记录缺少必要信息');
}
let siteName = metadata?.siteName;
// 获取当前状态
const currentStatus = deployment.status;
const newStatus = currentStatus === 'inactive' ? 'success' : 'inactive';
if (newStatus === 'inactive') {
// 为站点设置一个其他的别名,格式为 upage-inactive-${uuid}
siteName = `upage-inactive-${generateUUID()}`;
} else {
if (!siteName) {
const url = new URL(deployment.url);
siteName = url.hostname.split('.')[0];
}
}
// 设置 name 为当前的 siteName
await setNetlifySiteName(connectionSettings.token, siteId, siteName);
// 更新状态
await updateDeploymentStatus(id, newStatus);
logger.info(`用户 ${userId}${newStatus === 'success' ? '开启' : '停止'} Netlify 站点 ${siteId} 的访问`);
return successResponse(id, `${newStatus === 'success' ? '开启' : '停止'}访问`);
} catch (error) {
logger.error(`切换Netlify部署 ${id} 访问状态失败:`, error);
return errorResponse(500, error instanceof Error ? error.message : '操作失败');
}
}