GeoShaPoH 5 months ago
commit
a06bcbc2e4
14 changed files with 1594 additions and 0 deletions
  1. 168 0
      .gitignore
  2. 136 0
      README.md
  3. 166 0
      SETUP.md
  4. 166 0
      app.py
  5. 45 0
      env.example
  6. BIN
      requirements.txt
  7. 60 0
      setup-proxy.sh
  8. 321 0
      static/app.js
  9. 1 0
      static/favicon.svg
  10. 87 0
      static/styles.css
  11. 199 0
      static/view.js
  12. 48 0
      temp.mysite.conf
  13. 142 0
      templates/index.html
  14. 55 0
      templates/view.html

+ 168 - 0
.gitignore

@@ -0,0 +1,168 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# Project specific
+uploads/
+*.tmp
+*.temp
+
+# Static files (keep them in version control)
+# static/
+
+# IDE files
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS generated files
+.DS_Store
+.DS_Store?
+._*
+.Spotlight-V100
+.Trashes
+ehthumbs.db
+Thumbs.db
+
+# Logs
+logs/
+*.log
+
+# Database
+*.db
+*.sqlite
+*.sqlite3
+
+# SSL certificates
+*.pem
+*.key
+*.crt
+*.csr 

+ 136 - 0
README.md

@@ -0,0 +1,136 @@
+# HokoriTemp - Servidor de Archivos Temporales
+
+Una aplicación web en Flask para subir y compartir archivos de forma temporal, similar a filebin pero con funcionalidades adicionales.
+
+## Características
+
+- **Subida de archivos:** Interfaz drag & drop para subir archivos
+- **Enlaces temporales:** Los archivos expiran automáticamente después de 24 horas
+- **Gestión de enlaces:** Página para ver y gestionar todos los enlaces activos del usuario
+- **Almacenamiento local:** Los enlaces se guardan en localStorage del navegador
+- **Interfaz moderna:** Diseño responsive con Tailwind CSS
+
+## Instalación
+
+1. **Clonar el repositorio:**
+```bash
+git clone <url-del-repositorio>
+cd hokoritemp
+```
+
+2. **Activar el entorno virtual:**
+```bash
+# Windows
+.\venv\Scripts\Activate.ps1
+
+# Linux/Mac
+source venv/bin/activate
+```
+
+3. **Instalar dependencias:**
+```bash
+pip install -r requirements.txt
+```
+
+## Uso
+
+### Ejecutar el servidor
+
+```bash
+# Ejecutar con configuración por defecto (localhost:5000)
+python app.py
+
+# Ejecutar en puerto específico
+python app.py --port 8080
+
+# Ejecutar en host específico
+python app.py --host 0.0.0.0 --port 8080
+
+# Ejecutar en modo debug
+python app.py --debug
+
+# Ejecutar con HTTPS (requiere certificados)
+python app.py --https
+```
+
+### Argumentos disponibles
+
+- `--host`: Host del servidor (default: 127.0.0.1)
+- `--port`: Puerto del servidor (default: 5000)
+- `--https`: Usar HTTPS
+- `--debug`: Modo debug
+
+## Estructura del proyecto
+
+```
+hokoritemp/
+├── app.py                 # Aplicación principal Flask
+├── requirements.txt       # Dependencias del proyecto
+├── uploads/              # Carpeta donde se guardan los archivos
+├── templates/            # Plantillas HTML
+│   ├── index.html       # Página principal de subida
+│   └── view.html        # Página de gestión de enlaces
+├── static/               # Archivos estáticos
+│   ├── styles.css       # Estilos CSS
+│   ├── app.js          # JavaScript principal
+│   └── view.js         # JavaScript para vista de enlaces
+└── README.md            # Este archivo
+```
+
+## Funcionalidades
+
+### Página Principal (/)
+- Interfaz drag & drop para subir archivos
+- Límite de 16MB por archivo
+- Generación automática de enlaces temporales
+- Guardado automático en localStorage
+
+### Página de Enlaces (/view)
+- Lista todos los enlaces guardados en localStorage
+- Muestra tiempo restante hasta expiración
+- Permite copiar y abrir enlaces
+- Filtra automáticamente enlaces expirados
+
+### API Endpoints
+- `POST /upload`: Subir archivo
+- `GET /download/<file_id>`: Descargar archivo
+- `GET /api/links`: Obtener enlaces (para futuras implementaciones)
+
+## Configuración
+
+### Variables de entorno
+Puedes crear un archivo `.env` con las siguientes variables:
+
+```env
+SECRET_KEY=tu_clave_secreta_aqui
+MAX_CONTENT_LENGTH=16777216
+UPLOAD_FOLDER=uploads
+```
+
+### Límites
+- Tamaño máximo de archivo: 16MB
+- Tiempo de expiración: 24 horas
+- Tipos de archivo: Todos los tipos permitidos
+
+## Desarrollo
+
+### Agregar nuevas funcionalidades
+1. Modifica `app.py` para agregar nuevas rutas
+2. Actualiza las plantillas HTML según sea necesario
+3. Agrega nuevas dependencias a `requirements.txt`
+
+### Personalización
+- Cambia el tiempo de expiración en `app.py` (línea 58)
+- Modifica el límite de tamaño en `app.py` (línea 12)
+- Personaliza el diseño editando las plantillas HTML
+
+## Seguridad
+
+- Los archivos se guardan con nombres únicos (UUID)
+- Se usa `secure_filename` para sanitizar nombres de archivo
+- Los archivos expiran automáticamente
+- No se almacenan metadatos sensibles
+
+## Licencia
+
+Este proyecto está bajo la licencia MIT. Ver el archivo LICENSE para más detalles. 

+ 166 - 0
SETUP.md

