348 lines
13 KiB
Python
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)
|