Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9ca4cee341
commit
c7aea8d419
@@ -0,0 +1,259 @@
|
||||
'use strict';
|
||||
// ============================================================================
|
||||
// 메일 서버 연동 모듈 — Arimail (v1.5.7, 요구 5 · v1.6.38 요구 2: 여러 서버)
|
||||
//
|
||||
// Arimail 은 OZ-Group 의 메일 게이트웨이(mail-gateway, 0.9.x 에서 AriMail 로 이름이 바뀜)다. Resend 로 받은 메일을 보관하고
|
||||
// 웹메일(/), 관리자 페이지(/admin), IMAP(993/143)·POP3(995/110)·SMTP 제출(465/587) 서버를 연다. 한 설치는 한 메일 도메인
|
||||
// (환경 파일 MAILBOX=admin@도메인)의 계정 "로그인ID@도메인" 을 다룬다.
|
||||
//
|
||||
// 이 모듈이 하는 일 — 웹메이커 쪽 "사이트 메일 시스템"(lib/mailsys.js)의 받는 메일 백엔드
|
||||
// - 관리자 API 로 계정을 만들고 지운다: 사이트 관리자 계정의 "메일 사용" 을 켤 때 Arimail 계정(같은 로그인 ID)을 만들고,
|
||||
// 끌 때 지운다. 비밀번호는 Arimail 이 PBKDF2 로 따로 보관하므로 켤 때 사용자가 메일 비밀번호를 정한다
|
||||
// (웹메이커는 사이트 관리자 비밀번호의 원문을 갖고 있지 않다 — scrypt 해시만).
|
||||
// - 상태: /api/health(버전) 와 관리자 목록(계정 수·제한)으로 연결 확인.
|
||||
// - 주소: 웹메일 주소(사이트의 /webmail 진입 파일과 "웹메일 열기" 버튼이 이곳으로), 메일 프로그램 설정값(IMAP/POP/SMTP 호스트·포트).
|
||||
//
|
||||
// Arimail 관리자 API (server/api.mjs)
|
||||
// POST /api/login {login,password} 헤더 X-Mail-Scope: admin → Set-Cookie: mg_admin=<토큰> (관리자 역할 계정만, 15분 10회 제한)
|
||||
// GET /api/admin/users → { users:[{id,login,name,mailbox,role,status,…}], domain, accounts:{used,limit}, policy, … }
|
||||
// POST /api/admin/users {login,name,password,role?,status?} → { id } (오류 코드: login_taken, weak_password, account_limit_reached, invalid_login)
|
||||
// PATCH /api/admin/users/<id> {name?,password?,role?,status?} DELETE /api/admin/users/<id>
|
||||
// GET /api/admin/status 서버 상태(포트·TLS·동기화)
|
||||
// 오류 응답은 { ok:false, error:'<코드>' } 와 HTTP 상태. 세션 쿠키는 Secure 속성이 있지만 서버 간 호출에서는 우리가 직접 붙이므로 http 로도 쓸 수 있다.
|
||||
//
|
||||
// v1.6.38 (요구 2): Arimail 이 웹메이커와 다른 서버에, 그것도 여러 대 있을 수 있다(webmaker.ibit.kr 121.152.149.45 ↔ webmail.ibit.kr 115.68.194.192).
|
||||
// 접속 정보는 등록부(lib/mailservers.js)가 준다 — 설정 파일 mail.* 의 "기본 서버" 와 관리자 콘솔에서 추가한 서버들.
|
||||
// 모든 동작은 forServer(server) 로 서버를 골라 쓴다. 모듈 최상위 함수들은 "기본 서버" 에 묶인 것이라 1.5.7 호출 코드가 그대로 돈다.
|
||||
// 관리자 세션 쿠키는 서버(주소+계정)마다 따로 20분 보관한다.
|
||||
// v1.6.39 (요구 2·3·4):
|
||||
// - 로그인 실패에 오류 코드를 붙이고, rate_limited(15분 10회 제한) 이면 등록부(lib/mailservers.js)에 잠금 해제 시각을 적는다.
|
||||
// - 사이트 관리자 비밀번호 ↔ 메일 비밀번호 동기화(setPassword): 웹메이커는 해시만 가지므로 "원문을 아는 순간"(사이트 관리자 로그인·메일 사용 켜기)에
|
||||
// lib/mailsys.syncPassword 가 Arimail 계정 비밀번호를 같은 값으로 맞춘다 — 분리 설치(다른 서버)에서도 웹메이커 계정으로 웹메일에 들어간다.
|
||||
// - 관리자 계정 보호(protectAdmin): 등록된 연동 관리자·관리자 역할 계정은 웹메이커에서 지우거나 역할을 내릴 수 없다.
|
||||
// ============================================================================
|
||||
const id = 'arimail';
|
||||
const label = 'Arimail (메일 게이트웨이)';
|
||||
const kind = 'mail';
|
||||
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
|
||||
|
||||
/** Arimail 오류 코드 → 사용자 문구 */
|
||||
const ERRORS = {
|
||||
invalid_credentials: 'Arimail 관리자 로그인에 실패했습니다 (아이디·비밀번호 확인 — 관리자 콘솔 › 메일 시스템 › 메일 서버 연동, 기본 서버는 설정 파일 mail.adminLogin / 보관소 arimail)',
|
||||
admin_only: 'Arimail 에 등록한 계정이 관리자 역할이 아닙니다',
|
||||
admin_login_required: 'Arimail 관리자 세션이 없습니다',
|
||||
rate_limited: 'Arimail 로그인 시도가 너무 많아 잠시 막혔습니다 (15분 뒤 다시)',
|
||||
login_taken: '같은 로그인 ID 의 Arimail 계정이 이미 있습니다',
|
||||
weak_password: '메일 비밀번호가 Arimail 의 비밀번호 정책에 맞지 않습니다 (관리자 페이지 › 비밀번호 정책)',
|
||||
account_limit_reached: 'Arimail 의 계정 수 제한(LIMIT_ACCOUNTS)에 이르렀습니다',
|
||||
invalid_login: '로그인 ID 를 Arimail 계정으로 쓸 수 없습니다 (영문 소문자·숫자·. _ - 2~64자)',
|
||||
not_found: 'Arimail 에 그 계정이 없습니다',
|
||||
validation: 'Arimail 이 입력값을 거부했습니다',
|
||||
cannot_delete_self: 'Arimail 관리자 자신의 계정은 지울 수 없습니다',
|
||||
pending_approval: '가입 승인을 기다리는 계정입니다',
|
||||
// v1.7.3 (요구 2): 사용량 API(1.0.9) 와 키 발급(POST /api/setup/usage-key) 오류
|
||||
usage_api_disabled: 'Arimail 의 사용량 API 가 꺼져 있습니다(키를 만들지 않았거나 지움) — 관리자 콘솔 › 메일 시스템 › 메일 서버 연동의 "사용량 API 키 발급" 을 누르거나 Arimail 전체 설정 › 사용량 API 카드에서 키를 만들어 넣으세요',
|
||||
unauthenticated: '사용량 API 키가 Arimail 의 키와 다릅니다 — Arimail 에서 키를 다시 만들었다면 새 키를 넣거나 "사용량 API 키 발급" 으로 다시 받으세요',
|
||||
forbidden_ip: '이 웹메이커 서버의 IP 가 Arimail 사용량 API 의 접속 허용 IP 목록에 없습니다 — Arimail 전체 설정 › 사용량 API › 접속 허용 IP 에 이 서버의 공인 IP(같은 서버면 127.0.0.1 도)를 더하거나 목록을 비우세요',
|
||||
primary_only: '사용량 API 키는 Arimail 기본 도메인에서만 만들 수 있습니다 — 이 서버 등록의 API 주소를 기본 도메인 호스트로 바꾸거나, Arimail 전체 설정 화면에서 만든 키를 직접 넣으세요',
|
||||
bad_request: 'Arimail 이 요청을 이해하지 못했습니다 (Arimail 버전이 낮을 수 있음 — 사용량 API 는 1.0.9 부터)',
|
||||
};
|
||||
/** v1.7.3: 사용량 API 가 들어간 Arimail 최소 버전 */
|
||||
const USAGE_MIN_VERSION = '1.0.9';
|
||||
/** 'x.y.z(-접미)' 두 버전 비교 — a<b 면 음수. 숫자가 아니면 0(비교 불가 → 막지 않음) */
|
||||
function cmpVersion(a, b) { const pa = String(a || '').match(/^(\d+)\.(\d+)\.(\d+)/), pb = String(b || '').match(/^(\d+)\.(\d+)\.(\d+)/); if (!pa || !pb) return 0; for (let i = 1; i <= 3; i++) { const d = Number(pa[i]) - Number(pb[i]); if (d) return d; } return 0; }
|
||||
const explain = (code, status) => ERRORS[code] || `Arimail 오류 ${code || status}`;
|
||||
|
||||
/** 서버 객체(등록부) → 접속 정보. 서버가 없으면 모두 빈 값 */
|
||||
function toSettings(server) {
|
||||
const s = server || {};
|
||||
const url = String(s.url || '').replace(/\/+$/, '');
|
||||
let host = s.host || ''; if (!host) { try { host = new URL(url).hostname; } catch (_) {} }
|
||||
return { id: s.id || '', nickname: s.nickname || '', ip: s.ip || '', url, apiUrl: String(s.apiUrl || '').replace(/\/+$/, '') || url, host, domain: String(s.domain || '').trim().toLowerCase(), adminLogin: String(s.adminLogin || 'admin').trim(), adminPassword: s.adminPassword || '', usageKey: s.usageKey || '' /* v1.7.3: 사용량 API 키(보관소) */ };
|
||||
}
|
||||
/** 등록부의 기본 서버 (없으면 null) — 설정 파일 mail.* 또는 관리자 콘솔에서 기본으로 지정한 서버 */
|
||||
function defaultServer() { try { return require('../mailservers').defaultServer(); } catch (_) { return null; } }
|
||||
|
||||
// ---------- 관리자 세션 (서버마다) ----------
|
||||
/** 로그인 결과 쿠키를 잠시 재사용한다 (같은 주소·계정, 20분). 401 이 나면 다시 로그인한다. 키 = 주소|계정 */
|
||||
const sessions = new Map();
|
||||
// 키에 비밀번호의 짧은 해시를 넣어 관리자 콘솔에서 비밀번호를 바꾸면 이전 세션을 버리고 다시 로그인하게 한다 (비밀번호 원문은 키에 넣지 않음)
|
||||
const sessionKey = (s) => `${s.apiUrl}|${s.adminLogin}|${require('crypto').createHash('sha256').update(String(s.adminPassword || '')).digest('hex').slice(0, 12)}`;
|
||||
/** 서버 이름표(오류 문구용) — 닉네임이 있으면 닉네임, 없으면 호스트 */
|
||||
const nameOf = (s) => (s.nickname ? `${s.nickname}${s.ip ? ' ' + s.ip : ''}` : s.host || s.apiUrl);
|
||||
|
||||
/**
|
||||
* 한 서버에 묶인 동작 묶음. mailsys 는 사이트마다 고른 서버로 이것을 만들어 쓴다.
|
||||
* @param {object|null} server 등록부 서버 객체(비밀번호 포함). null 이면 기본 서버
|
||||
*/
|
||||
function forServer(server) {
|
||||
const resolve = () => toSettings(server || defaultServer());
|
||||
const settings = () => resolve();
|
||||
const configured = () => { const s = resolve(); return !!(s.url && s.domain && s.adminLogin && s.adminPassword); };
|
||||
/** 웹메일·관리자 페이지 주소 */
|
||||
const webmailUrl = () => { const s = resolve(); return s.url ? s.url + '/' : null; };
|
||||
const adminUrl = () => { const s = resolve(); return s.url ? s.url + '/admin' : null; };
|
||||
/** 메일 프로그램 설정값 (사용자 안내용). Arimail 은 웹·IMAP·POP·SMTP 가 같은 아이디·비밀번호다 */
|
||||
const clientSettings = () => { const s = resolve(); if (!s.host) return null; return { host: s.host, imap: { host: s.host, ports: [993, 143], note: '993 SSL/TLS 또는 143 STARTTLS' }, pop: { host: s.host, ports: [995, 110], note: '995 SSL/TLS 또는 110 STARTTLS' }, smtp: { host: s.host, ports: [465, 587], note: '465 SSL/TLS 또는 587 STARTTLS, 인증: 메일 비밀번호' }, username: '로그인 ID 또는 로그인ID@' + s.domain }; };
|
||||
/** 로그인 ID → 메일 주소 */
|
||||
const mailboxFor = (login) => `${String(login).toLowerCase()}@${resolve().domain}`;
|
||||
|
||||
async function login() {
|
||||
const s = resolve(); if (!s.url) throw httpError('Arimail 주소가 없습니다 (관리자 콘솔 › 메일 시스템 › 메일 서버 연동)', 503); if (!s.adminPassword) throw httpError(`Arimail 관리자 비밀번호가 보관소에 없습니다 (${nameOf(s)} — 메일 서버 연동에서 비밀번호를 다시 넣으세요)`, 503);
|
||||
let r; try { r = await fetch(`${s.apiUrl}/api/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Mail-Scope': 'admin' }, body: JSON.stringify({ login: s.adminLogin, password: s.adminPassword }), signal: AbortSignal.timeout(15000) }); }
|
||||
catch (e) { throw httpError(`Arimail 연결 실패 (${nameOf(s)}, ${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
// v1.6.39 (요구 2): 오류 코드를 예외에 붙여 호출자가 구분할 수 있게 하고, 로그인 잠금(rate_limited / 429)이면 등록부에 해제 시각을 적어
|
||||
// 관리자 콘솔의 "연결 확인" 이 잠금이 풀릴 때까지 다시 시도하지 않게 한다 (시도할수록 잠금이 길어진다)
|
||||
const code = data.error || (r.status === 429 ? 'rate_limited' : null);
|
||||
const err = Object.assign(httpError(explain(code, r.status), 502), { code });
|
||||
if (code === 'rate_limited') { try { err.lockedUntil = require('../mailservers').noteLocked(s.id); } catch (_) {} }
|
||||
throw err;
|
||||
}
|
||||
// Set-Cookie: mg_admin=<토큰>; Path=/; HttpOnly; Secure; … — 토큰 부분만 보관한다
|
||||
const raw = (typeof r.headers.getSetCookie === 'function' ? r.headers.getSetCookie() : [r.headers.get('set-cookie')].filter(Boolean)).find((c) => /^mg_admin=/.test(c)) || '';
|
||||
const m = raw.match(/^(mg_admin=[^;]*)/); if (!m) throw httpError('Arimail 로그인 응답에 세션 쿠키가 없습니다', 502);
|
||||
sessions.set(sessionKey(s), { cookie: m[1], at: Date.now() });
|
||||
return m[1];
|
||||
}
|
||||
async function cookie() { const s = resolve(); const c = sessions.get(sessionKey(s)); if (c && Date.now() - c.at < 20 * 60 * 1000) return c.cookie; return login(); }
|
||||
/**
|
||||
* 관리자 API 호출. 401/403(세션 없음)이면 한 번 다시 로그인해서 재시도한다.
|
||||
* @returns 응답 JSON (ok:false 면 예외)
|
||||
*/
|
||||
async function call(method, p, body, retried = false) {
|
||||
const s = resolve(); const ck = await cookie();
|
||||
let r; try { r = await fetch(`${s.apiUrl}/api${p}`, { method, headers: { 'Content-Type': 'application/json', 'X-Mail-Scope': 'admin', Cookie: ck }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(20000) }); }
|
||||
catch (e) { throw httpError(`Arimail 연결 실패 (${nameOf(s)}, ${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if ((r.status === 401 || (r.status === 403 && data.error === 'admin_login_required')) && !retried) { sessions.delete(sessionKey(s)); return call(method, p, body, true); }
|
||||
if (!r.ok) throw Object.assign(httpError(explain(data.error, r.status), r.status >= 500 ? 502 : 400), { code: data.error || null });
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------- 계정 ----------
|
||||
async function health() {
|
||||
const s = resolve(); if (!s.url) return { ok: false, detail: 'Arimail 주소가 없습니다' };
|
||||
try { const r = await fetch(`${s.apiUrl}/api/health`, { signal: AbortSignal.timeout(8000) }); const d = await r.json().catch(() => ({})); return { ok: r.ok && d.ok !== false, version: d.version || null, status: r.status, detail: r.ok ? `Arimail ${d.version || ''} 응답` : `응답 ${r.status}` }; }
|
||||
catch (e) { return { ok: false, detail: `연결 실패 (${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}` }; }
|
||||
}
|
||||
/** 계정 목록 + 도메인·제한 */
|
||||
async function listUsers() { const d = await call('GET', '/admin/users'); return { users: d.users || [], domain: d.domain || resolve().domain, accounts: d.accounts || null, policy: d.policy || null, limits: d.limits || null }; }
|
||||
async function findUser(login) { const l = String(login || '').toLowerCase(); const { users } = await listUsers(); return users.find((u) => String(u.login).toLowerCase() === l) || null; }
|
||||
/**
|
||||
* 계정 만들기. 같은 로그인 ID 가 이미 있으면 그 계정을 돌려준다(existing:true) — 비밀번호가 주어졌으면 그 비밀번호로 재설정.
|
||||
* @param {{login:string, name?:string, password:string, role?:'admin'|'user', reuseId?:string}} o
|
||||
*/
|
||||
async function createUser(o) {
|
||||
const login = String(o.login || '').toLowerCase(); if (!login) throw httpError('로그인 ID 가 없습니다');
|
||||
if (!o.password) throw httpError('메일 비밀번호를 입력하세요 (Arimail 계정의 웹메일·IMAP·POP·SMTP 비밀번호)');
|
||||
try { const r = await call('POST', '/admin/users', { login, name: o.name || login, password: o.password, role: o.role === 'admin' ? 'admin' : 'user' }); return { id: r.id, login, mailbox: mailboxFor(login), existing: false }; }
|
||||
catch (e) {
|
||||
if (e.code !== 'login_taken') throw e;
|
||||
const u = await findUser(login); if (!u) throw e;
|
||||
// v1.6.31 (요구 9, 검토 S3): 이미 있는 계정은 이 사이트가 소유한 것(o.reuseId === u.id)일 때만 비밀번호를 새로 정한다. 아니면 다른 사이트의 계정일 수 있으므로 건드리지 않고 알린다
|
||||
if (!(o.reuseId && String(o.reuseId) === String(u.id))) throw Object.assign(httpError(`메일 서버에 같은 아이디(${login})의 계정이 이미 있습니다. 다른 사이트의 계정일 수 있어 그대로 두었습니다 — 다른 아이디를 쓰거나 관리자가 소유를 확인한 뒤 연결하세요`, 409), { code: 'login_conflict', providerId: u.id });
|
||||
if (o.password) await call('PATCH', `/admin/users/${encodeURIComponent(u.id)}`, { password: o.password, status: 'active' });
|
||||
return { id: u.id, login, mailbox: u.mailbox || mailboxFor(login), existing: true };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* v1.6.39 (요구 4): 관리자 계정 보호 — 등록된 연동 관리자 계정(adminLogin)과 Arimail 의 관리자 역할 계정은 웹메이커가 지우거나 역할·상태를 내리지 않는다.
|
||||
* 웹메이커가 다루는 것은 사이트 메일 계정(user 역할)뿐이다. 서버 기본 관리자를 잃으면 Arimail 자체에 들어갈 길이 없어진다.
|
||||
*/
|
||||
function protectAdmin(u, what) {
|
||||
const s = resolve(); if (!u) return;
|
||||
if (String(u.login || '').toLowerCase() === String(s.adminLogin || '').toLowerCase() || u.role === 'admin') throw Object.assign(httpError(`Arimail 관리자 계정 "${u.login}" 은(는) 웹메이커에서 ${what} 수 없습니다 — 서버 기본 관리자·연동 관리자 계정은 보호됩니다 (필요하면 Arimail 관리자 페이지 ${adminUrl() || ''} 에서 직접)`, 403), { code: 'protected_admin' });
|
||||
}
|
||||
async function updateUser(userId, fields) {
|
||||
// 역할을 user 로 내리거나 상태를 비활성으로 바꾸는 요청은 관리자 계정이면 거절 (비밀번호·이름 변경, 역할을 admin 으로 올리기는 허용)
|
||||
if ((fields && fields.role && fields.role !== 'admin') || (fields && fields.status && fields.status !== 'active')) protectAdmin(await getUserById(userId), '역할·상태를 바꿀');
|
||||
await call('PATCH', `/admin/users/${encodeURIComponent(userId)}`, fields); return true;
|
||||
}
|
||||
/** v1.6.31 (요구 9): 공급자 계정 id 로 조회·삭제 — 로그인 이름이 같은 다른 사이트의 계정을 건드리지 않는다 */
|
||||
async function getUserById(uid) { const { users } = await listUsers(); return users.find((u) => String(u.id) === String(uid)) || null; }
|
||||
async function deleteUserById(uid) { protectAdmin(await getUserById(uid), '지울'); await call('DELETE', `/admin/users/${encodeURIComponent(uid)}`); return true; }
|
||||
/** 로그인 ID 로 계정을 찾아 지운다 (없으면 false) */
|
||||
async function deleteUserByLogin(login) { const u = await findUser(login); if (!u) return false; protectAdmin(u, '지울'); await call('DELETE', `/admin/users/${encodeURIComponent(u.id)}`); return true; }
|
||||
/**
|
||||
* v1.6.39 (요구 3): 로그인 ID 의 계정 비밀번호를 바꾼다 (사이트 관리자 비밀번호 ↔ 메일 비밀번호 동기화용). 계정이 없으면 null
|
||||
* @returns {Promise<string|null>} 바꾼 계정의 id
|
||||
*/
|
||||
async function setPassword(login, password) { if (!password) throw httpError('비밀번호가 비어 있습니다'); const u = await findUser(login); if (!u) return null; await call('PATCH', `/admin/users/${encodeURIComponent(u.id)}`, { password }); return u.id; }
|
||||
async function adminStatus() { return call('GET', '/admin/status'); }
|
||||
|
||||
// ---------- v1.7.3 (요구 2): 사용량 API (docs/USAGE-API.md, Arimail 1.0.9) ----------
|
||||
/**
|
||||
* 사용량 API 를 쓸 수 있는 버전인지 health 로 확인. 낮은 버전이면 안내 예외(1.0.8 은 /api/usage 가 없어 401/404 만 돌려주므로 "키가 틀렸다" 로 오해하기 전에 막는다)
|
||||
* @returns {Promise<string|null>} 버전 문자열
|
||||
*/
|
||||
async function requireUsageVersion() {
|
||||
const s = resolve(); const h = await health(); if (!h.ok) throw httpError(`Arimail(${nameOf(s)})에 연결할 수 없습니다 — ${h.detail}`, 502);
|
||||
if (h.version && cmpVersion(h.version, USAGE_MIN_VERSION) < 0) throw Object.assign(httpError(`Arimail ${h.version} 에는 사용량 API 가 없습니다 — ${nameOf(s)} 의 Arimail 을 ${USAGE_MIN_VERSION} 이상으로 업데이트하면 메일 사용량이 보입니다`, 409), { code: 'usage_unsupported', version: h.version });
|
||||
return h.version || null;
|
||||
}
|
||||
/**
|
||||
* 사용량 조회 — GET /api/usage(모든 도메인 요약) 또는 /api/usage/<도메인>(계정 목록·일별 추이 포함). 키는 등록부 서버 객체의 usageKey(보관소).
|
||||
* 응답은 Arimail 문서 그대로(totals/domains[] 또는 storage/traffic/accountList…). 실패는 코드(code)를 붙인 예외 — 호출자는 문구를 그대로 보여 주면 된다.
|
||||
* @param {string} [domain] 비우면 전체 요약
|
||||
* @param {{days?:number}} [o] 일별 추이·계정별 기간 합계의 기간(1~366, 기본 30)
|
||||
*/
|
||||
async function usage(domain = '', o = {}) {
|
||||
const s = resolve(); const key = String(s.usageKey || '');
|
||||
if (!s.apiUrl) throw httpError('Arimail 주소가 없습니다 (관리자 콘솔 › 메일 시스템 › 메일 서버 연동)', 503);
|
||||
if (!key) throw Object.assign(httpError(`메일 서버 "${nameOf(s)}" 의 사용량 API 키가 없습니다 — 관리자 콘솔 › 메일 시스템 › 메일 서버 연동에서 "사용량 API 키 발급" 을 누르거나 Arimail 전체 설정 › 사용량 API 카드에서 만든 키를 넣으세요`, 409), { code: 'no_usage_key' });
|
||||
const days = Math.min(366, Math.max(1, Number(o.days) || 30));
|
||||
let r; try { r = await fetch(`${s.apiUrl}/api/usage${domain ? '/' + encodeURIComponent(String(domain).toLowerCase()) : ''}?days=${days}`, { headers: { Authorization: 'Bearer ' + key, Accept: 'application/json' }, signal: AbortSignal.timeout(20000) }); }
|
||||
catch (e) { throw httpError(`Arimail 연결 실패 (${nameOf(s)}, ${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
// 1.0.8 이하는 /api/usage 자체가 없어 401(세션 없음)·404 로 답한다 — 버전을 확인해 "키가 틀렸다" 대신 업데이트 안내를 낸다
|
||||
if (r.status === 401 || r.status === 404) await requireUsageVersion();
|
||||
const code = data.error || (r.status === 404 ? 'usage_api_disabled' : r.status === 401 ? 'unauthenticated' : r.status === 403 ? 'forbidden_ip' : r.status === 429 ? 'rate_limited' : `http_${r.status}`);
|
||||
throw Object.assign(httpError(ERRORS[code] || `Arimail 사용량 API 오류 ${code}`, r.status >= 500 ? 502 : (r.status === 429 ? 429 : 409)), { code, httpStatus: r.status });
|
||||
}
|
||||
return data;
|
||||
}
|
||||
/**
|
||||
* 사용량 API 키 발급 — 관리자 세션으로 POST /api/setup/usage-key {action:'generate'} (기본 도메인 관리자만; 기존 키가 있으면 교체되어 이전 키는 즉시 무효).
|
||||
* 돌려받은 키는 호출자(lib/mailservers.issueUsageKey)가 보관소에 저장한다. 키는 기록·로그에 남기지 않는다.
|
||||
* @returns {Promise<{key:string, endpoint:string|null}>}
|
||||
*/
|
||||
async function issueUsageKey() {
|
||||
await requireUsageVersion();
|
||||
const d = await call('POST', '/setup/usage-key', { action: 'generate' });
|
||||
if (!d || !d.key) throw httpError('Arimail 이 사용량 API 키를 돌려주지 않았습니다 (응답에 key 없음)', 502);
|
||||
return { key: String(d.key), endpoint: d.endpoint || null };
|
||||
}
|
||||
/** Arimail 쪽 사용량 API 키 삭제(API 끄기) — 관리자 세션 필요 */
|
||||
async function revokeUsageKey() { await call('POST', '/setup/usage-key', { action: 'revoke' }); return true; }
|
||||
|
||||
/** 연결 상태 (설정 페이지·관리자 콘솔 "연결 확인") */
|
||||
async function status() {
|
||||
const s = resolve(); const base = { enabled: true, provider: id, serverId: s.id || null, nickname: s.nickname || null, ip: s.ip || null, url: s.url || null, domain: s.domain || null, adminLogin: s.adminLogin, webmail: webmailUrl(), admin: adminUrl(), client: clientSettings(), configured: configured() };
|
||||
if (!s.url) return { ...base, ok: false, detail: 'Arimail 주소가 없습니다. 관리자 콘솔 › 메일 시스템 › 메일 서버 연동에서 서버를 등록하거나(다른 서버의 Arimail 도 가능) 이 서버에서 deploy/install-arimail.sh 를 실행하세요' };
|
||||
const h = await health(); if (!h.ok) return { ...base, ok: false, health: h, detail: `Arimail(${nameOf(s)})에 연결할 수 없습니다 — ${h.detail}` };
|
||||
if (!s.domain) return { ...base, ok: false, health: h, detail: `Arimail ${h.version || ''} 응답 — 메일 도메인을 넣으세요` };
|
||||
if (!s.adminPassword) return { ...base, ok: false, health: h, detail: `Arimail ${h.version || ''} 응답 — 관리자 비밀번호가 없어 계정을 관리할 수 없습니다 (보기 전용)` };
|
||||
try {
|
||||
const l = await listUsers();
|
||||
const mismatch = l.domain && l.domain !== s.domain ? ` · 주의: Arimail 의 도메인은 ${l.domain} 인데 등록한 메일 도메인은 ${s.domain}` : '';
|
||||
return { ...base, ok: !mismatch, health: h, version: h.version, serverDomain: l.domain, accounts: l.accounts, users: l.users.length, detail: `연결 성공 — Arimail ${h.version || ''}${s.nickname ? ` · ${nameOf(s)}` : ''} · 도메인 ${l.domain} · 계정 ${l.users.length}개${l.accounts && l.accounts.limit ? ` / 최대 ${l.accounts.limit}` : ''}${mismatch}` };
|
||||
} catch (e) {
|
||||
// v1.6.39: code(rate_limited 등)와 잠금 해제 시각을 함께 돌려 관리자 콘솔이 버튼을 막고 안내할 수 있게 한다
|
||||
return { ...base, ok: false, health: h, version: h.version, code: e.code || null, lockedUntil: e.lockedUntil || null, detail: `Arimail ${h.version || ''} 응답 — 관리자 로그인 실패: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { server: server || null, settings, configured, webmailUrl, adminUrl, clientSettings, mailboxFor, health, login, call, listUsers, findUser, createUser, updateUser, setPassword, getUserById, deleteUserById, deleteUserByLogin, adminStatus, status, usage, issueUsageKey, revokeUsageKey, requireUsageVersion /* v1.7.3 */ };
|
||||
}
|
||||
|
||||
// 모듈 최상위 = 기본 서버에 묶인 동작 (1.5.7 호출 코드 호환: connectors.mail().status() 등). 호출할 때마다 기본 서버를 다시 고른다
|
||||
const base = forServer(null);
|
||||
module.exports = {
|
||||
id, label, kind, ERRORS, forServer, toSettings, cmpVersion, USAGE_MIN_VERSION,
|
||||
settings: () => base.settings(), configured: () => base.configured(), webmailUrl: () => base.webmailUrl(), adminUrl: () => base.adminUrl(), clientSettings: () => base.clientSettings(), mailboxFor: (l) => base.mailboxFor(l),
|
||||
health: () => base.health(), login: () => base.login(), call: (...a) => base.call(...a), listUsers: () => base.listUsers(), findUser: (l) => base.findUser(l), createUser: (o) => base.createUser(o), updateUser: (u, f) => base.updateUser(u, f),
|
||||
setPassword: (l, p) => base.setPassword(l, p),
|
||||
getUserById: (u) => base.getUserById(u), deleteUserById: (u) => base.deleteUserById(u), deleteUserByLogin: (l) => base.deleteUserByLogin(l), adminStatus: () => base.adminStatus(), status: () => base.status(),
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
'use strict';
|
||||
// ============================================================================
|
||||
// 소스 보관 연동 모듈 — Gitea (v1.5.7, 요구 6)
|
||||
//
|
||||
// 사이트 버전(sites/<ID>/main, sites/<ID>/changes/NNNN-종류 브랜치)을 보관하는 git 서비스에 "어떻게 접속하는지" 를
|
||||
// 이 모듈이 맡는다. git 푸시 자체는 lib/gitstore.js 가 표준 git 명령으로 하고, 이 모듈은
|
||||
// - 접속 정보: 푸시 주소(remote)·웹 주소(web)·브랜치 접두어(prefix) — 설정 파일 source.* (1.4.14~1.4.18 은 systemd 환경 변수
|
||||
// SITES_GIT_REMOTE/SITES_GIT_WEB/SITES_GIT_PREFIX 였고, 1.5.7 첫 기동 때 설정 파일로 옮겨진다)
|
||||
// - 링크: 브랜치·커밋을 브라우저로 보는 주소
|
||||
// - 오류 해석: git/ssh 오류를 운영자가 조치할 수 있는 문구로(gitHint)
|
||||
// - 상태 확인: git ls-remote(SSH 푸시 경로) + Gitea API(/api/v1, 토큰이 있을 때)
|
||||
// - 준비 작업(API 토큰이 있을 때): 저장소 만들기, 서비스 서버 SSH 공개키 등록 — deploy/install-gitea.sh 가 하던 일을
|
||||
// 설정 페이지에서도 할 수 있게
|
||||
// 를 담당한다. 다른 git 서비스(GitLab 등)로 바꾸려면 같은 내보내기 모양의 모듈을 만들어 connectors/index.js 에 등록한다.
|
||||
// ============================================================================
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { execFile, execFileSync } = require('child_process');
|
||||
|
||||
const id = 'gitea';
|
||||
const label = 'Gitea (git 저장소)';
|
||||
const kind = 'source';
|
||||
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
|
||||
|
||||
/** 설정 파일에서 읽은 접속 정보 (호출할 때마다 읽어 설정 페이지에서 바꾼 값이 재시작 없이 적용된다) */
|
||||
function settings() {
|
||||
const config = require('../config');
|
||||
return {
|
||||
remote: String(config.get('source.remote') || '').trim(),
|
||||
web: String(config.get('source.web') || '').replace(/\/+$/, ''),
|
||||
prefix: String(config.get('source.prefix') || 'sites').replace(/^\/+|\/+$/g, '') || 'sites',
|
||||
apiUrl: String(config.get('source.apiUrl') || '').replace(/\/+$/, ''),
|
||||
user: String(config.get('source.user') || 'webmaker').trim(),
|
||||
repo: String(config.get('source.repo') || 'webmaker/sites').trim(),
|
||||
token: config.get('source.token') || '',
|
||||
};
|
||||
}
|
||||
/** 푸시 주소가 있어야 동작한다 (API 주소·토큰은 선택) */
|
||||
function configured() { return !!settings().remote; }
|
||||
function branchUrl(branch) { const { web } = settings(); return web ? `${web}/src/branch/${encodeURIComponent(branch)}` : null; }
|
||||
function commitUrl(sha) { const { web } = settings(); return web ? `${web}/commit/${sha}` : null; }
|
||||
|
||||
/** git/ssh 오류를 운영자가 조치할 수 있는 문구로 바꾼다 (1.4.14 gitstore.gitHint 를 모듈로 옮김) */
|
||||
function gitHint(msg) {
|
||||
if (/Permission denied \(publickey|correct access rights|Could not read from remote repository/.test(msg)) return `소스 저장소(Gitea) 접근 거부 — 이 서버의 키가 저장소 계정(${settings().user})에 등록되지 않았거나 쓰기 권한이 없습니다. 초기 설정 페이지(또는 관리자 콘솔 › 설정 › 소스 저장소)의 공개키를 Gitea 계정 키로 등록하거나, 서버에서 deploy/install-gitea.sh 를 다시 실행하세요. (${msg.slice(0, 160)})`;
|
||||
if (/Could not resolve hostname|Connection (refused|timed out)|No route to host/.test(msg)) return `소스 저장소 서버에 연결할 수 없습니다 — 로컬 Gitea 컨테이너(webmaker-gitea)가 실행 중인지(docker ps), 주소·포트·방화벽을 확인하세요. (${msg.slice(0, 160)})`;
|
||||
if (/Host key verification failed|REMOTE HOST IDENTIFICATION/.test(msg)) return `소스 저장소 서버 키가 바뀌었습니다 — 서비스 서버의 ~/.ssh/known_hosts 에서 해당 항목을 지우세요. (${msg.slice(0, 160)})`;
|
||||
if (/non-fast-forward|fetch first|rejected/.test(msg)) return `원격 브랜치와 이력이 달라 푸시가 거부되었습니다 (${msg.slice(0, 160)})`;
|
||||
return msg;
|
||||
}
|
||||
/** 푸시 주소의 호스트(ssh 별칭)에 쓰이는 이 서버의 공개키 — Gitea 계정에 등록해야 하는 값 */
|
||||
function publicKey() {
|
||||
const { remote } = settings(); if (!remote) return null;
|
||||
const m = remote.match(/^(?:ssh:\/\/)?(?:([^@]+)@)?([^:/]+)/); const host = m ? m[2] : null; if (!host) return null;
|
||||
try {
|
||||
const out = execFileSync('ssh', ['-G', host], { timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
|
||||
const files = out.split('\n').filter((l) => l.startsWith('identityfile ')).map((l) => l.slice(13).trim().replace(/^~/, os.homedir()));
|
||||
for (const f of files) { try { return { file: f, publicKey: fs.readFileSync(f + '.pub', 'utf8').trim() }; } catch (_) {} }
|
||||
return { file: files[0] || null, publicKey: null };
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
// ---------- Gitea API (선택: source.apiUrl + 토큰) ----------
|
||||
/**
|
||||
* Gitea REST API 호출. 토큰 인증(Authorization: token …). 가입 금지·로그인 필수(REQUIRE_SIGNIN_VIEW)로 설치된
|
||||
* 로컬 Gitea 는 토큰 없이는 /api/v1/version 도 403 이므로 토큰이 없으면 API 기능을 쓰지 않는다.
|
||||
*/
|
||||
async function api(method, p, body) {
|
||||
const s = settings(); if (!s.apiUrl) throw httpError('Gitea API 주소(source.apiUrl)가 없습니다'); if (!s.token) throw httpError('Gitea API 토큰(source.token)이 없습니다');
|
||||
let r; try { r = await fetch(`${s.apiUrl}/api/v1${p}`, { method, headers: { Authorization: `token ${s.token}`, 'Content-Type': 'application/json', Accept: 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(15000) }); }
|
||||
catch (e) { throw httpError(`Gitea API 연결 실패 (${s.apiUrl}): ${e.cause ? e.cause.code || e.message : e.message}`, 502); }
|
||||
const text = await r.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch (_) { data = { raw: text.slice(0, 200) }; }
|
||||
if (!r.ok) { const msg = (data && (data.message || data.error)) || text.slice(0, 200); throw httpError(r.status === 401 ? 'Gitea API 토큰이 올바르지 않습니다 (401)' : r.status === 403 ? `Gitea API 권한 없음 (403): ${msg}` : `Gitea API 오류 ${r.status}: ${msg}`, 502); }
|
||||
return data;
|
||||
}
|
||||
/** API 로 보는 상태: 버전, 저장소 존재 */
|
||||
async function apiStatus() {
|
||||
const s = settings(); if (!s.apiUrl || !s.token) return { ok: false, skipped: true, detail: s.apiUrl ? 'API 토큰 없음 (SSH 푸시만 사용)' : 'API 주소 없음 (SSH 푸시만 사용)' };
|
||||
const v = await api('GET', '/version'); let repo = null;
|
||||
try { const r = await api('GET', `/repos/${s.repo}`); repo = { exists: true, private: !!r.private, size: r.size, url: r.html_url }; } catch (e) { repo = { exists: false, detail: e.message }; }
|
||||
return { ok: true, version: v && v.version, repo, user: s.user, detail: `Gitea ${v && v.version} · 저장소 ${s.repo} ${repo.exists ? '있음' : '없음'}` };
|
||||
}
|
||||
/** 저장소가 없으면 만든다 (비공개). 토큰 계정이 소유자여야 한다 */
|
||||
async function ensureRepo() {
|
||||
const s = settings(); const [owner, name] = s.repo.split('/');
|
||||
try { await api('GET', `/repos/${s.repo}`); return { created: false, repo: s.repo }; } catch (_) {}
|
||||
if (owner !== s.user) throw httpError(`저장소 소유자(${owner})가 토큰 계정(${s.user})과 달라 만들 수 없습니다. Gitea 에서 직접 만드세요`);
|
||||
await api('POST', '/user/repos', { name, private: true, auto_init: false, default_branch: 'main', description: 'AI Webmaker 사이트 소스 버전 (sites/<사이트ID>/main, sites/<사이트ID>/changes/NNNN-종류)' });
|
||||
return { created: true, repo: s.repo };
|
||||
}
|
||||
/** 이 서버의 공개키를 토큰 계정의 SSH 키로 등록 (이미 있으면 그대로) */
|
||||
async function registerKey() {
|
||||
const k = publicKey(); if (!k || !k.publicKey) throw httpError('이 서버의 SSH 공개키를 찾지 못했습니다 (푸시 주소의 ssh 별칭에 IdentityFile 이 있어야 합니다. deploy/install-gitea.sh 가 만듭니다)');
|
||||
const body = k.publicKey.split(/\s+/)[1]; const list = await api('GET', '/user/keys');
|
||||
if ((list || []).some((x) => String(x.key || '').split(/\s+/)[1] === body)) return { registered: false, title: null };
|
||||
const title = `webmaker-service@${os.hostname()}`;
|
||||
await api('POST', '/user/keys', { title, key: k.publicKey, read_only: false });
|
||||
return { registered: true, title };
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 상태 (관리자 콘솔 › 설정 › 소스 저장소, 설정 페이지 "연결 확인").
|
||||
* SSH 푸시 경로는 git ls-remote 로 실제 확인하고, API 는 토큰이 있을 때만 본다.
|
||||
*/
|
||||
function status() {
|
||||
const s = settings(); const key = publicKey();
|
||||
return new Promise((resolve) => {
|
||||
const base = { enabled: !!s.remote, provider: id, remote: s.remote || null, web: s.web || null, prefix: s.prefix, apiUrl: s.apiUrl || null, user: s.user, repo: s.repo, publicKey: key ? key.publicKey : null, keyFile: key ? key.file : null };
|
||||
if (!s.remote) return resolve({ ...base, ok: false, detail: '푸시 주소(source.remote)가 없어 소스를 원격에 보관하지 않습니다 (로컬 버전 기록만 사용). 서버에서 deploy/install-gitea.sh 를 실행하면 전용 Gitea 를 만들고 이 값을 채웁니다' });
|
||||
const started = Date.now();
|
||||
execFile('git', ['ls-remote', '--heads', s.remote, `refs/heads/${s.prefix}/*/main`], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_SSH_COMMAND: 'ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=12' }, timeout: 30000, maxBuffer: 8 * 1024 * 1024 }, async (err, stdout, stderr) => {
|
||||
const ms = Date.now() - started; let apiInfo = null; try { apiInfo = await apiStatus(); } catch (e) { apiInfo = { ok: false, detail: e.message }; }
|
||||
if (err) return resolve({ ...base, ok: false, ms, api: apiInfo, detail: gitHint((stderr || err.message).trim().split('\n').map((l) => l.trim()).filter(Boolean).slice(-3).join(' ')) });
|
||||
const n = stdout.split('\n').filter(Boolean).length;
|
||||
resolve({ ...base, ok: true, ms, sites: n, api: apiInfo, detail: `연결 성공 — 저장소에 사이트 브랜치 ${n}개${apiInfo && apiInfo.ok ? ` · ${apiInfo.detail}` : ''}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
/** 짧은 요약 (관리자 첫 화면) */
|
||||
function info() { const s = settings(); return { provider: id, enabled: !!s.remote, remote: s.remote ? s.remote.replace(/^.*[:/]([^/]+\/[^/]+?)(\.git)?$/, '$1') : null, web: s.web || null }; }
|
||||
|
||||
module.exports = { id, label, kind, settings, configured, branchUrl, commitUrl, gitHint, publicKey, api, apiStatus, ensureRepo, registerKey, status, info };
|
||||
@@ -0,0 +1,48 @@
|
||||
'use strict';
|
||||
// ============================================================================
|
||||
// 외부 서비스 연동 모듈 등록소 (v1.5.7, 요구 5·6)
|
||||
//
|
||||
// 웹메이커가 다른 제품으로 바꿔 쓸 수 있는 외부 서비스는 "종류(kind)" 로 묶고, 종류마다 연동 모듈을 하나씩 고른다.
|
||||
// source : 사이트 소스 보관(git 원격) — gitea (lib/connectors/gitea.js)
|
||||
// mail : 받는 메일·웹메일·메일 계정 서버 — arimail (lib/connectors/arimail.js, 구 mail-gateway)
|
||||
// 어느 모듈을 쓰는지는 설정 파일(lib/config.js)의 source.provider / mail.provider 가 정하고, 모듈이 접속에 쓰는 주소·계정도
|
||||
// 같은 설정 파일(source.*, mail.*)에서 읽는다. 비밀값(토큰·비밀번호)은 암호화 보관소(lib/vault.js)에 있다.
|
||||
//
|
||||
// 모듈이 지켜야 하는 공통 모양
|
||||
// id, label, kind 이름표
|
||||
// settings() 설정 파일에서 읽은 접속 정보 (비밀값 포함, 서버 내부 전용)
|
||||
// configured() 동작에 필요한 값이 모두 있는지
|
||||
// status() → Promise<{enabled, provider, ok, detail, …}> 연결 상태 (관리자 콘솔·설정 페이지 "연결 확인")
|
||||
// 종류별 추가 동작은 각 모듈 주석을 본다. 다른 제품(예: GitLab, 다른 메일 서버)으로 바꾸려면 같은 모양의 모듈을 이 폴더에
|
||||
// 추가하고 아래 MODULES 와 config SCHEMA 의 provider 선택지에 넣으면 된다 — 나머지 코드(gitstore·mailsys)는 모듈만 본다.
|
||||
// ============================================================================
|
||||
const MODULES = {
|
||||
source: { gitea: () => require('./gitea') },
|
||||
mail: { arimail: () => require('./arimail') },
|
||||
};
|
||||
const KIND_LABEL = { source: '소스 보관', mail: '메일 서버' };
|
||||
|
||||
/** 설정에서 고른 모듈 id (source 는 기본 gitea, mail 은 기본 none) */
|
||||
function providerOf(kind) {
|
||||
const config = require('../config');
|
||||
const v = kind === 'source' ? config.get('source.provider') : config.get('mail.provider');
|
||||
return v || (kind === 'source' ? 'gitea' : 'none');
|
||||
}
|
||||
/** 지금 켜져 있는 모듈 (연동 안 함이면 null) */
|
||||
function active(kind) { const f = (MODULES[kind] || {})[providerOf(kind)]; return f ? f() : null; }
|
||||
function source() { return active('source'); }
|
||||
function mail() { return active('mail'); }
|
||||
/** 모듈 목록 (화면용) */
|
||||
function list() {
|
||||
return Object.entries(MODULES).flatMap(([kind, mods]) => Object.entries(mods).map(([id, f]) => { const m = f(); return { kind, kindLabel: KIND_LABEL[kind], id, label: m.label, active: providerOf(kind) === id, configured: m.configured() }; }));
|
||||
}
|
||||
/** 연결 상태. 모듈이 없으면(연동 안 함) 그 사실을 돌려준다 */
|
||||
async function status(kind) {
|
||||
const m = active(kind);
|
||||
if (!m) return { enabled: false, provider: 'none', kind, ok: false, detail: `${KIND_LABEL[kind] || kind} 연동을 쓰지 않습니다 (설정 파일 ${kind}.provider = none)` };
|
||||
try { return { kind, ...(await m.status()) }; } catch (e) { return { kind, enabled: true, provider: m.id, ok: false, detail: e.message }; }
|
||||
}
|
||||
/** "연결 확인" 버튼용 — {ok, detail} 로 간단히 */
|
||||
async function test(kind) { const s = await status(kind); return { ok: !!s.ok, detail: s.detail, status: s }; }
|
||||
|
||||
module.exports = { MODULES, KIND_LABEL, providerOf, active, source, mail, list, status, test };
|
||||
Reference in New Issue
Block a user