@@ -0,0 +1,166 @@
+# Configuración del Proyecto HokoriTemp
+
+## Configuración Inicial
+
+### 1. Variables de Entorno
+
+Copia el archivo `env.example` como `.env` y configura las variables según tu entorno:
+
+```bash
+cp env.example .env
+```
+
+### 2. Configurar .env
+
+Edita el archivo `.env` con tus valores:
+
+```env
+# Clave secreta (¡OBLIGATORIO cambiar!)
+SECRET_KEY=tu_clave_secreta_muy_segura_aqui
+
+# Configuración del servidor
+HOST=127.0.0.1
+PORT=5000
+DEBUG=False
+
+# Configuración del dominio (para reverse proxy)
+DOMAIN=temp.mysite.net
+
+# Configuración de archivos
+MAX_FILE_SIZE=16777216  # 16MB en bytes
+UPLOAD_FOLDER=uploads
+FILE_EXPIRATION_HOURS=24
+```
+
+### 3. Generar Clave Secreta
+
+Para generar una clave secreta segura, puedes usar Python:
+
+```python
+import secrets
+print(secrets.token_hex(32))
+```
+
+## Estructura de Archivos
+
+```
+hokoritemp/
+├── app.py                 # Aplicación principal
+├── requirements.txt       # Dependencias
+├── .env                  # Variables de entorno (crear desde env.example)
+├── .gitignore           # Archivos a ignorar en Git
+├── env.example          # Ejemplo de variables de entorno
+├── README.md            # Documentación principal
+├── SETUP.md             # Este archivo
+├── uploads/             # Carpeta de archivos (se crea automáticamente)
+└── templates/           # Plantillas HTML
+    ├── index.html
+    └── view.html
+```
+
+## Comandos Útiles
+
+### Desarrollo
+```bash
+# Activar entorno virtual
+.\venv\Scripts\Activate.ps1
+
+# Instalar dependencias
+pip install -r requirements.txt
+
+# Ejecutar en modo desarrollo
+python app.py --debug
+
+# Ejecutar en puerto específico
+python app.py --port 8080
+```
+
+### Producción
+```bash
+# Ejecutar en modo producción
+python app.py --host 0.0.0.0 --port 80
+
+# Con HTTPS (requiere certificados)
+python app.py --https
+```
+
+## Variables de Entorno Disponibles
+
+| Variable | Descripción | Valor por Defecto |
+|----------|-------------|-------------------|
+| `SECRET_KEY` | Clave secreta de Flask | `tu_clave_secreta_aqui` |
+| `HOST` | Host del servidor | `127.0.0.1` |
+| `PORT` | Puerto del servidor | `5000` |
+| `DEBUG` | Modo debug | `False` |
+| `DOMAIN` | Dominio para enlaces (reverse proxy) | `temp.mysite.net` |
+| `MAX_FILE_SIZE` | Tamaño máximo de archivo (bytes) | `16777216` (16MB) |
+| `UPLOAD_FOLDER` | Carpeta de archivos | `uploads` |
+| `FILE_EXPIRATION_HOURS` | Horas hasta expiración | `24` |
+
+## Seguridad
+
+### Archivos Sensibles
+- `.env` - Contiene variables sensibles (NO subir a Git)
+- `uploads/` - Contiene archivos subidos (NO subir a Git)
+- `venv/` - Entorno virtual (NO subir a Git)
+
+### Recomendaciones
+1. **Nunca** subas el archivo `.env` a Git
+2. Cambia la `SECRET_KEY` por defecto
+3. Configura límites de archivo apropiados
+4. Usa HTTPS en producción
+5. Configura un firewall apropiado
+
+## Troubleshooting
+
+### Error: "No module named 'dotenv'"
+```bash
+pip install python-dotenv
+```
+
+### Error: "Permission denied" al crear carpeta uploads
+```bash
+# En Windows, ejecutar como administrador
+# En Linux/Mac
+chmod 755 uploads/
+```
+
+### Error: "Address already in use"
+```bash
+# Cambiar puerto
+python app.py --port 8080
+```
+
+## Despliegue
+
+### Con Apache Reverse Proxy
+1. Copiar `temp.mysite.conf` a `/etc/httpd/conf.d/`
+2. Configurar el dominio en `.env`
+3. Ejecutar la app: `python app.py --host 127.0.0.1 --port 5000`
+4. Acceder a `http://temp.mysite.net`
+
+### Heroku
+1. Crear `Procfile`:
+```
+web: python app.py --host 0.0.0.0 --port $PORT
+```
+
+2. Configurar variables de entorno en Heroku Dashboard
+
+### Docker
+1. Crear `Dockerfile`:
+```dockerfile
+FROM python:3.9-slim
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install -r requirements.txt
+COPY . .
+EXPOSE 5000
+CMD ["python", "app.py", "--host", "0.0.0.0"]
+```
+
+2. Construir y ejecutar:
+```bash
+docker build -t hokoritemp .
+docker run -p 5000:5000 hokoritemp
+``` 

+ 166 - 0
app.py

