SHA256
568 lines
21 KiB
JavaScript
568 lines
21 KiB
JavaScript
import { createRequesterPairingMaterial, decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash } from './js/lib/device-pairing.js';
|
|
import { loadPluginSettings, loadSessionMaterial, savePluginSettings, saveSessionMaterial, clearSessionMaterial } from './js/lib/session-store.js';
|
|
import { ShineApiClient } from './js/lib/shine-api.js';
|
|
import {
|
|
DEFAULT_SHINE_SERVER_LOGIN,
|
|
buildHttpBase,
|
|
readWalletProfileByLogin,
|
|
resolveShineServerByUserLogin,
|
|
} from './js/lib/shine-server-resolver.js';
|
|
|
|
const state = {
|
|
api: null,
|
|
settings: {
|
|
serverLogin: DEFAULT_SHINE_SERVER_LOGIN,
|
|
serverHttp: buildHttpBase('shineup.me'),
|
|
serverUrl: 'wss://shineup.me/ws',
|
|
login: '',
|
|
},
|
|
requesterMaterial: null,
|
|
pairingId: '',
|
|
expiresAtMs: 0,
|
|
shortCode: '',
|
|
trustedSessionOnline: false,
|
|
pollTimer: 0,
|
|
activeSession: null,
|
|
connectionOnline: false,
|
|
walletProfile: null,
|
|
signing: {
|
|
selectedKeyId: 'device',
|
|
selectedDeviceName: '',
|
|
devicesResolvedAtMs: 0,
|
|
},
|
|
statusText: '',
|
|
statusKind: 'info',
|
|
};
|
|
|
|
function setStatus(message = '', kind = 'info') {
|
|
state.statusText = String(message || '');
|
|
state.statusKind = kind === 'error' ? 'error' : 'info';
|
|
}
|
|
|
|
function stopPoll() {
|
|
if (state.pollTimer) {
|
|
clearTimeout(state.pollTimer);
|
|
state.pollTimer = 0;
|
|
}
|
|
}
|
|
|
|
function clearPairingState() {
|
|
stopPoll();
|
|
state.requesterMaterial = null;
|
|
state.pairingId = '';
|
|
state.expiresAtMs = 0;
|
|
state.shortCode = '';
|
|
state.trustedSessionOnline = false;
|
|
}
|
|
|
|
function ensureApi(serverUrl = state.settings.serverUrl) {
|
|
const normalized = String(serverUrl || '').trim() || 'wss://shineup.me/ws';
|
|
if (!state.api || state.api.serverUrl !== normalized) {
|
|
state.api?.close();
|
|
state.api = new ShineApiClient(normalized);
|
|
}
|
|
return state.api;
|
|
}
|
|
|
|
async function loadStateFromStorage() {
|
|
const settings = await loadPluginSettings();
|
|
state.settings = {
|
|
serverLogin: String(settings?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(),
|
|
serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'),
|
|
serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws',
|
|
login: String(settings?.login || '').trim(),
|
|
};
|
|
state.activeSession = await loadSessionMaterial();
|
|
state.walletProfile = state.activeSession?.walletProfile || null;
|
|
state.signing = {
|
|
selectedKeyId: String(state.activeSession?.selectedKeyId || 'device'),
|
|
selectedDeviceName: String(state.activeSession?.selectedDeviceName || ''),
|
|
devicesResolvedAtMs: Number(state.activeSession?.devicesResolvedAtMs || 0),
|
|
};
|
|
}
|
|
|
|
async function persistSettings(nextSettings = {}) {
|
|
state.settings = {
|
|
...state.settings,
|
|
...nextSettings,
|
|
};
|
|
await savePluginSettings(state.settings);
|
|
return state.settings;
|
|
}
|
|
|
|
async function resolveServerForLogin(login) {
|
|
const cleanLogin = String(login || state.settings.login || '').trim();
|
|
if (!cleanLogin) {
|
|
state.settings = {
|
|
...state.settings,
|
|
login: '',
|
|
serverLogin: '',
|
|
};
|
|
await savePluginSettings(state.settings);
|
|
return { ok: true, resolved: false };
|
|
}
|
|
|
|
const resolved = await resolveShineServerByUserLogin(cleanLogin);
|
|
state.settings = {
|
|
...state.settings,
|
|
login: cleanLogin,
|
|
serverLogin: resolved.serverLogin,
|
|
serverHttp: resolved.serverHttp,
|
|
serverUrl: resolved.serverUrl,
|
|
};
|
|
await savePluginSettings(state.settings);
|
|
return { ok: true, resolved: true, ...resolved };
|
|
}
|
|
|
|
async function saveActiveSessionRecord() {
|
|
if (!state.activeSession) return;
|
|
const nextRecord = {
|
|
...state.activeSession,
|
|
walletProfile: state.walletProfile,
|
|
selectedKeyId: state.signing.selectedKeyId,
|
|
selectedDeviceName: state.signing.selectedDeviceName,
|
|
devicesResolvedAtMs: state.signing.devicesResolvedAtMs,
|
|
};
|
|
state.activeSession = nextRecord;
|
|
await saveSessionMaterial(nextRecord);
|
|
}
|
|
|
|
function shortKey(value = '', size = 10) {
|
|
const raw = String(value || '').trim();
|
|
return raw ? raw.slice(0, size) : '';
|
|
}
|
|
|
|
function extractErrorCode(message = '') {
|
|
const match = String(message || '').match(/\(([A-Z0-9_]+)\)\s*$/i);
|
|
return match ? String(match[1]).toUpperCase() : '';
|
|
}
|
|
|
|
function toWalletErrorMessage(error, fallback = 'Не удалось выполнить операцию кошелька.') {
|
|
const raw = String(error?.message || '').trim();
|
|
const code = String(error?.code || extractErrorCode(raw) || '').toUpperCase();
|
|
if (code === 'PAIRING_NOT_AVAILABLE') {
|
|
return 'Для этого логина ещё не включено подключение по коду. На доверенном устройстве откройте «Подключить по коду» и нажмите «Включить подключение по коду» или задайте дополнительный пароль.';
|
|
}
|
|
if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') {
|
|
return 'Сейчас нет ни одной онлайн доверенной сессии этого пользователя. Откройте SHiNE на другом уже подключённом устройстве и держите его в сети.';
|
|
}
|
|
if (code === 'PAIRING_PASSWORD_INVALID') {
|
|
return 'Дополнительный пароль подключения не подходит.';
|
|
}
|
|
return raw || fallback;
|
|
}
|
|
|
|
function buildSigningKeyOptions(walletProfile) {
|
|
const rootKey = String(walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
|
|
const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim();
|
|
const options = [];
|
|
if (rootKey) {
|
|
options.push({
|
|
id: 'root',
|
|
label: `rootKey (ed25519, ${shortKey(rootKey)})`,
|
|
keyType: 'ed25519',
|
|
publicKeyBase58: rootKey,
|
|
});
|
|
}
|
|
if (deviceKey) {
|
|
options.push({
|
|
id: 'device',
|
|
label: `deviceKey (ed25519, ${shortKey(deviceKey)})`,
|
|
keyType: 'ed25519',
|
|
publicKeyBase58: deviceKey,
|
|
});
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) {
|
|
const published = Array.isArray(publishedHomeservers) ? publishedHomeservers : [];
|
|
const homeserverSessions = Array.isArray(serverSessions)
|
|
? serverSessions.filter((item) => Number(item?.sessionType || 0) === 100)
|
|
: [];
|
|
const onlineHomeservers = homeserverSessions.filter((item) => !!item?.onlineOnThisServer);
|
|
|
|
return published.map((item) => {
|
|
let onlineState = 'unknown';
|
|
if (published.length === 1) {
|
|
onlineState = onlineHomeservers.length > 0 ? 'online' : 'offline';
|
|
} else if (onlineHomeservers.length === 0) {
|
|
onlineState = 'offline';
|
|
} else if (onlineHomeservers.length === published.length) {
|
|
onlineState = 'online';
|
|
}
|
|
return {
|
|
...item,
|
|
onlineState,
|
|
onlineLabel: onlineState === 'online' ? 'online' : onlineState === 'offline' ? 'offline' : 'unknown',
|
|
};
|
|
});
|
|
}
|
|
|
|
async function hydrateWalletProfile(login) {
|
|
const cleanLogin = String(login || state.activeSession?.login || state.settings.login || '').trim();
|
|
if (!cleanLogin) throw new Error('Нет логина для чтения PDA кошелька.');
|
|
const profile = await readWalletProfileByLogin(cleanLogin);
|
|
const signingKeyOptions = buildSigningKeyOptions(profile);
|
|
const selectedKeyId = signingKeyOptions.some((item) => item.id === state.signing.selectedKeyId)
|
|
? state.signing.selectedKeyId
|
|
: (signingKeyOptions[0]?.id || '');
|
|
const selectedDeviceName = state.signing.selectedDeviceName
|
|
|| String(profile?.homeserverSessions?.[0]?.sessionName || '');
|
|
|
|
state.walletProfile = {
|
|
...profile,
|
|
signingKeyOptions,
|
|
homeserverSessions: Array.isArray(profile.homeserverSessions) ? profile.homeserverSessions.map((item) => ({
|
|
...item,
|
|
onlineState: 'unknown',
|
|
onlineLabel: 'unknown',
|
|
})) : [],
|
|
};
|
|
state.signing = {
|
|
...state.signing,
|
|
selectedKeyId,
|
|
selectedDeviceName,
|
|
};
|
|
await saveActiveSessionRecord();
|
|
return state.walletProfile;
|
|
}
|
|
|
|
async function resumeActiveSession({ keepConnected = false } = {}) {
|
|
const sessionRecord = await loadSessionMaterial();
|
|
state.activeSession = sessionRecord;
|
|
if (!sessionRecord) {
|
|
state.connectionOnline = false;
|
|
setStatus('Wallet-session ещё не подключена.', 'info');
|
|
return { ok: true, connected: false };
|
|
}
|
|
|
|
try {
|
|
await persistSettings({
|
|
serverLogin: String(sessionRecord?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(),
|
|
serverHttp: String(sessionRecord?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim(),
|
|
serverUrl: String(sessionRecord?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim(),
|
|
login: String(sessionRecord?.login || state.settings.login || '').trim(),
|
|
});
|
|
const resumed = await ensureApi().resumeSession(sessionRecord);
|
|
state.connectionOnline = !!keepConnected;
|
|
if (!keepConnected) {
|
|
ensureApi().close();
|
|
state.api = null;
|
|
setStatus(`Wallet-session сохранена для @${resumed.login}. Подключение будет открываться только по действию.`, 'info');
|
|
} else {
|
|
setStatus(`Wallet-session активна для @${resumed.login}.`, 'info');
|
|
}
|
|
return { ok: true, connected: true, login: resumed.login, sessionId: resumed.sessionId };
|
|
} catch (error) {
|
|
state.connectionOnline = false;
|
|
setStatus(error.message || 'Не удалось восстановить wallet-session.', 'error');
|
|
return { ok: false, connected: false, error: state.statusText };
|
|
}
|
|
}
|
|
|
|
async function attachApprovedSession(payload) {
|
|
if (String(payload?.type || '') !== 'shine-esp-session-attach') {
|
|
throw new Error('Доверенное устройство вернуло неподдерживаемый payload. Для plugin нужен session-only approve.');
|
|
}
|
|
|
|
const login = String(payload?.login || state.settings.login || '').trim();
|
|
const approvedSession = payload?.session || {};
|
|
const sessionRecord = {
|
|
login,
|
|
sessionId: String(approvedSession?.sessionId || '').trim(),
|
|
sessionKey: state.requesterMaterial?.sessionKey || '',
|
|
sessionPrivPkcs8: state.requesterMaterial?.sessionPrivPkcs8 || '',
|
|
sessionType: Number(approvedSession?.sessionType || 50) || 50,
|
|
serverLogin: state.settings.serverLogin,
|
|
serverHttp: state.settings.serverHttp,
|
|
serverUrl: state.settings.serverUrl,
|
|
};
|
|
if (!sessionRecord.login || !sessionRecord.sessionId || !sessionRecord.sessionKey || !sessionRecord.sessionPrivPkcs8) {
|
|
throw new Error('Получен неполный session-only payload');
|
|
}
|
|
|
|
await clearSessionMaterial();
|
|
state.activeSession = sessionRecord;
|
|
await hydrateWalletProfile(login);
|
|
await saveActiveSessionRecord();
|
|
await persistSettings({
|
|
login: sessionRecord.login,
|
|
serverLogin: sessionRecord.serverLogin,
|
|
serverHttp: sessionRecord.serverHttp,
|
|
serverUrl: sessionRecord.serverUrl,
|
|
});
|
|
state.connectionOnline = false;
|
|
}
|
|
|
|
async function pollPairingStatus() {
|
|
if (!state.pairingId || !state.requesterMaterial) return;
|
|
try {
|
|
const payload = await ensureApi().getTrustedDeviceLoginStatus(state.pairingId);
|
|
const stateValue = String(payload?.state || '');
|
|
if (stateValue === 'created') {
|
|
state.pollTimer = setTimeout(() => {
|
|
void pollPairingStatus();
|
|
}, 2200);
|
|
return;
|
|
}
|
|
if (stateValue === 'approved') {
|
|
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, state.requesterMaterial);
|
|
await attachApprovedSession(decoded);
|
|
clearPairingState();
|
|
setStatus('Wallet-session создана и сохранена. Кошелёк остаётся офлайн до запроса подписи.', 'info');
|
|
return;
|
|
}
|
|
if (stateValue === 'rejected') {
|
|
clearPairingState();
|
|
setStatus('Заявка отклонена на доверенном устройстве.', 'error');
|
|
return;
|
|
}
|
|
if (stateValue === 'expired' || stateValue === 'canceled') {
|
|
clearPairingState();
|
|
setStatus('Ожидание подключения завершено.', 'error');
|
|
return;
|
|
}
|
|
state.pollTimer = setTimeout(() => {
|
|
void pollPairingStatus();
|
|
}, 2200);
|
|
} catch (error) {
|
|
clearPairingState();
|
|
setStatus(error.message || 'Не удалось проверить pairing-статус.', 'error');
|
|
}
|
|
}
|
|
|
|
async function startPairing({ login, usePassword, password }) {
|
|
const cleanLogin = String(login || '').trim();
|
|
if (!cleanLogin) {
|
|
throw new Error('Введите логин.');
|
|
}
|
|
|
|
await persistSettings({ login: cleanLogin });
|
|
await resolveServerForLogin(cleanLogin);
|
|
clearPairingState();
|
|
setStatus('Проверяем пользователя и создаём wallet-session заявку...', 'info');
|
|
|
|
const api = ensureApi();
|
|
const user = await api.getUser(cleanLogin);
|
|
if (user?.exists !== true) {
|
|
throw new Error('Пользователь не найден.');
|
|
}
|
|
|
|
state.requesterMaterial = await createRequesterPairingMaterial();
|
|
const passwordHash = usePassword
|
|
? await deriveEspPairingPasswordHash(cleanLogin, String(password || ''))
|
|
: '';
|
|
const payload = await api.startTrustedDeviceLogin({
|
|
login: cleanLogin,
|
|
passwordHash,
|
|
requesterSessionKey: state.requesterMaterial.sessionKey,
|
|
payloadType: 1,
|
|
});
|
|
|
|
state.pairingId = String(payload?.pairingId || '').trim();
|
|
state.expiresAtMs = Number(payload?.expiresAtMs || 0);
|
|
state.shortCode = String(payload?.shortCode || '');
|
|
state.trustedSessionOnline = !!payload?.trustedSessionOnline;
|
|
if (!state.pairingId) {
|
|
throw new Error('Сервер не вернул pairingId.');
|
|
}
|
|
|
|
state.pollTimer = setTimeout(() => {
|
|
void pollPairingStatus();
|
|
}, 1800);
|
|
|
|
setStatus('Wallet-session заявка создана. Ожидаем подтверждение на доверенном устройстве.', 'info');
|
|
return {
|
|
pairingId: state.pairingId,
|
|
shortCode: String(payload?.shortCode || ''),
|
|
expiresAtMs: state.expiresAtMs,
|
|
trustedSessionOnline: !!payload?.trustedSessionOnline,
|
|
};
|
|
}
|
|
|
|
async function cancelPairing() {
|
|
if (!state.pairingId || !state.requesterMaterial?.sessionKey) {
|
|
clearPairingState();
|
|
return { ok: true };
|
|
}
|
|
await ensureApi().cancelTrustedDeviceLogin(state.pairingId, state.requesterMaterial.sessionKey);
|
|
clearPairingState();
|
|
setStatus('Ожидание подключения отменено.', 'info');
|
|
return { ok: true };
|
|
}
|
|
|
|
async function disconnectSession() {
|
|
ensureApi().close();
|
|
state.api = null;
|
|
await clearSessionMaterial();
|
|
state.activeSession = null;
|
|
state.connectionOnline = false;
|
|
state.walletProfile = null;
|
|
state.signing = {
|
|
selectedKeyId: 'device',
|
|
selectedDeviceName: '',
|
|
devicesResolvedAtMs: 0,
|
|
};
|
|
setStatus('Сохранённая wallet-session удалена из plugin.', 'info');
|
|
return { ok: true };
|
|
}
|
|
|
|
async function refreshWalletDevices() {
|
|
if (!state.activeSession?.login) {
|
|
throw new Error('Сначала подключите wallet-session.');
|
|
}
|
|
await hydrateWalletProfile(state.activeSession.login);
|
|
const resumed = await resumeActiveSession({ keepConnected: true });
|
|
if (!resumed.ok) {
|
|
throw new Error(resumed.error || 'Не удалось открыть wallet-session.');
|
|
}
|
|
try {
|
|
const sessions = await ensureApi().listSessions();
|
|
state.walletProfile = {
|
|
...state.walletProfile,
|
|
homeserverSessions: mergeHomeserverStatuses(state.walletProfile?.homeserverSessions, sessions),
|
|
};
|
|
state.signing.devicesResolvedAtMs = Date.now();
|
|
if (!state.signing.selectedDeviceName && state.walletProfile.homeserverSessions[0]?.sessionName) {
|
|
state.signing.selectedDeviceName = state.walletProfile.homeserverSessions[0].sessionName;
|
|
}
|
|
await saveActiveSessionRecord();
|
|
setStatus('Список доверенных homeserver-устройств обновлён.', 'info');
|
|
return {
|
|
ok: true,
|
|
devices: state.walletProfile.homeserverSessions,
|
|
};
|
|
} finally {
|
|
ensureApi().close();
|
|
state.api = null;
|
|
state.connectionOnline = false;
|
|
}
|
|
}
|
|
|
|
async function updateSigningSelection({ selectedKeyId, selectedDeviceName } = {}) {
|
|
state.signing = {
|
|
...state.signing,
|
|
selectedKeyId: String(selectedKeyId || state.signing.selectedKeyId || ''),
|
|
selectedDeviceName: String(selectedDeviceName || state.signing.selectedDeviceName || ''),
|
|
};
|
|
await saveActiveSessionRecord();
|
|
return { ok: true };
|
|
}
|
|
|
|
async function prepareSignSignal() {
|
|
if (!state.activeSession?.login) {
|
|
throw new Error('Сначала подключите wallet-session.');
|
|
}
|
|
if (!state.signing.selectedKeyId) {
|
|
throw new Error('Не выбран ключ подписи.');
|
|
}
|
|
if (!state.signing.selectedDeviceName) {
|
|
throw new Error('Не выбрано устройство homeserver.');
|
|
}
|
|
const selectedDevice = (state.walletProfile?.homeserverSessions || []).find((item) => item.sessionName === state.signing.selectedDeviceName);
|
|
if (!selectedDevice) {
|
|
throw new Error('Выбранное устройство не найдено в PDA аккаунта.');
|
|
}
|
|
setStatus(
|
|
`Каркас готов: запрос подписи должен идти через ${selectedDevice.sessionName}. Сам signaling подписи ещё не доделан.`,
|
|
'info',
|
|
);
|
|
return {
|
|
ok: true,
|
|
pending: true,
|
|
};
|
|
}
|
|
|
|
function snapshot() {
|
|
return {
|
|
settings: { ...state.settings },
|
|
pairing: {
|
|
active: !!state.pairingId,
|
|
pairingId: state.pairingId,
|
|
expiresAtMs: state.expiresAtMs,
|
|
shortCode: state.shortCode,
|
|
trustedSessionOnline: state.trustedSessionOnline,
|
|
},
|
|
session: state.activeSession ? { ...state.activeSession } : null,
|
|
connectionOnline: state.connectionOnline,
|
|
walletProfile: state.walletProfile ? { ...state.walletProfile } : null,
|
|
signing: { ...state.signing },
|
|
status: {
|
|
text: state.statusText,
|
|
kind: state.statusKind,
|
|
},
|
|
};
|
|
}
|
|
|
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
|
(async () => {
|
|
const type = String(message?.type || '');
|
|
if (type === 'wallet:getState') {
|
|
await loadStateFromStorage();
|
|
sendResponse({ ok: true, state: snapshot() });
|
|
return;
|
|
}
|
|
if (type === 'wallet:saveSettings') {
|
|
await persistSettings(message?.payload || {});
|
|
sendResponse({ ok: true, state: snapshot() });
|
|
return;
|
|
}
|
|
if (type === 'wallet:resolveServerInfo') {
|
|
const result = await resolveServerForLogin(String(message?.payload?.login || '').trim());
|
|
sendResponse({ ok: true, result, state: snapshot() });
|
|
return;
|
|
}
|
|
if (type === 'wallet:startPairing') {
|
|
const result = await startPairing(message?.payload || {});
|
|
sendResponse({ ok: true, result, state: snapshot() });
|
|
return;
|
|
}
|
|
if (type === 'wallet:cancelPairing') {
|
|
const result = await cancelPairing();
|
|
sendResponse({ ok: true, result, state: snapshot() });
|
|
return;
|
|
}
|
|
if (type === 'wallet:resumeSession') {
|
|
const result = await resumeActiveSession();
|
|
sendResponse({ ok: true, result, state: snapshot() });
|
|
return;
|
|
}
|
|
if (type === 'wallet:refreshWalletDevices') {
|
|
const result = await refreshWalletDevices();
|
|
sendResponse({ ok: true, result, state: snapshot() });
|
|
return;
|
|
}
|
|
if (type === 'wallet:updateSigningSelection') {
|
|
const result = await updateSigningSelection(message?.payload || {});
|
|
sendResponse({ ok: true, result, state: snapshot() });
|
|
return;
|
|
}
|
|
if (type === 'wallet:prepareSignSignal') {
|
|
const result = await prepareSignSignal();
|
|
sendResponse({ ok: true, result, state: snapshot() });
|
|
return;
|
|
}
|
|
if (type === 'wallet:disconnectSession') {
|
|
const result = await disconnectSession();
|
|
sendResponse({ ok: true, result, state: snapshot() });
|
|
return;
|
|
}
|
|
sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' });
|
|
})().catch((error) => {
|
|
const message = toWalletErrorMessage(error, 'Unknown error');
|
|
setStatus(message, 'error');
|
|
sendResponse({ ok: false, error: message, state: snapshot() });
|
|
});
|
|
return true;
|
|
});
|
|
|
|
void loadStateFromStorage().then(async () => {
|
|
if (state.activeSession?.login) {
|
|
await hydrateWalletProfile(state.activeSession.login).catch(() => {});
|
|
setStatus(`Wallet-session сохранена для @${state.activeSession.login}. Подключение будет открываться только по действию.`, 'info');
|
|
}
|
|
}).catch((error) => {
|
|
setStatus(error?.message || 'Не удалось инициализировать wallet plugin.', 'error');
|
|
});
|