1919along with this program. If not, see <https://www.gnu.org/licenses/>.
2020
2121'''
22- # 👇 esto siempre va primero
23- import eventlet
24- eventlet .monkey_patch ()
25-
26- import os
27- import json
28- from datetime import timedelta
29- from urllib .parse import urlparse
30- from werkzeug .middleware .proxy_fix import ProxyFix
31- from werkzeug .utils import secure_filename
32- import logging
33- from logging .handlers import RotatingFileHandler
34- import bleach
35-
36- from flask import Flask , request , redirect , url_for , send_from_directory , render_template
37- from flask_socketio import SocketIO , join_room , leave_room , send
38- from flask_login import LoginManager , UserMixin , login_user , logout_user , login_required , current_user
39- from flask_argon2 import Argon2
40- from argon2 import PasswordHasher
41- from flask_talisman import Talisman
42- from flask_limiter import Limiter
43- from flask_limiter .util import get_remote_address
44- from flask_wtf import CSRFProtect
45-
46- # ---------------------------
47- # Utiles para sanitizar / orígenes
48- # ---------------------------
49- def normalize_origin (o : str ):
50- if not o :
51- return None
52- o = o .strip ()
53- if '://' not in o :
54- o = 'https://' + o
55- parsed = urlparse (o )
56- netloc = parsed .netloc or parsed .path
57- scheme = parsed .scheme or 'https'
58- return f"{ scheme } ://{ netloc } "
59-
60- # ---------------------------
61- # CONFIG INICIAL
62- # ---------------------------
63- app = Flask (__name__ )
64-
65- # Configuración de ProxyFix, crucial para render y otros servicios de proxy
66- app .wsgi_app = ProxyFix (app .wsgi_app , x_proto = 1 , x_host = 1 )
67-
68- app .secret_key = os .environ .get ("SECRET_KEY" , "dev_secret" )
69-
70- app .config .update (
71- SESSION_COOKIE_SECURE = True ,
72- SESSION_COOKIE_HTTPONLY = True ,
73- # El valor 'None' es necesario para que las cookies de sesión se envíen
74- # con los handshakes de websocket, si el cliente y el servidor están
75- # en dominios diferentes. 'Lax' puede causar problemas en este escenario.
76- SESSION_COOKIE_SAMESITE = None ,
77- REMEMBER_COOKIE_HTTPONLY = True ,
78- PERMANENT_SESSION_LIFETIME = timedelta (hours = 8 ),
79- MAX_CONTENT_LENGTH = 25 * 1024 * 1024 ,
80- )
81-
82- # ---------------------------
83- # ARCHIVOS / UPLOAD
84- # ---------------------------
85- UPLOAD_FOLDER = './cuarentena'
86- os .makedirs (UPLOAD_FOLDER , exist_ok = True )
87- app .config ['UPLOAD_FOLDER' ] = UPLOAD_FOLDER
88- ALLOWED_EXTENSIONS = {'.txt' , '.pdf' , '.png' , '.jpg' , '.jpeg' , '.gif' }
89-
90- def allowed_file (filename : str ) -> bool :
91- _ , ext = os .path .splitext (filename .lower ())
92- return ext in ALLOWED_EXTENSIONS
93-
94- # ---------------------------
95- # AUTH / USERS (demo)
96- # ---------------------------
97- argon2 = Argon2 (app )
98- ph = PasswordHasher ()
99-
100- users = {
101- os .getenv ("ADMIN_USER" , "admin" ): {
102- "password" : ph .hash (os .getenv ("ADMIN_PASS" , "admin123" )),
103- "role" : "administrator"
104- },
105- os .getenv ("CLIENT_USER" , "cliente" ): {
106- "password" : ph .hash (os .getenv ("CLIENT_PASS" , "cliente123" )),
107- "role" : "cliente"
108- },
109- os .getenv ("USR_USER" , "usuario" ): {
110- "password" : ph .hash (os .getenv ("USR_PASS" , "usuario123" )),
111- "role" : "usuario"
112- }
113- }
114-
115- try :
116- demo_users_env = os .getenv ("DEMO_USERS" , "[]" )
117- demo_users = json .loads (demo_users_env )
118- for u in demo_users :
119- users [u ["username" ]] = {
120- "password" : ph .hash (u ["password" ]),
121- "role" : u .get ("role" , "usuario" )
122- }
123- except Exception as e :
124- print (f"[WARN] No se pudieron cargar demo_users: { e } " )
125-
126- login_manager = LoginManager (app )
127- login_manager .login_view = 'login'
128-
129- class Usuario (UserMixin ):
130- def __init__ (self , username , role ):
131- self .id = username
132- self .rol = role
133-
134- @login_manager .user_loader
135- def load_user (user_id ):
136- if user_id in users :
137- return Usuario (user_id , users [user_id ]['role' ])
138- return None
139-
140- # ---------------------------
141- # CSRF, TALISMAN, RATE LIMITER
142- # ---------------------------
143- csrf = CSRFProtect (app )
144- limiter = Limiter (
145- get_remote_address ,
146- app = app ,
147- default_limits = ["200 per day" , "50 per hour" ]
148- )
149-
150- _default_origins = "https://pichat-k0bi.onrender.com,http://localhost:10000"
151- origins_env = os .environ .get ("ALLOWED_ORIGINS" , _default_origins )
152- ALLOWED_ORIGINS = []
153- for part in origins_env .split ("," ):
154- n = normalize_origin (part )
155- if n :
156- ALLOWED_ORIGINS .append (n )
157-
158- # Volvemos a 'manage_session=False' para evitar conflictos con Flask-Login.
159- # La combinación de Flask-Login y Socket.IO es compleja, y esta configuración
160- # es a menudo necesaria para que la sesión HTTP esté disponible en los
161- # eventos de Socket.IO.
162- socketio = SocketIO (app , cors_allowed_origins = ALLOWED_ORIGINS , manage_session = False )
163-
164- # Intentamos eximir SocketIO del CSRF (lo ideal es eximir la ruta /socket.io)
165- try :
166- csrf .exempt (socketio )
167- except Exception :
168- pass
169-
170- wss_origins = []
171- for o in ALLOWED_ORIGINS :
172- if o .startswith ("https://" ):
173- wss_origins .append (o .replace ("https://" , "wss://" ))
174- elif o .startswith ("http://" ):
175- wss_origins .append (o .replace ("http://" , "ws://" ))
176- else :
177- wss_origins .append (o )
178-
179- connect_src_value = "'self'"
180- if wss_origins :
181- connect_src_value += " " + " " .join (wss_origins )
182-
183- csp = {
184- 'default-src' : "'self'" ,
185- 'img-src' : "'self' data:" ,
186- 'style-src' : "'self' 'unsafe-inline'" ,
187- 'script-src' : "'self'" ,
188- 'connect-src' : connect_src_value ,
189- }
190-
191- Talisman (
192- app ,
193- content_security_policy = csp ,
194- force_https = True ,
195- strict_transport_security = True ,
196- strict_transport_security_max_age = 31536000 ,
197- frame_options = "DENY" ,
198- referrer_policy = "no-referrer" ,
199- session_cookie_secure = True ,
200- content_security_policy_nonce_in = ['script-src' ],
201- )
202-
203- # ---------------------------
204- # RUTAS / ENDPOINTS HTTP
205- # ---------------------------
206- @app .route ('/' )
207- def home ():
208- if current_user .is_authenticated :
209- return redirect (url_for ('inicio' ))
210- return redirect (url_for ('login' ))
211-
212- @app .route ('/login' , methods = ['GET' , 'POST' ])
213- @limiter .limit ("5/minute; 20/hour" )
214- def login ():
215- if current_user .is_authenticated :
216- return redirect (url_for ('inicio' ))
217- if request .method == 'POST' :
218- user = (request .form .get ('usuario' ) or "" )[:64 ]
219- password = (request .form .get ('clave' ) or "" )[:256 ]
220- if user in users :
221- try :
222- ph .verify (users [user ]['password' ], password )
223- login_user (Usuario (user , users [user ]['role' ]))
224- return redirect (url_for ('inicio' ))
225- except Exception :
226- pass
227- return render_template ("login.html" , error = "Credenciales inválidas." )
228- return render_template ("login.html" )
229-
230- @app .route ('/logout' )
231- @login_required
232- def logout ():
233- logout_user ()
234- return redirect (url_for ('login' ))
235-
236- @app .route ('/inicio' )
237- @login_required
238- def inicio ():
239- return render_template ('inicio.html' , current_user = current_user )
240-
241- @app .route ('/subir' , methods = ['GET' , 'POST' ])
242- @login_required
243- @limiter .limit ("20/hour" )
244- def subir ():
245- if current_user .rol == 'usuario' :
246- return 'No tienes permiso para subir archivos' , 403
247- if request .method == 'POST' :
248- f = request .files .get ('archivo' )
249- if not f or f .filename == '' :
250- return 'No se seleccionó archivo' , 400
251- filename = secure_filename (f .filename )
252- if not allowed_file (filename ):
253- return 'Tipo de archivo no permitido' , 400
254- f .save (os .path .join (app .config ['UPLOAD_FOLDER' ], filename ))
255- return redirect (url_for ('listar' ))
256- return render_template ("subir.html" )
257-
258- @app .route ('/listar' )
259- @login_required
260- def listar ():
261- archivos = os .listdir (UPLOAD_FOLDER )
262- return render_template ("listar.html" , archivos = archivos )
263-
264- @app .route ('/descargar/<nombre>' )
265- @login_required
266- def descargar (nombre ):
267- return send_from_directory (UPLOAD_FOLDER , nombre , as_attachment = True )
268-
269- @app .route ('/eliminar/<nombre>' , methods = ['POST' ])
270- @login_required
271- def eliminar (nombre ):
272- if current_user .rol != 'administrator' :
273- return 'No tienes permiso para eliminar archivos' , 403
274- safe = secure_filename (nombre )
275- target = os .path .join (UPLOAD_FOLDER , safe )
276- if os .path .commonpath ([os .path .abspath (target ), os .path .abspath (UPLOAD_FOLDER )]) != os .path .abspath (UPLOAD_FOLDER ):
277- return "Ruta inválida" , 400
278- try :
279- os .remove (target )
280- except FileNotFoundError :
281- pass
282- return redirect (url_for ('listar' ))
283-
284- @app .route ('/chat' )
285- @login_required
286- def chat ():
287- return render_template ('chat.html' , current_user = current_user )
288-
289- # ---------------------------
290- # SOCKET.IO EVENTS
291- # ---------------------------
292- chat_rooms = {}
293-
294- @socketio .on ('join' )
295- @limiter .limit ("1/minute" )
296- def on_join (data ):
297- # Validar que el usuario esté autenticado. 'current_user' proviene de la cookie
298- # de sesión, que debe enviarse con el handshake de websocket.
299- username = getattr (current_user , "id" , None )
300- if not username :
301- send ({'msg' : 'Usuario no autenticado.' , 'type' : 'error' })
302- return
303-
304- # Validar que los datos existan.
305- room_code = (data .get ('room' ) or "" )[:64 ]
306- password = (data .get ('password' ) or "" )[:128 ]
307- is_group = bool (data .get ('is_group' , False ))
308-
309- if not room_code or not password :
310- send ({'msg' : 'Parámetros inválidos.' , 'type' : 'error' })
311- return
312-
313- if room_code not in chat_rooms :
314- chat_rooms [room_code ] = argon2 .generate_password_hash (password )
315- else :
316- if not argon2 .check_password_hash (chat_rooms [room_code ], password ):
317- send ({'msg' : 'Acceso denegado.' , 'type' : 'error' })
318- return
319-
320- join_room (room_code )
321- send ({'msg' : f"👋 { username } se ha unido." , 'user' : 'Servidor' , 'is_group' : is_group }, to = room_code )
322-
323- @socketio .on ('leave' )
324- def on_leave (data ):
325- username = getattr (current_user , "id" , None ) or "Anon"
326- room_code = data .get ('room' )
327- if room_code :
328- leave_room (room_code )
329- send ({'msg' : f"🚪 { username } ha salido." , 'user' : 'Servidor' }, to = room_code )
330-
331- @socketio .on ('message' )
332- @limiter .limit ("1/minute" )
333- def handle_message (data ):
334- username = getattr (current_user , "id" , None ) or "Anon"
335- room = (data .get ('room' ) or "" )[:64 ]
336- raw_msg = data .get ('msg' )
337- msg = clean_text (raw_msg )
338- is_group = bool (data .get ('is_group' , False ))
339-
340- send ({'msg' : msg , 'user' : username , 'is_group' : is_group }, to = room )
341-
342- # ---------------------------
343- # LOGGING & ERRORES
344- # ---------------------------
345- if not app .debug :
346- handler = RotatingFileHandler ('app.log' , maxBytes = 5_000_000 , backupCount = 3 )
347- handler .setLevel (logging .INFO )
348- app .logger .addHandler (handler )
349-
350- @app .errorhandler (400 )
351- def bad_request (e ): return "Solicitud inválida" , 400
352-
353- @app .errorhandler (403 )
354- def forbidden (e ): return "Prohibido" , 403
355-
356- @app .errorhandler (404 )
357- def not_found (e ): return "No encontrado" , 404
358-
359- @app .errorhandler (500 )
360- def server_error (e ):
361- app .logger .exception ("Error 500" )
362- return "Error del servidor" , 500
363-
364- @login_manager .unauthorized_handler
365- def unauthorized ():
366- return redirect (url_for ('login' ))
367-
368- def clean_text (s : str ) -> str :
369- s = (s or "" )[:2000 ]
370- ALLOWED_TAGS = []
371- ALLOWED_ATTRS = {}
372- ALLOWED_PROTOCOLS = ['http' , 'https' ]
373- return bleach .clean (s , tags = ALLOWED_TAGS , attributes = ALLOWED_ATTRS , protocols = ALLOWED_PROTOCOLS , strip = True )
374-
375- # ---------------------------
376- # ENTRYPOINTS
377- # ---------------------------
378- if __name__ == '__main__' :
379- port = int (os .environ .get ("PORT" , 10000 ))
380- socketio .run (app , host = '0.0.0.0' , port = port )
381-
382- # para Gunicorn / Render / Railway
383- application = app
384- socketio_app = socketio
22+ from flask import Flask , request , jsonify , redirect , url_for , send_from_directory , render_template from flask_socketio import SocketIO , join_room , leave_room , send from flask_login import LoginManager , UserMixin , login_user , logout_user , login_required , current_user from werkzeug .utils import secure_filename from flask_argon2 import Argon2 import os , json from argon2 import PasswordHasher
23+ # --- CONFIGURACIÓN INICIAL ---
24+ app = Flask (__name__ ) socketio = SocketIO (app , cors_allowed_origins = "*" )
25+ # SocketIO envuelve a Flask
26+ app .secret_key = os .environ .get ("SECRET_KEY" , "a-very-secret-key-for-dev" ) argon2 = Argon2 (app ) ph = PasswordHasher ()
27+ # --- CONFIGURACIÓN DE CARPETAS ---
28+ UPLOAD_FOLDER = './cuarentena' os .makedirs (UPLOAD_FOLDER , exist_ok = True ) app .config ['UPLOAD_FOLDER' ] = UPLOAD_FOLDER # --- USUARIOS BASE ---
29+ users = { os .getenv ("ADMIN_USER" , "admin" ): { "password" : ph .hash (os .getenv ("ADMIN_PASS" , "admin123" )), "role" : "administrator" }, os .getenv ("CLIENT_USER" , "cliente" ): { "password" : ph .hash (os .getenv ("CLIENT_PASS" , "cliente123" )), "role" : "cliente" }, os .getenv ("USR_USER" , "usuario" ): { "password" : ph .hash (os .getenv ("USR_PASS" , "usuario123" )), "role" : "usuario" } } # --- DEMO USERS DESDE ENV ---
30+ try : demo_users_env = os .getenv ("DEMO_USERS" , "[]" ) demo_users = json .loads (demo_users_env ) for u in demo_users : users [u ["username" ]] = { "password" : ph .hash (u ["password" ]), "role" : u .get ("role" , "usuario" ) } except Exception as e : print (f"[WARN] No se pudieron cargar demo_users: { e } " )
31+ # --- LOGIN MANAGER ---
32+ login_manager = LoginManager (app ) login_manager .login_view = 'login' class Usuario (UserMixin ): def __init__ (self , username , role ): self .id = username self .rol = role @login_manager .user_loader def load_user (user_id ): if user_id in users : return Usuario (user_id , users [user_id ]['role' ]) return None
33+ # --- RUTAS ---
34+ @app .route ('/' ) def home (): if current_user .is_authenticated : return redirect (url_for ('inicio' )) return redirect (url_for ('login' )) @app .route ('/login' , methods = ['GET' , 'POST' ]) def login (): if current_user .is_authenticated : return redirect (url_for ('inicio' )) if request .method == 'POST' : user = request .form ['usuario' ] password = request .form ['clave' ] if user in users : try : ph .verify (users [user ]['password' ], password ) login_user (Usuario (user , users [user ]['role' ])) return redirect (url_for ('inicio' )) except Exception : pass return render_template ("login.html" , error = "Credenciales inválidas." ) return render_template ("login.html" ) @app .route ('/logout' ) @login_required def logout (): logout_user () return redirect (url_for ('login' )) @app .route ('/inicio' ) @login_required def inicio (): return render_template ('inicio.html' , current_user = current_user )
35+ # --- FUNCIONALIDAD DE ARCHIVOS ---
36+ @app .route ('/subir' , methods = ['GET' , 'POST' ]) @login_required def subir (): if current_user .rol == 'usuario' : return 'No tienes permiso para subir archivos' , 403 if request .method == 'POST' : if 'archivo' not in request .files : return 'No se encontró el archivo' , 400 archivo = request .files ['archivo' ] if archivo .filename == '' : return 'No se seleccionó ningún archivo' , 400 filename = secure_filename (archivo .filename ) archivo .save (os .path .join (app .config ['UPLOAD_FOLDER' ], filename )) return redirect (url_for ('listar' )) return render_template ("subir.html" ) @app .route ('/listar' ) @login_required def listar (): archivos = os .listdir (UPLOAD_FOLDER ) return render_template ("listar.html" , archivos = archivos ) @app .route ('/descargar/<nombre>' ) @login_required def descargar (nombre ): return send_from_directory (UPLOAD_FOLDER , nombre , as_attachment = True ) @app .route ('/eliminar/<nombre>' ) @login_required def eliminar (nombre ): if current_user .rol != 'administrator' : return 'No tienes permiso para eliminar archivos' , 403 try : os .remove (os .path .join (UPLOAD_FOLDER , secure_filename (nombre ))) except FileNotFoundError : pass return redirect (url_for ('listar' )) @app .route ('/chat' ) @login_required def chat (): return render_template ('chat.html' , current_user = current_user )
37+ # --- SOCKET.IO ---
38+ chat_rooms = {} @socketio .on ('join' ) def on_join (data ): username = current_user .id room_code = data ['room' ] password = data ['password' ] is_group = data .get ('is_group' , False ) if room_code not in chat_rooms : chat_rooms [room_code ] = argon2 .generate_password_hash (password ) else : if not argon2 .check_password_hash (chat_rooms [room_code ], password ): send ({'msg' : 'Contraseña incorrecta.' , 'type' : 'error' }) return join_room (room_code ) send ({'msg' : f"👋 { username } se ha unido." , 'user' : 'Servidor' , 'is_group' : is_group }, to = room_code ) @socketio .on ('leave' ) def on_leave (data ): username = current_user .id room_code = data ['room' ] leave_room (room_code ) send ({'msg' : f"🚪 { username } ha salido." , 'user' : 'Servidor' }, to = room_code ) @socketio .on ('message' ) def handle_message (data ): username = current_user .id room = data ['room' ] msg = data ['msg' ] is_group = data .get ('is_group' , False ) send ({'msg' : msg , 'user' : username , 'is_group' : is_group }, to = room )
39+ # --- INICIO ---
40+ if __name__ == '__main__' : port = int (os .environ .get ("PORT" , 10000 )) socketio .run (app , host = '0.0.0.0' , port = port ) # 👉 Para gunicorn/render
41+ application = app
0 commit comments