@@ -0,0 +1,166 @@
+import os
+import argparse
+import uuid
+import shutil
+from datetime import datetime, timedelta
+from flask import Flask, render_template, request, jsonify, send_file, redirect, url_for
+from werkzeug.utils import secure_filename
+from dotenv import load_dotenv
+import json
+
+# Cargar variables de entorno desde .env
+load_dotenv()
+
+app = Flask(__name__)
+app.config['MAX_CONTENT_LENGTH'] = int(os.getenv('MAX_FILE_SIZE', 16 * 1024 * 1024))  # 16MB max file size
+app.config['UPLOAD_FOLDER'] = os.getenv('UPLOAD_FOLDER', 'uploads')
+app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'tu_clave_secreta_aqui')
+
+# Manejador de errores para archivos demasiado grandes
+@app.errorhandler(413)
+def too_large(e):
+    return jsonify({
+        'error': f'El archivo es demasiado grande. Tamaño máximo: {app.config["MAX_CONTENT_LENGTH"] // (1024*1024)}MB'
+    }), 413
+
+# Manejador de errores general
+@app.errorhandler(500)
+def internal_error(e):
+    return jsonify({'error': 'Error interno del servidor'}), 500
+
+@app.errorhandler(404)
+def not_found(e):
+    return jsonify({'error': 'Página no encontrada'}), 404
+
+# Verificar y crear carpeta de uploads si no existe
+def ensure_upload_folder():
+    if not os.path.exists(app.config['UPLOAD_FOLDER']):
+        os.makedirs(app.config['UPLOAD_FOLDER'])
+        print(f"Carpeta '{app.config['UPLOAD_FOLDER']}' creada automáticamente.")
+
+# Configurar argumentos de línea de comandos
+def parse_arguments():
+    parser = argparse.ArgumentParser(description='Servidor de archivos temporales')
+    parser.add_argument('--host', default=os.getenv('HOST', '127.0.0.1'), help='Host del servidor (default: 127.0.0.1)')
+    parser.add_argument('--port', type=int, default=int(os.getenv('PORT', 5000)), help='Puerto del servidor (default: 5000)')
+    parser.add_argument('--https', action='store_true', help='Usar HTTPS')
+    parser.add_argument('--debug', action='store_true', default=os.getenv('DEBUG', 'False').lower() == 'true', help='Modo debug')
+    return parser.parse_args()
+
+@app.route('/')
+def index():
+    """Página principal para subir archivos"""
+    # Pasar configuración al template
+    max_file_size = app.config['MAX_CONTENT_LENGTH']
+    max_file_size_mb = max_file_size // (1024 * 1024)
+    
+    return render_template('index.html', 
+                         max_file_size=max_file_size,
+                         max_file_size_mb=max_file_size_mb)
+
+@app.route('/view')
+def view():
+    """Página para ver enlaces guardados en localStorage"""
+    return render_template('view.html')
+
+@app.route('/upload', methods=['POST'])
+def upload_file():
+    """Manejar la subida de archivos"""
+    try:
+        # Verificar si hay archivo en la request
+        if 'file' not in request.files:
+            return jsonify({'error': 'No se seleccionó ningún archivo'}), 400
+        
+        file = request.files['file']
+        if file.filename == '':
+            return jsonify({'error': 'No se seleccionó ningún archivo'}), 400
+        
+        # Validar tamaño del archivo antes de procesar
+        max_size = app.config['MAX_CONTENT_LENGTH']
+        max_size_mb = max_size // (1024 * 1024)
+        
+        # Verificar content_length si está disponible
+        if request.content_length and request.content_length > max_size:
+            return jsonify({
+                'error': f'El archivo es demasiado grande. Tamaño máximo: {max_size_mb}MB'
+            }), 413
+        
+        # Verificar el tamaño del archivo después de recibirlo
+        if file:
+            # Leer el archivo en chunks para verificar el tamaño
+            file.seek(0, 2)  # Ir al final del archivo
+            file_size = file.tell()
+            file.seek(0)  # Volver al inicio
+            
+            if file_size > max_size:
+                return jsonify({
+                    'error': f'El archivo es demasiado grande. Tamaño máximo: {max_size_mb}MB'
+                }), 413
+            
+            # Generar nombre único para el archivo
+            filename = secure_filename(file.filename)
+            if not filename:
+                return jsonify({'error': 'Nombre de archivo no válido'}), 400
+                
+            file_id = str(uuid.uuid4())
+            file_extension = os.path.splitext(filename)[1]
+            unique_filename = f"{file_id}{file_extension}"
+            
+            # Guardar archivo
+            file_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
+            file.save(file_path)
+            
+            # Crear enlace temporal (expira en 24 horas)
+            # Usar el dominio configurado o el host actual
+            domain = os.getenv('DOMAIN', request.host)
+            protocol = 'https' if request.is_secure else 'http'
+            download_url = f"{protocol}://{domain}/download/{file_id}"
+            
+            return jsonify({
+                'success': True,
+                'download_url': download_url,
+                'file_id': file_id,
+                'original_filename': filename,
+                'expires_at': (datetime.now() + timedelta(hours=24)).isoformat()
+            })
+        else:
+            return jsonify({'error': 'No se pudo procesar el archivo'}), 400
+            
+    except Exception as e:
+        print(f"Error en upload: {str(e)}")
+        return jsonify({'error': 'Error interno del servidor'}), 500
+
+@app.route('/download/<file_id>')
+def download_file(file_id):
+    """Descargar archivo por ID"""
+    # Buscar archivo por ID
+    for filename in os.listdir(app.config['UPLOAD_FOLDER']):
+        if filename.startswith(file_id):
+            file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
+            return send_file(file_path, as_attachment=True)
+    
+    return "Archivo no encontrado", 404
+
+@app.route('/api/links')
+def get_links():
+    """API para obtener enlaces guardados (simulado)"""
+    # En una implementación real, esto vendría de una base de datos
+    return jsonify([])
+
+if __name__ == '__main__':
+    args = parse_arguments()
+    
+    # Asegurar que existe la carpeta de uploads
+    ensure_upload_folder()
+    
+    # Configurar protocolo
+    protocol = 'https' if args.https else 'http'
+    
+    print(f"Servidor iniciando en {protocol}://{args.host}:{args.port}")
+    print(f"Modo debug: {'Activado' if args.debug else 'Desactivado'}")
+    
+    app.run(
+        host=args.host,
+        port=args.port,
+        debug=args.debug
+    ) 

+ 45 - 0
env.example

@@ -0,0 +1,45 @@
+# Configuración de la aplicación Flask
+# Copia este archivo como .env y modifica los valores según tu entorno
+
+# Clave secreta para Flask (cambia esto por una clave segura)
+SECRET_KEY=tu_clave_secreta_muy_segura_aqui
+
+# Configuración de archivos
+MAX_CONTENT_LENGTH=16777216
+UPLOAD_FOLDER=uploads
+
+# Configuración del servidor
+HOST=127.0.0.1
+PORT=5000
+DEBUG=False
+
+# Configuración del dominio (para reverse proxy)
+DOMAIN=temp.mysite.net
+
+# Configuración de seguridad
+# Tiempo de expiración en horas (por defecto 24 horas)
+FILE_EXPIRATION_HOURS=24
+
+# Configuración de HTTPS (opcional)
+# SSL_CERT_FILE=cert.pem
+# SSL_KEY_FILE=key.pem
+
+# Configuración de base de datos (para futuras implementaciones)
+# DATABASE_URL=sqlite:///hokoritemp.db
+
+# Configuración de logging
+LOG_LEVEL=INFO
+LOG_FILE=logs/app.log
+
+# Configuración de límites
+# Tamaño máximo de archivo en bytes (16MB por defecto)
+MAX_FILE_SIZE=16777216
+
+# Tipos de archivo permitidos (dejar vacío para permitir todos)
+# ALLOWED_EXTENSIONS=.txt,.pdf,.doc,.docx,.jpg,.png,.gif,.zip,.rar
+
+# Configuración de limpieza automática
+# Habilitar limpieza automática de archivos expirados
+AUTO_CLEANUP=True
+# Frecuencia de limpieza en horas
+CLEANUP_INTERVAL_HOURS=1 

