281 lines
6.9 KiB
JavaScript
281 lines
6.9 KiB
JavaScript
const APP_KEY_STORAGE = 'grok2api_app_key';
|
|
const PUBLIC_KEY_STORAGE = 'grok2api_public_key';
|
|
const APP_KEY_ENC_PREFIX = 'enc:v1:';
|
|
const APP_KEY_XOR_PREFIX = 'enc:xor:';
|
|
const APP_KEY_SECRET = 'grok2api-admin-key';
|
|
let cachedAdminKey = null;
|
|
let cachedPublicKey = null;
|
|
|
|
const textEncoder = new TextEncoder();
|
|
const textDecoder = new TextDecoder();
|
|
|
|
function toBase64(bytes) {
|
|
let binary = '';
|
|
bytes.forEach(b => { binary += String.fromCharCode(b); });
|
|
return btoa(binary);
|
|
}
|
|
|
|
function fromBase64(base64) {
|
|
const binary = atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function xorCipher(bytes, keyBytes) {
|
|
const out = new Uint8Array(bytes.length);
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
out[i] = bytes[i] ^ keyBytes[i % keyBytes.length];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function xorEncrypt(plain) {
|
|
const data = textEncoder.encode(plain);
|
|
const key = textEncoder.encode(APP_KEY_SECRET);
|
|
const cipher = xorCipher(data, key);
|
|
return `${APP_KEY_XOR_PREFIX}${toBase64(cipher)}`;
|
|
}
|
|
|
|
function xorDecrypt(stored) {
|
|
if (!stored.startsWith(APP_KEY_XOR_PREFIX)) return stored;
|
|
const payload = stored.slice(APP_KEY_XOR_PREFIX.length);
|
|
const data = fromBase64(payload);
|
|
const key = textEncoder.encode(APP_KEY_SECRET);
|
|
const plain = xorCipher(data, key);
|
|
return textDecoder.decode(plain);
|
|
}
|
|
|
|
async function deriveKey(salt) {
|
|
const keyMaterial = await crypto.subtle.importKey(
|
|
'raw',
|
|
textEncoder.encode(APP_KEY_SECRET),
|
|
'PBKDF2',
|
|
false,
|
|
['deriveKey']
|
|
);
|
|
return crypto.subtle.deriveKey(
|
|
{
|
|
name: 'PBKDF2',
|
|
salt,
|
|
iterations: 100000,
|
|
hash: 'SHA-256'
|
|
},
|
|
keyMaterial,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
async function encryptAppKey(plain) {
|
|
if (!plain) return '';
|
|
if (!crypto?.subtle) return xorEncrypt(plain);
|
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const key = await deriveKey(salt);
|
|
const cipher = await crypto.subtle.encrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
key,
|
|
textEncoder.encode(plain)
|
|
);
|
|
return `${APP_KEY_ENC_PREFIX}${toBase64(salt)}:${toBase64(iv)}:${toBase64(new Uint8Array(cipher))}`;
|
|
}
|
|
|
|
async function decryptAppKey(stored) {
|
|
if (!stored) return '';
|
|
if (stored.startsWith(APP_KEY_XOR_PREFIX)) return xorDecrypt(stored);
|
|
if (!stored.startsWith(APP_KEY_ENC_PREFIX)) return stored;
|
|
if (!crypto?.subtle) return '';
|
|
const parts = stored.split(':');
|
|
if (parts.length !== 5) return '';
|
|
const salt = fromBase64(parts[2]);
|
|
const iv = fromBase64(parts[3]);
|
|
const cipher = fromBase64(parts[4]);
|
|
const key = await deriveKey(salt);
|
|
const plain = await crypto.subtle.decrypt(
|
|
{ name: 'AES-GCM', iv },
|
|
key,
|
|
cipher
|
|
);
|
|
return textDecoder.decode(plain);
|
|
}
|
|
|
|
async function getStoredAppKey() {
|
|
const stored = localStorage.getItem(APP_KEY_STORAGE) || '';
|
|
if (!stored) return '';
|
|
try {
|
|
return await decryptAppKey(stored);
|
|
} catch (e) {
|
|
clearStoredAppKey();
|
|
return '';
|
|
}
|
|
}
|
|
|
|
async function getStoredPublicKey() {
|
|
const stored = localStorage.getItem(PUBLIC_KEY_STORAGE) || '';
|
|
if (!stored) return '';
|
|
try {
|
|
return await decryptAppKey(stored);
|
|
} catch (e) {
|
|
clearStoredPublicKey();
|
|
return '';
|
|
}
|
|
}
|
|
|
|
async function storeAppKey(appKey) {
|
|
if (!appKey) {
|
|
clearStoredAppKey();
|
|
return;
|
|
}
|
|
const encrypted = await encryptAppKey(appKey);
|
|
localStorage.setItem(APP_KEY_STORAGE, encrypted || '');
|
|
}
|
|
|
|
async function storePublicKey(publicKey) {
|
|
if (!publicKey) {
|
|
clearStoredPublicKey();
|
|
return;
|
|
}
|
|
const encrypted = await encryptAppKey(publicKey);
|
|
localStorage.setItem(PUBLIC_KEY_STORAGE, encrypted || '');
|
|
}
|
|
|
|
function clearStoredAppKey() {
|
|
localStorage.removeItem(APP_KEY_STORAGE);
|
|
cachedAdminKey = null;
|
|
}
|
|
|
|
function clearStoredPublicKey() {
|
|
localStorage.removeItem(PUBLIC_KEY_STORAGE);
|
|
cachedPublicKey = null;
|
|
}
|
|
|
|
async function verifyKey(url, key) {
|
|
const headers = key ? { 'Authorization': `Bearer ${key}` } : {};
|
|
const res = await fetch(url, { method: 'GET', headers });
|
|
return res.ok;
|
|
}
|
|
|
|
async function ensureAdminKey() {
|
|
if (cachedAdminKey) return cachedAdminKey;
|
|
const appKey = await getStoredAppKey();
|
|
if (!appKey) {
|
|
window.location.href = '/admin/login';
|
|
return null;
|
|
}
|
|
try {
|
|
const ok = await verifyKey('/v1/admin/verify', appKey);
|
|
if (!ok) throw new Error('Unauthorized');
|
|
cachedAdminKey = `Bearer ${appKey}`;
|
|
return cachedAdminKey;
|
|
} catch (e) {
|
|
clearStoredAppKey();
|
|
window.location.href = '/admin/login';
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function ensurePublicKey() {
|
|
if (cachedPublicKey !== null) return cachedPublicKey;
|
|
|
|
const key = await getStoredPublicKey();
|
|
if (!key) {
|
|
try {
|
|
const ok = await verifyKey('/v1/public/verify', '');
|
|
if (ok) {
|
|
cachedPublicKey = '';
|
|
return cachedPublicKey;
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (!key) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const ok = await verifyKey('/v1/public/verify', key);
|
|
if (!ok) throw new Error('Unauthorized');
|
|
cachedPublicKey = `Bearer ${key}`;
|
|
return cachedPublicKey;
|
|
} catch (e) {
|
|
clearStoredPublicKey();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function buildAuthHeaders(apiKey) {
|
|
return apiKey ? { 'Authorization': apiKey } : {};
|
|
}
|
|
|
|
function logout() {
|
|
clearStoredAppKey();
|
|
clearStoredPublicKey();
|
|
window.location.href = '/admin/login';
|
|
}
|
|
|
|
function publicLogout() {
|
|
clearStoredPublicKey();
|
|
window.location.href = '/login';
|
|
}
|
|
|
|
async function fetchStorageType() {
|
|
const apiKey = await ensureAdminKey();
|
|
if (apiKey === null) return null;
|
|
try {
|
|
const res = await fetch('/v1/admin/storage', {
|
|
headers: buildAuthHeaders(apiKey)
|
|
});
|
|
if (!res.ok) return null;
|
|
const data = await res.json();
|
|
return (data && data.type) ? String(data.type) : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function formatStorageLabel(type) {
|
|
if (!type) return '-';
|
|
const normalized = type.toLowerCase();
|
|
const map = {
|
|
local: 'local',
|
|
mysql: 'mysql',
|
|
pgsql: 'pgsql',
|
|
postgres: 'pgsql',
|
|
postgresql: 'pgsql',
|
|
redis: 'redis'
|
|
};
|
|
return map[normalized] || '-';
|
|
}
|
|
|
|
async function updateStorageModeButton() {
|
|
const buttons = Array.from(document.querySelectorAll('[data-storage-mode-btn]'));
|
|
if (!buttons.length) return;
|
|
buttons.forEach((btn) => {
|
|
btn.textContent = '...';
|
|
btn.title = '存储模式';
|
|
btn.classList.remove('storage-ready');
|
|
});
|
|
const storageType = await fetchStorageType();
|
|
const label = formatStorageLabel(storageType);
|
|
buttons.forEach((btn) => {
|
|
btn.textContent = label === '-' ? label : label.toUpperCase();
|
|
btn.title = '存储模式';
|
|
if (label !== '-') {
|
|
btn.classList.add('storage-ready');
|
|
}
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', updateStorageModeButton);
|
|
} else {
|
|
updateStorageModeButton();
|
|
}
|