| import os |
| import json |
| import hashlib |
| import datetime |
| import time |
| import uuid |
| import pytz |
| from fastapi import FastAPI, Request, Response |
| from fastapi.responses import PlainTextResponse, JSONResponse |
| from fastapi.middleware.cors import CORSMiddleware |
| import gspread |
| from google.oauth2.service_account import Credentials |
| import uvicorn |
| from apscheduler.schedulers.background import BackgroundScheduler |
| from apscheduler.triggers.cron import CronTrigger |
|
|
| app = FastAPI() |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| SHEET_ID = os.getenv('SHEET_ID') |
| TOKEN_EXPIRE_MIN = 15 |
| TORONTO_TZ = pytz.timezone('America/Toronto') |
| T_WINDOW = 10 |
| |
| |
| TRANSFER_LIMIT = 1500 |
| CARD_PAY_LIMIT = 200 |
|
|
| |
| visit_history = {} |
| BLACKLIST = os.getenv("BLACKLIST_IPS", "").split(",") |
|
|
| def get_gc_client(): |
| creds_json = os.getenv("G_CREDS") |
| info = json.loads(creds_json) |
| creds = Credentials.from_service_account_info(info, scopes=[ |
| "https://www.googleapis.com/auth/spreadsheets", |
| "https://www.googleapis.com/auth/drive" |
| ]) |
| return gspread.authorize(creds) |
|
|
| gc = get_gc_client() |
| spreadsheet = gc.open_by_key(SHEET_ID) |
|
|
| |
|
|
| def is_rate_limited(ip: str): |
| now = time.time() |
| if ip in [x.strip() for x in BLACKLIST if x.strip()]: |
| return True |
| if ip not in visit_history: |
| visit_history[ip] = [] |
| visit_history[ip] = [t for t in visit_history[ip] if now - t < 10] |
| if len(visit_history[ip]) >= 30: |
| return True |
| visit_history[ip].append(now) |
| return False |
|
|
| @app.middleware("http") |
| async def ddos_protection_middleware(request: Request, call_next): |
| forwarded = request.headers.get("X-Forwarded-For") |
| ip = forwarded.split(",")[0].strip() if forwarded else request.client.host |
| if is_rate_limited(ip): |
| return PlainTextResponse("error: too many requests", status_code=429) |
| response = await call_next(request) |
| return response |
|
|
|
|
| @app.middleware("http") |
| async def add_security_headers(request, call_next): |
| response = await call_next(request) |
| |
| |
| response.headers["X-Content-Type-Options"] = "nosniff" |
| |
| |
| response.headers["X-Frame-Options"] = "DENY" |
| |
| |
| response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" |
| |
| |
| |
| response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" |
| |
| |
| response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" |
| |
| return response |
|
|
|
|
|
|
|
|
| |
|
|
| def get_now_toronto(): |
| return datetime.datetime.now(TORONTO_TZ).strftime('%Y-%m-%d %H:%M:%S') |
|
|
| def write_system_log(user: str, action: str, status: str, request: Request): |
| try: |
| sh = spreadsheet.worksheet('SystemLogs') |
| now = get_now_toronto() |
| forwarded = request.headers.get("X-Forwarded-For") |
| ip = forwarded.split(",")[0].strip() if forwarded else request.client.host |
| sh.append_row([now, user, action, status, ip]) |
| except: pass |
|
|
| def create_session_in_sheet(username: str): |
| sh = spreadsheet.worksheet('Sessions') |
| token = str(uuid.uuid4()) |
| |
| now_utc = datetime.datetime.now(datetime.timezone.utc) |
| expire_at = (now_utc + datetime.timedelta(minutes=TOKEN_EXPIRE_MIN)).isoformat() |
| |
| rows = sh.get_all_values() |
| updated = False |
| for i, row in enumerate(rows[1:], start=2): |
| if row[1] == username: |
| sh.update(values=[[token, username, expire_at]], range_name=f"A{i}:C{i}") |
| updated = True |
| break |
| if not updated: |
| sh.append_row([token, username, expire_at]) |
| return token |
|
|
|
|
| def read_account(username: str): |
| try: |
| sh = spreadsheet.worksheet('Accounts') |
| rows = sh.get_all_values() |
| for i, row in enumerate(rows[1:], start=2): |
| if row[0].strip() == username.strip(): |
| |
| row = row + ["0"] * (10 - len(row)) |
| return { |
| "rowIndex": i, "username": row[0], "passwordHex": row[1], |
| "money": float(row[2] or 0), "logs": json.loads(row[3]) if row[3] else [], |
| "admin": str(row[4]).upper() == "TRUE", "email": row[5] if len(row) > 5 else "", |
| "cardHash": row[6] if len(row) > 6 else "", "payPinHash": row[7] if len(row) > 7 else "", |
| "dailyTransTotal": float(row[8] or 0), |
| "dailyCardTotal": float(row[9] or 0) |
| } |
| except: return None |
| return None |
|
|
| def find_account_by_card_hash(card_hash: str): |
| try: |
| sh = spreadsheet.worksheet('Accounts') |
| rows = sh.get_all_values() |
| for i, row in enumerate(rows[1:], start=2): |
| if len(row) >= 7 and row[6].strip() == card_hash.strip(): |
| return read_account(row[0]) |
| except: return None |
| return None |
|
|
| |
|
|
| @app.get("/") |
| @app.head("/") |
| async def heartbeat(): |
| return PlainTextResponse("API Active: 10s Security Window Enabled") |
|
|
| @app.get("/ping") |
| async def ping(): |
| return PlainTextResponse("ping ok") |
|
|
| |
| async def register(username: str, passwordHex: str, pin: str, request: Request): |
| if read_account(username): |
| write_system_log(username, "REGISTER", "FAIL: EXISTS", request) |
| return PlainTextResponse("username wrong") |
| generated_card_hash = str(uuid.uuid4()) |
| sh = spreadsheet.worksheet('Accounts') |
| |
| sh.append_row([username, passwordHex, 0, "[]", "FALSE", "", generated_card_hash, pin, 0, 0]) |
| write_system_log(username, "REGISTER", "SUCCESS", request) |
| return PlainTextResponse(f"register ok|{generated_card_hash}") |
|
|
| @app.get("/getinfo_by_card") |
| async def getinfo_by_card(card_hash: str, request: Request): |
| acc = find_account_by_card_hash(card_hash) |
| if not acc: return PlainTextResponse("error: invalid card") |
| return JSONResponse({"ok": True, "username": acc["username"], "money": acc["money"], "admin": acc["admin"], "logs": acc["logs"]}) |
|
|
| @app.get("/transfer_by_card") |
| async def transfer_by_card(card_hash: str, pin: str, t: str, to: str, money: str, request: Request): |
| try: |
| if abs(int(time.time()) - int(t)) > T_WINDOW: |
| return PlainTextResponse("error: expired") |
| except: return PlainTextResponse("error: invalid time") |
|
|
| acc_f = find_account_by_card_hash(card_hash) |
| if not acc_f: return PlainTextResponse("error: invalid card") |
| |
| |
| expected_sign = hashlib.sha256(f"{t}{acc_f['payPinHash']}".encode()).hexdigest() |
| if pin.strip() != expected_sign: |
| write_system_log(acc_f["username"], "CARD_PAY", "FAIL: WRONG DYNAMIC PIN", request) |
| return PlainTextResponse("error: wrong pin") |
| |
| acc_t = read_account(to) |
| if not acc_t: return PlainTextResponse("error: to user wrong") |
|
|
| if acc_f["username"] == acc_t["username"]: |
| return PlainTextResponse("error: to user wrong") |
| |
| amount = float(money) |
| if not acc_f["admin"]: |
| if amount <= 0 or acc_f["money"] < amount: |
| return PlainTextResponse("error: money wrong") |
| |
| if acc_f["dailyCardTotal"] + amount > CARD_PAY_LIMIT: |
| return PlainTextResponse("error: money wrong") |
| |
| acc_f["money"] = round(acc_f["money"] - amount, 2) |
| acc_t["money"] = round(acc_t["money"] + amount, 2) |
| |
| acc_f["dailyCardTotal"] = round(acc_f["dailyCardTotal"] + amount, 2) |
| |
| local_time = get_now_toronto() |
| acc_f["logs"].append(f"{local_time} Card Paid Đ{money} to {to}.") |
| acc_t["logs"].append(f"{local_time} Received Đ{money} from Card({acc_f['username']}).") |
|
|
| sh_acc = spreadsheet.worksheet('Accounts') |
| for acc in [acc_f, acc_t]: |
| row_data = [ |
| acc['username'], acc['passwordHex'], acc['money'], json.dumps(acc['logs']), |
| str(acc['admin']).upper(), acc['email'], acc['cardHash'], acc['payPinHash'], |
| acc['dailyTransTotal'], acc['dailyCardTotal'] |
| ] |
| |
| sh_acc.update(values=[row_data], range_name=f"A{acc['rowIndex']}:J{acc['rowIndex']}") |
| |
| write_system_log(acc_f["username"], "CARD_PAY", f"SUCCESS: {money} TO {to}", request) |
| return PlainTextResponse("pay ok") |
|
|
| @app.get("/login") |
| async def login(username: str, t: str, sign: str, request: Request): |
| acc = read_account(username) |
| if not acc: return PlainTextResponse("wrong") |
| expected = hashlib.sha256(f"{username}{t}{acc['passwordHex']}".encode()).hexdigest() |
| if sign == expected and abs(int(time.time()) - int(t)) < T_WINDOW: |
| token = create_session_in_sheet(username) |
| write_system_log(username, "LOGIN", "SUCCESS", request) |
| return PlainTextResponse(token) |
| write_system_log(username, "LOGIN", "Failed", request) |
| return PlainTextResponse("error: bad sign") |
|
|
| @app.get("/getinfo") |
| async def get_info(t: str, sign: str, request: Request): |
| sh_s = spreadsheet.worksheet('Sessions') |
| s_rows = sh_s.get_all_values() |
| |
| now = datetime.datetime.now(datetime.timezone.utc) |
| |
| for row in s_rows[1:]: |
| token, user, expire_str = row[0], row[1], row[2] |
| |
| expire_dt = datetime.datetime.fromisoformat(expire_str) |
| if expire_dt.tzinfo is None: |
| expire_dt = expire_dt.replace(tzinfo=datetime.timezone.utc) |
| |
| if now > expire_dt: continue |
| |
| if hashlib.sha256(f"{token}{t}".encode()).hexdigest() == sign: |
| acc = read_account(user) |
| return JSONResponse({"ok": True, "money": acc["money"], "admin": acc["admin"], "logs": acc["logs"]}) |
| return PlainTextResponse("error: invalid session") |
|
|
|
|
| @app.get("/transfer") |
| async def transfer(t: str, sign: str, to: str, money: str, request: Request): |
| sh_s = spreadsheet.worksheet('Sessions') |
| s_rows = sh_s.get_all_values() |
| |
| now = datetime.datetime.now(datetime.timezone.utc) |
| |
| for row in s_rows[1:]: |
| token, user, expire_str = row[0], row[1], row[2] |
| |
| |
| expire_dt = datetime.datetime.fromisoformat(expire_str) |
| if expire_dt.tzinfo is None: |
| expire_dt = expire_dt.replace(tzinfo=datetime.timezone.utc) |
| |
| if now > expire_dt: continue |
| |
| acc_f = read_account(user) |
| if not acc_f: continue |
| |
| if hashlib.sha256(f"{token}{t}{acc_f['passwordHex']}".encode()).hexdigest() == sign: |
| acc_t = read_account(to) |
| if not acc_t or (acc_t == acc_f and not acc_f["admin"]): return PlainTextResponse("error: to user wrong") |
| |
| amount = float(money) |
| if not acc_f["admin"]: |
| if amount <= 0 or acc_f["money"] < amount: |
| return PlainTextResponse("error: money wrong") |
| |
| if acc_f["dailyTransTotal"] + amount > TRANSFER_LIMIT: |
| return PlainTextResponse("error: money wrong") |
| |
| |
| acc_f["money"] = round(acc_f["money"] - amount, 2) |
| acc_t["money"] = round(acc_t["money"] + amount, 2) |
| |
| acc_f["dailyTransTotal"] = round(acc_f["dailyTransTotal"] + amount, 2) |
| |
| local_time = get_now_toronto() |
| acc_f["logs"].append(f"{local_time} Sent Đ{money} to {to}.") |
| acc_t["logs"].append(f"{local_time} Received Đ{money} from {user}.") |
| |
| sh_acc = spreadsheet.worksheet('Accounts') |
| for acc in [acc_f, acc_t]: |
| row_data = [ |
| acc['username'], acc['passwordHex'], acc['money'], json.dumps(acc['logs']), |
| str(acc['admin']).upper(), acc['email'], acc['cardHash'], acc['payPinHash'], |
| acc['dailyTransTotal'], acc['dailyCardTotal'] |
| ] |
| |
| sh_acc.update(values=[row_data], range_name=f"A{acc['rowIndex']}:J{acc['rowIndex']}") |
| |
| write_system_log(acc_f["username"], "Transfer", f"SUCCESS: {money} TO {to}", request) |
| return PlainTextResponse("transfer ok") |
| |
| return PlainTextResponse("error: security check failed") |
|
|
|
|
| @app.get("/logintoken") |
| async def login_token(t: str, sign: str, request: Request): |
| sh_s = spreadsheet.worksheet('Sessions') |
| s_rows = sh_s.get_all_values() |
| |
| |
| now = datetime.datetime.now(datetime.timezone.utc) |
| |
| |
| try: |
| if abs(int(time.time()) - int(t)) > T_WINDOW: |
| return PlainTextResponse("error: expired") |
| except: |
| return PlainTextResponse("error: invalid time") |
|
|
| for row in s_rows[1:]: |
| |
| row = row + [""] * (3 - len(row)) |
| token, user, expire_str = row[0], row[1], row[2] |
| |
| |
| try: |
| expire_dt = datetime.datetime.fromisoformat(expire_str) |
| if expire_dt.tzinfo is None: |
| expire_dt = expire_dt.replace(tzinfo=datetime.timezone.utc) |
| except: |
| continue |
| |
| |
| if now > expire_dt: |
| continue |
| |
| |
| if hashlib.sha256(f"{token}{t}".encode()).hexdigest() == sign: |
| return PlainTextResponse(user) |
| |
| return PlainTextResponse("error: valid") |
|
|
|
|
| @app.get("/logout") |
| async def logout(t: str, sign: str, request: Request): |
| sh_s = spreadsheet.worksheet('Sessions') |
| s_rows = sh_s.get_all_values() |
| for i, row in enumerate(s_rows[1:], start=2): |
| token, user = row[0], row[1] |
| if hashlib.sha256(f"{token}{t}".encode()).hexdigest() == sign: |
| sh_s.delete_rows(i) |
| return PlainTextResponse("logout ok") |
| return PlainTextResponse("error: invalid session") |
|
|
|
|
| |
|
|
| def apply_weekly_interest(): |
| try: |
| print(f"[{get_now_toronto()}] >>> STARTING INTEREST JOB (Positive & Negative) <<<") |
| sh = spreadsheet.worksheet('Accounts') |
| all_data = sh.get_all_values() |
| if len(all_data) <= 1: return |
| |
| rows = all_data[1:] |
| updated_data = [] |
| interest_rate = 1.0375 |
| now_time = get_now_toronto() |
|
|
| for row in rows: |
| |
| row = row + ["0"] * (10 - len(row)) |
| try: |
| current_money = float(row[2] or 0) |
| |
| if current_money != 0: |
| new_money = round(current_money * interest_rate, 2) |
| interest_diff = round(new_money - current_money, 2) |
| |
| |
| try: |
| logs = json.loads(row[3]) if (row[3] and row[3].startswith('[')) else [] |
| except: |
| logs = [] |
| |
| |
| prefix = "+" if interest_diff > 0 else "" |
| logs.append(f"{now_time} Weekly Interest: {prefix}Đ{interest_diff}") |
| |
| row[2] = str(new_money) |
| row[3] = json.dumps(logs) |
| |
| updated_data.append(row) |
| except Exception as e: |
| print(f"Row skip error: {e}") |
| updated_data.append(row) |
|
|
| if updated_data: |
| |
| sh.update(values=updated_data, range_name=f'A2:J{len(updated_data) + 1}') |
| |
| print(f"[{get_now_toronto()}] >>> INTEREST JOB SUCCESSFUL <<<") |
| except Exception as e: |
| print(f"CRITICAL ERROR in interest job: {e}") |
|
|
|
|
| def reset_daily_limits(): |
| """每天 00:00 重置所有用户的 I 和 J 列""" |
| try: |
| print(f"[{get_now_toronto()}] >>> STARTING DAILY LIMIT RESET <<<") |
| sh = spreadsheet.worksheet('Accounts') |
| all_data = sh.get_all_values() |
| if len(all_data) <= 1: return |
| |
| row_count = len(all_data) |
| |
| zero_data = [["0", "0"]] * (row_count - 1) |
| |
| |
| sh.update(values=zero_data, range_name=f"I2:J{row_count}") |
| print(f"[{get_now_toronto()}] >>> DAILY LIMIT RESET SUCCESSFUL <<<") |
| except Exception as e: |
| print(f"CRITICAL ERROR in reset limits job: {e}") |
|
|
|
|
| |
|
|
| scheduler = BackgroundScheduler(timezone=TORONTO_TZ) |
|
|
| |
| trigger = CronTrigger(day_of_week='fri', hour=17, minute=30) |
| scheduler.add_job(apply_weekly_interest, trigger) |
|
|
| |
| reset_trigger = CronTrigger(hour=4, minute=0) |
| scheduler.add_job(reset_daily_limits, reset_trigger) |
|
|
| scheduler.start() |
|
|
| print("Scheduler started: Weekly interest will be applied every Friday at 17:30 EST.") |
| print("Scheduler started: Daily limits will be reset every day at 00:00 EST.") |
|
|
| if __name__ == "__main__": |
| uvicorn.run(app, host="0.0.0.0", port=7860) |
|
|