BIN
requirements.txt


+ 60 - 0
setup-proxy.sh

@@ -0,0 +1,60 @@
+#!/bin/bash
+
+# Script para configurar HokoriTemp con Apache reverse proxy
+# Ejecutar como root o con sudo
+
+echo "Configurando HokoriTemp con Apache reverse proxy..."
+
+# 1. Verificar que Apache esté instalado
+if ! command -v httpd &> /dev/null; then
+    echo "Error: Apache no está instalado"
+    echo "Instala Apache con: sudo yum install httpd (CentOS/RHEL)"
+    echo "o: sudo apt install apache2 (Ubuntu/Debian)"
+    exit 1
+fi
+
+# 2. Habilitar módulos necesarios
+echo "Habilitando módulos de Apache..."
+a2enmod proxy
+a2enmod proxy_http
+a2enmod headers
+
+# 3. Copiar configuración
+echo "Copiando configuración de Apache..."
+cp temp.mysite.conf /etc/httpd/conf.d/
+# Para Ubuntu/Debian usar: cp temp.mysite.conf /etc/apache2/sites-available/
+
+# 4. Habilitar sitio (Ubuntu/Debian)
+# a2ensite temp.mysite.conf
+
+# 5. Crear archivo .env
+echo "Creando archivo .env..."
+cp env.example .env
+
+# 6. Generar clave secreta
+echo "Generando clave secreta..."
+SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
+sed -i "s/tu_clave_secreta_muy_segura_aqui/$SECRET_KEY/" .env
+
+# 7. Configurar dominio
+read -p "Ingresa tu dominio (ej: temp.mysite.net): " DOMAIN
+sed -i "s/temp.mysite.net/$DOMAIN/" .env
+
+# 8. Reiniciar Apache
+echo "Reiniciando Apache..."
+systemctl restart httpd
+# Para Ubuntu/Debian: systemctl restart apache2
+
+# 9. Verificar configuración
+echo "Verificando configuración de Apache..."
+apache2ctl configtest
+
+echo ""
+echo "✅ Configuración completada!"
+echo ""
+echo "Para ejecutar la aplicación:"
+echo "1. Activa el entorno virtual: source venv/bin/activate"
+echo "2. Ejecuta la app: python app.py --host 127.0.0.1 --port 5000"
+echo "3. Accede a: http://$DOMAIN"
+echo ""
+echo "Para ejecutar como servicio, crea un archivo systemd o usa supervisor" 

+ 321 - 0
static/app.js

