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://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

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
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)