const MONTHS = Object.freeze({ Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 }); function parseDownloadTrafficLogTime(line) { if (!line || typeof line !== 'string') return null; const match = line.match(/\[(\d{2})\/([A-Za-z]{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})\s*([+-]\d{4})?\]/); if (!match) return null; const month = MONTHS[match[2]]; if (month === undefined) return null; const year = Number(match[3]); const day = Number(match[1]); const hour = Number(match[4]); const minute = Number(match[5]); const second = Number(match[6]); if ([year, day, hour, minute, second].some((value) => !Number.isFinite(value))) return null; let utcMillis = Date.UTC(year, month, day, hour, minute, second); const timezone = match[7]; if (timezone && /^[+-]\d{4}$/.test(timezone)) { const sign = timezone[0] === '+' ? 1 : -1; const timezoneHours = Number(timezone.slice(1, 3)); const timezoneMinutes = Number(timezone.slice(3, 5)); if (timezoneHours > 23 || timezoneMinutes > 59) return null; utcMillis -= sign * ((timezoneHours * 60) + timezoneMinutes) * 60 * 1000; } const parsed = new Date(utcMillis); if (Number.isNaN(parsed.getTime())) return null; // Date.UTC normalizes impossible dates; reject them instead of charging the wrong day. const offsetMinutes = timezone ? (timezone[0] === '+' ? 1 : -1) * ((Number(timezone.slice(1, 3)) * 60) + Number(timezone.slice(3, 5))) : 0; const localDate = new Date(parsed.getTime() + (offsetMinutes * 60 * 1000)); if ( localDate.getUTCFullYear() !== year || localDate.getUTCMonth() !== month || localDate.getUTCDate() !== day || localDate.getUTCHours() !== hour || localDate.getUTCMinutes() !== minute || localDate.getUTCSeconds() !== second ) { return null; } return parsed; } function parseDownloadTrafficLine(line, fallbackDate = new Date()) { if (!line || typeof line !== 'string') return null; const trimmed = line.trim(); if (!trimmed || !/\bGET\b/i.test(trimmed)) return null; const statusMatch = trimmed.match(/"\s*(\d{3})\s+(\d+|-)\b/); if (!statusMatch) return null; const statusCode = Number(statusMatch[1]); const bytesSent = statusMatch[2] === '-' ? 0 : Number(statusMatch[2]); if (![200, 206].includes(statusCode) || !Number.isFinite(bytesSent) || bytesSent <= 0) return null; let objectKey = null; const requestMatch = trimmed.match(/"(?:GET|HEAD)\s+([^" ]+)\s+HTTP\//i); if (requestMatch?.[1]) { let requestPath = requestMatch[1]; const queryIndex = requestPath.indexOf('?'); if (queryIndex >= 0) requestPath = requestPath.slice(0, queryIndex); requestPath = requestPath.replace(/^https?:\/\/[^/]+/i, '').replace(/^\/+/, ''); try { requestPath = decodeURIComponent(requestPath); } catch { // Keep the raw path when the log contains malformed percent encoding. } objectKey = requestPath || null; } if (!objectKey) { const keyMatch = trimmed.match(/\buser_(\d+)\/[^\s"]+/); objectKey = keyMatch?.[0] || null; } if (!objectKey) return null; const userMatch = objectKey.match(/(?:^|\/)user_(\d+)\//); if (!userMatch) return null; const userId = Number(userMatch[1]); if (!Number.isSafeInteger(userId) || userId <= 0) return null; return { userId, bytes: Math.floor(bytesSent), objectKey, eventAt: parseDownloadTrafficLogTime(trimmed) || fallbackDate }; } module.exports = { parseDownloadTrafficLine, parseDownloadTrafficLogTime };