@@ -0,0 +1,321 @@
+// HokoriTemp - JavaScript principal
+
+let selectedFile = null;
+// Usar configuración del servidor si está disponible, sino usar valor por defecto
+const MAX_FILE_SIZE = (typeof SERVER_CONFIG !== 'undefined' && SERVER_CONFIG.maxFileSize) 
+    ? SERVER_CONFIG.maxFileSize 
+    : 16 * 1024 * 1024; // 16MB por defecto
+
+// DOM Elements
+const dropZone = document.getElementById('dropZone');
+const fileInput = document.getElementById('fileInput');
+const fileInfo = document.getElementById('fileInfo');
+const fileName = document.getElementById('fileName');
+const fileSize = document.getElementById('fileSize');
+const removeFile = document.getElementById('removeFile');
+const uploadForm = document.getElementById('uploadForm');
+const uploadBtn = document.getElementById('uploadBtn');
+const uploadBtnText = document.getElementById('uploadBtnText');
+const uploadBtnLoading = document.getElementById('uploadBtnLoading');
+const resultSection = document.getElementById('resultSection');
+const emptyResultSection = document.getElementById('emptyResultSection');
+const errorSection = document.getElementById('errorSection');
+const errorMessage = document.getElementById('errorMessage');
+const downloadLink = document.getElementById('downloadLink');
+
+// Event Listeners
+dropZone.addEventListener('click', () => fileInput.click());
+dropZone.addEventListener('dragover', handleDragOver);
+dropZone.addEventListener('dragleave', handleDragLeave);
+dropZone.addEventListener('drop', handleDrop);
+fileInput.addEventListener('change', handleFileSelect);
+removeFile.addEventListener('click', removeSelectedFile);
+uploadForm.addEventListener('submit', handleUpload);
+
+// Inicializar estado
+document.addEventListener('DOMContentLoaded', function() {
+    // Mostrar el estado vacío del resultado al cargar
+    emptyResultSection.classList.remove('hidden');
+    emptyResultSection.classList.add('fade-in');
+});
+
+function handleDragOver(e) {
+    e.preventDefault();
+    dropZone.classList.add('dragover');
+}
+
+function handleDragLeave(e) {
+    e.preventDefault();
+    dropZone.classList.remove('dragover');
+}
+
+function handleDrop(e) {
+    e.preventDefault();
+    dropZone.classList.remove('dragover');
+    const files = e.dataTransfer.files;
+    if (files.length > 0) {
+        handleFile(files[0]);
+    }
+}
+
+function handleFileSelect(e) {
+    const file = e.target.files[0];
+    if (file) {
+        handleFile(file);
+    }
+}
+
+function handleFile(file) {
+    // Validar tamaño del archivo
+    if (file.size > MAX_FILE_SIZE) {
+        showError(`El archivo es demasiado grande. Tamaño máximo: ${getMaxFileSizeMB()}MB`);
+        return;
+    }
+
+    selectedFile = file;
+    fileName.textContent = file.name;
+    fileSize.textContent = formatFileSize(file.size);
+    
+    // Aplicar clase de advertencia si el archivo es grande (>10MB)
+    if (file.size > 10 * 1024 * 1024) {
+        fileInfo.classList.add('file-size-warning');
+    } else {
+        fileInfo.classList.remove('file-size-warning');
+    }
+    
+    fileInfo.classList.remove('hidden');
+    uploadBtn.disabled = false;
+    hideError();
+}
+
+function removeSelectedFile() {
+    selectedFile = null;
+    fileInput.value = '';
+    fileInfo.classList.add('hidden');
+    fileInfo.classList.remove('file-size-warning', 'file-size-error');
+    uploadBtn.disabled = true;
+    // Ocultar resultado si hay uno mostrado
+    hideResult();
+}
+
+function formatFileSize(bytes) {
+    if (bytes === 0) return '0 Bytes';
+    const k = 1024;
+    const sizes = ['Bytes', 'KB', 'MB', 'GB'];
+    const i = Math.floor(Math.log(bytes) / Math.log(k));
+    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
+}
+
+async function handleUpload(e) {
+    e.preventDefault();
+    
+    if (!selectedFile) {
+        showError('Por favor selecciona un archivo');
+        return;
+    }
+
+    // Validar tamaño del archivo antes de subir
+    if (selectedFile.size > MAX_FILE_SIZE) {
+        showError(`El archivo es demasiado grande. Tamaño máximo: ${getMaxFileSizeMB()}MB`);
+        return;
+    }
+
+    // Show loading state
+    uploadBtn.disabled = true;
+    uploadBtnText.classList.add('hidden');
+    uploadBtnLoading.classList.remove('hidden');
+    hideError();
+    hideResult();
+
+    const formData = new FormData();
+    formData.append('file', selectedFile);
+
+    try {
+        const response = await fetch('/upload', {
+            method: 'POST',
+            body: formData
+        });
+
+        // Verificar si la respuesta es JSON válido
+        const contentType = response.headers.get('content-type');
+        const isJson = contentType && contentType.includes('application/json');
+
+        // Manejar diferentes códigos de respuesta
+        if (response.status === 413) {
+            showError(`El archivo es demasiado grande. Tamaño máximo: ${getMaxFileSizeMB()}MB`);
+            return;
+        }
+
+        if (!response.ok) {
+            let errorMessage = `Error del servidor (${response.status})`;
+            
+            if (isJson) {
+                try {
+                    const errorData = await response.json();
+                    errorMessage = errorData.error || errorMessage;
+                } catch (e) {
+                    console.error('Error parsing JSON response:', e);
+                }
+            } else {
+                // Si no es JSON, intentar leer como texto
+                try {
+                    const errorText = await response.text();
+                    console.error('Error response:', errorText);
+                    
+                    // Detectar errores específicos basados en el contenido
+                    if (errorText.includes('Request Entity Too Large') || errorText.includes('413')) {
+                        errorMessage = `El archivo es demasiado grande. Tamaño máximo: ${getMaxFileSizeMB()}MB`;
+                    } else if (errorText.includes('413')) {
+                        errorMessage = `El archivo es demasiado grande. Tamaño máximo: ${getMaxFileSizeMB()}MB`;
+                    } else {
+                        errorMessage = `Error del servidor: ${response.statusText}`;
+                    }
+                } catch (e) {
+                    console.error('Error reading response text:', e);
+                }
+            }
+            
+            showError(errorMessage);
+            return;
+        }
+
+        if (isJson) {
+            const result = await response.json();
+
+            if (result.success) {
+                // Save to localStorage
+                saveLinkToStorage(result);
+                showResult(result.download_url);
+                showNotification('Archivo subido exitosamente', 'success');
+            } else {
+                showError(result.error || 'Error al subir el archivo');
+            }
+        } else {
+            showError('Respuesta inesperada del servidor');
+        }
+    } catch (error) {
+        console.error('Upload error:', error);
+        
+        // Determinar el tipo de error
+        if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
+            showError('Error de conexión. Verifica tu conexión a internet e intenta de nuevo.');
+        } else if (error.name === 'TypeError' && error.message.includes('JSON')) {
+            showError('Error al procesar la respuesta del servidor.');
+        } else {
+            showError('Error inesperado. Intenta de nuevo.');
+        }
+    } finally {
+        // Reset loading state
+        uploadBtn.disabled = false;
+        uploadBtnText.classList.remove('hidden');
+        uploadBtnLoading.classList.add('hidden');
+    }
+}
+
+function saveLinkToStorage(result) {
+    const links = JSON.parse(localStorage.getItem('tempFileLinks') || '[]');
+    const newLink = {
+        id: result.file_id,
+        url: result.download_url,
+        filename: result.original_filename,
+        expiresAt: result.expires_at,
+        createdAt: new Date().toISOString()
+    };
+    links.push(newLink);
+    localStorage.setItem('tempFileLinks', JSON.stringify(links));
+}
+
+function showResult(url) {
+    downloadLink.value = url;
+    resultSection.classList.remove('hidden');
+    resultSection.classList.add('fade-in');
+    emptyResultSection.classList.add('hidden');
+}
+
+function showError(message) {
+    errorMessage.textContent = message;
+    errorSection.classList.remove('hidden');
+    showNotification(message, 'error');
+}
+
+function hideError() {
+    errorSection.classList.add('hidden');
+}
+
+function hideResult() {
+    resultSection.classList.add('hidden');
+    resultSection.classList.remove('fade-in');
+    emptyResultSection.classList.remove('hidden');
+    emptyResultSection.classList.add('fade-in');
+}
+
+function copyLink() {
+    downloadLink.select();
+    document.execCommand('copy');
+    // Show feedback
+    const copyBtn = event.target;
+    const originalText = copyBtn.textContent;
+    copyBtn.textContent = '¡Copiado!';
+    setTimeout(() => {
+        copyBtn.textContent = originalText;
+    }, 2000);
+    showNotification('Enlace copiado al portapapeles', 'success');
+}
+
+function openLink() {
+    window.open(downloadLink.value, '_blank');
+}
+
+function resetForm() {
+    removeSelectedFile();
+    hideResult();
+    hideError();
+    // Mostrar el estado vacío del resultado
+    emptyResultSection.classList.remove('hidden');
+}
+
+// Función para mostrar notificaciones
+function showNotification(message, type = 'info') {
+    // Crear elemento de notificación
+    const notification = document.createElement('div');
+    notification.className = `notification ${type}`;
+    notification.textContent = message;
+    
+    // Agregar al DOM
+    document.body.appendChild(notification);
+    
+    // Mostrar con animación
+    setTimeout(() => {
+        notification.classList.add('show');
+    }, 100);
+    
+    // Ocultar después de 3 segundos
+    setTimeout(() => {
+        notification.classList.remove('show');
+        setTimeout(() => {
+            document.body.removeChild(notification);
+        }, 300);
+    }, 3000);
+}
+
+// Función helper para obtener el tamaño máximo en MB
+function getMaxFileSizeMB() {
+    return (typeof SERVER_CONFIG !== 'undefined' && SERVER_CONFIG.maxFileSizeMB) 
+        ? SERVER_CONFIG.maxFileSizeMB 
+        : MAX_FILE_SIZE / (1024 * 1024);
+}
+
+// Función para validar archivo antes de subir
+function validateFile(file) {
+    const maxSize = MAX_FILE_SIZE;
+    const maxSizeMB = getMaxFileSizeMB();
+    
+    if (file.size > maxSize) {
+        return {
+            valid: false,
+            message: `El archivo es demasiado grande. Tamaño máximo: ${maxSizeMB}MB`
+        };
+    }
+    
+    return { valid: true };
+} 

