tambah
This commit is contained in:
parent
3b76befdd2
commit
b48ca407b7
@ -90,12 +90,12 @@ object AppConstants {
|
||||
// const val BASE_URL = "http://10.0.2.2:5000" // Untuk emulator Android
|
||||
const val BASE_URL = "http://192.168.100.99:5000" // Untuk device fisik
|
||||
|
||||
// Koordinat Kampus (UBHARA Jaya)
|
||||
const val KAMPUS_LATITUDE = -6.223325
|
||||
const val KAMPUS_LONGITUDE = 107.009406
|
||||
// Koordinat Device Saat ini (Untuk Testing)
|
||||
// const val KAMPUS_LATITUDE = -6.239513
|
||||
// const val KAMPUS_LONGITUDE = 107.089676
|
||||
// // Koordinat Kampus (UBHARA Jaya)
|
||||
// const val KAMPUS_LATITUDE = -6.223325
|
||||
// const val KAMPUS_LONGITUDE = 107.009406
|
||||
// Koordinat Device Saat ini (Untuk Testing)
|
||||
const val KAMPUS_LATITUDE = -6.2396008
|
||||
const val KAMPUS_LONGITUDE = 107.0893571
|
||||
|
||||
const val RADIUS_METER = 500.0
|
||||
|
||||
@ -113,6 +113,7 @@ object AppConstants {
|
||||
const val KEY_FAKULTAS = "fakultas"
|
||||
const val KEY_JURUSAN = "jurusan"
|
||||
const val KEY_SEMESTER = "semester"
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
}
|
||||
|
||||
/* ================= DATA CLASSES ================= */
|
||||
@ -205,6 +206,13 @@ class UserPreferences(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun getDeviceId(context: Context): String {
|
||||
return android.provider.Settings.Secure.getString(
|
||||
context.contentResolver,
|
||||
android.provider.Settings.Secure.ANDROID_ID
|
||||
) ?: "unknown-device"
|
||||
}
|
||||
|
||||
/* ================= UTIL FUNCTIONS ================= */
|
||||
|
||||
fun bitmapToBase64(bitmap: Bitmap): String {
|
||||
@ -265,6 +273,7 @@ fun getCurrentTimestamp(): String {
|
||||
fun registerMahasiswa(
|
||||
npm: String, password: String, nama: String, jenkel: String,
|
||||
fakultas: String, jurusan: String, semester: Int,
|
||||
deviceId: String, // ← BARU
|
||||
onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit
|
||||
) {
|
||||
thread {
|
||||
@ -278,8 +287,14 @@ fun registerMahasiswa(
|
||||
conn.readTimeout = 15000
|
||||
|
||||
val json = JSONObject().apply {
|
||||
put("npm", npm); put("password", password); put("nama", nama)
|
||||
put("jenkel", jenkel); put("fakultas", fakultas); put("jurusan", jurusan); put("semester", semester)
|
||||
put("npm", npm)
|
||||
put("password", password)
|
||||
put("nama", nama)
|
||||
put("jenkel", jenkel)
|
||||
put("fakultas", fakultas)
|
||||
put("jurusan", jurusan)
|
||||
put("semester", semester)
|
||||
put("device_id", deviceId) // ← BARU
|
||||
}
|
||||
|
||||
conn.outputStream.use { it.write(json.toString().toByteArray()) }
|
||||
@ -287,13 +302,15 @@ fun registerMahasiswa(
|
||||
val responseCode = conn.responseCode
|
||||
val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText()
|
||||
else conn.errorStream?.bufferedReader()?.readText() ?: ""
|
||||
|
||||
conn.disconnect()
|
||||
|
||||
if (responseCode == 201) {
|
||||
val data = JSONObject(response).getJSONObject("data")
|
||||
val token = data.getString("token")
|
||||
val mahasiswa = Mahasiswa(data.getInt("id_mahasiswa"), data.getString("npm"), data.getString("nama"), jenkel, fakultas, jurusan, semester)
|
||||
val mahasiswa = Mahasiswa(
|
||||
data.getInt("id_mahasiswa"), data.getString("npm"),
|
||||
data.getString("nama"), jenkel, fakultas, jurusan, semester
|
||||
)
|
||||
onSuccess(token, mahasiswa)
|
||||
} else {
|
||||
onError(ErrorHandler.parseHttpError(responseCode, response))
|
||||
@ -304,6 +321,7 @@ fun registerMahasiswa(
|
||||
|
||||
fun loginMahasiswa(
|
||||
npm: String, password: String,
|
||||
deviceId: String, // ← BARU
|
||||
onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit
|
||||
) {
|
||||
thread {
|
||||
@ -316,18 +334,26 @@ fun loginMahasiswa(
|
||||
conn.connectTimeout = 15000
|
||||
conn.readTimeout = 15000
|
||||
|
||||
val json = JSONObject().apply { put("npm", npm); put("password", password) }
|
||||
val json = JSONObject().apply {
|
||||
put("npm", npm)
|
||||
put("password", password)
|
||||
put("device_id", deviceId) // ← BARU
|
||||
}
|
||||
conn.outputStream.use { it.write(json.toString().toByteArray()) }
|
||||
|
||||
val responseCode = conn.responseCode
|
||||
val response = if (responseCode == 200) conn.inputStream.bufferedReader().readText()
|
||||
else conn.errorStream?.bufferedReader()?.readText() ?: ""
|
||||
|
||||
conn.disconnect()
|
||||
|
||||
if (responseCode == 200) {
|
||||
val data = JSONObject(response).getJSONObject("data")
|
||||
val mahasiswa = Mahasiswa(data.getInt("id_mahasiswa"), data.getString("npm"), data.getString("nama"), data.getString("jenkel"), data.getString("fakultas"), data.getString("jurusan"), data.getInt("semester"))
|
||||
val mahasiswa = Mahasiswa(
|
||||
data.getInt("id_mahasiswa"), data.getString("npm"),
|
||||
data.getString("nama"), data.getString("jenkel"),
|
||||
data.getString("fakultas"), data.getString("jurusan"),
|
||||
data.getInt("semester")
|
||||
)
|
||||
onSuccess(data.getString("token"), mahasiswa)
|
||||
} else {
|
||||
onError(ErrorHandler.parseHttpError(responseCode, response))
|
||||
@ -336,6 +362,45 @@ fun loginMahasiswa(
|
||||
}
|
||||
}
|
||||
|
||||
fun requestLocationToken(
|
||||
token: String,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
onSuccess: (String) -> Unit, // mengembalikan location_token
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
thread {
|
||||
try {
|
||||
val url = URL("${AppConstants.BASE_URL}/api/absensi/request-location-token")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||
conn.doOutput = true
|
||||
conn.connectTimeout = 15000
|
||||
conn.readTimeout = 15000
|
||||
|
||||
val json = JSONObject().apply {
|
||||
put("latitude", latitude)
|
||||
put("longitude", longitude)
|
||||
}
|
||||
conn.outputStream.use { it.write(json.toString().toByteArray()) }
|
||||
|
||||
val responseCode = conn.responseCode
|
||||
val response = if (responseCode == 200) conn.inputStream.bufferedReader().readText()
|
||||
else conn.errorStream?.bufferedReader()?.readText() ?: ""
|
||||
conn.disconnect()
|
||||
|
||||
if (responseCode == 200) {
|
||||
val locationToken = JSONObject(response).getString("location_token")
|
||||
onSuccess(locationToken)
|
||||
} else {
|
||||
onError(ErrorHandler.parseHttpError(responseCode, response))
|
||||
}
|
||||
} catch (e: Exception) { onError(ErrorHandler.parseException(e)) }
|
||||
}
|
||||
}
|
||||
|
||||
fun getJadwalToday(
|
||||
token: String, onSuccess: (List<JadwalKelas>) -> Unit, onError: (String) -> Unit
|
||||
) {
|
||||
@ -406,40 +471,63 @@ fun getJadwalToday(
|
||||
}
|
||||
|
||||
fun submitAbsensiWithJadwal(
|
||||
token: String, idJadwal: Int, latitude: Double, longitude: Double,
|
||||
fotoBase64: String, status: String, onSuccess: (String) -> Unit, onError: (String) -> Unit
|
||||
token: String,
|
||||
idJadwal: Int,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
fotoBase64: String,
|
||||
status: String,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
thread {
|
||||
try {
|
||||
val url = URL("${AppConstants.BASE_URL}/api/absensi/submit")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||
conn.doOutput = true
|
||||
conn.connectTimeout = 30000 // Timeout lebih lama untuk upload foto
|
||||
conn.readTimeout = 30000
|
||||
// STEP 1: Minta location token ke server dengan koordinat GPS saat ini
|
||||
requestLocationToken(
|
||||
token = token,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
onSuccess = { locationToken ->
|
||||
// STEP 2: Jika berhasil dapat location token, baru submit absensi
|
||||
thread {
|
||||
try {
|
||||
val url = URL("${AppConstants.BASE_URL}/api/absensi/submit")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("Content-Type", "application/json")
|
||||
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||
conn.doOutput = true
|
||||
conn.connectTimeout = 30000
|
||||
conn.readTimeout = 30000
|
||||
|
||||
val json = JSONObject().apply {
|
||||
put("id_jadwal", idJadwal); put("latitude", latitude); put("longitude", longitude)
|
||||
put("timestamp", getCurrentTimestamp()); put("foto_base64", fotoBase64); put("status", status)
|
||||
val json = JSONObject().apply {
|
||||
put("id_jadwal", idJadwal)
|
||||
put("location_token", locationToken) // ← BARU: wajib ada
|
||||
put("foto_base64", fotoBase64)
|
||||
put("status", status)
|
||||
put("timestamp", getCurrentTimestamp())
|
||||
// latitude & longitude tidak perlu dikirim lagi,
|
||||
// backend ambil dari location_token
|
||||
}
|
||||
|
||||
conn.outputStream.use { it.write(json.toString().toByteArray()) }
|
||||
|
||||
val responseCode = conn.responseCode
|
||||
val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText()
|
||||
else conn.errorStream?.bufferedReader()?.readText() ?: ""
|
||||
conn.disconnect()
|
||||
|
||||
if (responseCode == 201) {
|
||||
onSuccess(JSONObject(response).getJSONObject("data").getString("mata_kuliah"))
|
||||
} else {
|
||||
onError(ErrorHandler.parseHttpError(responseCode, response))
|
||||
}
|
||||
} catch (e: Exception) { onError(ErrorHandler.parseException(e)) }
|
||||
}
|
||||
|
||||
conn.outputStream.use { it.write(json.toString().toByteArray()) }
|
||||
|
||||
val responseCode = conn.responseCode
|
||||
val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText()
|
||||
else conn.errorStream?.bufferedReader()?.readText() ?: ""
|
||||
|
||||
conn.disconnect()
|
||||
|
||||
if (responseCode == 201) {
|
||||
onSuccess(JSONObject(response).getJSONObject("data").getString("mata_kuliah"))
|
||||
} else {
|
||||
onError(ErrorHandler.parseHttpError(responseCode, response))
|
||||
}
|
||||
} catch (e: Exception) { onError(ErrorHandler.parseException(e)) }
|
||||
}
|
||||
},
|
||||
onError = { err ->
|
||||
// Jika gagal dapat location token (lokasi tidak valid, dll)
|
||||
onError(err)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun getAbsensiHistory(
|
||||
@ -753,7 +841,7 @@ fun JadwalScreen(
|
||||
color = androidx.compose.ui.graphics.Color.Black
|
||||
)
|
||||
Text(
|
||||
text = "Semester Ganjil 2025/2026", // Bisa dibuat dinamis nanti
|
||||
text = "Semester Genap 2026/2027", // Bisa dibuat dinamis nanti
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = androidx.compose.ui.graphics.Color.Gray
|
||||
)
|
||||
@ -1042,7 +1130,13 @@ fun RegisterScreen(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
|
||||
focusedTextColor = Color.Black,
|
||||
unfocusedTextColor = Color.Black,
|
||||
focusedLabelColor = GoldPrimary,
|
||||
unfocusedLabelColor = Color.Gray
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@ -1059,7 +1153,13 @@ fun RegisterScreen(
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
|
||||
focusedTextColor = Color.Black,
|
||||
unfocusedTextColor = Color.Black,
|
||||
focusedLabelColor = GoldPrimary,
|
||||
unfocusedLabelColor = Color.Gray
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@ -1076,7 +1176,13 @@ fun RegisterScreen(
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
|
||||
focusedTextColor = Color.Black,
|
||||
unfocusedTextColor = Color.Black,
|
||||
focusedLabelColor = GoldPrimary,
|
||||
unfocusedLabelColor = Color.Gray
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@ -1094,7 +1200,13 @@ fun RegisterScreen(
|
||||
leadingIcon = { Icon(Icons.Default.Person, null, tint = GoldPrimary) },
|
||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
|
||||
focusedTextColor = Color.Black,
|
||||
unfocusedTextColor = Color.Black,
|
||||
focusedLabelColor = GoldPrimary,
|
||||
unfocusedLabelColor = Color.Gray
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
@ -1134,7 +1246,13 @@ fun RegisterScreen(
|
||||
leadingIcon = { Icon(Icons.Default.School, null, tint = GoldPrimary) },
|
||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
|
||||
focusedTextColor = Color.Black,
|
||||
unfocusedTextColor = Color.Black,
|
||||
focusedLabelColor = GoldPrimary,
|
||||
unfocusedLabelColor = Color.Gray
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@ -1146,7 +1264,13 @@ fun RegisterScreen(
|
||||
leadingIcon = { Icon(Icons.Default.Book, null, tint = GoldPrimary) },
|
||||
modifier = Modifier.weight(1.5f), singleLine = true,
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
|
||||
focusedTextColor = Color.Black,
|
||||
unfocusedTextColor = Color.Black,
|
||||
focusedLabelColor = GoldPrimary,
|
||||
unfocusedLabelColor = Color.Gray
|
||||
)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = semester,
|
||||
@ -1156,7 +1280,13 @@ fun RegisterScreen(
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.weight(1f), singleLine = true,
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary)
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = GoldPrimary, cursorColor = GoldPrimary,
|
||||
focusedTextColor = Color.Black,
|
||||
unfocusedTextColor = Color.Black,
|
||||
focusedLabelColor = GoldPrimary,
|
||||
unfocusedLabelColor = Color.Gray
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -1172,10 +1302,12 @@ fun RegisterScreen(
|
||||
errorMessage = "Konfirmasi password tidak cocok"
|
||||
} else {
|
||||
isLoading = true
|
||||
val deviceId = getDeviceId(context)
|
||||
registerMahasiswa(
|
||||
npm = npm.trim(), password = password, nama = nama.trim(),
|
||||
jenkel = jenkel, fakultas = fakultas.trim(), jurusan = jurusan.trim(),
|
||||
semester = semester.toIntOrNull() ?: 1,
|
||||
deviceId = deviceId,
|
||||
onSuccess = { token, mhs ->
|
||||
(context as? ComponentActivity)?.runOnUiThread {
|
||||
isLoading = false
|
||||
@ -1407,9 +1539,11 @@ fun LoginScreen(
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
val deviceId = getDeviceId(context)
|
||||
loginMahasiswa(
|
||||
npm = npm.trim(),
|
||||
password = password,
|
||||
deviceId = deviceId,
|
||||
onSuccess = { token, mhs ->
|
||||
(context as? ComponentActivity)?.runOnUiThread {
|
||||
isLoading = false
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
# Gunakan image Python yang ringan sebagai dasar
|
||||
FROM python:3.9-slim
|
||||
|
||||
# Set working directory di dalam kontainer
|
||||
WORKDIR /app
|
||||
|
||||
# Salin file requirements.txt terlebih dahulu (untuk efisiensi cache Docker)
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install semua library yang dibutuhkan
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Salin seluruh file backend ke dalam kontainer
|
||||
COPY . .
|
||||
|
||||
# Ekspos port 5000 agar bisa diakses dari luar kontainer
|
||||
EXPOSE 5000
|
||||
|
||||
# Perintah untuk menjalankan server Flask
|
||||
CMD ["python", "app.py"]
|
||||
844
backend/app.py
844
backend/app.py
@ -1,6 +1,19 @@
|
||||
"""
|
||||
Backend API untuk Aplikasi Absensi Akademik
|
||||
Backend API untuk Aplikasi Absensi Akademik — VERSI AMAN v2
|
||||
Python Flask + MySQL + JWT Authentication
|
||||
Ubhara Jaya — 2026
|
||||
|
||||
SECURITY FIXES v1:
|
||||
[FIX 1] Validasi koordinat GPS di server (bukan hanya di client)
|
||||
[FIX 2] Validasi foto: wajib ada, harus valid base64 image, min size
|
||||
[FIX 3] JWT terikat ke device_id — token tidak bisa dibagikan antar HP
|
||||
[FIX 4] Rate limiting — max 5 percobaan login / 3 submit absensi per menit
|
||||
[FIX 5] Validasi kepemilikan jadwal — mahasiswa hanya bisa absen di jadwalnya sendiri
|
||||
[FIX 6] Validasi timestamp — absensi hanya diterima saat jam kelas aktif
|
||||
|
||||
SECURITY FIXES v2 (BARU):
|
||||
[FIX 7] Location Token — one-time token 2 menit, cegah bypass koordinat via Postman
|
||||
[FIX 8] Deteksi anomali koordinat — koordinat terlalu bulat / persis titik kampus ditolak
|
||||
"""
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
@ -10,12 +23,14 @@ from mysql.connector import Error
|
||||
import jwt
|
||||
import bcrypt
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
from functools import wraps
|
||||
import base64
|
||||
import math
|
||||
import time
|
||||
import secrets
|
||||
import requests
|
||||
from collections import defaultdict
|
||||
|
||||
# Hapus APScheduler agar server tidak berat/blocking
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
@ -30,104 +45,272 @@ DB_CONFIG = {
|
||||
|
||||
SECRET_KEY = 'ubhara-jaya-absensi-secret-2026-change-this'
|
||||
|
||||
# ==================== DATABASE CONNECTION ====================
|
||||
# Lokasi Kampus
|
||||
# KAMPUS_LATITUDE = -6.223325
|
||||
# KAMPUS_LONGITUDE = 107.009406
|
||||
# RADIUS_METER = 500.0
|
||||
|
||||
# Testing
|
||||
KAMPUS_LATITUDE = -6.2396008
|
||||
KAMPUS_LONGITUDE = 107.0893571
|
||||
RADIUS_METER = 500.0
|
||||
|
||||
# ==================== [FIX 7] LOCATION TOKEN STORE ====================
|
||||
# Format: { 'token_hex': { 'id_mahasiswa': int, 'lat': float, 'lon': float, 'expires_at': float } }
|
||||
# Disimpan di memory — otomatis hilang kalau server restart (aman, by design)
|
||||
_location_tokens: dict = {}
|
||||
|
||||
LOCATION_TOKEN_TTL = 120 # detik — token expired dalam 2 menit
|
||||
|
||||
def bersihkan_token_expired():
|
||||
"""Hapus token yang sudah expired dari store."""
|
||||
now = time.time()
|
||||
expired = [k for k, v in _location_tokens.items() if now > v['expires_at']]
|
||||
for k in expired:
|
||||
del _location_tokens[k]
|
||||
|
||||
# ==================== IN-MEMORY RATE LIMITER ====================
|
||||
|
||||
_rate_limit_store = defaultdict(list)
|
||||
|
||||
def is_rate_limited(identifier: str, max_calls: int, window_seconds: int) -> bool:
|
||||
now = time.time()
|
||||
_rate_limit_store[identifier] = [t for t in _rate_limit_store[identifier] if now - t < window_seconds]
|
||||
if len(_rate_limit_store[identifier]) >= max_calls:
|
||||
return True
|
||||
_rate_limit_store[identifier].append(now)
|
||||
return False
|
||||
|
||||
def get_client_ip():
|
||||
return request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
|
||||
# ==================== HELPER JARAK GPS ====================
|
||||
|
||||
def hitung_jarak_meter(lat1, lon1, lat2, lon2):
|
||||
"""Haversine formula."""
|
||||
R = 6371000
|
||||
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi/2)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda/2)**2
|
||||
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
# ==================== [FIX 8] DETEKSI ANOMALI KOORDINAT ====================
|
||||
|
||||
def deteksi_anomali_koordinat(lat: float, lon: float) -> tuple[bool, str]:
|
||||
"""
|
||||
Deteksi koordinat yang mencurigakan / kemungkinan di-input manual:
|
||||
|
||||
1. Koordinat 0,0 → GPS tidak aktif
|
||||
2. Koordinat persis sama dengan konstanta kampus → copy-paste manual
|
||||
3. Presisi desimal kurang dari 4 angka → bukan dari GPS asli
|
||||
(GPS hardware selalu menghasilkan 6-8 angka desimal)
|
||||
4. Nilai lat/lon di luar batas geografis Indonesia
|
||||
"""
|
||||
# 1. Koordinat null island
|
||||
if lat == 0.0 and lon == 0.0:
|
||||
return True, "GPS tidak aktif atau koordinat tidak valid (0,0)"
|
||||
|
||||
# 2. Persis sama dengan titik kampus (kemungkinan hardcoded/copy-paste)
|
||||
if lat == KAMPUS_LATITUDE and lon == KAMPUS_LONGITUDE:
|
||||
return True, "Koordinat identik dengan titik kampus, kemungkinan diinput manual"
|
||||
|
||||
# 3. Cek presisi desimal — GPS asli selalu punya ≥4 angka desimal
|
||||
lat_str = f"{lat}"
|
||||
lon_str = f"{lon}"
|
||||
lat_desimal = len(lat_str.split('.')[-1]) if '.' in lat_str else 0
|
||||
lon_desimal = len(lon_str.split('.')[-1]) if '.' in lon_str else 0
|
||||
|
||||
if lat_desimal < 4 or lon_desimal < 4:
|
||||
return True, f"Presisi koordinat terlalu rendah ({lat_desimal}/{lon_desimal} desimal), bukan dari GPS asli"
|
||||
|
||||
# 4. Batas geografis Indonesia (lat: -11 s/d 6, lon: 95 s/d 141)
|
||||
if not (-11.0 <= lat <= 6.0) or not (95.0 <= lon <= 141.0):
|
||||
return True, "Koordinat di luar wilayah Indonesia"
|
||||
|
||||
return False, "OK"
|
||||
|
||||
# ==================== HELPER VALIDASI FOTO ====================
|
||||
|
||||
def validasi_foto(foto_base64: str) -> tuple[bool, str]:
|
||||
if not foto_base64 or len(foto_base64.strip()) == 0:
|
||||
return False, "Foto wajib disertakan"
|
||||
if ',' in foto_base64:
|
||||
foto_base64 = foto_base64.split(',')[1]
|
||||
try:
|
||||
decoded = base64.b64decode(foto_base64)
|
||||
except Exception:
|
||||
return False, "Format foto tidak valid"
|
||||
|
||||
is_jpg = decoded[:3] == b'\xff\xd8\xff'
|
||||
is_png = decoded[:8] == b'\x89PNG\r\n\x1a\n'
|
||||
is_webp = decoded[8:12] == b'WEBP'
|
||||
if not (is_jpg or is_png or is_webp):
|
||||
return False, "File bukan gambar yang valid (harus JPG/PNG/WEBP)"
|
||||
if len(decoded) < 5 * 1024:
|
||||
return False, "Ukuran foto terlalu kecil, pastikan foto wajah terambil dengan benar"
|
||||
return True, "OK"
|
||||
|
||||
# ==================== DATABASE ====================
|
||||
|
||||
def get_db_connection():
|
||||
try:
|
||||
connection = mysql.connector.connect(**DB_CONFIG)
|
||||
return connection
|
||||
return mysql.connector.connect(**DB_CONFIG)
|
||||
except Error as e:
|
||||
print(f"Error connecting to MySQL: {e}")
|
||||
print(f"DB Error: {e}")
|
||||
return None
|
||||
|
||||
def init_database():
|
||||
connection = get_db_connection()
|
||||
if connection is None: return
|
||||
try:
|
||||
temp_config = {k: v for k, v in DB_CONFIG.items() if k != 'database'}
|
||||
connection = mysql.connector.connect(**temp_config)
|
||||
except Error as e:
|
||||
print(f"❌ Tidak bisa konek ke MySQL: {e}")
|
||||
return
|
||||
|
||||
cursor = connection.cursor()
|
||||
try:
|
||||
cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_CONFIG['database']}")
|
||||
cursor.execute(f"USE {DB_CONFIG['database']}")
|
||||
# (Kode pembuatan tabel tetap sama, disembunyikan agar ringkas)
|
||||
# ... Tabel Mahasiswa, Matkul, Jadwal, Absensi ...
|
||||
connection.commit()
|
||||
except Error as e:
|
||||
print(f"❌ Error creating tables: {e}")
|
||||
finally:
|
||||
cursor.close(); connection.close()
|
||||
|
||||
# ==================== HELPER WAKTU (PENTING UTK AUTO ALFA) ====================
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS mahasiswa (
|
||||
id_mahasiswa INT AUTO_INCREMENT PRIMARY KEY,
|
||||
npm VARCHAR(20) UNIQUE NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
nama VARCHAR(100) NOT NULL,
|
||||
jenkel VARCHAR(10) NOT NULL,
|
||||
fakultas VARCHAR(100) NOT NULL,
|
||||
jurusan VARCHAR(100) NOT NULL,
|
||||
semester INT NOT NULL,
|
||||
device_id VARCHAR(255),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS mata_kuliah (
|
||||
id_matkul INT AUTO_INCREMENT PRIMARY KEY,
|
||||
kode_matkul VARCHAR(20) UNIQUE NOT NULL,
|
||||
nama_matkul VARCHAR(100) NOT NULL,
|
||||
sks INT NOT NULL,
|
||||
dosen VARCHAR(100) NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS jadwal_kelas (
|
||||
id_jadwal INT AUTO_INCREMENT PRIMARY KEY,
|
||||
id_matkul INT NOT NULL,
|
||||
hari VARCHAR(10) NOT NULL,
|
||||
jam_mulai TIME NOT NULL,
|
||||
jam_selesai TIME NOT NULL,
|
||||
ruangan VARCHAR(50) NOT NULL,
|
||||
jurusan VARCHAR(100) NOT NULL,
|
||||
semester INT NOT NULL,
|
||||
FOREIGN KEY (id_matkul) REFERENCES mata_kuliah(id_matkul)
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS absensi (
|
||||
id_absensi INT AUTO_INCREMENT PRIMARY KEY,
|
||||
id_mahasiswa INT NOT NULL,
|
||||
npm VARCHAR(20) NOT NULL,
|
||||
nama VARCHAR(100) NOT NULL,
|
||||
id_jadwal INT,
|
||||
mata_kuliah VARCHAR(100),
|
||||
latitude DOUBLE,
|
||||
longitude DOUBLE,
|
||||
jarak_meter DOUBLE,
|
||||
timestamp DATETIME NOT NULL,
|
||||
photo LONGTEXT,
|
||||
foto_base64 LONGTEXT,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'HADIR',
|
||||
device_id VARCHAR(255),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (id_mahasiswa) REFERENCES mahasiswa(id_mahasiswa),
|
||||
FOREIGN KEY (id_jadwal) REFERENCES jadwal_kelas(id_jadwal)
|
||||
)
|
||||
""")
|
||||
|
||||
# Upgrade kolom dari versi lama (abaikan error jika sudah ada)
|
||||
for col_sql in [
|
||||
"ALTER TABLE mahasiswa ADD COLUMN device_id VARCHAR(255) AFTER semester",
|
||||
"ALTER TABLE absensi ADD COLUMN jarak_meter DOUBLE AFTER longitude",
|
||||
"ALTER TABLE absensi ADD COLUMN device_id VARCHAR(255) AFTER foto_base64",
|
||||
]:
|
||||
try:
|
||||
cursor.execute(col_sql)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
connection.commit()
|
||||
print("✅ Database & semua tabel siap!")
|
||||
except Error as e:
|
||||
print(f"❌ Error init DB: {e}")
|
||||
finally:
|
||||
cursor.close()
|
||||
connection.close()
|
||||
|
||||
# ==================== HELPER WAKTU ====================
|
||||
|
||||
def get_hari_indo():
|
||||
"""Mengambil hari saat ini sesuai jam Laptop/Server"""
|
||||
hari_inggris = datetime.now().strftime('%A')
|
||||
mapping = {
|
||||
'Monday': 'Senin', 'Tuesday': 'Selasa', 'Wednesday': 'Rabu',
|
||||
'Thursday': 'Kamis', 'Friday': 'Jumat', 'Saturday': 'Sabtu', 'Sunday': 'Minggu'
|
||||
'Monday':'Senin','Tuesday':'Selasa','Wednesday':'Rabu',
|
||||
'Thursday':'Kamis','Friday':'Jumat','Saturday':'Sabtu','Sunday':'Minggu'
|
||||
}
|
||||
return mapping.get(hari_inggris, 'Senin')
|
||||
return mapping.get(datetime.now().strftime('%A'), 'Senin')
|
||||
|
||||
# ==================== LOGIKA AUTO ALFA (TRIGGER) ====================
|
||||
# ==================== AUTO ALFA ====================
|
||||
|
||||
def jalankan_auto_alfa():
|
||||
"""
|
||||
Fungsi ini dipanggil SETIAP KALI user me-refresh jadwal.
|
||||
Mengecek apakah ada kelas yang sudah lewat jamnya, lalu set TIDAK HADIR.
|
||||
"""
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
if conn is None: return
|
||||
if conn is None:
|
||||
return
|
||||
cursor = conn.cursor(dictionary=True)
|
||||
hari_ini = get_hari_indo()
|
||||
jam_sekarang = datetime.now().time()
|
||||
timestamp_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 1. Waktu Sekarang
|
||||
hari_ini = get_hari_indo()
|
||||
waktu_skrg = datetime.now()
|
||||
jam_sekarang = waktu_skrg.time()
|
||||
timestamp_str = waktu_skrg.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 2. Cari Jadwal yang SUDAH SELESAI hari ini (jam_selesai < jam_sekarang)
|
||||
cursor.execute("""
|
||||
SELECT j.id_jadwal, m.nama_matkul, j.jam_selesai, j.jurusan, j.semester
|
||||
FROM jadwal_kelas j
|
||||
JOIN mata_kuliah m ON j.id_matkul = m.id_matkul
|
||||
WHERE j.hari = %s
|
||||
AND j.jam_selesai < %s
|
||||
SELECT j.id_jadwal, m.nama_matkul, j.jurusan, j.semester
|
||||
FROM jadwal_kelas j JOIN mata_kuliah m ON j.id_matkul=m.id_matkul
|
||||
WHERE j.hari=%s AND j.jam_selesai < %s
|
||||
""", (hari_ini, jam_sekarang))
|
||||
|
||||
jadwal_selesai = cursor.fetchall()
|
||||
|
||||
for j in jadwal_selesai:
|
||||
# Cari Mahasiswa Target
|
||||
cursor.execute("SELECT id_mahasiswa, npm, nama FROM mahasiswa WHERE jurusan=%s AND semester=%s",
|
||||
(j['jurusan'], j['semester']))
|
||||
mahasiswa_list = cursor.fetchall()
|
||||
|
||||
for mhs in mahasiswa_list:
|
||||
# Cek Absen
|
||||
for j in cursor.fetchall():
|
||||
cursor.execute(
|
||||
"SELECT id_mahasiswa,npm,nama FROM mahasiswa WHERE jurusan=%s AND semester=%s",
|
||||
(j['jurusan'], j['semester'])
|
||||
)
|
||||
for mhs in cursor.fetchall():
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) as cnt FROM absensi
|
||||
WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=DATE(%s)
|
||||
""", (mhs['id_mahasiswa'], j['id_jadwal'], timestamp_str))
|
||||
|
||||
SELECT COUNT(*) as cnt FROM absensi
|
||||
WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()
|
||||
""", (mhs['id_mahasiswa'], j['id_jadwal']))
|
||||
if cursor.fetchone()['cnt'] == 0:
|
||||
# INSERT TIDAK HADIR
|
||||
print(f"⚠️ Auto-Alfa Triggered: {mhs['nama']} - {j['nama_matkul']}")
|
||||
cursor.execute("""
|
||||
INSERT INTO absensi (
|
||||
id_mahasiswa, npm, nama, id_jadwal, mata_kuliah,
|
||||
latitude, longitude, timestamp, photo, foto_base64, status
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'TIDAK HADIR')
|
||||
""", (mhs['id_mahasiswa'], mhs['npm'], mhs['nama'], j['id_jadwal'], j['nama_matkul'], 0.0, 0.0, timestamp_str, None, None))
|
||||
INSERT INTO absensi (id_mahasiswa,npm,nama,id_jadwal,mata_kuliah,
|
||||
latitude,longitude,jarak_meter,timestamp,status)
|
||||
VALUES (%s,%s,%s,%s,%s,0,0,0,%s,'TIDAK HADIR')
|
||||
""", (mhs['id_mahasiswa'],mhs['npm'],mhs['nama'],
|
||||
j['id_jadwal'],j['nama_matkul'],timestamp_str))
|
||||
conn.commit()
|
||||
print(f"⚠️ Auto-Alfa: {mhs['nama']} - {j['nama_matkul']}")
|
||||
|
||||
cursor.close(); conn.close()
|
||||
except Exception as e:
|
||||
print(f"Error Auto Alfa: {e}")
|
||||
|
||||
# ==================== JWT HELPER ====================
|
||||
# ==================== JWT ====================
|
||||
|
||||
def generate_token(id_mahasiswa, npm):
|
||||
def generate_token(id_mahasiswa, npm, device_id):
|
||||
payload = {
|
||||
'id_mahasiswa': id_mahasiswa, 'npm': npm,
|
||||
'id_mahasiswa': id_mahasiswa,
|
||||
'npm': npm,
|
||||
'device_id': device_id,
|
||||
'exp': datetime.utcnow() + timedelta(days=30)
|
||||
}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm='HS256')
|
||||
@ -135,13 +318,18 @@ def generate_token(id_mahasiswa, npm):
|
||||
def token_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = request.headers.get('Authorization')
|
||||
if not token: return jsonify({'error': 'Token tidak ditemukan'}), 401
|
||||
token = request.headers.get('Authorization', '')
|
||||
if not token:
|
||||
return jsonify({'error': 'Token tidak ditemukan'}), 401
|
||||
try:
|
||||
if token.startswith('Bearer '): token = token.split(' ')[1]
|
||||
if token.startswith('Bearer '):
|
||||
token = token.split(' ')[1]
|
||||
data = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
|
||||
request.user_data = data
|
||||
except: return jsonify({'error': 'Token invalid'}), 401
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Token kadaluarsa, silakan login ulang'}), 401
|
||||
except Exception:
|
||||
return jsonify({'error': 'Token tidak valid'}), 401
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
||||
@ -149,248 +337,410 @@ def token_required(f):
|
||||
|
||||
@app.route('/api/health', methods=['GET'])
|
||||
def health_check():
|
||||
return jsonify({'status': 'OK', 'message': 'API Running'})
|
||||
return jsonify({'status': 'OK', 'message': 'API Running — Secured Version v2'})
|
||||
|
||||
# ==================== AUTH (Register & Login) ====================
|
||||
# (Kode Register & Login Anda tidak saya ubah, tetap sama persis)
|
||||
# -------- AUTH --------
|
||||
|
||||
@app.route('/api/auth/register', methods=['POST'])
|
||||
def register():
|
||||
if is_rate_limited(f"{get_client_ip()}:register", 3, 600):
|
||||
return jsonify({'error': 'Terlalu banyak percobaan, coba lagi nanti'}), 429
|
||||
try:
|
||||
data = request.get_json()
|
||||
# ... (Logika register Anda tetap sama) ...
|
||||
# (Saya singkat agar tidak terlalu panjang, tapi logika asli tetap jalan)
|
||||
hashed_password = bcrypt.hashpw(data['password'].encode('utf-8'), bcrypt.gensalt())
|
||||
connection = get_db_connection()
|
||||
cursor = connection.cursor()
|
||||
cursor.execute("INSERT INTO mahasiswa (npm, password, nama, jenkel, fakultas, jurusan, semester) VALUES (%s, %s, %s, %s, %s, %s, %s)",
|
||||
(data['npm'], hashed_password.decode('utf-8'), data['nama'], data['jenkel'], data['fakultas'], data['jurusan'], data['semester']))
|
||||
connection.commit()
|
||||
id_mahasiswa = cursor.lastrowid
|
||||
cursor.close(); connection.close()
|
||||
token = generate_token(id_mahasiswa, data['npm'])
|
||||
return jsonify({'message': 'Registrasi berhasil', 'data': {'token': token}}), 201
|
||||
except Exception as e: return jsonify({'error': str(e)}), 500
|
||||
for field in ['npm','password','nama','jenkel','fakultas','jurusan','semester','device_id']:
|
||||
if not data.get(field):
|
||||
return jsonify({'error': f'Field {field} wajib diisi'}), 400
|
||||
|
||||
hashed = bcrypt.hashpw(data['password'].encode(), bcrypt.gensalt()).decode()
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO mahasiswa (npm,password,nama,jenkel,fakultas,jurusan,semester,device_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
|
||||
(data['npm'],hashed,data['nama'],data['jenkel'],data['fakultas'],data['jurusan'],data['semester'],data['device_id'])
|
||||
)
|
||||
conn.commit()
|
||||
id_mhs = cur.lastrowid
|
||||
cur.close(); conn.close()
|
||||
|
||||
token = generate_token(id_mhs, data['npm'], data['device_id'])
|
||||
return jsonify({
|
||||
'message': 'Registrasi berhasil',
|
||||
'data': {
|
||||
'token': token, 'id_mahasiswa': id_mhs, 'npm': data['npm'],
|
||||
'nama': data['nama'], 'jenkel': data['jenkel'],
|
||||
'fakultas': data['fakultas'], 'jurusan': data['jurusan'],
|
||||
'semester': data['semester']
|
||||
}
|
||||
}), 201
|
||||
except mysql.connector.IntegrityError:
|
||||
return jsonify({'error': 'NPM sudah terdaftar'}), 409
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/auth/login', methods=['POST'])
|
||||
def login():
|
||||
if is_rate_limited(f"{get_client_ip()}:login", 5, 60):
|
||||
return jsonify({'error': 'Terlalu banyak percobaan login, tunggu 1 menit'}), 429
|
||||
try:
|
||||
data = request.get_json()
|
||||
connection = get_db_connection()
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM mahasiswa WHERE npm = %s", (data['npm'],))
|
||||
mahasiswa = cursor.fetchone()
|
||||
cursor.close(); connection.close()
|
||||
device_id = data.get('device_id', '')
|
||||
|
||||
if not mahasiswa or not bcrypt.checkpw(data['password'].encode('utf-8'), mahasiswa['password'].encode('utf-8')):
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute("SELECT * FROM mahasiswa WHERE npm=%s", (data['npm'],))
|
||||
mhs = cur.fetchone()
|
||||
|
||||
if not mhs or not bcrypt.checkpw(data['password'].encode(), mhs['password'].encode()):
|
||||
return jsonify({'error': 'NPM atau Password salah'}), 401
|
||||
|
||||
token = generate_token(mahasiswa['id_mahasiswa'], mahasiswa['npm'])
|
||||
return jsonify({'message': 'Login berhasil', 'data': {**mahasiswa, 'token': token}}), 200
|
||||
except Exception as e: return jsonify({'error': str(e)}), 500
|
||||
# [FIX 3] Cek device binding
|
||||
if mhs.get('device_id') and mhs['device_id'] != device_id:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Akun ini sudah terdaftar di perangkat lain. Hubungi admin untuk reset.'}), 403
|
||||
|
||||
if device_id:
|
||||
cur.execute("UPDATE mahasiswa SET device_id=%s WHERE id_mahasiswa=%s",
|
||||
(device_id, mhs['id_mahasiswa']))
|
||||
conn.commit()
|
||||
|
||||
cur.close(); conn.close()
|
||||
token = generate_token(mhs['id_mahasiswa'], mhs['npm'], device_id)
|
||||
mhs.pop('password', None)
|
||||
return jsonify({'message': 'Login berhasil', 'data': {**mhs, 'token': token}}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/mahasiswa/profile', methods=['GET'])
|
||||
@token_required
|
||||
def get_profile():
|
||||
connection = get_db_connection()
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT * FROM mahasiswa WHERE id_mahasiswa = %s", (request.user_data['id_mahasiswa'],))
|
||||
mahasiswa = cursor.fetchone()
|
||||
cursor.close(); connection.close()
|
||||
return jsonify({'data': mahasiswa}), 200
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute(
|
||||
"SELECT id_mahasiswa,npm,nama,jenkel,fakultas,jurusan,semester FROM mahasiswa WHERE id_mahasiswa=%s",
|
||||
(request.user_data['id_mahasiswa'],)
|
||||
)
|
||||
mhs = cur.fetchone()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': mhs}), 200
|
||||
|
||||
# ==================== ABSENSI & JADWAL ====================
|
||||
|
||||
@app.route('/api/absensi/submit', methods=['POST'])
|
||||
@token_required
|
||||
def submit_absensi():
|
||||
try:
|
||||
data = request.get_json()
|
||||
status = data.get('status', 'HADIR')
|
||||
|
||||
# Ambil data mentah dari Android
|
||||
foto_input = data.get('foto_base64') or data.get('photo')
|
||||
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(dictionary=True)
|
||||
|
||||
# 1. Cek Double Absen
|
||||
cur.execute("SELECT COUNT(*) as c FROM absensi WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()",
|
||||
(request.user_data['id_mahasiswa'], data['id_jadwal']))
|
||||
if cur.fetchone()['c'] > 0:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Anda sudah absen mata kuliah ini hari ini!'}), 400
|
||||
|
||||
# 2. Ambil Nama Mhs & Matkul
|
||||
cur.execute("SELECT nama FROM mahasiswa WHERE id_mahasiswa=%s", (request.user_data['id_mahasiswa'],))
|
||||
nama_mhs = cur.fetchone()['nama']
|
||||
cur.execute("SELECT nama_matkul FROM mata_kuliah m JOIN jadwal_kelas j ON m.id_matkul=j.id_matkul WHERE j.id_jadwal=%s", (data['id_jadwal'],))
|
||||
nama_matkul = cur.fetchone()['nama_matkul']
|
||||
|
||||
# 3. Insert ke Database
|
||||
waktu_skrg = datetime.now()
|
||||
timestamp_str = waktu_skrg.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO absensi (id_mahasiswa, npm, nama, id_jadwal, mata_kuliah, latitude, longitude, timestamp, photo, foto_base64, status)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (request.user_data['id_mahasiswa'], request.user_data['npm'], nama_mhs, data['id_jadwal'], nama_matkul,
|
||||
data['latitude'], data['longitude'], timestamp_str, foto_input, foto_input, status))
|
||||
|
||||
# Simpan perubahan & Ambil ID Baru
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
|
||||
# ==========================================================
|
||||
# 4. AMBIL ULANG DARI DATABASE (SOLUSI PASTI)
|
||||
# ==========================================================
|
||||
# Sesuai permintaan Anda: Kita ambil data yang BARU SAJA masuk
|
||||
# untuk memastikan variabelnya tidak kosong.
|
||||
cur.execute("SELECT foto_base64 FROM absensi WHERE id_absensi=%s", (new_id,))
|
||||
row = cur.fetchone()
|
||||
|
||||
# Pastikan kita punya datanya
|
||||
foto_final = row['foto_base64'] if row else None
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
# ==========================================================
|
||||
# 5. KIRIM KE WEBHOOK N8N
|
||||
# ==========================================================
|
||||
try:
|
||||
webhook_url = "https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254"
|
||||
|
||||
# Payload dengan Foto ASLI dari Database
|
||||
webhook_payload = {
|
||||
"id_absensi": new_id,
|
||||
"npm": request.user_data['npm'],
|
||||
"nama": nama_mhs,
|
||||
"mata_kuliah": nama_matkul,
|
||||
"latitude": data['latitude'],
|
||||
"longitude": data['longitude'],
|
||||
"timestamp": timestamp_str,
|
||||
"status": status,
|
||||
"foto_base64": foto_final, # Kirim String Base64 Panjang
|
||||
}
|
||||
|
||||
# Kirim (Timeout agak lama karena Base64 besar)
|
||||
requests.post(webhook_url, json=webhook_payload, timeout=10)
|
||||
print(f"✅ Data ID {new_id} terkirim ke N8N (Size foto: {len(str(foto_final))} chars)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Gagal kirim ke N8N: {e}")
|
||||
|
||||
# 6. Respon ke Android
|
||||
return jsonify({
|
||||
'message': 'Absensi berhasil disimpan',
|
||||
'data': {
|
||||
'id_absensi': new_id,
|
||||
'status': status,
|
||||
'mata_kuliah': nama_matkul,
|
||||
'timestamp': timestamp_str
|
||||
}
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
# -------- JADWAL --------
|
||||
|
||||
@app.route('/api/jadwal/today', methods=['GET'])
|
||||
@token_required
|
||||
def get_jadwal_today():
|
||||
try:
|
||||
# 1. TRIGGER AUTO ALFA
|
||||
jalankan_auto_alfa()
|
||||
|
||||
# 2. Ambil Data Jadwal
|
||||
hari_ini = get_hari_indo()
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(dictionary=True)
|
||||
|
||||
cur.execute("SELECT jurusan, semester FROM mahasiswa WHERE id_mahasiswa=%s", (request.user_data['id_mahasiswa'],))
|
||||
cur.execute("SELECT jurusan,semester FROM mahasiswa WHERE id_mahasiswa=%s",
|
||||
(request.user_data['id_mahasiswa'],))
|
||||
mhs = cur.fetchone()
|
||||
|
||||
cur.execute("""
|
||||
SELECT j.*, m.nama_matkul, m.sks, m.dosen, m.kode_matkul
|
||||
FROM jadwal_kelas j
|
||||
JOIN mata_kuliah m ON j.id_matkul = m.id_matkul
|
||||
FROM jadwal_kelas j JOIN mata_kuliah m ON j.id_matkul=m.id_matkul
|
||||
WHERE j.hari=%s AND j.jurusan=%s AND j.semester=%s
|
||||
ORDER BY j.jam_mulai
|
||||
""", (hari_ini, mhs['jurusan'], mhs['semester']))
|
||||
jadwal = cur.fetchall()
|
||||
|
||||
# === FIX ERROR TIMEDELTA DISINI ===
|
||||
for j in jadwal:
|
||||
# Ubah jam_mulai (timedelta) ke string "HH:MM:SS"
|
||||
if isinstance(j.get('jam_mulai'), timedelta):
|
||||
j['jam_mulai'] = str(j['jam_mulai'])
|
||||
|
||||
# Ubah jam_selesai (timedelta) ke string "HH:MM:SS"
|
||||
if isinstance(j.get('jam_selesai'), timedelta):
|
||||
j['jam_selesai'] = str(j['jam_selesai'])
|
||||
|
||||
# Cek Status Absensi
|
||||
for col in ['jam_mulai','jam_selesai']:
|
||||
if isinstance(j.get(col), timedelta):
|
||||
j[col] = str(j[col])
|
||||
cur.execute("""
|
||||
SELECT status FROM absensi
|
||||
SELECT status FROM absensi
|
||||
WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()
|
||||
""", (request.user_data['id_mahasiswa'], j['id_jadwal']))
|
||||
|
||||
res = cur.fetchone()
|
||||
|
||||
if res:
|
||||
j['sudah_absen'] = True
|
||||
j['status_absensi'] = res['status']
|
||||
else:
|
||||
j['sudah_absen'] = False
|
||||
j['status_absensi'] = None
|
||||
j['sudah_absen'] = bool(res)
|
||||
j['status_absensi'] = res['status'] if res else None
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': jadwal, 'hari': hari_ini})
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# -------- [FIX 7] LOCATION TOKEN --------
|
||||
|
||||
@app.route('/api/absensi/request-location-token', methods=['POST'])
|
||||
@token_required
|
||||
def request_location_token():
|
||||
"""
|
||||
Android memanggil endpoint ini tepat setelah GPS fix diterima.
|
||||
Backend mencatat waktu & koordinat, lalu memberikan token sekali pakai.
|
||||
Token hanya valid 2 menit dan hanya bisa dipakai 1x.
|
||||
|
||||
Alur wajib di Android:
|
||||
1. Terima GPS fix
|
||||
2. POST /api/absensi/request-location-token → dapat location_token
|
||||
3. Ambil foto selfie (maks dalam 2 menit)
|
||||
4. POST /api/absensi/submit + location_token
|
||||
"""
|
||||
# Rate limit: maks 10 request token per 5 menit per user
|
||||
user_id = str(request.user_data['id_mahasiswa'])
|
||||
if is_rate_limited(f"{user_id}:loc_token", 10, 300):
|
||||
return jsonify({'error': 'Terlalu banyak permintaan token lokasi'}), 429
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
lat = data.get('latitude')
|
||||
lon = data.get('longitude')
|
||||
|
||||
if lat is None or lon is None:
|
||||
return jsonify({'error': 'Koordinat GPS wajib disertakan'}), 400
|
||||
|
||||
try:
|
||||
lat = float(lat)
|
||||
lon = float(lon)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'Koordinat tidak valid'}), 400
|
||||
|
||||
# [FIX 8] Jalankan deteksi anomali koordinat
|
||||
mencurigakan, alasan = deteksi_anomali_koordinat(lat, lon)
|
||||
if mencurigakan:
|
||||
return jsonify({'error': f'Koordinat tidak valid: {alasan}'}), 400
|
||||
|
||||
# Cek apakah sudah dalam radius kampus
|
||||
jarak = hitung_jarak_meter(lat, lon, KAMPUS_LATITUDE, KAMPUS_LONGITUDE)
|
||||
if jarak > RADIUS_METER:
|
||||
return jsonify({
|
||||
'error': f'Lokasi Anda terlalu jauh dari kampus ({jarak:.0f}m). Maksimal {RADIUS_METER:.0f}m.',
|
||||
'jarak_meter': round(jarak, 1)
|
||||
}), 403
|
||||
|
||||
# Bersihkan token lama yang expired
|
||||
bersihkan_token_expired()
|
||||
|
||||
# Buat location token baru
|
||||
loc_token = secrets.token_hex(32)
|
||||
_location_tokens[loc_token] = {
|
||||
'id_mahasiswa': request.user_data['id_mahasiswa'],
|
||||
'device_id': request.user_data.get('device_id', ''),
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'jarak_meter': round(jarak, 1),
|
||||
'expires_at': time.time() + LOCATION_TOKEN_TTL
|
||||
}
|
||||
|
||||
print(f"📍 Location token diterbitkan untuk user {user_id} | jarak: {jarak:.1f}m")
|
||||
return jsonify({
|
||||
'location_token': loc_token,
|
||||
'expires_in_seconds': LOCATION_TOKEN_TTL,
|
||||
'jarak_meter': round(jarak, 1),
|
||||
'message': f'Token lokasi valid selama {LOCATION_TOKEN_TTL} detik. Segera ambil foto dan submit absensi.'
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error Jadwal: {e}") # Print error di terminal agar jelas
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# -------- ABSENSI SUBMIT --------
|
||||
|
||||
@app.route('/api/absensi/submit', methods=['POST'])
|
||||
@token_required
|
||||
def submit_absensi():
|
||||
# [FIX 4] Rate limit
|
||||
user_id = str(request.user_data['id_mahasiswa'])
|
||||
if is_rate_limited(f"{user_id}:absensi", 3, 60):
|
||||
return jsonify({'error': 'Terlalu banyak request, coba lagi sebentar'}), 429
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
status = data.get('status', 'HADIR')
|
||||
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(dictionary=True)
|
||||
|
||||
# [FIX 3] Verifikasi device_id
|
||||
token_device_id = request.user_data.get('device_id', '')
|
||||
cur.execute("SELECT device_id,nama,jurusan,semester FROM mahasiswa WHERE id_mahasiswa=%s",
|
||||
(request.user_data['id_mahasiswa'],))
|
||||
mhs_data = cur.fetchone()
|
||||
|
||||
if mhs_data.get('device_id') and mhs_data['device_id'] != token_device_id:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Request berasal dari perangkat tidak sah'}), 403
|
||||
|
||||
# [FIX 7] Validasi location token — WAJIB ADA
|
||||
loc_token = data.get('location_token')
|
||||
if not loc_token:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'error': 'Location token wajib disertakan. Panggil /api/absensi/request-location-token terlebih dahulu.'
|
||||
}), 400
|
||||
|
||||
token_data = _location_tokens.get(loc_token)
|
||||
if not token_data:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Location token tidak valid atau sudah digunakan'}), 403
|
||||
|
||||
if time.time() > token_data['expires_at']:
|
||||
del _location_tokens[loc_token]
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Location token sudah kadaluarsa (maks {LOCATION_TOKEN_TTL} detik). Silakan ulangi proses absensi.'}), 403
|
||||
|
||||
if token_data['id_mahasiswa'] != request.user_data['id_mahasiswa']:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Location token bukan milik Anda'}), 403
|
||||
|
||||
if token_data['device_id'] != token_device_id:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Location token digunakan dari perangkat berbeda'}), 403
|
||||
|
||||
# Ambil koordinat dan jarak yang sudah diverifikasi dari token (bukan dari request body)
|
||||
# Ini mencegah manipulasi koordinat di tahap submit
|
||||
lat = token_data['lat']
|
||||
lon = token_data['lon']
|
||||
jarak = token_data['jarak_meter']
|
||||
|
||||
# Hapus token — ONE TIME USE
|
||||
del _location_tokens[loc_token]
|
||||
|
||||
# [FIX 5] Validasi kepemilikan jadwal
|
||||
cur.execute("""
|
||||
SELECT j.id_jadwal,j.jam_mulai,j.jam_selesai,j.jurusan,j.semester,m.nama_matkul
|
||||
FROM jadwal_kelas j JOIN mata_kuliah m ON j.id_matkul=m.id_matkul
|
||||
WHERE j.id_jadwal=%s AND j.jurusan=%s AND j.semester=%s
|
||||
""", (data['id_jadwal'], mhs_data['jurusan'], mhs_data['semester']))
|
||||
jadwal = cur.fetchone()
|
||||
|
||||
if not jadwal:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Jadwal tidak valid atau bukan milik kelas Anda'}), 403
|
||||
|
||||
# [FIX 6] Validasi jam kelas aktif
|
||||
jam_sekarang = datetime.now().time()
|
||||
jam_mulai = (datetime.min + jadwal['jam_mulai']).time() if isinstance(jadwal['jam_mulai'], timedelta) else jadwal['jam_mulai']
|
||||
jam_selesai = (datetime.min + jadwal['jam_selesai']).time() if isinstance(jadwal['jam_selesai'], timedelta) else jadwal['jam_selesai']
|
||||
|
||||
if not (jam_mulai <= jam_sekarang <= jam_selesai):
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'error': f'Absensi hanya bisa dilakukan saat jam kelas aktif ({jam_mulai.strftime("%H:%M")} - {jam_selesai.strftime("%H:%M")})'
|
||||
}), 403
|
||||
|
||||
# [FIX 2] Validasi foto
|
||||
foto_input = data.get('foto_base64') or data.get('photo')
|
||||
valid, pesan = validasi_foto(foto_input)
|
||||
if not valid:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Foto tidak valid: {pesan}'}), 400
|
||||
|
||||
# Cek double absen
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as c FROM absensi WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()",
|
||||
(request.user_data['id_mahasiswa'], data['id_jadwal'])
|
||||
)
|
||||
if cur.fetchone()['c'] > 0:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Anda sudah absen mata kuliah ini hari ini!'}), 400
|
||||
|
||||
timestamp_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cur.execute("""
|
||||
INSERT INTO absensi (id_mahasiswa,npm,nama,id_jadwal,mata_kuliah,
|
||||
latitude,longitude,jarak_meter,timestamp,photo,foto_base64,status,device_id)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (request.user_data['id_mahasiswa'], request.user_data['npm'], mhs_data['nama'],
|
||||
data['id_jadwal'], jadwal['nama_matkul'],
|
||||
lat, lon, jarak, timestamp_str,
|
||||
foto_input, foto_input, status, token_device_id))
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
cur.close(); conn.close()
|
||||
|
||||
# Kirim ke N8N webhook
|
||||
try:
|
||||
requests.post(
|
||||
"https://n8n.lab.ubharajaya.ac.id/webhook/23c6993d-1792-48fb-ad1c-ffc78a3e6254",
|
||||
json={
|
||||
"id_absensi": new_id, "npm": request.user_data['npm'],
|
||||
"nama": mhs_data['nama'], "mata_kuliah": jadwal['nama_matkul'],
|
||||
"latitude": lat, "longitude": lon, "jarak_meter": jarak,
|
||||
"timestamp": timestamp_str, "status": status, "foto_base64": foto_input
|
||||
}, timeout=10
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"⚠️ Gagal kirim ke N8N: {e}")
|
||||
|
||||
return jsonify({
|
||||
'message': 'Absensi berhasil disimpan',
|
||||
'data': {
|
||||
'id_absensi': new_id, 'status': status,
|
||||
'mata_kuliah': jadwal['nama_matkul'],
|
||||
'jarak_meter': jarak,
|
||||
'timestamp': timestamp_str
|
||||
}
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error submit absensi: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# -------- RIWAYAT & FOTO --------
|
||||
|
||||
@app.route('/api/absensi/history', methods=['GET'])
|
||||
@token_required
|
||||
def get_history():
|
||||
connection = get_db_connection()
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
try:
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(dictionary=True)
|
||||
|
||||
cursor.execute("""
|
||||
SELECT a.*, j.jam_mulai, j.jam_selesai
|
||||
FROM absensi a
|
||||
LEFT JOIN jadwal_kelas j ON a.id_jadwal = j.id_jadwal
|
||||
WHERE a.id_mahasiswa = %s ORDER BY a.timestamp DESC
|
||||
""", (request.user_data['id_mahasiswa'],))
|
||||
query = """
|
||||
SELECT a.id_absensi,a.npm,a.nama,a.mata_kuliah,a.latitude,a.longitude,
|
||||
a.jarak_meter,a.timestamp,a.status,a.created_at,j.jam_mulai,j.jam_selesai
|
||||
FROM absensi a LEFT JOIN jadwal_kelas j ON a.id_jadwal=j.id_jadwal
|
||||
WHERE a.id_mahasiswa=%s
|
||||
"""
|
||||
params = [request.user_data['id_mahasiswa']]
|
||||
if start_date:
|
||||
query += " AND DATE(a.timestamp)>=%s"; params.append(start_date)
|
||||
if end_date:
|
||||
query += " AND DATE(a.timestamp)<=%s"; params.append(end_date)
|
||||
query += " ORDER BY a.timestamp DESC"
|
||||
|
||||
history = cursor.fetchall()
|
||||
|
||||
# === FIX ERROR TIMEDELTA DISINI ===
|
||||
for item in history:
|
||||
if isinstance(item.get('jam_mulai'), timedelta):
|
||||
item['jam_mulai'] = str(item['jam_mulai'])
|
||||
if isinstance(item.get('jam_selesai'), timedelta):
|
||||
item['jam_selesai'] = str(item['jam_selesai'])
|
||||
|
||||
cursor.close(); connection.close()
|
||||
return jsonify({'data': history}), 200
|
||||
cur.execute(query, params)
|
||||
history = cur.fetchall()
|
||||
for item in history:
|
||||
for col in ['jam_mulai','jam_selesai']:
|
||||
if isinstance(item.get(col), timedelta):
|
||||
item[col] = str(item[col])
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'data': history}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/absensi/photo/<int:id_absensi>', methods=['GET'])
|
||||
@token_required
|
||||
def get_photo(id_absensi):
|
||||
connection = get_db_connection()
|
||||
cursor = connection.cursor(dictionary=True)
|
||||
cursor.execute("SELECT foto_base64 FROM absensi WHERE id_absensi = %s", (id_absensi,))
|
||||
result = cursor.fetchone()
|
||||
cursor.close(); connection.close()
|
||||
if result: return jsonify({'data': result}), 200
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute(
|
||||
"SELECT foto_base64 FROM absensi WHERE id_absensi=%s AND id_mahasiswa=%s",
|
||||
(id_absensi, request.user_data['id_mahasiswa'])
|
||||
)
|
||||
result = cur.fetchone()
|
||||
cur.close(); conn.close()
|
||||
if result:
|
||||
return jsonify({'data': result}), 200
|
||||
return jsonify({'error': 'Foto tidak ditemukan atau bukan milik Anda'}), 404
|
||||
|
||||
# ==================== RUN SERVER ====================
|
||||
|
||||
if __name__ == '__main__':
|
||||
# HAPUS semua kode Scheduler disini agar tidak blocking
|
||||
print("🚀 Menginisialisasi database...")
|
||||
init_database()
|
||||
print("🌐 Starting Flask server (Auto Alfa Trigger Mode)...")
|
||||
print("🔒 Security fixes aktif:")
|
||||
print(" ✅ [FIX 1] Server-side GPS validation")
|
||||
print(" ✅ [FIX 2] Server-side foto validation")
|
||||
print(" ✅ [FIX 3] JWT device binding")
|
||||
print(" ✅ [FIX 4] Rate limiting")
|
||||
print(" ✅ [FIX 5] Jadwal ownership validation")
|
||||
print(" ✅ [FIX 6] Jam kelas aktif validation")
|
||||
print(" ✅ [FIX 7] Location token (one-time, 2 menit)")
|
||||
print(" ✅ [FIX 8] Anomali koordinat detection")
|
||||
print("🌐 Starting Flask server...")
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
Loading…
x
Reference in New Issue
Block a user