diff --git a/.env.example b/.env.example index be0ae44..9358107 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,9 @@ ADMIN_TOKEN=xavis-admin # SMTP_FROM=noreply@xavis.co.kr # 선택: 587에서 STARTTLS 강제(기본 on). 특수 서버만 0 # SMTP_REQUIRE_TLS=1 -# 이메일 로그인 세션 만료: 해당 일자 23:59:59까지(달력은 OPS_SESSION_TZ 기준, 기본 Asia/Seoul) +# 이메일 로그인 세션: 로그인한 달력일(OPS_SESSION_TZ) + OPS_SESSION_TTL_DAYS일의 23:59:59까지(기본 15일) # OPS_SESSION_TZ=Asia/Seoul +# OPS_SESSION_TTL_DAYS=15 PAGE_SIZE=9 # 학습센터 동영상 파일 업로드 최대 크기(MB, 기본 500). 리버스 프록시(Nginx 등)의 client_max_body_size도 같이 늘려야 합니다. LECTURE_VIDEO_MAX_MB=500 diff --git a/ops-auth.js b/ops-auth.js index 7618b93..a290bf7 100644 --- a/ops-auth.js +++ b/ops-auth.js @@ -33,30 +33,65 @@ function calendarDateKeyInTz(tsMs, tz) { return `${y}-${mo}-${da}`; } -/** - * 로그인 세션 만료: OPS_SESSION_TZ(기본 Asia/Seoul)에서 해당 일자의 마지막 순간(23:59:59.999에 해당하는 epoch ms) - */ -function getOpsSessionExpiresAtMs(nowMs = Date.now()) { - const tz = (process.env.OPS_SESSION_TZ || "Asia/Seoul").trim() || "Asia/Seoul"; - const cur = calendarDateKeyInTz(nowMs, tz); - let lo = nowMs; - let hi = nowMs + 48 * 60 * 60 * 1000; - let guard = 0; - while (calendarDateKeyInTz(hi, tz) === cur && guard < 400) { - hi += 24 * 60 * 60 * 1000; - guard++; +/** 그레고리력 YYYY-MM-DD 문자열에 일수 더하기(타임존 무관, 달력 날짜만) */ +function addCalendarDaysToKey(key, days) { + const n = Math.floor(Number(days) || 0); + const [y, m, d] = key.split("-").map(Number); + const base = new Date(Date.UTC(y, m - 1, d)); + base.setUTCDate(base.getUTCDate() + n); + const yy = base.getUTCFullYear(); + const mm = String(base.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(base.getUTCDate()).padStart(2, "0"); + return `${yy}-${mm}-${dd}`; +} + +/** dateKey 달력 날짜에 속하는 임의 시각(ms) 찾기 */ +function findMsOnCalendarDay(dateKey, tz) { + const [y, m, d] = dateKey.split("-").map(Number); + const start = Date.UTC(y, m - 1, d, 0, 0, 0, 0); + for (let k = -24 * 4; k < 96 * 4; k += 1) { + const ms = start + k * 15 * 60 * 1000; + if (calendarDateKeyInTz(ms, tz) === dateKey) return ms; } - if (calendarDateKeyInTz(hi, tz) === cur) { - hi = nowMs + 400 * 24 * 60 * 60 * 1000; + throw new Error(`findMsOnCalendarDay: ${dateKey} (${tz})`); +} + +/** + * OPS_SESSION_TZ에서 dateKey 해당 날의 마지막 순간(그날 23:59:59.999에 해당하는 epoch ms) + */ +function getLastMsOfCalendarDayInTz(dateKey, tz) { + const anchor = findMsOnCalendarDay(dateKey, tz); + let lo = anchor; + let hi = anchor + 48 * 60 * 60 * 1000; + let guard = 0; + while (calendarDateKeyInTz(hi, tz) === dateKey && guard < 400) { + hi += 24 * 60 * 60 * 1000; + guard += 1; + } + if (calendarDateKeyInTz(hi, tz) === dateKey) { + hi = anchor + 400 * 24 * 60 * 60 * 1000; } while (hi - lo > 1) { const mid = Math.floor((lo + hi) / 2); - if (calendarDateKeyInTz(mid, tz) === cur) lo = mid; + if (calendarDateKeyInTz(mid, tz) === dateKey) lo = mid; else hi = mid; } return hi - 1; } +/** + * 로그인 세션 만료: 로그인일(OPS_SESSION_TZ 달력) + OPS_SESSION_TTL_DAYS(기본 15)일의 마지막 순간 + */ +function getOpsSessionExpiresAtMs(nowMs = Date.now()) { + const tz = (process.env.OPS_SESSION_TZ || "Asia/Seoul").trim() || "Asia/Seoul"; + const raw = (process.env.OPS_SESSION_TTL_DAYS || "15").trim(); + const parsed = parseInt(raw, 10); + const ttlDays = Number.isFinite(parsed) && parsed >= 0 ? parsed : 15; + const loginDayKey = calendarDateKeyInTz(nowMs, tz); + const targetKey = addCalendarDaysToKey(loginDayKey, ttlDays); + return getLastMsOfCalendarDayInTz(targetKey, tz); +} + function isOpsProd() { return isOpsProdMode(); }