163 lines
8.5 KiB
Python
163 lines
8.5 KiB
Python
"""Local bootstrap and administrator-only license management."""
|
|
import json
|
|
import secrets
|
|
import threading
|
|
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect, FileResponse
|
|
from django.template import engines
|
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
|
from django.views.decorators.http import require_http_methods
|
|
from monitor_runtime.paths import data, resource, DESKTOP
|
|
from monitor_runtime.licensing import check_license, import_license, request_document
|
|
from monitor_runtime.bootstrap import has_admin, finish_setup, local_addresses
|
|
|
|
SETUP_TOKEN = secrets.token_urlsafe(32)
|
|
SETUP_LOCK = threading.Lock()
|
|
RESTART_REQUESTED = threading.Event()
|
|
SERVICE_STATE = {'phase': 'starting', 'reason': ''}
|
|
|
|
PAGE = """<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
|
<title>Monitor 安装与授权</title><style>
|
|
body{font:16px/1.7 "Microsoft YaHei",sans-serif;background:#eef2f6;color:#182635;margin:0;padding:32px}
|
|
main{max-width:720px;margin:auto;background:white;border-radius:16px;padding:32px;box-shadow:0 4px 30px #17334815}
|
|
h1{font-size:26px;margin-top:0}label{display:block;margin-top:14px}input,select,button{font:inherit;padding:9px;border:1px solid #cad4de;border-radius:7px}
|
|
input:not([type=file]),select{width:95%}button{background:#125cc8;color:white;cursor:pointer;margin-top:20px}
|
|
.notice{padding:14px;background:#eef4ff;border-radius:8px;overflow-wrap:anywhere}.error{color:#b32828}
|
|
small{display:block;color:#667482}a{color:#125cc8}</style><main>
|
|
<h1>{% if setup %}首次安装设置{% else %}授权管理{% endif %}</h1>
|
|
<p class="notice">{{ status.reason }}{% if status.license %}<br>客户:{{ status.license.customer }}<br>
|
|
有效期:{{ status.license.expires_at|default:"永久" }}{% endif %}</p>
|
|
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
|
<p>导出本机申请文件,交给发行方签发,再导入签名授权文件。授权绑定当前电脑。</p>
|
|
<form method="post" action="/license/request">{% csrf_token %}<input type="hidden" name="setup_token">
|
|
<button>导出机器申请文件</button></form>
|
|
<form method="post" action="/license/import" enctype="multipart/form-data">{% csrf_token %}
|
|
<input type="hidden" name="setup_token"><label>签名授权文件 <input type="file" name="license" accept=".json" required></label>
|
|
<button>导入授权{% if not setup %}并重启服务{% endif %}</button></form>
|
|
{% if setup %}<form method="post" action="/setup">{% csrf_token %}<input type="hidden" name="setup_token">
|
|
<label>管理员用户名<input name="username" autocomplete="username" required maxlength="150"></label>
|
|
<label>密码(至少 12 位)<input type="password" name="password" autocomplete="new-password" required minlength="12"></label>
|
|
<label>确认密码<input type="password" name="confirm" autocomplete="new-password" required></label>
|
|
<label>局域网网卡<select name="address" required>{% for ip in addresses %}<option>{{ ip }}</option>{% endfor %}</select></label>
|
|
<small>请先导入授权。初始化只允许从安装电脑完成;完成后局域网用户才能登录。</small>
|
|
<button>创建管理员并启动</button></form>{% else %}<p><a href="/login">返回登录</a> · <a href="/">进入系统</a></p>{% endif %}
|
|
<script>
|
|
const key='monitor-setup-token'; if(location.hash){sessionStorage.setItem(key,location.hash.slice(1));history.replaceState(null,'',location.pathname);}
|
|
document.querySelectorAll('[name=setup_token]').forEach(e=>e.value=sessionStorage.getItem(key)||'');
|
|
{% if not setup %}sessionStorage.removeItem(key);{% endif %}
|
|
</script></main></html>"""
|
|
|
|
def is_local(request):
|
|
return request.META.get('REMOTE_ADDR') in ('127.0.0.1', '::1')
|
|
|
|
def admin_allowed(request):
|
|
user = getattr(request, 'user', None)
|
|
return bool(user and user.is_authenticated and (
|
|
user.is_superuser or user.groups.filter(name='system_admin').exists()))
|
|
|
|
def authorized(request, write=False):
|
|
if not has_admin():
|
|
if not is_local(request):
|
|
return False
|
|
return not write or secrets.compare_digest(request.POST.get('setup_token', ''), SETUP_TOKEN)
|
|
return admin_allowed(request)
|
|
|
|
def page(request, error='', code=200):
|
|
html = engines['django'].from_string(PAGE).render({
|
|
'setup': not has_admin(), 'status': check_license(), 'error': error,
|
|
'addresses': local_addresses() if not has_admin() else [],
|
|
}, request)
|
|
response = HttpResponse(html, status=code)
|
|
response['Cache-Control'] = 'no-store'
|
|
response['Referrer-Policy'] = 'no-referrer'
|
|
return response
|
|
|
|
@csrf_exempt
|
|
@ensure_csrf_cookie
|
|
@require_http_methods(['GET', 'POST'])
|
|
def setup(request):
|
|
if has_admin() or not authorized(request, request.method == 'POST'):
|
|
return HttpResponse('初始化入口不可用', status=403)
|
|
if request.method == 'POST':
|
|
try:
|
|
with SETUP_LOCK:
|
|
if request.POST.get('password') != request.POST.get('confirm'):
|
|
raise ValueError('两次密码输入不一致')
|
|
finish_setup(request.POST.get('username', ''), request.POST.get('password', ''),
|
|
request.POST.get('address', ''))
|
|
RESTART_REQUESTED.set()
|
|
return HttpResponse('<meta charset="utf-8"><p>设置完成,服务正在重启。稍后重新打开桌面快捷方式进入登录页面。</p>')
|
|
except Exception as exc:
|
|
return page(request, str(exc), 400)
|
|
return page(request)
|
|
|
|
@ensure_csrf_cookie
|
|
def license_page(request):
|
|
if not authorized(request):
|
|
return HttpResponseRedirect('/login') if not getattr(request.user, 'is_authenticated', False) else HttpResponse(status=403)
|
|
return page(request)
|
|
|
|
@csrf_exempt
|
|
@require_http_methods(['POST'])
|
|
def license_request(request):
|
|
if not authorized(request, True):
|
|
return HttpResponse(status=403)
|
|
response = JsonResponse(request_document(), json_dumps_params={'indent': 2})
|
|
response['Content-Disposition'] = 'attachment; filename="monitor-license-request.json"'
|
|
return response
|
|
|
|
@csrf_exempt
|
|
@require_http_methods(['POST'])
|
|
def license_import(request):
|
|
if not authorized(request, True):
|
|
return HttpResponse(status=403)
|
|
try:
|
|
uploaded = request.FILES.get('license')
|
|
if not uploaded or uploaded.size > 32768:
|
|
raise ValueError('请选择小于 32KB 的授权文件')
|
|
import_license(uploaded.read())
|
|
if has_admin():
|
|
RESTART_REQUESTED.set()
|
|
return page(request)
|
|
except Exception as exc:
|
|
return page(request, str(exc), 400)
|
|
|
|
def license_status(request):
|
|
if not authorized(request):
|
|
return HttpResponse(status=403)
|
|
return JsonResponse({**check_license(), 'service': SERVICE_STATE})
|
|
|
|
def private_file(request, path, area='storage'):
|
|
if not request.user.is_authenticated:
|
|
return HttpResponse(status=403)
|
|
root = data('static/storage' if area == 'storage' else 'static/upload/audio').resolve()
|
|
filename = (root / path).resolve()
|
|
allowed = {'.jpg', '.jpeg', '.png', '.mp4', '.wav', '.mp3', '.ogg'}
|
|
if not filename.is_relative_to(root) or not filename.is_file() or filename.suffix.lower() not in allowed:
|
|
return HttpResponse(status=404)
|
|
response = FileResponse(filename.open('rb'))
|
|
response['Cache-Control'] = 'private, no-store'
|
|
response['X-Content-Type-Options'] = 'nosniff'
|
|
return response
|
|
|
|
class LicenseMiddleware:
|
|
def __init__(self, get_response):
|
|
self.get_response = get_response
|
|
|
|
def __call__(self, request):
|
|
if not DESKTOP:
|
|
return self.get_response(request)
|
|
path = request.path_info
|
|
# WhiteNoise only serves immutable build-time assets, never mutable data.
|
|
public_asset = path.startswith('/static/') and not path.startswith(('/static/storage/', '/static/upload/'))
|
|
exceptions = {'/setup', '/license', '/license/status', '/license/request', '/license/import',
|
|
'/login', '/logout', '/user/openCaptcha'}
|
|
if not has_admin() and not public_asset and path not in {'/setup', '/license', '/license/request', '/license/import', '/license/status'}:
|
|
return HttpResponseRedirect('/setup') if is_local(request) else HttpResponse(status=403)
|
|
if path not in exceptions and not public_asset:
|
|
status = check_license()
|
|
if not status['valid']:
|
|
if '/open' in path or path.startswith('/inner/'):
|
|
return JsonResponse({'code': 0, 'msg': status['reason'], 'error': 'license_invalid'}, status=403)
|
|
return HttpResponseRedirect('/license')
|
|
return self.get_response(request)
|