+ 1 - 0
static/favicon.svg

@@ -0,0 +1 @@
+ 

+ 87 - 0
static/styles.css

@@ -0,0 +1,87 @@
+/* Estilos para HokoriTemp */
+
+.drop-zone {
+    transition: all 0.3s ease;
+}
+
+.drop-zone.dragover {
+    border-color: #3b82f6;
+    background-color: #eff6ff;
+}
+
+.result-section {
+    transition: all 0.3s ease;
+}
+
+.fade-in {
+    animation: fadeIn 0.3s ease-in;
+}
+
+@keyframes fadeIn {
+    from { 
+        opacity: 0; 
+        transform: translateY(10px); 
+    }
+    to { 
+        opacity: 1; 
+        transform: translateY(0); 
+    }
+}
+
+/* Estilos para archivos grandes */
+.file-size-warning {
+    background-color: #fef3c7;
+    border-color: #f59e0b;
+    color: #92400e;
+}
+
+.file-size-error {
+    background-color: #fee2e2;
+    border-color: #ef4444;
+    color: #991b1b;
+}
+
+/* Estilos para loading */
+.loading-spinner {
+    animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+    from { transform: rotate(0deg); }
+    to { transform: rotate(360deg); }
+}
+
+/* Estilos para notificaciones */
+.notification {
+    position: fixed;
+    top: 20px;
+    right: 20px;
+    z-index: 1000;
+    padding: 1rem;
+    border-radius: 0.5rem;
+    box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
+    transform: translateX(100%);
+    transition: transform 0.3s ease;
+}
+
+.notification.show {
+    transform: translateX(0);
+}
+
+.notification.success {
+    background-color: #d1fae5;
+    border: 1px solid #10b981;
+    color: #065f46;
+}
+
+.notification.error {
+    background-color: #fee2e2;
+    border: 1px solid #ef4444;
+    color: #991b1b;
+}
+
+.notification.warning {
+    background-color: #fef3c7;
+    border: 1px solid #f59e0b;
+    color: #92400e;
+} 

+ 199 - 0
static/view.js

