1919along with this program. If not, see <https://www.gnu.org/licenses/>.
2020
2121'''
22+ import os
23+ import json
24+ from datetime import timedelta
25+ from urllib .parse import urlparse
26+
2227from flask import Flask , request , jsonify , redirect , url_for , send_from_directory , render_template
2328from flask_socketio import SocketIO , join_room , leave_room , send
2429from flask_login import LoginManager , UserMixin , login_user , logout_user , login_required , current_user
2530from werkzeug .utils import secure_filename
2631from flask_argon2 import Argon2
27- import os , json
2832from argon2 import PasswordHasher
2933import logging
3034from logging .handlers import RotatingFileHandler
31- from datetime import timedelta
3235from flask_talisman import Talisman
3336from flask_limiter import Limiter
3437from flask_limiter .util import get_remote_address
3538import bleach
3639from flask_wtf import CSRFProtect
3740
38-
39- # --- CONFIGURACIÓN INICIAL ---
40-
41- ALLOWED_TAGS = [] # sin HTML permitido
41+ # ---------------------------
42+ # Utiles para sanitizar / orígenes
43+ # ---------------------------
44+ def normalize_origin (o : str ):
45+ if not o :
46+ return None
47+ o = o .strip ()
48+ # quitar path si lo puso (p.ej. "https://mi.app/chat")
49+ if '://' not in o :
50+ # permitir que pasen dominios sin esquema
51+ o = 'https://' + o
52+ parsed = urlparse (o )
53+ netloc = parsed .netloc or parsed .path
54+ scheme = parsed .scheme or 'https'
55+ return f"{ scheme } ://{ netloc } "
56+
57+ # ---------------------------
58+ # CONFIG INICIAL
59+ # ---------------------------
60+ ALLOWED_TAGS = []
4261ALLOWED_ATTRS = {}
4362ALLOWED_PROTOCOLS = ['http' , 'https' ]
4463
4564app = Flask (__name__ )
46- #CSRFProtect(app)
47-
48- csrf = CSRFProtect ()
49- csrf .init_app (app )
50-
51- # Excluir sockets del CSRF
52- csrf .exempt (socketio_app )
53-
54- #socketio = SocketIO(app, cors_allowed_origins="*") # SocketIO envuelve a Flask
55- app .secret_key = os .environ .get ("SECRET_KEY" )
65+ app .secret_key = os .environ .get ("SECRET_KEY" , "dev_secret" )
5666
5767app .config .update (
58- SESSION_COOKIE_SECURE = True , # solo por HTTPS
59- SESSION_COOKIE_HTTPONLY = True , # no accesible por JS
60- SESSION_COOKIE_SAMESITE = None , # o "Strict" si no integras con otros dominios
68+ SESSION_COOKIE_SECURE = True ,
69+ SESSION_COOKIE_HTTPONLY = True ,
70+ SESSION_COOKIE_SAMESITE = None , # importante para que cookies se envíen con websocket handshakes cross-site
6171 REMEMBER_COOKIE_HTTPONLY = True ,
6272 PERMANENT_SESSION_LIFETIME = timedelta (hours = 8 ),
63- MAX_CONTENT_LENGTH = 25 * 1024 * 1024 , # 25MB uploads
73+ MAX_CONTENT_LENGTH = 25 * 1024 * 1024 ,
6474)
6575
66- # Limitar orígenes del socket (quitar el "*")
67- ALLOWED_ORIGINS = os .environ .get ("ALLOWED_ORIGINS" , "https://pichat-k0bi.onrender.com/chat" ).split ("," )
68- socketio = SocketIO (app , cors_allowed_origins = ALLOWED_ORIGINS ) # evita CSRF en websockets
76+ # ---------------------------
77+ # ALLOWED ORIGINS: parsear desde env para no hardcodear paths
78+ # ---------------------------
79+ _default_origins = "https://pichat-k0bi.onrender.com,https://*.railway.app,http://localhost:5000"
80+ origins_env = os .environ .get ("ALLOWED_ORIGINS" , _default_origins )
81+ ALLOWED_ORIGINS = []
82+ for part in origins_env .split ("," ):
83+ n = normalize_origin (part )
84+ if n :
85+ ALLOWED_ORIGINS .append (n )
86+
87+ # socketio: allow the parsed origins (no paths)
88+ socketio = SocketIO (app , cors_allowed_origins = ALLOWED_ORIGINS , manage_session = False )
89+
90+ # ---------------------------
91+ # CSRF: init y EXEMPT para socketio (handshake)
92+ # ---------------------------
93+ csrf = CSRFProtect ()
94+ csrf .init_app (app )
95+
96+ # intentamos eximir SocketIO del CSRF (lo ideal es eximir la ruta /socket.io)
97+ # Si no funcionara por alguna razón, el try/except evita romper el boot.
98+ try :
99+ csrf .exempt (socketio ) # normalmente esto evita que flask-wtf valide el handshake
100+ except Exception :
101+ try :
102+ csrf .exempt ('socketio' )
103+ except Exception :
104+ pass
69105
106+ # ---------------------------
107+ # TALISMAN / CSP: construir connect-src dinámicamente a partir de ALLOWED_ORIGINS
108+ # ---------------------------
109+ wss_origins = []
110+ for o in ALLOWED_ORIGINS :
111+ if o .startswith ("https://" ):
112+ wss_origins .append (o .replace ("https://" , "wss://" ))
113+ elif o .startswith ("http://" ):
114+ wss_origins .append (o .replace ("http://" , "ws://" ))
115+ else :
116+ wss_origins .append (o )
70117
71- argon2 = Argon2 (app )
72- ph = PasswordHasher ()
118+ connect_src_value = "'self'"
119+ if wss_origins :
120+ connect_src_value += " " + " " .join (wss_origins )
73121
74- # Content Security Policy estricta (ajústala a tus assets/CDNs reales)
75122csp = {
76123 'default-src' : "'self'" ,
77124 'img-src' : "'self' data:" ,
78- 'style-src' : "'self' 'unsafe-inline'" , # mejor sin 'unsafe-inline' si usas sólo archivos .css
79- 'script-src' : "'self'" , # sin inline scripts
80- 'connect-src' : "'self' wss://pichat-k0bi.onrender.com" ,
125+ 'style-src' : "'self' 'unsafe-inline'" ,
126+ 'script-src' : "'self'" ,
127+ 'connect-src' : connect_src_value ,
81128}
82129
83- # Fuerza HTTPS + headers seguros
84130Talisman (
85131 app ,
86132 content_security_policy = csp ,
93139 content_security_policy_nonce_in = ['script-src' ],
94140)
95141
96- # Rate limiting global y por endpoint
142+ # ---------------------------
143+ # RATE LIMITER
144+ # ---------------------------
97145limiter = Limiter (get_remote_address , app = app , default_limits = ["200 per minute" ])
98146
99- # --- CONFIGURACIÓN DE CARPETAS ---
147+ # ---------------------------
148+ # ARCHIVOS / UPLOAD
149+ # ---------------------------
100150UPLOAD_FOLDER = './cuarentena'
101151os .makedirs (UPLOAD_FOLDER , exist_ok = True )
102152app .config ['UPLOAD_FOLDER' ] = UPLOAD_FOLDER
153+ ALLOWED_EXTENSIONS = {'.txt' , '.pdf' , '.png' , '.jpg' , '.jpeg' , '.gif' }
154+
155+ def allowed_file (filename : str ) -> bool :
156+ _ , ext = os .path .splitext (filename .lower ())
157+ return ext in ALLOWED_EXTENSIONS
158+
159+ # ---------------------------
160+ # AUTH / USERS (demo)
161+ # ---------------------------
162+ argon2 = Argon2 (app )
163+ ph = PasswordHasher ()
103164
104- # --- USUARIOS BASE ---
105165users = {
106166 os .getenv ("ADMIN_USER" , "admin" ): {
107167 "password" : ph .hash (os .getenv ("ADMIN_PASS" , "admin123" )),
117177 }
118178}
119179
120- # --- DEMO USERS DESDE ENV ---
180+ # demo users desde env (JSON)
121181try :
122182 demo_users_env = os .getenv ("DEMO_USERS" , "[]" )
123183 demo_users = json .loads (demo_users_env )
129189except Exception as e :
130190 print (f"[WARN] No se pudieron cargar demo_users: { e } " )
131191
132- # --- LOGIN MANAGER ---
133192login_manager = LoginManager (app )
134193login_manager .login_view = 'login'
135194
@@ -144,15 +203,17 @@ def load_user(user_id):
144203 return Usuario (user_id , users [user_id ]['role' ])
145204 return None
146205
147- # --- RUTAS ---
206+ # ---------------------------
207+ # RUTAS / ENDPOINTS HTTP
208+ # ---------------------------
148209@app .route ('/' )
149210def home ():
150211 if current_user .is_authenticated :
151212 return redirect (url_for ('inicio' ))
152213 return redirect (url_for ('login' ))
153214
154215@app .route ('/login' , methods = ['GET' , 'POST' ])
155- @limiter .limit ("5/minute; 20/hour" ) # fuerza bruta
216+ @limiter .limit ("5/minute; 20/hour" )
156217def login ():
157218 if current_user .is_authenticated :
158219 return redirect (url_for ('inicio' ))
@@ -169,7 +230,6 @@ def login():
169230 except Exception :
170231 pass
171232
172- # mensaje genérico: no reveles si el usuario existe
173233 return render_template ("login.html" , error = "Credenciales inválidas." )
174234 return render_template ("login.html" )
175235
@@ -184,13 +244,6 @@ def logout():
184244def inicio ():
185245 return render_template ('inicio.html' , current_user = current_user )
186246
187- # --- FUNCIONALIDAD DE ARCHIVOS ---
188- ALLOWED_EXTENSIONS = {'.txt' , '.pdf' , '.png' , '.jpg' , '.jpeg' , '.gif' }
189-
190- def allowed_file (filename : str ) -> bool :
191- _ , ext = os .path .splitext (filename .lower ())
192- return ext in ALLOWED_EXTENSIONS
193-
194247@app .route ('/subir' , methods = ['GET' , 'POST' ])
195248@login_required
196249@limiter .limit ("20/hour" )
@@ -211,7 +264,6 @@ def subir():
211264 return redirect (url_for ('listar' ))
212265 return render_template ("subir.html" )
213266
214-
215267@app .route ('/listar' )
216268@login_required
217269def listar ():
@@ -223,7 +275,7 @@ def listar():
223275def descargar (nombre ):
224276 return send_from_directory (UPLOAD_FOLDER , nombre , as_attachment = True )
225277
226- @app .route ('/eliminar/<nombre>' , methods = ['POST' ]) # evita GET peligrosos
278+ @app .route ('/eliminar/<nombre>' , methods = ['POST' ])
227279@login_required
228280def eliminar (nombre ):
229281 if current_user .rol != 'administrator' :
@@ -238,19 +290,25 @@ def eliminar(nombre):
238290 pass
239291 return redirect (url_for ('listar' ))
240292
241-
242293@app .route ('/chat' )
243294@login_required
244295def chat ():
245296 return render_template ('chat.html' , current_user = current_user )
246297
247- # --- SOCKET.IO ---
298+ # ---------------------------
299+ # SOCKET.IO EVENTS
300+ # ---------------------------
248301chat_rooms = {}
249302
250303@socketio .on ('join' )
251304@limiter .limit ("1/minute" )
252305def on_join (data ):
253- username = current_user .id
306+ # current_user debería venir vía cookie de sesión si SameSite y CORS correctos
307+ username = getattr (current_user , "id" , None )
308+ if not username :
309+ send ({'msg' : 'Usuario no autenticado.' , 'type' : 'error' })
310+ return
311+
254312 room_code = (data .get ('room' ) or "" )[:64 ]
255313 password = (data .get ('password' ) or "" )[:128 ]
256314 is_group = bool (data .get ('is_group' , False ))
@@ -269,28 +327,28 @@ def on_join(data):
269327 join_room (room_code )
270328 send ({'msg' : f"👋 { username } se ha unido." , 'user' : 'Servidor' , 'is_group' : is_group }, to = room_code )
271329
272-
273330@socketio .on ('leave' )
274331def on_leave (data ):
275- username = current_user .id
276- room_code = data ['room' ]
277- leave_room (room_code )
278- send ({'msg' : f"🚪 { username } ha salido." , 'user' : 'Servidor' }, to = room_code )
332+ username = getattr (current_user , "id" , None ) or "Anon"
333+ room_code = data .get ('room' )
334+ if room_code :
335+ leave_room (room_code )
336+ send ({'msg' : f"🚪 { username } ha salido." , 'user' : 'Servidor' }, to = room_code )
279337
280338@socketio .on ('message' )
281- @limiter .limit ("1/minute" ) # evita spam de mensajes
339+ @limiter .limit ("1/minute" )
282340def handle_message (data ):
283- username = current_user . id
341+ username = getattr ( current_user , "id" , None ) or "Anon"
284342 room = (data .get ('room' ) or "" )[:64 ]
285343 raw_msg = data .get ('msg' )
286344 msg = clean_text (raw_msg )
287345 is_group = bool (data .get ('is_group' , False ))
288346
289- # opcional: valida que el usuario esté realmente en esa room
290-
291347 send ({'msg' : msg , 'user' : username , 'is_group' : is_group }, to = room )
292348
293-
349+ # ---------------------------
350+ # LOGGING & ERRORES
351+ # ---------------------------
294352if not app .debug :
295353 handler = RotatingFileHandler ('app.log' , maxBytes = 5_000_000 , backupCount = 3 )
296354 handler .setLevel (logging .INFO )
@@ -306,24 +364,25 @@ def forbidden(e): return "Prohibido", 403
306364def not_found (e ): return "No encontrado" , 404
307365
308366@app .errorhandler (500 )
309- def server_error (e ):
367+ def server_error (e ):
310368 app .logger .exception ("Error 500" )
311369 return "Error del servidor" , 500
370+
312371@login_manager .unauthorized_handler
313372def unauthorized ():
314373 return redirect (url_for ('login' ))
315374
316375def clean_text (s : str ) -> str :
317- s = (s or "" )[:2000 ] # límite de tamaño de mensaje
376+ s = (s or "" )[:2000 ]
318377 return bleach .clean (s , tags = ALLOWED_TAGS , attributes = ALLOWED_ATTRS , protocols = ALLOWED_PROTOCOLS , strip = True )
319378
320-
321-
322- # --- INICIO ---
379+ # ---------------------------
380+ # ENTRYPOINTS
381+ # ------------------------ ---
323382if __name__ == '__main__' :
324383 port = int (os .environ .get ("PORT" , 10000 ))
325384 socketio .run (app , host = '0.0.0.0' , port = port )
326385
327- # 👉 Para gunicorn/render
386+ # para Gunicorn / Render / Railway
328387application = app
329388socketio_app = socketio
0 commit comments