This commit is contained in:
202310715297 RAIHAN ARIQ MUZAKKI 2026-05-02 21:07:00 +07:00
parent 3b76befdd2
commit b48ca407b7
3 changed files with 783 additions and 319 deletions

View File

@ -90,12 +90,12 @@ object AppConstants {
// const val BASE_URL = "http://10.0.2.2:5000" // Untuk emulator Android // 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 const val BASE_URL = "http://192.168.100.99:5000" // Untuk device fisik
// Koordinat Kampus (UBHARA Jaya) // // Koordinat Kampus (UBHARA Jaya)
const val KAMPUS_LATITUDE = -6.223325 // const val KAMPUS_LATITUDE = -6.223325
const val KAMPUS_LONGITUDE = 107.009406 // const val KAMPUS_LONGITUDE = 107.009406
// Koordinat Device Saat ini (Untuk Testing) // Koordinat Device Saat ini (Untuk Testing)
// const val KAMPUS_LATITUDE = -6.239513 const val KAMPUS_LATITUDE = -6.2396008
// const val KAMPUS_LONGITUDE = 107.089676 const val KAMPUS_LONGITUDE = 107.0893571
const val RADIUS_METER = 500.0 const val RADIUS_METER = 500.0
@ -113,6 +113,7 @@ object AppConstants {
const val KEY_FAKULTAS = "fakultas" const val KEY_FAKULTAS = "fakultas"
const val KEY_JURUSAN = "jurusan" const val KEY_JURUSAN = "jurusan"
const val KEY_SEMESTER = "semester" const val KEY_SEMESTER = "semester"
const val KEY_DEVICE_ID = "device_id"
} }
/* ================= DATA CLASSES ================= */ /* ================= 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 ================= */ /* ================= UTIL FUNCTIONS ================= */
fun bitmapToBase64(bitmap: Bitmap): String { fun bitmapToBase64(bitmap: Bitmap): String {
@ -265,6 +273,7 @@ fun getCurrentTimestamp(): String {
fun registerMahasiswa( fun registerMahasiswa(
npm: String, password: String, nama: String, jenkel: String, npm: String, password: String, nama: String, jenkel: String,
fakultas: String, jurusan: String, semester: Int, fakultas: String, jurusan: String, semester: Int,
deviceId: String, // ← BARU
onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit
) { ) {
thread { thread {
@ -278,8 +287,14 @@ fun registerMahasiswa(
conn.readTimeout = 15000 conn.readTimeout = 15000
val json = JSONObject().apply { val json = JSONObject().apply {
put("npm", npm); put("password", password); put("nama", nama) put("npm", npm)
put("jenkel", jenkel); put("fakultas", fakultas); put("jurusan", jurusan); put("semester", semester) 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()) } conn.outputStream.use { it.write(json.toString().toByteArray()) }
@ -287,13 +302,15 @@ fun registerMahasiswa(
val responseCode = conn.responseCode val responseCode = conn.responseCode
val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText() val response = if (responseCode == 201) conn.inputStream.bufferedReader().readText()
else conn.errorStream?.bufferedReader()?.readText() ?: "" else conn.errorStream?.bufferedReader()?.readText() ?: ""
conn.disconnect() conn.disconnect()
if (responseCode == 201) { if (responseCode == 201) {
val data = JSONObject(response).getJSONObject("data") val data = JSONObject(response).getJSONObject("data")
val token = data.getString("token") 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) onSuccess(token, mahasiswa)
} else { } else {
onError(ErrorHandler.parseHttpError(responseCode, response)) onError(ErrorHandler.parseHttpError(responseCode, response))
@ -304,6 +321,7 @@ fun registerMahasiswa(
fun loginMahasiswa( fun loginMahasiswa(
npm: String, password: String, npm: String, password: String,
deviceId: String, // ← BARU
onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit onSuccess: (String, Mahasiswa) -> Unit, onError: (String) -> Unit
) { ) {
thread { thread {
@ -316,18 +334,26 @@ fun loginMahasiswa(
conn.connectTimeout = 15000 conn.connectTimeout = 15000
conn.readTimeout = 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()) } conn.outputStream.use { it.write(json.toString().toByteArray()) }
val responseCode = conn.responseCode val responseCode = conn.responseCode
val response = if (responseCode == 200) conn.inputStream.bufferedReader().readText() val response = if (responseCode == 200) conn.inputStream.bufferedReader().readText()
else conn.errorStream?.bufferedReader()?.readText() ?: "" else conn.errorStream?.bufferedReader()?.readText() ?: ""
conn.disconnect() conn.disconnect()
if (responseCode == 200) { if (responseCode == 200) {
val data = JSONObject(response).getJSONObject("data") 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) onSuccess(data.getString("token"), mahasiswa)
} else { } else {
onError(ErrorHandler.parseHttpError(responseCode, response)) 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( fun getJadwalToday(
token: String, onSuccess: (List<JadwalKelas>) -> Unit, onError: (String) -> Unit token: String, onSuccess: (List<JadwalKelas>) -> Unit, onError: (String) -> Unit
) { ) {
@ -406,40 +471,63 @@ fun getJadwalToday(
} }
fun submitAbsensiWithJadwal( fun submitAbsensiWithJadwal(
token: String, idJadwal: Int, latitude: Double, longitude: Double, token: String,
fotoBase64: String, status: String, onSuccess: (String) -> Unit, onError: (String) -> Unit idJadwal: Int,
latitude: Double,
longitude: Double,
fotoBase64: String,
status: String,
onSuccess: (String) -> Unit,
onError: (String) -> Unit
) { ) {
thread { // STEP 1: Minta location token ke server dengan koordinat GPS saat ini
try { requestLocationToken(
val url = URL("${AppConstants.BASE_URL}/api/absensi/submit") token = token,
val conn = url.openConnection() as HttpURLConnection latitude = latitude,
conn.requestMethod = "POST" longitude = longitude,
conn.setRequestProperty("Content-Type", "application/json") onSuccess = { locationToken ->
conn.setRequestProperty("Authorization", "Bearer $token") // STEP 2: Jika berhasil dapat location token, baru submit absensi
conn.doOutput = true thread {
conn.connectTimeout = 30000 // Timeout lebih lama untuk upload foto try {
conn.readTimeout = 30000 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 { val json = JSONObject().apply {
put("id_jadwal", idJadwal); put("latitude", latitude); put("longitude", longitude) put("id_jadwal", idJadwal)
put("timestamp", getCurrentTimestamp()); put("foto_base64", fotoBase64); put("status", status) 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()) } onError = { err ->
// Jika gagal dapat location token (lokasi tidak valid, dll)
val responseCode = conn.responseCode onError(err)
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)) }
}
} }
fun getAbsensiHistory( fun getAbsensiHistory(
@ -753,7 +841,7 @@ fun JadwalScreen(
color = androidx.compose.ui.graphics.Color.Black color = androidx.compose.ui.graphics.Color.Black
) )
Text( Text(
text = "Semester Ganjil 2025/2026", // Bisa dibuat dinamis nanti text = "Semester Genap 2026/2027", // Bisa dibuat dinamis nanti
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = androidx.compose.ui.graphics.Color.Gray color = androidx.compose.ui.graphics.Color.Gray
) )
@ -1042,7 +1130,13 @@ fun RegisterScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), 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)) Spacer(modifier = Modifier.height(8.dp))
@ -1059,7 +1153,13 @@ fun RegisterScreen(
}, },
modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), 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)) Spacer(modifier = Modifier.height(8.dp))
@ -1076,7 +1176,13 @@ fun RegisterScreen(
}, },
modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), 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)) Spacer(modifier = Modifier.height(16.dp))
@ -1094,7 +1200,13 @@ fun RegisterScreen(
leadingIcon = { Icon(Icons.Default.Person, null, tint = GoldPrimary) }, leadingIcon = { Icon(Icons.Default.Person, null, tint = GoldPrimary) },
modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), 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)) Spacer(modifier = Modifier.height(12.dp))
@ -1134,7 +1246,13 @@ fun RegisterScreen(
leadingIcon = { Icon(Icons.Default.School, null, tint = GoldPrimary) }, leadingIcon = { Icon(Icons.Default.School, null, tint = GoldPrimary) },
modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), 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)) Spacer(modifier = Modifier.height(8.dp))
@ -1146,7 +1264,13 @@ fun RegisterScreen(
leadingIcon = { Icon(Icons.Default.Book, null, tint = GoldPrimary) }, leadingIcon = { Icon(Icons.Default.Book, null, tint = GoldPrimary) },
modifier = Modifier.weight(1.5f), singleLine = true, modifier = Modifier.weight(1.5f), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), 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( OutlinedTextField(
value = semester, value = semester,
@ -1156,7 +1280,13 @@ fun RegisterScreen(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f), singleLine = true, modifier = Modifier.weight(1f), singleLine = true,
shape = androidx.compose.foundation.shape.RoundedCornerShape(12.dp), 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" errorMessage = "Konfirmasi password tidak cocok"
} else { } else {
isLoading = true isLoading = true
val deviceId = getDeviceId(context)
registerMahasiswa( registerMahasiswa(
npm = npm.trim(), password = password, nama = nama.trim(), npm = npm.trim(), password = password, nama = nama.trim(),
jenkel = jenkel, fakultas = fakultas.trim(), jurusan = jurusan.trim(), jenkel = jenkel, fakultas = fakultas.trim(), jurusan = jurusan.trim(),
semester = semester.toIntOrNull() ?: 1, semester = semester.toIntOrNull() ?: 1,
deviceId = deviceId,
onSuccess = { token, mhs -> onSuccess = { token, mhs ->
(context as? ComponentActivity)?.runOnUiThread { (context as? ComponentActivity)?.runOnUiThread {
isLoading = false isLoading = false
@ -1407,9 +1539,11 @@ fun LoginScreen(
} }
isLoading = true isLoading = true
val deviceId = getDeviceId(context)
loginMahasiswa( loginMahasiswa(
npm = npm.trim(), npm = npm.trim(),
password = password, password = password,
deviceId = deviceId,
onSuccess = { token, mhs -> onSuccess = { token, mhs ->
(context as? ComponentActivity)?.runOnUiThread { (context as? ComponentActivity)?.runOnUiThread {
isLoading = false isLoading = false

View File

@ -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"]

View File

@ -1,6 +1,19 @@
""" """
Backend API untuk Aplikasi Absensi Akademik Backend API untuk Aplikasi Absensi Akademik VERSI AMAN v2
Python Flask + MySQL + JWT Authentication 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 from flask import Flask, request, jsonify
@ -10,12 +23,14 @@ from mysql.connector import Error
import jwt import jwt
import bcrypt import bcrypt
from datetime import datetime, timedelta from datetime import datetime, timedelta
import os
from functools import wraps from functools import wraps
import base64 import base64
import math
import time
import secrets
import requests import requests
from collections import defaultdict
# Hapus APScheduler agar server tidak berat/blocking
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
@ -30,104 +45,272 @@ DB_CONFIG = {
SECRET_KEY = 'ubhara-jaya-absensi-secret-2026-change-this' 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(): def get_db_connection():
try: try:
connection = mysql.connector.connect(**DB_CONFIG) return mysql.connector.connect(**DB_CONFIG)
return connection
except Error as e: except Error as e:
print(f"Error connecting to MySQL: {e}") print(f"DB Error: {e}")
return None return None
def init_database(): def init_database():
connection = get_db_connection() try:
if connection is None: return 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() cursor = connection.cursor()
try: try:
cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_CONFIG['database']}") cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_CONFIG['database']}")
cursor.execute(f"USE {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(): def get_hari_indo():
"""Mengambil hari saat ini sesuai jam Laptop/Server"""
hari_inggris = datetime.now().strftime('%A')
mapping = { mapping = {
'Monday': 'Senin', 'Tuesday': 'Selasa', 'Wednesday': 'Rabu', 'Monday':'Senin','Tuesday':'Selasa','Wednesday':'Rabu',
'Thursday': 'Kamis', 'Friday': 'Jumat', 'Saturday': 'Sabtu', 'Sunday': 'Minggu' '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(): 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: try:
conn = get_db_connection() conn = get_db_connection()
if conn is None: return if conn is None:
return
cursor = conn.cursor(dictionary=True) 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(""" cursor.execute("""
SELECT j.id_jadwal, m.nama_matkul, j.jam_selesai, j.jurusan, j.semester SELECT j.id_jadwal, m.nama_matkul, j.jurusan, j.semester
FROM jadwal_kelas j FROM jadwal_kelas j JOIN mata_kuliah m ON j.id_matkul=m.id_matkul
JOIN mata_kuliah m ON j.id_matkul = m.id_matkul WHERE j.hari=%s AND j.jam_selesai < %s
WHERE j.hari = %s
AND j.jam_selesai < %s
""", (hari_ini, jam_sekarang)) """, (hari_ini, jam_sekarang))
jadwal_selesai = cursor.fetchall() for j in cursor.fetchall():
cursor.execute(
for j in jadwal_selesai: "SELECT id_mahasiswa,npm,nama FROM mahasiswa WHERE jurusan=%s AND semester=%s",
# Cari Mahasiswa Target (j['jurusan'], j['semester'])
cursor.execute("SELECT id_mahasiswa, npm, nama FROM mahasiswa WHERE jurusan=%s AND semester=%s", )
(j['jurusan'], j['semester'])) for mhs in cursor.fetchall():
mahasiswa_list = cursor.fetchall()
for mhs in mahasiswa_list:
# Cek Absen
cursor.execute(""" cursor.execute("""
SELECT COUNT(*) as cnt FROM absensi SELECT COUNT(*) as cnt FROM absensi
WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=DATE(%s) WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()
""", (mhs['id_mahasiswa'], j['id_jadwal'], timestamp_str)) """, (mhs['id_mahasiswa'], j['id_jadwal']))
if cursor.fetchone()['cnt'] == 0: if cursor.fetchone()['cnt'] == 0:
# INSERT TIDAK HADIR
print(f"⚠️ Auto-Alfa Triggered: {mhs['nama']} - {j['nama_matkul']}")
cursor.execute(""" cursor.execute("""
INSERT INTO absensi ( INSERT INTO absensi (id_mahasiswa,npm,nama,id_jadwal,mata_kuliah,
id_mahasiswa, npm, nama, id_jadwal, mata_kuliah, latitude,longitude,jarak_meter,timestamp,status)
latitude, longitude, timestamp, photo, foto_base64, status VALUES (%s,%s,%s,%s,%s,0,0,0,%s,'TIDAK HADIR')
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'TIDAK HADIR') """, (mhs['id_mahasiswa'],mhs['npm'],mhs['nama'],
""", (mhs['id_mahasiswa'], mhs['npm'], mhs['nama'], j['id_jadwal'], j['nama_matkul'], 0.0, 0.0, timestamp_str, None, None)) j['id_jadwal'],j['nama_matkul'],timestamp_str))
conn.commit() conn.commit()
print(f"⚠️ Auto-Alfa: {mhs['nama']} - {j['nama_matkul']}")
cursor.close(); conn.close() cursor.close(); conn.close()
except Exception as e: except Exception as e:
print(f"Error Auto Alfa: {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 = { payload = {
'id_mahasiswa': id_mahasiswa, 'npm': npm, 'id_mahasiswa': id_mahasiswa,
'npm': npm,
'device_id': device_id,
'exp': datetime.utcnow() + timedelta(days=30) 'exp': datetime.utcnow() + timedelta(days=30)
} }
return jwt.encode(payload, SECRET_KEY, algorithm='HS256') return jwt.encode(payload, SECRET_KEY, algorithm='HS256')
@ -135,13 +318,18 @@ def generate_token(id_mahasiswa, npm):
def token_required(f): def token_required(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
token = request.headers.get('Authorization') token = request.headers.get('Authorization', '')
if not token: return jsonify({'error': 'Token tidak ditemukan'}), 401 if not token:
return jsonify({'error': 'Token tidak ditemukan'}), 401
try: 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']) data = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
request.user_data = data 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 f(*args, **kwargs)
return decorated return decorated
@ -149,248 +337,410 @@ def token_required(f):
@app.route('/api/health', methods=['GET']) @app.route('/api/health', methods=['GET'])
def health_check(): def health_check():
return jsonify({'status': 'OK', 'message': 'API Running'}) return jsonify({'status': 'OK', 'message': 'API Running — Secured Version v2'})
# ==================== AUTH (Register & Login) ==================== # -------- AUTH --------
# (Kode Register & Login Anda tidak saya ubah, tetap sama persis)
@app.route('/api/auth/register', methods=['POST']) @app.route('/api/auth/register', methods=['POST'])
def register(): def register():
if is_rate_limited(f"{get_client_ip()}:register", 3, 600):
return jsonify({'error': 'Terlalu banyak percobaan, coba lagi nanti'}), 429
try: try:
data = request.get_json() data = request.get_json()
# ... (Logika register Anda tetap sama) ... for field in ['npm','password','nama','jenkel','fakultas','jurusan','semester','device_id']:
# (Saya singkat agar tidak terlalu panjang, tapi logika asli tetap jalan) if not data.get(field):
hashed_password = bcrypt.hashpw(data['password'].encode('utf-8'), bcrypt.gensalt()) return jsonify({'error': f'Field {field} wajib diisi'}), 400
connection = get_db_connection()
cursor = connection.cursor() hashed = bcrypt.hashpw(data['password'].encode(), bcrypt.gensalt()).decode()
cursor.execute("INSERT INTO mahasiswa (npm, password, nama, jenkel, fakultas, jurusan, semester) VALUES (%s, %s, %s, %s, %s, %s, %s)", conn = get_db_connection()
(data['npm'], hashed_password.decode('utf-8'), data['nama'], data['jenkel'], data['fakultas'], data['jurusan'], data['semester'])) cur = conn.cursor()
connection.commit() cur.execute(
id_mahasiswa = cursor.lastrowid "INSERT INTO mahasiswa (npm,password,nama,jenkel,fakultas,jurusan,semester,device_id) VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
cursor.close(); connection.close() (data['npm'],hashed,data['nama'],data['jenkel'],data['fakultas'],data['jurusan'],data['semester'],data['device_id'])
token = generate_token(id_mahasiswa, data['npm']) )
return jsonify({'message': 'Registrasi berhasil', 'data': {'token': token}}), 201 conn.commit()
except Exception as e: return jsonify({'error': str(e)}), 500 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']) @app.route('/api/auth/login', methods=['POST'])
def login(): 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: try:
data = request.get_json() data = request.get_json()
connection = get_db_connection() device_id = data.get('device_id', '')
cursor = connection.cursor(dictionary=True)
cursor.execute("SELECT * FROM mahasiswa WHERE npm = %s", (data['npm'],))
mahasiswa = cursor.fetchone()
cursor.close(); connection.close()
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 return jsonify({'error': 'NPM atau Password salah'}), 401
token = generate_token(mahasiswa['id_mahasiswa'], mahasiswa['npm']) # [FIX 3] Cek device binding
return jsonify({'message': 'Login berhasil', 'data': {**mahasiswa, 'token': token}}), 200 if mhs.get('device_id') and mhs['device_id'] != device_id:
except Exception as e: return jsonify({'error': str(e)}), 500 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']) @app.route('/api/mahasiswa/profile', methods=['GET'])
@token_required @token_required
def get_profile(): def get_profile():
connection = get_db_connection() conn = get_db_connection()
cursor = connection.cursor(dictionary=True) cur = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM mahasiswa WHERE id_mahasiswa = %s", (request.user_data['id_mahasiswa'],)) cur.execute(
mahasiswa = cursor.fetchone() "SELECT id_mahasiswa,npm,nama,jenkel,fakultas,jurusan,semester FROM mahasiswa WHERE id_mahasiswa=%s",
cursor.close(); connection.close() (request.user_data['id_mahasiswa'],)
return jsonify({'data': mahasiswa}), 200 )
mhs = cur.fetchone()
cur.close(); conn.close()
return jsonify({'data': mhs}), 200
# ==================== ABSENSI & JADWAL ==================== # -------- 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
@app.route('/api/jadwal/today', methods=['GET']) @app.route('/api/jadwal/today', methods=['GET'])
@token_required @token_required
def get_jadwal_today(): def get_jadwal_today():
try: try:
# 1. TRIGGER AUTO ALFA
jalankan_auto_alfa() jalankan_auto_alfa()
# 2. Ambil Data Jadwal
hari_ini = get_hari_indo() hari_ini = get_hari_indo()
conn = get_db_connection() conn = get_db_connection()
cur = conn.cursor(dictionary=True) 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() mhs = cur.fetchone()
cur.execute(""" cur.execute("""
SELECT j.*, m.nama_matkul, m.sks, m.dosen, m.kode_matkul SELECT j.*, m.nama_matkul, m.sks, m.dosen, m.kode_matkul
FROM jadwal_kelas j FROM jadwal_kelas j JOIN mata_kuliah m ON j.id_matkul=m.id_matkul
JOIN mata_kuliah m ON j.id_matkul = m.id_matkul
WHERE j.hari=%s AND j.jurusan=%s AND j.semester=%s WHERE j.hari=%s AND j.jurusan=%s AND j.semester=%s
ORDER BY j.jam_mulai ORDER BY j.jam_mulai
""", (hari_ini, mhs['jurusan'], mhs['semester'])) """, (hari_ini, mhs['jurusan'], mhs['semester']))
jadwal = cur.fetchall() jadwal = cur.fetchall()
# === FIX ERROR TIMEDELTA DISINI ===
for j in jadwal: for j in jadwal:
# Ubah jam_mulai (timedelta) ke string "HH:MM:SS" for col in ['jam_mulai','jam_selesai']:
if isinstance(j.get('jam_mulai'), timedelta): if isinstance(j.get(col), timedelta):
j['jam_mulai'] = str(j['jam_mulai']) j[col] = str(j[col])
# 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
cur.execute(""" cur.execute("""
SELECT status FROM absensi SELECT status FROM absensi
WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE() WHERE id_mahasiswa=%s AND id_jadwal=%s AND DATE(timestamp)=CURDATE()
""", (request.user_data['id_mahasiswa'], j['id_jadwal'])) """, (request.user_data['id_mahasiswa'], j['id_jadwal']))
res = cur.fetchone() res = cur.fetchone()
j['sudah_absen'] = bool(res)
if res: j['status_absensi'] = res['status'] if res else None
j['sudah_absen'] = True
j['status_absensi'] = res['status']
else:
j['sudah_absen'] = False
j['status_absensi'] = None
cur.close(); conn.close() cur.close(); conn.close()
return jsonify({'data': jadwal, 'hari': hari_ini}) 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: except Exception as e:
print(f"Error Jadwal: {e}") # Print error di terminal agar jelas
return jsonify({'error': str(e)}), 500 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']) @app.route('/api/absensi/history', methods=['GET'])
@token_required @token_required
def get_history(): def get_history():
connection = get_db_connection() try:
cursor = connection.cursor(dictionary=True) 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(""" query = """
SELECT a.*, j.jam_mulai, j.jam_selesai SELECT a.id_absensi,a.npm,a.nama,a.mata_kuliah,a.latitude,a.longitude,
FROM absensi a a.jarak_meter,a.timestamp,a.status,a.created_at,j.jam_mulai,j.jam_selesai
LEFT JOIN jadwal_kelas j ON a.id_jadwal = j.id_jadwal 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 WHERE a.id_mahasiswa=%s
""", (request.user_data['id_mahasiswa'],)) """
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() cur.execute(query, params)
history = cur.fetchall()
# === FIX ERROR TIMEDELTA DISINI === for item in history:
for item in history: for col in ['jam_mulai','jam_selesai']:
if isinstance(item.get('jam_mulai'), timedelta): if isinstance(item.get(col), timedelta):
item['jam_mulai'] = str(item['jam_mulai']) item[col] = str(item[col])
if isinstance(item.get('jam_selesai'), timedelta): cur.close(); conn.close()
item['jam_selesai'] = str(item['jam_selesai']) return jsonify({'data': history}), 200
except Exception as e:
cursor.close(); connection.close() return jsonify({'error': str(e)}), 500
return jsonify({'data': history}), 200
@app.route('/api/absensi/photo/<int:id_absensi>', methods=['GET']) @app.route('/api/absensi/photo/<int:id_absensi>', methods=['GET'])
@token_required @token_required
def get_photo(id_absensi): def get_photo(id_absensi):
connection = get_db_connection() conn = get_db_connection()
cursor = connection.cursor(dictionary=True) cur = conn.cursor(dictionary=True)
cursor.execute("SELECT foto_base64 FROM absensi WHERE id_absensi = %s", (id_absensi,)) cur.execute(
result = cursor.fetchone() "SELECT foto_base64 FROM absensi WHERE id_absensi=%s AND id_mahasiswa=%s",
cursor.close(); connection.close() (id_absensi, request.user_data['id_mahasiswa'])
if result: return jsonify({'data': result}), 200 )
return jsonify({'error': 'Not found'}), 404 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 ==================== # ==================== RUN SERVER ====================
if __name__ == '__main__': if __name__ == '__main__':
# HAPUS semua kode Scheduler disini agar tidak blocking
print("🚀 Menginisialisasi database...") print("🚀 Menginisialisasi database...")
init_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) app.run(debug=True, host='0.0.0.0', port=5000)