@@ -0,0 +1,199 @@
+// HokoriTemp - JavaScript para la página de vista
+
+// DOM Elements
+const linksContainer = document.getElementById('linksContainer');
+const emptyState = document.getElementById('emptyState');
+const loadingState = document.getElementById('loadingState');
+
+// Load links on page load
+document.addEventListener('DOMContentLoaded', loadLinks);
+
+function loadLinks() {
+    const links = JSON.parse(localStorage.getItem('tempFileLinks') || '[]');
+    
+    // Filter expired links
+    const currentTime = new Date();
+    const validLinks = links.filter(link => {
+        const expiresAt = new Date(link.expiresAt);
+        return expiresAt > currentTime;
+    });
+
+    // Update localStorage with only valid links
+    localStorage.setItem('tempFileLinks', JSON.stringify(validLinks));
+
+    // Hide loading
+    loadingState.classList.add('hidden');
+
+    if (validLinks.length === 0) {
+        showEmptyState();
+    } else {
+        showLinks(validLinks);
+    }
+}
+
+function showEmptyState() {
+    emptyState.classList.remove('hidden');
+}
+
+function showLinks(links) {
+    const linksHTML = links.map(link => createLinkCard(link)).join('');
+    linksContainer.innerHTML = linksHTML;
+}
+
+function createLinkCard(link) {
+    const createdAt = new Date(link.createdAt);
+    const expiresAt = new Date(link.expiresAt);
+    const timeLeft = getTimeLeft(expiresAt);
+    const isExpired = expiresAt <= new Date();
+
+    return `
+        <div class="bg-white rounded-lg shadow-lg p-6 mb-4 ${isExpired ? 'opacity-50' : ''}">
+            <div class="flex items-start justify-between">
+                <div class="flex-1">
+                    <div class="flex items-center space-x-3">
+                        <svg class="h-8 w-8 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
+                        </svg>
+                        <div>
+                            <h3 class="text-lg font-medium text-gray-900">${link.filename}</h3>
+                            <p class="text-sm text-gray-500">Creado: ${formatDate(createdAt)}</p>
+                        </div>
+                    </div>
+                    
+                    <div class="mt-4">
+                        <div class="flex items-center space-x-2">
+                            <span class="text-sm text-gray-500">Enlace:</span>
+                            <input type="text" value="${link.url}" class="flex-1 px-3 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" readonly>
+                            <button onclick="copyLink('${link.url}')" class="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
+                                Copiar
+                            </button>
+                        </div>
+                    </div>
+
+                    <div class="mt-3 flex items-center justify-between">
+                        <div class="flex items-center space-x-4">
+                            <span class="text-sm text-gray-500">
+                                ${isExpired ? 'Expirado' : `Expira en: ${timeLeft}`}
+                            </span>
+                            ${isExpired ? '' : `
+                                <button onclick="openLink('${link.url}')" class="text-sm text-blue-600 hover:text-blue-800 underline">
+                                    Abrir enlace
+                                </button>
+                            `}
+                        </div>
+                        <button onclick="removeLink('${link.id}')" class="text-sm text-red-600 hover:text-red-800 underline">
+                            Eliminar
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    `;
+}
+
+function getTimeLeft(expiresAt) {
+    const now = new Date();
+    const diff = expiresAt - now;
+    
+    if (diff <= 0) return 'Expirado';
+    
+    const hours = Math.floor(diff / (1000 * 60 * 60));
+    const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
+    
+    if (hours > 0) {
+        return `${hours}h ${minutes}m`;
+    } else {
+        return `${minutes}m`;
+    }
+}
+
+function formatDate(date) {
+    return date.toLocaleString('es-ES', {
+        year: 'numeric',
+        month: 'short',
+        day: 'numeric',
+        hour: '2-digit',
+        minute: '2-digit'
+    });
+}
+
+function copyLink(url) {
+    navigator.clipboard.writeText(url).then(() => {
+        // Show feedback
+        const button = event.target;
+        const originalText = button.textContent;
+        button.textContent = '¡Copiado!';
+        setTimeout(() => {
+            button.textContent = originalText;
+        }, 2000);
+        
+        // Show notification
+        showNotification('Enlace copiado al portapapeles', 'success');
+    }).catch(() => {
+        // Fallback for older browsers
+        const textArea = document.createElement('textarea');
+        textArea.value = url;
+        document.body.appendChild(textArea);
+        textArea.select();
+        document.execCommand('copy');
+        document.body.removeChild(textArea);
+        
+        const button = event.target;
+        const originalText = button.textContent;
+        button.textContent = '¡Copiado!';
+        setTimeout(() => {
+            button.textContent = originalText;
+        }, 2000);
+        
+        showNotification('Enlace copiado al portapapeles', 'success');
+    });
+}
+
+function openLink(url) {
+    window.open(url, '_blank');
+}
+
+function removeLink(linkId) {
+    const links = JSON.parse(localStorage.getItem('tempFileLinks') || '[]');
+    const updatedLinks = links.filter(link => link.id !== linkId);
+    localStorage.setItem('tempFileLinks', JSON.stringify(updatedLinks));
+    
+    // Reload the page to update the display
+    location.reload();
+}
+
+// Función para mostrar notificaciones
+function showNotification(message, type = 'info') {
+    // Crear elemento de notificación
+    const notification = document.createElement('div');
+    notification.className = `notification ${type}`;
+    notification.textContent = message;
+    
+    // Agregar al DOM
+    document.body.appendChild(notification);
+    
+    // Mostrar con animación
+    setTimeout(() => {
+        notification.classList.add('show');
+    }, 100);
+    
+    // Ocultar después de 3 segundos
+    setTimeout(() => {
+        notification.classList.remove('show');
+        setTimeout(() => {
+            document.body.removeChild(notification);
+        }, 300);
+    }, 3000);
+}
+
+// Auto-refresh every minute to update expiration times
+setInterval(() => {
+    const links = document.querySelectorAll('[data-expires-at]');
+    links.forEach(linkElement => {
+        const expiresAt = new Date(linkElement.dataset.expiresAt);
+        const timeLeftElement = linkElement.querySelector('.time-left');
+        if (timeLeftElement) {
+            timeLeftElement.textContent = getTimeLeft(expiresAt);
+        }
+    });
+}, 60000); 

+ 48 - 0
temp.mysite.conf

@@ -0,0 +1,48 @@
+# Configuración de Apache para temp.mysite.net
+# Guardar en /etc/httpd/conf.d/temp.mysite.conf
+
+<VirtualHost *:80>
+    ServerName temp.mysite.net
+    ServerAlias www.temp.mysite.net
+    
+    # Logs
+    ErrorLog logs/temp.mysite.net-error.log
+    CustomLog logs/temp.mysite.net-access.log combined
+    
+    # Reverse Proxy simple
+    ProxyPreserveHost On
+    ProxyPass / http://127.0.0.1:5000/
+    ProxyPassReverse / http://127.0.0.1:5000/
+    
+    # Timeout para archivos grandes
+    ProxyTimeout 300
+    
+    # Configuración para manejar errores 413
+    ProxyErrorOverride Off
+</VirtualHost>
+
+# Configuración HTTPS (opcional)
+<VirtualHost *:443>
+    ServerName temp.mysite.net
+    ServerAlias www.temp.mysite.net
+    
+    # SSL
+    SSLEngine on
+    SSLCertificateFile /path/to/your/cert.crt
+    SSLCertificateKeyFile /path/to/your/key.key
+    
+    # Logs
+    ErrorLog logs/temp.mysite.net-ssl-error.log
+    CustomLog logs/temp.mysite.net-ssl-access.log combined
+    
+    # Reverse Proxy
+    ProxyPreserveHost On
+    ProxyPass / http://127.0.0.1:5000/
+    ProxyPassReverse / http://127.0.0.1:5000/
+    
+    # Timeout para archivos grandes
+    ProxyTimeout 300
+    
+    # Configuración para manejar errores 413
+    ProxyErrorOverride Off
+</VirtualHost> 

+ 142 - 0
templates/index.html

