277 lines
10 KiB
Python
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 "—"
|
|
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 "—"
|
|
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 — thereisnospoonadu.com</h1>
|
|
<span class="refresh-note">Last updated: {now} | 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()
|