2026-10-26 11:02:58 +00:00
/**
* 메일 사용량 정보 제공 API(1.0.9, 요구사항 1) — 웹메이커 같은 외부 관리 시스템이 디스크 사용량·발송량 등을 통합 관리할 수 있게 읽기 전용 통계를 내줍니다.
*
* 주소(어느 호스트로 와도 같음, nginx 뒤 HTTPS):
* GET /api/usage 모든 도메인의 요약(기본 도메인 먼저)
* GET /api/usage/<도메인> 그 도메인의 상세(기본으로 계정 목록·일별 추이 포함)
* 쿼리: days=30(일별 추이·계정별 기간 통수의 기간, 1~366) · accounts=0|1(계정 목록 포함, 요약은 기본 0·상세는 기본 1) · series=0|1(일별 추이, 상세는 기본 1)
*
* 보안(요구사항: webmaker 의 해당 기능에서만 접근할 수 있게)
* 1) 비밀 키: 전체 설정 화면의 "사용량 API" 카드에서 만든 키(USAGE_API_KEY, 기본 도메인의 설정 파일에만 저장, 32바이트 난수)를 `Authorization: Bearer <키>`(또는 X-Usage-Key)로
* 보내야 합니다. 키가 없으면 API 자체가 꺼진 상태(404)이고, 비교는 상수 시간(safeEqual)입니다. 키는 만들 때 한 번만 보여 주며 설정 화면에는 앞뒤 일부만 보입니다. 언제든 다시 만들거나(교체) 지울 수 있습니다.
* 2) 접속 IP 제한(선택): USAGE_API_ALLOW 에 웹메이커 서버의 IP 나 CIDR(쉼표 구분)을 적으면 그 밖의 주소는 키가 맞아도 403. nginx 가 넘긴 X-Forwarded-For 의 첫 주소(또는 CF-Connecting-IP)를 봅니다.
* 3) 시도 제한: 키가 틀린 요청은 IP 마다 15분에 10회(그 뒤 429), 성공 요청도 IP 마다 1분에 120회(무차별 조회 방지). 모두 메모리 제한(env.RATE)입니다.
* 4) 읽기 전용·비밀값 없음: 메일 내용·주소록·비밀번호·API 키는 절대 들어가지 않고, 수(통·바이트)·제한·계정 이름(로그인 ID·메일함 주소·역할·상태)만 나갑니다. 호출은 감사 기록(audit_log)에 남습니다.
*
* 응답(도메인 하나)
* domain, primary, enabled, hosts, authMode, mailbox, inbound, outbound, timezone, generatedAt
* accounts : { local, external, total, limit, percent } — 게이트웨이 계정 수 / 웹메이커 등 외부 계정 수 / 시스템 제한(0 = 없음)
* storage : { bytes, limit, percent, largeFiles:{count, bytes, sent} } — 모든 메일함의 사용량(MIME 기준, 원본 .eml 크기) / 대용량 첨부파일(별도 저장)
* messages : { total, inbound, outbound, drafts, failed } — 저장된 메일 수(폴더 무관, 휴지통 포함; 영구 삭제한 것은 없음)
* traffic : { day, week, month: { sent, received, total, sentBytes, receivedBytes, start, end }, monthlyLimit, monthlyPercent } — 통계 시간대 기준(오늘·이번 주(월~)·이달)
* daily : [ { date:'YYYY-MM-DD', sent, received, sentBytes, receivedBytes } ] (최근 days 일, 오래된 날부터)
* accountList : [ { login, name, mailbox, role, source:'local'|'www'|'webmaker', status, messages, bytes, limit, percent, lastLoginAt, sent, received, sentBytes, receivedBytes } ] (기간 = days)
* limits : { system:{…}, admin:{…} } — 설치 시 시스템 제한과 관리자 제한(안내용)
*/
import { json , bad , safeEqual , now } from './util.mjs' ;
2026-10-26 11:47:45 +00:00
import { systemLimits , readAdminLimits , mailboxUsage , mailboxQuota , totalUsage , accountCount , statsTimezone , periodBounds , countMail , percentOf , limitState , statsNow } from './limits.mjs' ;
2026-10-26 11:02:58 +00:00
import { listUsers , externalUsers , authMode , audit , mailboxOwner , standalone } from './auth.mjs' ;
import { largeFileStats } from './files.mjs' ;
import { inboundMode , outboundMode } from './providers/index.mjs' ;
import { mailDomain } from './config.mjs' ;
export const USAGE_FAIL_LIMIT = 10 , USAGE_RATE_LIMIT = 120 ;
const DAY_MS = 86400000 ;
// ------------------------------------------------------------------ 접속 IP 허용 목록
/** "1.2.3.4, 10.0.0.0/8, 2001:db8::1" 같은 문자열을 항목 배열로. 빈 문자열이면 제한 없음(빈 배열). */
export function parseAllowList ( text ) {
return String ( text || '' ). split ( /[\s,]+/ ). map (( s ) => s . trim ()). filter ( Boolean );
}
const ipv4ToInt = ( ip ) => { const m = String ( ip ). match ( /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/ ); if ( ! m ) return null ; const parts = m . slice ( 1 ). map ( Number ); if ( parts . some (( n ) => n > 255 )) return null ; return (( parts [ 0 ] << 24 ) >>> 0 ) + ( parts [ 1 ] << 16 ) + ( parts [ 2 ] << 8 ) + parts [ 3 ]; };
/** IPv6 주소를 소문자 전개형(8그룹 16진수)으로. 잘못된 주소는 null. "::ffff:1.2.3.4" 꼴은 IPv4 로 비교하도록 호출자가 먼저 벗깁니다. */
function expandIpv6 ( ip ) {
const s = String ( ip ). toLowerCase (); if ( ! /^[0-9a-f:]+$/ . test ( s ) || s . split ( '::' ). length > 2 ) return null ;
const [ head , tail = '' ] = s . split ( '::' ); const h = head ? head . split ( ':' ) : [], t = tail ? tail . split ( ':' ) : [];
if ( h . length + t . length > 8 || ( s . indexOf ( '::' ) < 0 && h . length !== 8 )) return null ;
const groups = [... h , ... Array ( 8 - h . length - t . length ). fill ( '0' ), ... t ];
if ( groups . some (( g ) => ! /^[0-9a-f]{1,4}$/ . test ( g ))) return null ;
return groups . map (( g ) => parseInt ( g , 16 ));
}
/**
* 접속 IP 가 허용 목록 안인지. 목록이 비어 있으면 모두 허용. IPv4 는 CIDR(/8~/32) 지원, IPv6 는 정확히 같은 주소 또는 /n 접두사.
* IPv4 주소가 "::ffff:1.2.3.4" 로 들어오면(IPv6 소켓) IPv4 로 봅니다.
*/
export function ipAllowed ( ip , list ) {
if ( ! list || ! list . length ) return true ;
let addr = String ( ip || '' ). trim (). toLowerCase (); if ( addr . startsWith ( '::ffff:' ) && addr . includes ( '.' )) addr = addr . slice ( 7 );
const v4 = ipv4ToInt ( addr ), v6 = v4 === null ? expandIpv6 ( addr ) : null ;
if ( v4 === null && ! v6 ) return false ;
for ( const entry of list ) {
const [ net , bitsRaw ] = entry . split ( '/' ); const bits = bitsRaw === undefined ? null : Number ( bitsRaw );
if ( v4 !== null ) {
const n = ipv4ToInt ( net ); if ( n === null ) continue ;
const b = bits === null ? 32 : bits ; if ( ! Number . isInteger ( b ) || b < 0 || b > 32 ) continue ;
const mask = b === 0 ? 0 : ( ~ 0 << ( 32 - b )) >>> 0 ; if ((( v4 & mask ) >>> 0 ) === (( n & mask ) >>> 0 )) return true ;
} else {
const n = expandIpv6 ( net ); if ( ! n ) continue ;
const b = bits === null ? 128 : bits ; if ( ! Number . isInteger ( b ) || b < 0 || b > 128 ) continue ;
let ok = true ; for ( let i = 0 ; i < 8 && ok ; i ++ ) { const take = Math . max ( 0 , Math . min ( 16 , b - i * 16 )); if ( ! take ) break ; const m = take === 16 ? 0xffff : ( 0xffff << ( 16 - take )) & 0xffff ; if (( v6 [ i ] & m ) !== ( n [ i ] & m )) ok = false ; }
if ( ok ) return true ;
}
}
return false ;
}
/**
* 허용 목록 항목 검사: 각 항목이 IPv4[/0~32] 또는 IPv6[/0~128] 이어야 합니다. 잘못된 항목 목록을 돌려줍니다(비어 있으면 통과).
* 저장 때 검사하지 않으면 오타 하나로 모든 요청이 403 이 되고 아무 데서도 알 수 없습니다.
*/
export function invalidAllowEntries ( text ) {
const bad = [];
for ( const entry of parseAllowList ( text )) {
const [ net , bitsRaw , extra ] = entry . split ( '/' ); if ( extra !== undefined ) { bad . push ( entry ); continue ; }
const v4 = ipv4ToInt ( net ), v6 = v4 === null ? expandIpv6 ( net ) : null ; if ( v4 === null && ! v6 ) { bad . push ( entry ); continue ; }
if ( bitsRaw !== undefined ) { const b = Number ( bitsRaw ); if ( ! /^\d{1,3}$/ . test ( bitsRaw ) || b > ( v4 !== null ? 32 : 128 )) bad . push ( entry ); }
}
return bad ;
}
/**
* 요청의 접속 IP(허용 목록·시도 제한용). 클라이언트가 마음대로 넣을 수 있는 헤더를 믿지 않습니다:
* 1) X-Real-IP(nginx 템플릿 1.0.9 부터 $remote_addr 로 넣음) → 2) X-Forwarded-For 의 **마지막** 주소(nginx 가 $proxy_add_x_forwarded_for 로 실제 접속 주소를 끝에 덧붙임;
* 첫 주소는 클라이언트가 보낸 값일 수 있음) → 3) 소켓 주소. CF-Connecting-IP 는 Cloudflare 뒤에서만 참이므로 USAGE_TRUST_CF=1 일 때만 봅니다.
*/
export function clientIp ( headers , remote = '' ) {
const get = ( k ) => String ( headers . get ( k ) || '' ). trim ();
if ( process . env . USAGE_TRUST_CF === '1' && get ( 'cf-connecting-ip' )) return get ( 'cf-connecting-ip' );
if ( get ( 'x-real-ip' )) return get ( 'x-real-ip' );
const xff = get ( 'x-forwarded-for' ). split ( ',' ). map (( s ) => s . trim ()). filter ( Boolean );
if ( xff . length ) return xff [ xff . length - 1 ];
return String ( remote || '' ). trim ();
}
// ------------------------------------------------------------------ 인증
/**
* 사용량 API 인증. 키는 기본 도메인(primaryEnv)의 설정에서만 읽습니다(도메인별 키가 아니라 서버 하나에 한 키 — 웹메이커가 모든 도메인을 한 번에 봄).
* @returns {{ok:true}|{ok:false, status:number, error:string}}
*/
export function authorizeUsage ( primaryEnv , request , ip ) {
const key = String ( primaryEnv . USAGE_API_KEY || '' ). trim ();
if ( ! key ) return { ok : false , status : 404 , error : 'usage_api_disabled' };
const rate = primaryEnv . RATE ;
if ( rate ? . blocked ( 'usage-fail:' + ip , { limit : USAGE_FAIL_LIMIT })) return { ok : false , status : 429 , error : 'rate_limited' };
const auth = request . headers . get ( 'authorization' ) || '' ;
const given = ( auth . match ( /^Bearer\s+(.+)$/i ) ? .[ 1 ] || request . headers . get ( 'x-usage-key' ) || '' ). trim ();
if ( ! given || ! safeEqual ( given , key )) { rate ? . hit ( 'usage-fail:' + ip , { limit : USAGE_FAIL_LIMIT , windowSec : 900 }); return { ok : false , status : 401 , error : 'unauthenticated' }; }
if ( ! ipAllowed ( ip , parseAllowList ( primaryEnv . USAGE_API_ALLOW ))) return { ok : false , status : 403 , error : 'forbidden_ip' };
// 성공 요청 빈도(IP 마다 1분 120회). hit() 가 false 를 돌려주면 한도를 넘은 것.
if ( rate && ! rate . hit ( 'usage:' + ip , { limit : USAGE_RATE_LIMIT , windowSec : 60 })) return { ok : false , status : 429 , error : 'rate_limited' };
return { ok : true };
}
// ------------------------------------------------------------------ 통계
/** 통계 시간대 기준의 날짜 문자열(YYYY-MM-DD). */
const dateKey = ( iso , tz ) => { try { return new Intl . DateTimeFormat ( 'en-CA' , { timeZone : tz , year : 'numeric' , month : '2-digit' , day : '2-digit' }). format ( new Date ( iso )); } catch { return String ( iso ). slice ( 0 , 10 ); } };
/**
* 최근 days 일의 일별 수·발신 통수와 바이트, 메일함별 기간 합계. 한 번의 조회로 기간 안의 메일(발신은 발송 완료 시각, 수신은 받은 시각)을 읽어 JS 에서 날짜별로 묶습니다
* (시간대 변환을 SQL 로 하기 어렵고, 메일 수가 많지 않아 충분히 빠름). 기간 시작은 오늘 0시(통계 시간대)에서 days-1 일을 뺀 시각입니다.
*/
async function dailySeries ( env , days , tz ) {
// 시작 = (오늘 0시 - (days-1)일) 이 속한 날의 0시. 24시간 단위로 빼기만 하면 서머타임 경계에서 한 시간이 밀리므로 그 날의 정오를 잡아 다시 0시를 구합니다.
2026-10-26 11:47:45 +00:00
const today = periodBounds ( 'day' , tz , statsNow ( env )); const approx = Date . parse ( today . start ) - ( days - 1 ) * DAY_MS ;
2026-10-26 11:02:58 +00:00
const start = periodBounds ( 'day' , tz , new Date ( approx + DAY_MS / 2 )). start , startMs = Date . parse ( start ), end = today . end ;
const rows = ( await env . DB . prepare ( `SELECT direction, mailbox, raw_size, sent_at, received_at FROM messages WHERE (direction='inbound' AND received_at>=? AND received_at<?) OR (direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?)` ). bind ( start , end , start , end ). all ()). results ;
const byDate = new Map (); for ( let i = 0 ; i < days ; i ++ ) { const d = dateKey ( new Date ( startMs + i * DAY_MS + DAY_MS / 2 ). toISOString (), tz ); byDate . set ( d , { date : d , sent : 0 , received : 0 , sentBytes : 0 , receivedBytes : 0 }); }
const byMailbox = new Map ();
for ( const r of rows ) {
const at = r . direction === 'outbound' ? r . sent_at : r . received_at ; const d = dateKey ( at , tz ); const bucket = byDate . get ( d ); const size = Number ( r . raw_size || 0 );
const mb = byMailbox . get ( r . mailbox ) || { sent : 0 , received : 0 , sentBytes : 0 , receivedBytes : 0 }; byMailbox . set ( r . mailbox , mb );
if ( r . direction === 'outbound' ) { if ( bucket ) { bucket . sent ++ ; bucket . sentBytes += size ; } mb . sent ++ ; mb . sentBytes += size ; }
else { if ( bucket ) { bucket . received ++ ; bucket . receivedBytes += size ; } mb . received ++ ; mb . receivedBytes += size ; }
}
return { start , end , daily : [... byDate . values ()], byMailbox };
}
/** 기간 통계에 바이트 합계를 더한 countMail. */
async function periodTraffic ( env , bounds ) {
const c = await countMail ( env , bounds );
const s = await env . DB . prepare ( "SELECT COALESCE(SUM(raw_size),0) AS b FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?" ). bind ( bounds . start , bounds . end ). first ();
const r = await env . DB . prepare ( "SELECT COALESCE(SUM(raw_size),0) AS b FROM messages WHERE direction='inbound' AND received_at>=? AND received_at<?" ). bind ( bounds . start , bounds . end ). first ();
return { ... c , sentBytes : Number ( s ? . b || 0 ), receivedBytes : Number ( r ? . b || 0 ) };
}
/**
* 도메인(테넌트 env) 하나의 사용량 보고서.
* @param {object} o days: 일별 추이·계정별 기간의 길이, accounts: 계정 목록 포함 여부, series: 일별 추이 포함 여부, entry: 등록부 항목(hosts·enabled·primary)
*/
export async function domainUsage ( env , { days = 30 , accounts = true , series = true , entry = null } = {}) {
const tz = statsTimezone ( env ), sys = systemLimits ( env ), admin = await readAdminLimits ( env );
const counts = await env . DB . prepare ( "SELECT COUNT(*) total, SUM(CASE WHEN direction='inbound' THEN 1 ELSE 0 END) inbound, SUM(CASE WHEN direction='outbound' AND state='sent' THEN 1 ELSE 0 END) outbound, SUM(CASE WHEN state IN ('draft','sending') THEN 1 ELSE 0 END) drafts, SUM(CASE WHEN state='failed' THEN 1 ELSE 0 END) failed FROM messages" ). first ();
const total = await totalUsage ( env ), local = await accountCount ( env ), ext = await externalUsers ( env );
2026-10-26 11:47:45 +00:00
const traffic = {}; for ( const kind of [ 'day' , 'week' , 'month' ]) traffic [ kind ] = await periodTraffic ( env , periodBounds ( kind , tz , statsNow ( env )));
const limits = await limitState ( env ); // [Ariweb Suite 변경] 경고 구간·제한 사유
2026-10-26 11:02:58 +00:00
const out = {
domain : mailDomain ( env ), primary : ! env . TENANTS || env . TENANTS . primary . env === env , enabled : entry ? entry . enabled !== false : true , hosts : entry ? . hosts || [ env . PUBLIC_HOST ]. filter ( Boolean ),
authMode : authMode ( env ), mailbox : String ( env . MAILBOX || '' ), inbound : inboundMode ( env ), outbound : outboundMode ( env ), timezone : tz , generatedAt : now (),
accounts : { local , external : ext . accounts . length , total : local + ext . accounts . length , limit : sys . accounts , percent : percentOf ( local , sys . accounts ), externalSource : ext . accounts . length || authMode ( env ) === 'webmaker' ? { kind : authMode ( env ), ok : ext . ok !== false , error : ext . error || null } : null },
storage : { bytes : total , limit : sys . totalBytes , percent : percentOf ( total , sys . totalBytes ), largeFiles : await largeFileStats ( env ) },
messages : { total : Number ( counts ? . total || 0 ), inbound : Number ( counts ? . inbound || 0 ), outbound : Number ( counts ? . outbound || 0 ), drafts : Number ( counts ? . drafts || 0 ), failed : Number ( counts ? . failed || 0 ) },
2026-10-26 11:47:45 +00:00
traffic : { ... traffic , monthlyLimit : sys . monthlyMessages , monthlyPercent : percentOf ( traffic . month . total , sys . monthlyMessages ), countingRule : 'per_final_recipient' , monthReset : limits . monthReset },
2026-10-26 11:02:58 +00:00
limits : { system : sys , admin },
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 포털 표시용: 사이트 대표/독립 구분, 운영 상태, 경고 구간(0/70/80/90/95/100), 제한 사유.
ariweb : entry ? . ariweb || env . TENANT ? . ariweb || null , operating : limits . operating , warnLevel : limits . warnLevel , warnLevels : limits . levels , limitedReasons : limits . limitedReasons , measuredAt : limits . measuredAt ,
2026-10-26 11:02:58 +00:00
};
if ( series || accounts ) {
const d = await dailySeries ( env , days , tz );
out . period = { days , start : d . start , end : d . end };
if ( series ) out . daily = d . daily ;
if ( accounts ) {
const rows = [];
const push = async ( u ) => { const bytes = await mailboxUsage ( env , u . mailbox ), limit = await mailboxQuota ( env , u . mailbox ), n = await env . DB . prepare ( 'SELECT COUNT(*) AS n FROM messages WHERE mailbox=?' ). bind ( u . mailbox ). first (); const t = d . byMailbox . get ( u . mailbox ) || { sent : 0 , received : 0 , sentBytes : 0 , receivedBytes : 0 }; rows . push ({ login : u . login , name : u . name || '' , mailbox : u . mailbox , role : u . role , source : u . source , status : u . status , messages : Number ( n ? . n || 0 ), bytes , limit , percent : percentOf ( bytes , limit ), lastLoginAt : u . lastLoginAt || null , ... t }); };
for ( const u of await listUsers ( env )) await push ({ ... u , source : 'local' , lastLoginAt : u . last_login_at });
for ( const a of ext . accounts ) await push ({ ... a , source : 'webmaker' });
// www 연동의 기본 메일함(관리자 계정 행이 없음): 메일함 주인(www 관리자) 이름으로 한 행.
if ( ! standalone ( env ) && ! rows . some (( r ) => r . mailbox === String ( env . MAILBOX || '' ). toLowerCase ())) { let owner = null ; try { owner = mailboxOwner ( env ); } catch { owner = null ; } await push ({ login : owner ? . login_id || owner ? . email || 'admin' , name : owner ? . name || '' , mailbox : String ( env . MAILBOX || '' ). toLowerCase (), role : 'admin' , source : 'www' , status : owner ? . status || 'active' , lastLoginAt : null }); }
// 계정 행이 없는데 메일이 있는 메일함(지운 계정): 주소만으로 한 행(사용량이 사라지지 않게). 메일이 있는 메일함 목록은 messages 표에서 직접 뽑습니다.
const withMail = ( await env . DB . prepare ( 'SELECT mailbox, COUNT(*) AS n FROM messages GROUP BY mailbox' ). all ()). results ;
for ( const m of withMail ) if ( m . n && ! rows . some (( r ) => r . mailbox === m . mailbox )) await push ({ login : null , name : '' , mailbox : m . mailbox , role : null , source : 'orphan' , status : 'deleted' , lastLoginAt : null });
out . accountList = rows ;
}
}
return out ;
}
// ------------------------------------------------------------------ HTTP 처리(main.mjs 가 /api/usage 로 부름)
const clampDays = ( v ) => { const n = Number ( v ); return Number . isInteger ( n ) && n >= 1 && n <= 366 ? n : 30 ; };
const flag = ( v , d ) => ( v === undefined || v === null || v === '' ? d : ! ( v === '0' || v === 'false' || v === 'no' ));
/**
* GET /api/usage[/<도메인>]. 인증·IP·빈도 검사 뒤 보고서를 JSON 으로 돌려줍니다. 감사 기록은 기본 도메인 DB 에 남깁니다.
* @param {object} tenants domains.mjs 의 테넌트 관리자
* @param {Request} request
* @param {{path:string[], ip:string, query:URLSearchParams}} o path: 'usage' 뒤의 조각
*/
export async function usageApi ( tenants , request , { path = [], ip = '' , query = new URLSearchParams () } = {}) {
const primary = tenants . primary . env ;
if ( request . method !== 'GET' ) return bad ( 'method_not_allowed' , 405 );
const auth = authorizeUsage ( primary , request , ip );
// 키가 없어 꺼진 상태(404)만 빼고 모든 거부(틀린 키·허용 밖 IP·빈도 초과)를 기록합니다 — 맞는 키로 허용 밖에서 부르는 것이 가장 중요한 신호.
if ( ! auth . ok ) { if ( auth . status !== 404 ) console . warn ( `사용량 API 거부 ${ ip } : ${ auth . error } ( ${ auth . status } )` ); return bad ( auth . error , auth . status ); }
const days = clampDays ( query . get ( 'days' ));
const headers = { 'Cache-Control' : 'no-store' };
let domain = '' ; try { domain = path [ 0 ] ? decodeURIComponent ( path [ 0 ]). trim (). toLowerCase () : '' ; } catch { return bad ( 'domain_not_found' , 404 ); }
try {
if ( domain ) {
const t = tenants . byDomain ( domain ); if ( ! t ) return bad ( 'domain_not_found' , 404 );
const report = await domainUsage ( t . env , { days , accounts : flag ( query . get ( 'accounts' ), true ), series : flag ( query . get ( 'series' ), true ), entry : t . entry });
await audit ( primary , 'usage-api' , 'usage_read' , domain , { ip , days });
return json ({ ok : true , version : primary . APP_VERSION || null , generatedAt : now (), ... report }, 200 , headers );
}
// 도메인 하나가 깨져도(DB 오류 등) 나머지 도메인의 수치는 돌려줍니다. 깨진 도메인은 { domain, error } 로 표시하고 합계에서는 뺍니다.
const domains = [];
for ( const t of tenants . all ()) {
try { domains . push ( await domainUsage ( t . env , { days , accounts : flag ( query . get ( 'accounts' ), false ), series : flag ( query . get ( 'series' ), false ), entry : t . entry })); }
catch ( e ) { console . error ( `사용량 API 도메인 오류( ${ t . entry . domain } )` , e . message ); domains . push ({ domain : t . entry . domain , primary : t . entry . domain === tenants . registry . primary , enabled : t . entry . enabled !== false , error : 'server_error' }); }
}
await audit ( primary , 'usage-api' , 'usage_read' , null , { ip , days , domains : domains . length });
const sum = ( k , f ) => domains . reduce (( n , d ) => n + ( d . error ? 0 : f ( d )), 0 );
return json ({ ok : true , version : primary . APP_VERSION || null , generatedAt : now (), primary : tenants . registry . primary , totals : { domains : domains . length , accounts : sum ( 'a' , ( d ) => d . accounts . total ), storageBytes : sum ( 's' , ( d ) => d . storage . bytes ), largeFileBytes : sum ( 'l' , ( d ) => d . storage . largeFiles . bytes ), messages : sum ( 'm' , ( d ) => d . messages . total ), sentMonth : sum ( 'sm' , ( d ) => d . traffic . month . sent ), receivedMonth : sum ( 'rm' , ( d ) => d . traffic . month . received ) }, domains }, 200 , headers );
} catch ( e ) { console . error ( '사용량 API 오류' , e . message ); return bad ( 'server_error' , 500 ); }
}