@@ -0,0 +1,142 @@
+<!DOCTYPE html>
+<html lang="es">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>HokoriTemp</title>
+    <link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
+    <script src="https://cdn.tailwindcss.com"></script>
+    <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
+</head>
+<body class="bg-gray-50 min-h-screen">
+    <div class="container mx-auto px-4 py-8">
+        <!-- Header -->
+        <div class="text-center mb-8">
+            <h1 class="text-4xl font-bold text-gray-800 mb-2">Subir Archivos Temporales</h1>
+            <p class="text-gray-600">Comparte archivos de forma segura y temporal</p>
+            <div class="mt-4">
+                <a href="/view" class="text-blue-600 hover:text-blue-800 underline">Ver mis enlaces activos</a>
+            </div>
+        </div>
+
+        <!-- Main Container -->
+        <div class="max-w-6xl mx-auto">
+            <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
+                
+                <!-- Upload Form -->
+                <div class="bg-white rounded-lg shadow-lg p-6">
+                    <h2 class="text-xl font-semibold text-gray-800 mb-4">Subir Archivo</h2>
+                    <form id="uploadForm" enctype="multipart/form-data">
+                        <div class="mb-6">
+                            <label class="block text-sm font-medium text-gray-700 mb-2">
+                                Selecciona un archivo para subir
+                            </label>
+                            <div id="dropZone" class="drop-zone border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-gray-400 transition-colors">
+                                <div class="space-y-4">
+                                    <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
+                                        <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
+                                    </svg>
+                                    <div>
+                                        <p class="text-lg font-medium text-gray-700">Arrastra y suelta tu archivo aquí</p>
+                                        <p class="text-sm text-gray-500">o haz clic para seleccionar</p>
+                                    </div>
+                                </div>
+                                <input type="file" id="fileInput" class="hidden" accept="*/*">
+                            </div>
+                        </div>
+
+                        <div id="fileInfo" class="hidden mb-6 p-4 bg-blue-50 rounded-lg">
+                            <div class="flex items-center justify-between">
+                                <div>
+                                    <p class="font-medium text-blue-900" id="fileName"></p>
+                                    <p class="text-sm text-blue-700" id="fileSize"></p>
+                                </div>
+                                <button type="button" id="removeFile" class="text-red-600 hover:text-red-800">
+                                    <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
+                                    </svg>
+                                </button>
+                            </div>
+                        </div>
+
+                        <button type="submit" id="uploadBtn" class="w-full bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed">
+                            <span id="uploadBtnText">Subir Archivo</span>
+                            <span id="uploadBtnLoading" class="hidden">
+                                <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
+                                    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+                                    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+                                </svg>
+                                Subiendo...
+                            </span>
+                        </button>
+                    </form>
+
+                    <!-- Error Section -->
+                    <div id="errorSection" class="hidden mt-6 bg-red-50 border border-red-200 rounded-lg p-4">
+                        <div class="flex">
+                            <div class="flex-shrink-0">
+                                <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
+                                    <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
+                                </svg>
+                            </div>
+                            <div class="ml-3">
+                                <h3 class="text-sm font-medium text-red-800" id="errorMessage"></h3>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- Result Section -->
+                <div id="resultSection" class="hidden bg-white rounded-lg shadow-lg p-6 result-section">
+                    <div class="text-center">
+                        <div class="mb-4">
+                            <svg class="mx-auto h-12 w-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
+                            </svg>
+                        </div>
+                        <h3 class="text-lg font-medium text-gray-900 mb-2">¡Archivo subido exitosamente!</h3>
+                        <p class="text-gray-600 mb-4">Tu archivo estará disponible por 24 horas</p>
+                        
+                        <div class="bg-gray-50 rounded-lg p-4 mb-4">
+                            <label class="block text-sm font-medium text-gray-700 mb-2">Enlace de descarga:</label>
+                            <div class="flex">
+                                <input type="text" id="downloadLink" class="flex-1 px-3 py-2 border border-gray-300 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" readonly>
+                                <button onclick="copyLink()" class="px-4 py-2 bg-blue-600 text-white rounded-r-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
+                                    Copiar
+                                </button>
+                            </div>
+                        </div>
+
+                        <div class="flex space-x-4">
+                            <button onclick="openLink()" class="flex-1 bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500">
+                                Abrir Enlace
+                            </button>
+                            <button onclick="resetForm()" class="flex-1 bg-gray-600 text-white py-2 px-4 rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500">
+                                Subir Otro
+                            </button>
+                        </div>
+                    </div>
+                </div>
+
+                <!-- Empty State for Result Section -->
+                <div id="emptyResultSection" class="hidden bg-gray-50 rounded-lg p-8 text-center result-section">
+                    <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
+                    </svg>
+                    <h3 class="mt-2 text-sm font-medium text-gray-900">Listo para subir</h3>
+                    <p class="mt-1 text-sm text-gray-500">Selecciona un archivo para comenzar</p>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        // Configuración del servidor
+        const SERVER_CONFIG = {
+            maxFileSize: {{ max_file_size }},
+            maxFileSizeMB: {{ max_file_size_mb }}
+        };
+    </script>
+    <script src="{{ url_for('static', filename='app.js') }}"></script>
+</body>
+</html> 

+ 55 - 0
templates/view.html

@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html lang="es">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>HokoriTemp - Enlaces Temporales</title>
+    <link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
+    <script src="https://cdn.tailwindcss.com"></script>
+    <link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
+</head>
+<body class="bg-gray-50 min-h-screen">
+    <div class="container mx-auto px-4 py-8">
+        <!-- Header -->
+        <div class="text-center mb-8">
+            <h1 class="text-4xl font-bold text-gray-800 mb-2">Mis Enlaces Temporales</h1>
+            <p class="text-gray-600">Gestiona tus archivos subidos temporalmente</p>
+            <div class="mt-4">
+                <a href="/" class="text-blue-600 hover:text-blue-800 underline">← Volver a subir archivos</a>
+            </div>
+        </div>
+
+        <!-- Links Container -->
+        <div class="max-w-4xl mx-auto">
+            <div id="linksContainer">
+                <!-- Links will be loaded here -->
+            </div>
+
+            <!-- Empty State -->
+            <div id="emptyState" class="hidden text-center py-12">
+                <svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
+                </svg>
+                <h3 class="mt-2 text-sm font-medium text-gray-900">No hay enlaces guardados</h3>
+                <p class="mt-1 text-sm text-gray-500">Los enlaces que generes aparecerán aquí.</p>
+                <div class="mt-6">
+                    <a href="/" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
+                        Subir mi primer archivo
+                    </a>
+                </div>
+            </div>
+
+            <!-- Loading State -->
+            <div id="loadingState" class="text-center py-12">
+                <svg class="animate-spin mx-auto h-8 w-8 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
+                    <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+                    <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+                </svg>
+                <p class="mt-2 text-sm text-gray-500">Cargando enlaces...</p>
+            </div>
+        </div>
+    </div>
+
+    <script src="{{ url_for('static', filename='view.js') }}"></script>
+</body>
+</html>