Files
2026-06-12 17:19:26 +08:00

277 lines
10 KiB
Python

#!/usr/bin/env python3
import re
import os
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime, timedelta
from collections import defaultdict
LOG_FILE = "/usr/local/nginx/logs/download_access.log"
LOG_RE = re.compile(
r'(?P<ip>\S+) - (?P<user>\S+) \[(?P<time>[^\]]+)\] "(?P<request>[^"]*)" '
r'(?P<status>\d+) (?P<bytes>\d+) "(?P<referer>[^"]*)" "(?P<ua>[^"]*)"'
)
DOWNLOAD_EXTS = re.compile(
r'\.(exe|md|pdf|zip|rar|7z|tar|gz|txt|doc|docx|xls|xlsx|ppt|pptx|deb)(\?|$)',
re.IGNORECASE
)
def fmt_bytes(b):
b = int(b)
for unit in ['B', 'KB', 'MB', 'GB']:
if b < 1024:
return "{:.1f} {}".format(b, unit)
b /= 1024
return "{:.1f} TB".format(b)
def parse_time(s):
try:
return datetime.strptime(s, "%d/%b/%Y:%H:%M:%S %z")
except Exception:
return None
def parse_logs():
entries = []
try:
with open(LOG_FILE, 'r', errors='replace') as f:
for line in f:
m = LOG_RE.match(line)
if not m:
continue
user = m.group('user')
entries.append({
'ip': m.group('ip'),
'user': user,
'time': parse_time(m.group('time')),
'request': m.group('request'),
'status': int(m.group('status')),
'bytes': int(m.group('bytes')),
})
except FileNotFoundError:
pass
return entries
def build_stats(entries):
users = defaultdict(lambda: {
'logins': [], 'downloads': [], 'total_bytes': 0, 'ips': set()
})
all_downloads = []
for e in entries:
u = e['user']
t = e['time']
req = e['request']
parts = req.split(' ')
path = parts[1] if len(parts) >= 2 else req
# For anonymous download entries, extract username from ?u= query param
if u == '-' and DOWNLOAD_EXTS.search(path) and e['status'] in (200, 206):
m = re.search(r'[?&]u=([^&\s]+)', path)
if m:
try:
from urllib.parse import unquote
u = unquote(m.group(1))
except Exception:
u = m.group(1)
else:
continue
if u == '-':
continue
users[u]['ips'].add(e['ip'])
# Count login: either /api/validate or /list/ success
if e['status'] == 200 and t:
if '/api/validate' in path or path.rstrip('/') == '/list':
users[u]['logins'].append(t)
if DOWNLOAD_EXTS.search(path) and e['status'] in (200, 206):
filename = path.split('/')[-1].split('?')[0]
try:
from urllib.parse import unquote
filename = unquote(filename)
# Tengine logs non-ASCII bytes as \xNN sequences; decode them as UTF-8
def _decode_nginx_escapes(s):
parts = re.split(r'((?:\\x[0-9A-Fa-f]{2})+)', s)
out = []
for part in parts:
if part.startswith('\\x'):
raw = bytes(int(h, 16) for h in re.findall(r'\\x([0-9A-Fa-f]{2})', part))
out.append(raw.decode('utf-8', errors='replace'))
else:
out.append(part)
return ''.join(out)
filename = _decode_nginx_escapes(filename)
except Exception:
pass
users[u]['downloads'].append({'time': t, 'file': filename, 'bytes': e['bytes']})
users[u]['total_bytes'] += e['bytes']
all_downloads.append({'user': u, 'time': t, 'file': filename, 'bytes': e['bytes']})
def sort_key(x):
t = x['time']
if t is None:
return datetime.min.replace(tzinfo=None)
try:
return t.replace(tzinfo=None)
except Exception:
return datetime.min.replace(tzinfo=None)
all_downloads.sort(key=sort_key, reverse=True)
return users, all_downloads
def render_html(users, all_downloads):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
user_rows = ""
for uname in sorted(users.keys()):
data = users[uname]
logins = sorted(data['logins'], reverse=True)
last_login = logins[0].strftime("%Y-%m-%d %H:%M") if logins else "&mdash;"
login_count = len(logins)
dl_count = len(data['downloads'])
total_data = fmt_bytes(data['total_bytes'])
ips = ", ".join(sorted(data['ips']))
if logins:
delta = datetime.now(logins[0].tzinfo) - logins[0]
is_active = delta < timedelta(hours=24)
else:
is_active = False
status_class = "active" if is_active else "inactive"
status_label = "Active 24h" if is_active else "Inactive"
user_rows += (
"<tr>"
"<td><span class='username'>{}</span></td>"
"<td><span class='badge {}'>{}</span></td>"
"<td>{}</td>"
"<td class='num'>{}</td>"
"<td class='num'>{}</td>"
"<td class='num'>{}</td>"
"<td class='ip'>{}</td>"
"</tr>"
).format(uname, status_class, status_label, last_login,
login_count, dl_count, total_data, ips)
dl_rows = ""
for d in all_downloads[:100]:
t = d['time'].strftime("%Y-%m-%d %H:%M") if d['time'] else "&mdash;"
dl_rows += (
"<tr>"
"<td>{}</td>"
"<td><span class='username'>{}</span></td>"
"<td class='filename'>{}</td>"
"<td class='num'>{}</td>"
"</tr>"
).format(t, d['user'], d['file'], fmt_bytes(d['bytes']))
total_users = len(users)
total_dls = sum(len(v['downloads']) for v in users.values())
total_data_all = fmt_bytes(sum(v['total_bytes'] for v in users.values()))
no_users = "<tr><td colspan='7' class='empty'>No authenticated activity yet</td></tr>"
no_dls = "<tr><td colspan='4' class='empty'>No downloads recorded yet</td></tr>"
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="60">
<title>Admin Dashboard</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f1f5f9; color: #1e293b; min-height: 100vh; }}
header {{ background: #ffffff; border-bottom: 1px solid #e2e8f0; padding: 20px 32px; display: flex; align-items: center; justify-content: space-between; }}
header h1 {{ font-size: 1.4rem; font-weight: 600; color: #7c3aed; }}
.refresh-note {{ font-size: 0.75rem; color: #94a3b8; }}
.stats-bar {{ display: flex; gap: 16px; padding: 24px 32px; }}
.stat-card {{ background: #ffffff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 18px 24px; flex: 1; }}
.stat-card .label {{ font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; }}
.stat-card .value {{ font-size: 2rem; font-weight: 700; color: #7c3aed; margin-top: 4px; }}
.section {{ padding: 0 32px 32px; }}
.section h2 {{ font-size: 1rem; font-weight: 600; color: #64748b; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.05em; }}
table {{ width: 100%; border-collapse: collapse; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 10px; overflow: hidden; }}
th {{ background: #f8fafc; padding: 12px 16px; text-align: left; font-size: 0.75rem; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 600; }}
td {{ padding: 12px 16px; border-top: 1px solid #f1f5f9; font-size: 0.875rem; }}
tr:hover td {{ background: #f8fafc; }}
.username {{ color: #2563eb; font-weight: 500; }}
.filename {{ color: #059669; font-family: monospace; font-size: 0.8rem; }}
.ip {{ color: #64748b; font-size: 0.8rem; font-family: monospace; }}
.num {{ text-align: right; color: #1e293b; }}
.badge {{ padding: 2px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 600; }}
.badge.active {{ background: #d1fae5; color: #065f46; }}
.badge.inactive {{ background: #f1f5f9; color: #94a3b8; }}
.empty {{ text-align: center; color: #94a3b8; padding: 32px; }}
</style>
</head>
<body>
<header>
<h1>Admin Dashboard &mdash; thereisnospoonadu.com</h1>
<span class="refresh-note">Last updated: {now} &nbsp;|&nbsp; Auto-refresh every 60s</span>
</header>
<div class="stats-bar">
<div class="stat-card"><div class="label">Total Users</div><div class="value">{total_users}</div></div>
<div class="stat-card"><div class="label">Total Downloads</div><div class="value">{total_dls}</div></div>
<div class="stat-card"><div class="label">Total Data Served</div><div class="value">{total_data_all}</div></div>
</div>
<div class="section">
<h2>User Summary</h2>
<table>
<thead><tr>
<th>Username</th><th>Status</th><th>Last Login</th>
<th style="text-align:right">Logins</th>
<th style="text-align:right">Downloads</th>
<th style="text-align:right">Data Used</th>
<th>IP Address(es)</th>
</tr></thead>
<tbody>{user_rows}</tbody>
</table>
</div>
<div class="section">
<h2>Download History (last 100)</h2>
<table>
<thead><tr><th>Time</th><th>User</th><th>File</th><th style="text-align:right">Size</th></tr></thead>
<tbody>{dl_rows}</tbody>
</table>
</div>
</body>
</html>""".format(
now=now,
total_users=total_users,
total_dls=total_dls,
total_data_all=total_data_all,
user_rows=user_rows if user_rows else no_users,
dl_rows=dl_rows if dl_rows else no_dls,
)
return html
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
entries = parse_logs()
users, all_downloads = build_stats(entries)
html = render_html(users, all_downloads)
body = html.encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
if __name__ == '__main__':
server = HTTPServer(('127.0.0.1', 8765), Handler)
print("Admin dashboard running on 127.0.0.1:8765")
server.serve_forever()