elemes/routes/compile.py

348 lines
13 KiB
Python

"""
Code compilation route.
"""
import os
import time
import uuid
import requests
from flask import Blueprint, request, jsonify
from flask_limiter import RateLimitExceeded
from services.token_service import validate_token
from extensions import limiter
compile_bp = Blueprint('compile', __name__)
COMPILER_WORKER_URL = os.environ.get('COMPILER_WORKER_URL', 'http://127.0.0.1:8080/execute')
COMPILER_WORKER_BASE_URL = os.environ.get('COMPILER_WORKER_BASE_URL', 'http://127.0.0.1:8080')
VELXIO_COMPILER_URL = os.environ.get('VELXIO_COMPILER_URL', 'http://127.0.0.1:80/api/compile/')
ANON_QUEUE_DIR = "/tmp/elemes_anon_queue"
# Ensure queue directory exists
os.makedirs(ANON_QUEUE_DIR, exist_ok=True)
def is_logged_in():
"""Check if the request has a valid student token (JSON, Form, or Cookie)."""
token = None
if request.is_json:
try:
token = request.get_json(force=True).get('token', '')
except Exception:
pass
if not token:
token = request.form.get('token', '')
if not token:
token = request.cookies.get('student_token', '')
token = str(token).strip()
return bool(token and validate_token(token))
def acquire_anon_slot():
"""Try to acquire a slot in the anonymous compilation queue."""
try:
# Cleanup stale files (> 60s)
now = time.time()
for f in os.listdir(ANON_QUEUE_DIR):
fpath = os.path.join(ANON_QUEUE_DIR, f)
try:
if now - os.path.getmtime(fpath) > 60:
os.remove(fpath)
except (OSError, IOError):
pass
# Check capacity
if len(os.listdir(ANON_QUEUE_DIR)) >= 20:
return None
slot_id = str(uuid.uuid4())
fpath = os.path.join(ANON_QUEUE_DIR, slot_id)
with open(fpath, 'w') as f:
f.write(str(now))
return fpath
except Exception:
return None
def release_anon_slot(fpath):
"""Release an acquired anonymous slot."""
if fpath and os.path.exists(fpath):
try:
os.remove(fpath)
except (OSError, IOError):
pass
@compile_bp.errorhandler(RateLimitExceeded)
def handle_rate_limit_exceeded(e):
"""Handle rate limit errors with a custom message."""
return jsonify({
'success': False,
'output': '',
'error': 'tunggu beberapa saat untuk kompilasi kembali, atau lakukan request token ke guru'
}), 429
@compile_bp.route('/compile', methods=['POST'])
@limiter.limit("1 per 2 minutes", exempt_when=is_logged_in)
def compile_code():
"""Forward code compilation to the worker service."""
try:
code = None
language = None
token = None
stdin = ""
if request.content_type and 'application/json' in request.content_type:
try:
json_data = request.get_json(force=True)
if json_data:
code = json_data.get('code', '')
language = json_data.get('language', '')
token = json_data.get('token', '').strip()
stdin = json_data.get('stdin', '') or ''
except Exception:
pass
if not code:
code = request.form.get('code', '')
language = request.form.get('language', '')
token = request.form.get('token', '').strip()
stdin = request.form.get('stdin', '') or ''
# Authorization logic:
# 1. If token is provided, it MUST be valid.
# 2. If no token, it is anonymous access (subject to rate limits & queue).
user_is_logged_in = False
if token:
if not validate_token(token):
return jsonify({'success': False, 'output': '', 'error': 'Unauthorized: Invalid token'})
user_is_logged_in = True
# Queue logic for anonymous users
slot_fpath = None
if not user_is_logged_in:
slot_fpath = acquire_anon_slot()
if not slot_fpath:
return jsonify({
'success': False,
'output': '',
'error': 'tunggu beberapa saat untuk kompilasi kembali, atau lakukan request token ke guru'
})
try:
if not code:
return jsonify({'success': False, 'output': '', 'error': 'No code provided'})
# Forward to worker
response = requests.post(
COMPILER_WORKER_URL,
json={'code': code, 'language': language, 'stdin': stdin},
timeout=15
)
return jsonify(response.json())
finally:
if slot_fpath:
release_anon_slot(slot_fpath)
except requests.exceptions.RequestException as re:
return jsonify({'success': False, 'output': '', 'error': f'Compiler service unavailable: {re}'})
except Exception as e:
return jsonify({'success': False, 'output': '', 'error': f'An error occurred: {e}'})
def _anon_session_slot_path(session_id):
"""Path untuk slot anonymous per session (release saat session selesai)."""
import re as _re
safe = _re.sub(r'[^A-Za-z0-9_-]', '_', session_id)
return os.path.join(ANON_QUEUE_DIR, f"sess-{safe}")
def _touch_anon_session_slot(session_id):
"""Refresh mtime slot — session aktif menahan slot (bukan dilepas saat create)."""
fpath = _anon_session_slot_path(session_id)
try:
if os.path.exists(fpath):
now = time.time()
os.utime(fpath, (now, now))
except (OSError, IOError):
pass
def _release_anon_session_slot(session_id):
"""Release slot anonymous setelah session terminal/stopped."""
release_anon_slot(_anon_session_slot_path(session_id))
@compile_bp.route('/compile/sessions', methods=['POST'])
@limiter.limit("1 per 2 minutes", exempt_when=is_logged_in)
def create_compile_session():
"""Buat sesi interaktif di worker (PTY) — proxy ke worker /sessions."""
try:
data = request.get_json(force=True) if request.is_json else {}
if not isinstance(data, dict):
data = {}
language = str(data.get('language', ''))
files = data.get('files')
active_file = str(data.get('active_file', '') or '')
stdin = str(data.get('stdin', '') or '')
token = str(data.get('token', '') or '').strip()
user_is_logged_in = False
if token:
if not validate_token(token):
return jsonify({'success': False, 'output': '', 'error': 'Unauthorized: Invalid token'}), 401
user_is_logged_in = True
if not language or not isinstance(files, list) or not files:
return jsonify({'success': False, 'output': '', 'error': 'No code provided'}), 400
slot_fpath = None
if not user_is_logged_in:
slot_fpath = acquire_anon_slot()
if not slot_fpath:
return jsonify({
'success': False,
'output': '',
'error': 'tunggu beberapa saat untuk kompilasi kembali, atau lakukan request token ke guru'
}), 429
try:
response = requests.post(
COMPILER_WORKER_BASE_URL + '/sessions',
json={
'language': language,
'files': files,
'active_file': active_file,
'stdin': stdin,
},
timeout=30
)
body = response.json()
session_id = body.get('session_id') if isinstance(body, dict) else None
if slot_fpath and session_id:
# Slot anonymous di-hold selama session: rename slot file → sess-<id>
target = _anon_session_slot_path(session_id)
try:
if os.path.exists(slot_fpath):
os.replace(slot_fpath, target)
slot_fpath = None
except (OSError, IOError):
pass
return jsonify(body), response.status_code
except requests.exceptions.RequestException as re:
return jsonify({'success': False, 'output': '', 'error': f'Compiler service unavailable: {re}'}), 502
finally:
if slot_fpath:
release_anon_slot(slot_fpath)
except Exception as e:
return jsonify({'success': False, 'output': '', 'error': f'An error occurred: {e}'}), 500
@compile_bp.route('/compile/sessions/<session_id>', methods=['GET'])
def get_compile_session(session_id):
"""Poll delta output session interaktif — proxy ke worker."""
try:
cursor = request.args.get('cursor', '0')
params = {'cursor': cursor} if cursor else {}
response = requests.get(
COMPILER_WORKER_BASE_URL + '/sessions/' + session_id,
params=params,
timeout=15
)
body = response.json()
if isinstance(body, dict):
status = body.get('status')
if status in ('exited', 'error', 'stopped', 'expired'):
# Session selesai — release slot anonymous bila ada
_release_anon_session_slot(session_id)
else:
_touch_anon_session_slot(session_id)
return jsonify(body), response.status_code
except requests.exceptions.RequestException as re:
return jsonify({'success': False, 'output': '', 'error': f'Compiler service unavailable: {re}'}), 502
@compile_bp.route('/compile/sessions/<session_id>/input', methods=['POST'])
@limiter.limit("10 per minute", exempt_when=is_logged_in)
def send_compile_session_input(session_id):
"""Kirim input ke proses interaktif — proxy ke worker."""
try:
data = request.get_json(force=True) if request.is_json else {}
text = str((data or {}).get('text', ''))
response = requests.post(
COMPILER_WORKER_BASE_URL + '/sessions/' + session_id + '/input',
json={'text': text},
timeout=15
)
return jsonify(response.json()), response.status_code
except requests.exceptions.RequestException as re:
return jsonify({'success': False, 'output': '', 'error': f'Compiler service unavailable: {re}'}), 502
@compile_bp.route('/compile/sessions/<session_id>', methods=['DELETE'])
def stop_compile_session(session_id):
"""Stop sesi interaktif — proxy ke worker."""
try:
response = requests.delete(
COMPILER_WORKER_BASE_URL + '/sessions/' + session_id,
timeout=15
)
_release_anon_session_slot(session_id)
return jsonify(response.json()), response.status_code
except requests.exceptions.RequestException as re:
return jsonify({'success': False, 'output': '', 'error': f'Compiler service unavailable: {re}'}), 502
def _hex_to_binary(hex_content):
"""Parse Intel HEX string into raw binary bytes."""
binary = bytearray()
for line in hex_content.strip().split('\n'):
line = line.strip()
if not line or not line.startswith(':'):
continue
byte_count = int(line[1:3], 16)
record_type = int(line[7:9], 16)
if record_type == 0: # Data record
data = bytes.fromhex(line[9:9 + byte_count * 2])
binary.extend(data)
elif record_type == 1: # EOF
break
return bytes(binary)
@compile_bp.route('/velxio-compile', methods=['POST'])
@compile_bp.route('/velxio-compile/', methods=['POST'])
@limiter.limit("1 per 2 minutes", exempt_when=is_logged_in)
def velxio_compile():
"""Proxy Velxio compilation requests to the Velxio service."""
slot_fpath = None
try:
# Check authorization from cookie if not in JSON
token = request.cookies.get('student_token')
user_is_logged_in = bool(token and validate_token(token))
if not user_is_logged_in:
slot_fpath = acquire_anon_slot()
if not slot_fpath:
return jsonify({
'success': False,
'output': '',
'error': 'tunggu beberapa saat untuk kompilasi kembali, atau lakukan request token ke guru'
})
# Forward the request to Velxio
response = requests.post(
VELXIO_COMPILER_URL,
json=request.get_json(silent=True) or {},
timeout=30
)
result = response.json()
# If Velxio only returned hex_content (AVR/Arduino), convert to binary
if result.get('success') and result.get('hex_content') and not result.get('binary_content'):
try:
binary = _hex_to_binary(result['hex_content'])
import base64
result['binary_content'] = base64.b64encode(binary).decode('ascii')
result['binary_type'] = 'bin'
except Exception as e:
# Non-fatal: hex_content still available for BLE path
print(f'hex_to_binary conversion failed: {e}')
return jsonify(result)
except requests.exceptions.RequestException as re:
return jsonify({'success': False, 'output': '', 'error': f'Velxio service unavailable: {re}'})
except Exception as e:
return jsonify({'success': False, 'output': '', 'error': f'An error occurred: {e}'})
finally:
if slot_fpath:
release_anon_slot(slot_fpath)