add sign in page

This commit is contained in:
yoyuzh
2026-03-18 11:50:03 +08:00
parent 7518dc158f
commit 8b0f77fa21
14 changed files with 1408 additions and 130 deletions

154
scripts/deploy-front-oss.mjs Executable file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import {spawnSync} from 'node:child_process';
import {
buildObjectKey,
createAuthorizationHeader,
encodeObjectKey,
getCacheControl,
getContentType,
listFiles,
normalizeEndpoint,
parseSimpleEnv,
} from './oss-deploy-lib.mjs';
const repoRoot = process.cwd();
const frontDir = path.join(repoRoot, 'front');
const distDir = path.join(frontDir, 'dist');
const envFilePath = path.join(repoRoot, '.env.oss.local');
function parseArgs(argv) {
return {
dryRun: argv.includes('--dry-run'),
skipBuild: argv.includes('--skip-build'),
};
}
async function loadEnvFileIfPresent() {
try {
const raw = await fs.readFile(envFilePath, 'utf-8');
const values = parseSimpleEnv(raw);
for (const [key, value] of Object.entries(values)) {
if (!process.env[key]) {
process.env[key] = value;
}
}
} catch (error) {
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
return;
}
throw error;
}
}
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function runBuild() {
const result = spawnSync('npm', ['run', 'build'], {
cwd: frontDir,
stdio: 'inherit',
shell: process.platform === 'win32',
});
if (result.status !== 0) {
throw new Error('Frontend build failed');
}
}
async function uploadFile({
bucket,
endpoint,
objectKey,
filePath,
accessKeyId,
accessKeySecret,
}) {
const body = await fs.readFile(filePath);
const contentType = getContentType(objectKey);
const date = new Date().toUTCString();
const url = `https://${bucket}.${normalizeEndpoint(endpoint)}/${encodeObjectKey(objectKey)}`;
const authorization = createAuthorizationHeader({
method: 'PUT',
bucket,
objectKey,
contentType,
date,
accessKeyId,
accessKeySecret,
});
const response = await fetch(url, {
method: 'PUT',
headers: {
Authorization: authorization,
'Cache-Control': getCacheControl(objectKey),
'Content-Length': String(body.byteLength),
'Content-Type': contentType,
Date: date,
},
body,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload failed for ${objectKey}: ${response.status} ${response.statusText}\n${text}`);
}
}
async function main() {
const {dryRun, skipBuild} = parseArgs(process.argv.slice(2));
await loadEnvFileIfPresent();
const accessKeyId = requireEnv('YOYUZH_OSS_ACCESS_KEY_ID');
const accessKeySecret = requireEnv('YOYUZH_OSS_ACCESS_KEY_SECRET');
const endpoint = requireEnv('YOYUZH_OSS_ENDPOINT');
const bucket = requireEnv('YOYUZH_OSS_BUCKET');
const remotePrefix = process.env.YOYUZH_OSS_PREFIX || '';
if (!skipBuild) {
runBuild();
}
const files = await listFiles(distDir);
if (files.length === 0) {
throw new Error('No files found in front/dist. Run the frontend build first.');
}
for (const filePath of files) {
const relativePath = path.relative(distDir, filePath).split(path.sep).join('/');
const objectKey = buildObjectKey(remotePrefix, relativePath);
if (dryRun) {
console.log(`[dry-run] upload ${relativePath} -> ${objectKey}`);
continue;
}
await uploadFile({
bucket,
endpoint,
objectKey,
filePath,
accessKeyId,
accessKeySecret,
});
console.log(`uploaded ${objectKey}`);
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});

126
scripts/oss-deploy-lib.mjs Normal file
View File

@@ -0,0 +1,126 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
const CONTENT_TYPES = new Map([
['.css', 'text/css; charset=utf-8'],
['.html', 'text/html; charset=utf-8'],
['.ico', 'image/x-icon'],
['.jpeg', 'image/jpeg'],
['.jpg', 'image/jpeg'],
['.js', 'text/javascript; charset=utf-8'],
['.json', 'application/json; charset=utf-8'],
['.map', 'application/json; charset=utf-8'],
['.png', 'image/png'],
['.svg', 'image/svg+xml'],
['.txt', 'text/plain; charset=utf-8'],
['.webmanifest', 'application/manifest+json; charset=utf-8'],
]);
export function normalizeEndpoint(endpoint) {
return endpoint.replace(/^https?:\/\//, '').replace(/\/+$/, '');
}
export function buildObjectKey(prefix, relativePath) {
const cleanPrefix = prefix.replace(/^\/+|\/+$/g, '');
const cleanRelativePath = relativePath.replace(/^\/+/, '');
return cleanPrefix ? `${cleanPrefix}/${cleanRelativePath}` : cleanRelativePath;
}
export function getCacheControl(relativePath) {
if (relativePath === 'index.html') {
return 'no-cache';
}
if (relativePath.startsWith('assets/')) {
return 'public,max-age=31536000,immutable';
}
return 'public,max-age=300';
}
export function getContentType(relativePath) {
const ext = path.extname(relativePath).toLowerCase();
return CONTENT_TYPES.get(ext) || 'application/octet-stream';
}
export function createAuthorizationHeader({
method,
bucket,
objectKey,
contentType,
date,
accessKeyId,
accessKeySecret,
}) {
const stringToSign = [
method.toUpperCase(),
'',
contentType,
date,
`/${bucket}/${objectKey}`,
].join('\n');
const signature = crypto
.createHmac('sha1', accessKeySecret)
.update(stringToSign)
.digest('base64');
return `OSS ${accessKeyId}:${signature}`;
}
export function encodeObjectKey(objectKey) {
return objectKey
.split('/')
.map((part) => encodeURIComponent(part))
.join('/');
}
export async function listFiles(rootDir) {
const entries = await fs.readdir(rootDir, {withFileTypes: true});
const files = [];
for (const entry of entries) {
const absolutePath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
files.push(...(await listFiles(absolutePath)));
continue;
}
if (entry.isFile()) {
files.push(absolutePath);
}
}
return files.sort();
}
export function parseSimpleEnv(rawText) {
const parsed = {};
for (const line of rawText.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const separatorIndex = trimmed.indexOf('=');
if (separatorIndex === -1) {
continue;
}
const key = trimmed.slice(0, separatorIndex).trim();
let value = trimmed.slice(separatorIndex + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
parsed[key] = value;
}
return parsed;
}

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildObjectKey,
createAuthorizationHeader,
getCacheControl,
getContentType,
normalizeEndpoint,
} from './oss-deploy-lib.mjs';
test('normalizeEndpoint strips scheme and trailing slashes', () => {
assert.equal(normalizeEndpoint('https://oss-ap-northeast-1.aliyuncs.com/'), 'oss-ap-northeast-1.aliyuncs.com');
});
test('buildObjectKey joins optional prefix with relative path', () => {
assert.equal(buildObjectKey('', 'assets/index.js'), 'assets/index.js');
assert.equal(buildObjectKey('portal', 'assets/index.js'), 'portal/assets/index.js');
});
test('getCacheControl keeps index uncached and assets immutable', () => {
assert.equal(getCacheControl('index.html'), 'no-cache');
assert.equal(getCacheControl('assets/index.js'), 'public,max-age=31536000,immutable');
assert.equal(getCacheControl('race/index.html'), 'public,max-age=300');
});
test('getContentType resolves common frontend asset types', () => {
assert.equal(getContentType('index.html'), 'text/html; charset=utf-8');
assert.equal(getContentType('assets/app.css'), 'text/css; charset=utf-8');
assert.equal(getContentType('assets/app.js'), 'text/javascript; charset=utf-8');
assert.equal(getContentType('favicon.png'), 'image/png');
});
test('createAuthorizationHeader is stable for a known request', () => {
const header = createAuthorizationHeader({
method: 'PUT',
bucket: 'demo-bucket',
objectKey: 'assets/index.js',
contentType: 'text/javascript; charset=utf-8',
date: 'Tue, 17 Mar 2026 12:00:00 GMT',
accessKeyId: 'test-id',
accessKeySecret: 'test-secret',
});
assert.equal(header, 'OSS test-id:JgyH7mTiSILGGWsnXJwg4KIBRO4=');
});