frist commit

This commit is contained in:
2026-05-04 19:15:00 +02:00
commit 52977daf72
18 changed files with 1324 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
FROM python:3.11-slim
RUN apt-get update && apt-get install -y openssh-server && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Crear directorio de datos con permisos abiertos para lectura del usuario ssh
RUN mkdir -p /app/data && chmod 777 /app/data
# Configurar SSH
RUN mkdir -p /var/run/sshd && \
useradd -m -s /bin/bash craig && \
echo 'craig:madrid' | chpasswd && \
mkdir -p /home/craig/.ssh && \
chown craig:craig /home/craig/.ssh
# Habilitar autenticacion por contrasena
RUN sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config && \
sed -i 's/PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config
# Al conectar como craig, forzar la ejecucion del visor de anuncios
RUN echo '' >> /etc/ssh/sshd_config && \
echo 'Match User craig' >> /etc/ssh/sshd_config && \
echo ' ForceCommand python3 /app/ssh_display.py' >> /etc/ssh/sshd_config && \
echo ' PermitTTY yes' >> /etc/ssh/sshd_config && \
echo ' AllowTcpForwarding no' >> /etc/ssh/sshd_config && \
echo ' X11Forwarding no' >> /etc/ssh/sshd_config
RUN chmod +x /app/start.sh
EXPOSE 5000 22
CMD ["/app/start.sh"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+66
View File
@@ -0,0 +1,66 @@
from flask import Blueprint, request, jsonify
import models
api_bp = Blueprint('api', __name__, url_prefix='/api')
@api_bp.route('/anuncios', methods=['GET'])
def api_listar():
categoria = request.args.get('categoria', '').strip() or None
busqueda = request.args.get('q', '').strip() or None
ciudad = request.args.get('ciudad', '').strip() or None
anuncios = models.listar_anuncios(categoria=categoria, busqueda=busqueda, ciudad=ciudad)
return jsonify(anuncios)
@api_bp.route('/anuncios/<int:anuncio_id>', methods=['GET'])
def api_ver(anuncio_id):
anuncio = models.obtener_anuncio(anuncio_id)
if not anuncio:
return jsonify({'error': 'Anuncio no encontrado'}), 404
return jsonify(anuncio)
@api_bp.route('/anuncios', methods=['POST'])
def api_crear():
data = request.get_json(force=True, silent=True) or request.form
titulo = (data.get('titulo') or '').strip()
contenido = (data.get('contenido') or '').strip()
categoria = (data.get('categoria') or 'general').strip()
contacto = (data.get('contacto') or 'madrid@craigslist.local').strip()
ciudad = (data.get('ciudad') or 'madrid').strip()
if not titulo or not contenido:
return jsonify({'error': 'Titulo y contenido son obligatorios'}), 400
anuncio_id, token = models.crear_anuncio(titulo, contenido, categoria, contacto, ciudad)
return jsonify({'id': anuncio_id, 'token': token, 'message': 'Anuncio creado'}), 201
@api_bp.route('/anuncios/<int:anuncio_id>', methods=['DELETE'])
def api_borrar(anuncio_id):
data = request.get_json(force=True, silent=True) or request.form
token = (data.get('token') or '').strip()
if models.borrar_anuncio(anuncio_id, token):
return jsonify({'message': 'Anuncio borrado'}), 200
return jsonify({'error': 'Anuncio no encontrado o token incorrecto'}), 404
@api_bp.route('/anuncios/<int:anuncio_id>', methods=['PUT'])
def api_actualizar(anuncio_id):
data = request.get_json(force=True, silent=True) or request.form
campos = {}
for k in ('titulo', 'contenido', 'categoria', 'contacto', 'ciudad'):
if k in data:
campos[k] = data[k]
if not campos:
return jsonify({'error': 'No hay campos para actualizar'}), 400
if models.actualizar_anuncio(anuncio_id, **campos):
return jsonify({'message': 'Anuncio actualizado'})
return jsonify({'error': 'Anuncio no encontrado'}), 404
@api_bp.route('/categorias', methods=['GET'])
def api_categorias():
ciudad = request.args.get('ciudad', '').strip() or None
return jsonify(models.listar_categorias(ciudad=ciudad))
+62
View File
@@ -0,0 +1,62 @@
from flask import Flask, render_template, request, redirect, url_for
import models
from api import api_bp
app = Flask(__name__)
app.register_blueprint(api_bp)
models.init_db()
CIUDADES = [
'madrid', 'barcelona', 'valencia', 'sevilla', 'zaragoza',
'malaga', 'murcia', 'palma', 'las-palmas', 'bilbao',
'alicante', 'cordoba', 'valladolid', 'vigo'
]
@app.route('/')
def index():
return render_template('index.html', modo='inicio', ciudades=CIUDADES)
@app.route('/c/<city>')
def ciudad(city):
categoria = request.args.get('categoria', '').strip() or None
busqueda = request.args.get('q', '').strip() or None
anuncios = models.listar_anuncios(categoria=categoria, busqueda=busqueda, ciudad=city)
categorias = models.listar_categorias(ciudad=city)
return render_template(
'index.html',
modo='ciudad',
city=city,
anuncios=anuncios,
categorias=categorias,
categoria_actual=categoria or '',
busqueda_actual=busqueda or ''
)
@app.route('/nuevo', methods=['POST'])
def nuevo():
titulo = request.form.get('titulo', '').strip()
contenido = request.form.get('contenido', '').strip()
categoria = request.form.get('categoria', 'general').strip()
contacto = request.form.get('contacto', 'madrid@craigslist.local').strip()
ciudad = request.form.get('ciudad', 'madrid').strip()
if titulo and contenido:
anuncio_id, token = models.crear_anuncio(titulo, contenido, categoria, contacto, ciudad)
return redirect(f'/c/{ciudad}?nuevo={anuncio_id}&token={token}')
return redirect(url_for('index'))
@app.route('/borrar/<int:anuncio_id>', methods=['POST'])
def borrar(anuncio_id):
token = request.form.get('token', '').strip()
models.borrar_anuncio(anuncio_id, token)
return redirect(request.referrer or '/')
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
BIN
View File
Binary file not shown.
Binary file not shown.
+34
View File
@@ -0,0 +1,34 @@
[
{
"id": 4,
"titulo": "test",
"contenido": "test",
"categoria": "general",
"fecha": "2026-04-30 14:42",
"contacto": "madrid@craigslist.local"
},
{
"id": 1,
"titulo": "Vendo bicicleta de carretera",
"contenido": "Bicicleta de carretera en buen estado. Precio negociable. Zona Chamberi, Madrid.",
"categoria": "venta",
"fecha": "2025-04-30 10:00",
"contacto": "bicis@madrid.es"
},
{
"id": 2,
"titulo": "Clases de guitarra flamenca",
"contenido": "Profesor con 10 anos de experiencia. Clases en persona o online. Primera clase gratis.",
"categoria": "servicios",
"fecha": "2025-04-30 11:30",
"contacto": "guitarra@flamenco.local"
},
{
"id": 3,
"titulo": "Se busca companero de piso",
"contenido": "Piso compartido en Malasana. 400 EUR/mes gastos incluidos. No fumadores.",
"categoria": "vivienda",
"fecha": "2025-04-30 09:15",
"contacto": "piso@malasana.local"
}
]
+8
View File
@@ -0,0 +1,8 @@
services:
madcraig:
build: .
ports:
- "5000:5000"
- "2222:22"
volumes:
- ./data:/app/data
+371
View File
@@ -0,0 +1,371 @@
import sqlite3
import os
import json
import threading
import secrets
from datetime import datetime, timedelta
import random
DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
DB_PATH = os.path.join(DATA_DIR, 'anuncios.db')
OLD_JSON_PATH = os.path.join(DATA_DIR, 'anuncios.json')
_lock = threading.Lock()
def _get_conn():
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def _add_column_if_missing(conn, table, column, col_type):
"""SQLite-safe ALTER TABLE ADD COLUMN if the column does not exist."""
cur = conn.execute(f"PRAGMA table_info({table})")
cols = [row['name'] for row in cur.fetchall()]
if column not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
def init_db():
os.makedirs(DATA_DIR, exist_ok=True)
with _lock:
conn = _get_conn()
conn.execute('''
CREATE TABLE IF NOT EXISTS anuncios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
contenido TEXT NOT NULL,
categoria TEXT NOT NULL DEFAULT 'general',
contacto TEXT NOT NULL DEFAULT 'madrid@craigslist.local',
ciudad TEXT NOT NULL DEFAULT 'madrid',
fecha TEXT NOT NULL
)
''')
_add_column_if_missing(conn, 'anuncios', 'ciudad', "TEXT NOT NULL DEFAULT 'madrid'")
_add_column_if_missing(conn, 'anuncios', 'token', "TEXT")
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_categoria ON anuncios(categoria)
''')
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_ciudad ON anuncios(ciudad)
''')
conn.execute('''
CREATE INDEX IF NOT EXISTS idx_fecha ON anuncios(fecha DESC)
''')
conn.commit()
conn.close()
_migrate_json()
def _migrate_json():
if not os.path.exists(OLD_JSON_PATH):
return
with _lock:
conn = _get_conn()
cur = conn.execute('SELECT COUNT(*) as total FROM anuncios')
if cur.fetchone()['total'] > 0:
conn.close()
return
with open(OLD_JSON_PATH, 'r', encoding='utf-8') as f:
anuncios = json.load(f)
for a in anuncios:
conn.execute('''
INSERT INTO anuncios (id, titulo, contenido, categoria, contacto, ciudad, fecha)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
a.get('id'),
a.get('titulo', ''),
a.get('contenido', ''),
a.get('categoria', 'general'),
a.get('contacto', 'madrid@craigslist.local'),
a.get('ciudad', 'madrid'),
a.get('fecha', datetime.now().strftime('%Y-%m-%d %H:%M'))
))
conn.execute("DELETE FROM sqlite_sequence WHERE name='anuncios'")
if anuncios:
max_id = max(a.get('id', 0) for a in anuncios)
conn.execute("INSERT INTO sqlite_sequence(name,seq) VALUES ('anuncios', ?)", (max_id,))
conn.commit()
conn.close()
try:
os.rename(OLD_JSON_PATH, OLD_JSON_PATH + '.backup')
except OSError:
pass
def listar_anuncios(categoria=None, busqueda=None, ciudad=None):
query = 'SELECT * FROM anuncios WHERE 1=1'
params = []
if ciudad:
query += ' AND ciudad = ?'
params.append(ciudad)
if categoria:
query += ' AND categoria = ?'
params.append(categoria)
if busqueda:
query += ' AND (titulo LIKE ? OR contenido LIKE ?)'
params.extend([f'%{busqueda}%', f'%{busqueda}%'])
query += ' ORDER BY fecha DESC'
with _lock:
conn = _get_conn()
cur = conn.execute(query, params)
rows = [dict(r) for r in cur.fetchall()]
conn.close()
return rows
def obtener_anuncio(anuncio_id):
with _lock:
conn = _get_conn()
cur = conn.execute('SELECT * FROM anuncios WHERE id = ?', (anuncio_id,))
row = cur.fetchone()
conn.close()
return dict(row) if row else None
def crear_anuncio(titulo, contenido, categoria, contacto, ciudad='madrid'):
fecha = datetime.now().strftime('%Y-%m-%d %H:%M')
token = secrets.token_urlsafe(16)
with _lock:
conn = _get_conn()
cur = conn.execute('''
INSERT INTO anuncios (titulo, contenido, categoria, contacto, ciudad, fecha, token)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (titulo, contenido, categoria, contacto, ciudad, fecha, token))
anuncio_id = cur.lastrowid
conn.commit()
conn.close()
return anuncio_id, token
def borrar_anuncio(anuncio_id, token=None):
with _lock:
conn = _get_conn()
cur = conn.execute('SELECT token FROM anuncios WHERE id = ?', (anuncio_id,))
row = cur.fetchone()
if not row:
conn.close()
return False
stored_token = row['token']
if stored_token and stored_token != token:
conn.close()
return False
cur = conn.execute('DELETE FROM anuncios WHERE id = ?', (anuncio_id,))
deleted = cur.rowcount
conn.commit()
conn.close()
return deleted > 0
def actualizar_anuncio(anuncio_id, titulo=None, contenido=None, categoria=None, contacto=None, ciudad=None):
campos = []
valores = []
if titulo is not None:
campos.append('titulo = ?')
valores.append(titulo)
if contenido is not None:
campos.append('contenido = ?')
valores.append(contenido)
if categoria is not None:
campos.append('categoria = ?')
valores.append(categoria)
if contacto is not None:
campos.append('contacto = ?')
valores.append(contacto)
if ciudad is not None:
campos.append('ciudad = ?')
valores.append(ciudad)
if not campos:
return False
valores.append(anuncio_id)
with _lock:
conn = _get_conn()
cur = conn.execute(
f"UPDATE anuncios SET {', '.join(campos)} WHERE id = ?",
valores
)
updated = cur.rowcount
conn.commit()
conn.close()
return updated > 0
def listar_categorias(ciudad=None):
query = 'SELECT DISTINCT categoria FROM anuncios WHERE 1=1'
params = []
if ciudad:
query += ' AND ciudad = ?'
params.append(ciudad)
query += ' ORDER BY categoria'
with _lock:
conn = _get_conn()
cur = conn.execute(query, params)
rows = [r['categoria'] for r in cur.fetchall()]
conn.close()
return rows
# ---------------------------------------------------------------------------
# Seed / migración helpers
# ---------------------------------------------------------------------------
_TITULOS = {
'trabajo': [
'Desarrollador backend senior',
'Community manager freelance',
'Repartidor con moto propia',
'Camarero/a para fin de semana',
'Diseñador UX/UI remoto',
'Ingeniero devops',
'Profesor de inglés nativo',
'Ayudante de almacén',
],
'venta': [
'Sofa chaise longue casi nuevo',
'iPhone 14 pro 256gb',
'Bicicleta de montaña trek',
'Libros de programación pack',
'Monitor 27 pulgadas 4k',
'Nevera combi no frost',
'Coche citroen c3 2019',
'Patinete electrico xiaomi',
],
'servicios': [
'Limpieza de hogar por horas',
'Reparación de electrodomesticos',
'Clases particulares de mates',
'Diseño web profesional',
'Fontanero económico',
'Paseador de perros',
'Traducciones inglés-español',
'Electricista autorizado',
],
'alquiler': [
'Piso centro 2 habitaciones',
'Habitación en piso compartido',
'Estudio luminoso con terraza',
'Alquiler temporal verano',
'Duplex con parking incluido',
'Chalet adosado zona norte',
'Loft industrial reformado',
'Apartamento playa primera línea',
],
'compra': [
'Busco piso en el centro',
'Compro coche gasolina manual',
'Se busca mobiliario vintage',
'Quiero comprar portatil gaming',
'Busco terreno para autocaravana',
'Compro material de escalada',
'Se busca piano de pared',
'Quiero comprar patinete eléctrico',
],
'eventos': [
'Concierto indie en la sala',
'Fiesta de inauguración local',
'Mercadillo artesanal domingo',
'Taller de cocina vegana',
'Ruta senderista guiada',
'Exposición fotográfica gratis',
'Open mic comedy night',
'Yoga en el parque sábados',
],
'tecnologia': [
'Raspberry pi 4 kit completo',
'Teclado mecánico custom',
'Router mesh wifi 6',
'Gafas realidad virtual',
'Disco duro externo 2tb',
'Webcam 4k para streaming',
'Impresora 3d ender 3',
'Switch oled con juegos',
],
'hogar': [
'Lámpara de pie nordica',
'Juego de sillas de comedor',
'Cortinas blackout a medida',
'Aspirador robot con mapeo',
'Planta monstera grande',
'Mesa de centro elevable',
'Colchón viscoelástico 150',
'Cubertería acero inox 72 piezas',
],
'ocio': [
'Entradas cine miercoles',
'Mazo cartas magic coleccion',
'Patines en línea talla 42',
'Videojuego elden ring ps5',
'Kitesurf completo principiante',
'Libro cocina japonesa',
'Entrada festival música',
'Guitarra acústica yamaha',
],
'contactos': [
'Busco compañero de piso',
'Quedada rol en la taberna',
'Club de lectura mensual',
'Grupo de running mañanas',
'Busco duo para ranked lol',
'Quedada idiomas intercambio',
'Compañero de escalada',
'Busco banda para batería',
],
}
_DESCRIPCIONES = {
'trabajo': 'Horario flexible. Se valora experiencia previa. Envía CV por correo.',
'venta': 'En perfecto estado. Precio negociable. Entrega en mano preferible.',
'servicios': 'Presupuesto sin compromiso. Atención rápida y profesional.',
'alquiler': 'Sin comisión de agencia. Gastos incluidos o no según caso.',
'compra': 'Busco buen estado y precio razonable. Pago al contado.',
'eventos': 'Entrada libre hasta completar aforo. Más info por privado.',
'tecnologia': 'Con garantía y caja original. Factura disponible.',
'hogar': 'Muy cuidado, sin ralladuras. Recogida en domicilio.',
'ocio': 'Perfecto para regalo o uso personal. Consulta detalles.',
'contactos': 'Gente con buen rollo y ganas de pasarlo bien. Únete!',
}
_CATEGORIAS = list(_TITULOS.keys())
def _random_date(days_back=90):
delta = timedelta(days=random.randint(0, days_back), hours=random.randint(0, 23))
return (datetime.now() - delta).strftime('%Y-%m-%d %H:%M')
def seed_anuncios(ciudades, por_ciudad=4):
"""Genera anuncios aleatorios distribuidos por ciudades."""
with _lock:
conn = _get_conn()
for ciudad in ciudades:
for _ in range(por_ciudad):
cat = random.choice(_CATEGORIAS)
titulo = random.choice(_TITULOS[cat])
contenido = _DESCRIPCIONES[cat]
contacto = f"{ciudad}@craigslist.local"
fecha = _random_date()
conn.execute('''
INSERT INTO anuncios (titulo, contenido, categoria, contacto, ciudad, fecha)
VALUES (?, ?, ?, ?, ?, ?)
''', (titulo, contenido, cat, contacto, ciudad, fecha))
conn.commit()
conn.close()
def migrar_ciudad_existentes(ciudad_default='madrid'):
"""Asigna ciudad a anuncios que aún no la tengan definida."""
with _lock:
conn = _get_conn()
conn.execute('''
UPDATE anuncios SET ciudad = ? WHERE ciudad IS NULL OR ciudad = ''
''', (ciudad_default,))
conn.commit()
conn.close()
+1
View File
@@ -0,0 +1 @@
Flask==3.0.0
+33
View File
@@ -0,0 +1,33 @@
import os
import sqlite3
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import models
CIUDADES = [
'madrid', 'barcelona', 'valencia', 'sevilla', 'zaragoza',
'malaga', 'murcia', 'palma', 'las-palmas', 'bilbao',
'alicante', 'cordoba', 'valladolid', 'vigo'
]
if __name__ == '__main__':
print("Inicializando base de datos...")
models.init_db()
print("Migrando ciudad en anuncios existentes...")
models.migrar_ciudad_existentes(ciudad_default='madrid')
conn = sqlite3.connect(os.path.join(os.environ.get('DATA_DIR', '/app/data'), 'anuncios.db'))
cur = conn.execute('SELECT COUNT(*) FROM anuncios')
count = cur.fetchone()[0]
conn.close()
if count == 0:
print("Generando anuncios aleatorios...")
models.seed_anuncios(ciudades=CIUDADES, por_ciudad=4)
else:
print(f"Ya existen {count} anuncios. No se generan seeds nuevos.")
print("Listo.")
+281
View File
@@ -0,0 +1,281 @@
import os
import sys
import textwrap
import models
def limpiar():
os.system('clear' if os.name != 'nt' else 'cls')
def pausa():
try:
input("\n [ENTER para continuar]")
except (EOFError, KeyboardInterrupt):
pass
def leer_linea(prompt, por_defecto=''):
try:
if por_defecto:
val = input(f" {prompt} [{por_defecto}]: ").strip()
return val if val else por_defecto
return input(f" {prompt}: ").strip()
except (EOFError, KeyboardInterrupt):
return None
def cabecera(titulo=""):
limpiar()
print()
print("=" * 64)
print(" MadCraig :: Madrid, Espana :: Terminal / SSH")
if titulo:
print(f" {titulo}")
print("=" * 64)
print()
def formatear_anuncio(a, idx=None):
prefijo = f" [{idx}] " if idx is not None else " "
lineas = []
lineas.append(f"{prefijo}[{a['categoria'].upper()}] {a['fecha']} (ID: {a['id']})")
lineas.append(f" Titulo: {a['titulo']}")
lineas.append(f" Contacto: {a['contacto']}")
lineas.append(" " + "-" * 58)
for parrafo in textwrap.wrap(a['contenido'], width=58):
lineas.append(f" {parrafo}")
lineas.append(" " + "=" * 58)
return "\n".join(lineas)
def menu_principal():
cabecera("Menu Principal")
print(" [L] Listar todos los anuncios")
print(" [C] Filtrar por categoria")
print(" [B] Buscar anuncios")
print(" [N] Nuevo anuncio")
print(" [M] Modificar anuncio")
print(" [E] Eliminar anuncio")
print(" [V] Ver anuncio por ID")
print(" [S] Salir")
print()
try:
op = input(" Opcion: ").strip().lower()
except (EOFError, KeyboardInterrupt):
op = 's'
return op
def pantalla_listar(anuncios=None, titulo="Anuncios"):
if anuncios is None:
anuncios = models.listar_anuncios()
cabecera(titulo)
if not anuncios:
print(" No hay anuncios.")
pausa()
return
for i, a in enumerate(anuncios, start=1):
print(formatear_anuncio(a, idx=i))
print()
print(f" Total: {len(anuncios)} anuncio(s)")
pausa()
def pantalla_filtrar_categoria():
cabecera("Filtrar por categoria")
categorias = models.listar_categorias()
if not categorias:
print(" No hay categorias disponibles.")
pausa()
return
print(" Categorias disponibles:")
for i, cat in enumerate(categorias, start=1):
print(f" [{i}] {cat}")
print()
cat = leer_linea("Nombre de la categoria")
if cat is None:
return
if not cat:
print(" Categoria vacia, mostrando todos.")
pausa()
pantalla_listar()
return
anuncios = models.listar_anuncios(categoria=cat)
pantalla_listar(anuncios, f"Categoria '{cat}'")
def pantalla_buscar():
cabecera("Buscar anuncios")
termino = leer_linea("Buscar (titulo/contenido)")
if termino is None:
return
if not termino:
print(" Busqueda vacia, mostrando todos.")
pausa()
pantalla_listar()
return
anuncios = models.listar_anuncios(busqueda=termino)
pantalla_listar(anuncios, f"Resultados para '{termino}'")
def pantalla_nuevo():
cabecera("Nuevo anuncio")
titulo = leer_linea("Titulo")
if titulo is None:
return
if not titulo:
print(" El titulo es obligatorio. Cancelado.")
pausa()
return
contenido = leer_linea("Contenido")
if contenido is None:
return
if not contenido:
print(" El contenido es obligatorio. Cancelado.")
pausa()
return
categoria = leer_linea("Categoria", "general")
if categoria is None:
return
contacto = leer_linea("Contacto", "madrid@craigslist.local")
if contacto is None:
return
aid = models.crear_anuncio(titulo, contenido, categoria, contacto)
print(f"\n Anuncio creado con ID {aid}.")
pausa()
def pantalla_editar():
cabecera("Modificar anuncio")
sid = leer_linea("ID del anuncio a modificar")
if sid is None:
return
if not sid.isdigit():
print(" ID no valido.")
pausa()
return
a = models.obtener_anuncio(int(sid))
if not a:
print(" Anuncio no encontrado.")
pausa()
return
print()
print(formatear_anuncio(a))
print()
print(" Deja en blanco para mantener el valor actual.")
print()
titulo = leer_linea("Nuevo titulo", a['titulo'])
if titulo is None:
return
contenido = leer_linea("Nuevo contenido", a['contenido'])
if contenido is None:
return
categoria = leer_linea("Nueva categoria", a['categoria'])
if categoria is None:
return
contacto = leer_linea("Nuevo contacto", a['contacto'])
if contacto is None:
return
campos = {}
if titulo != a['titulo']:
campos['titulo'] = titulo
if contenido != a['contenido']:
campos['contenido'] = contenido
if categoria != a['categoria']:
campos['categoria'] = categoria
if contacto != a['contacto']:
campos['contacto'] = contacto
if not campos:
print(" No se realizaron cambios.")
pausa()
return
if models.actualizar_anuncio(int(sid), **campos):
print(" Anuncio actualizado correctamente.")
else:
print(" No se pudo actualizar.")
pausa()
def pantalla_eliminar():
cabecera("Eliminar anuncio")
sid = leer_linea("ID del anuncio a eliminar")
if sid is None:
return
if not sid.isdigit():
print(" ID no valido.")
pausa()
return
a = models.obtener_anuncio(int(sid))
if not a:
print(" Anuncio no encontrado.")
pausa()
return
print()
print(formatear_anuncio(a))
print()
try:
conf = input(" Confirmar eliminacion [s/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
conf = 'n'
if conf == 's':
if models.borrar_anuncio(int(sid)):
print(" Anuncio eliminado.")
else:
print(" No se pudo eliminar.")
else:
print(" Cancelado.")
pausa()
def pantalla_ver():
cabecera("Ver anuncio")
sid = leer_linea("ID del anuncio")
if sid is None:
return
if not sid.isdigit():
print(" ID no valido.")
pausa()
return
a = models.obtener_anuncio(int(sid))
if not a:
print(" Anuncio no encontrado.")
pausa()
return
print()
print(formatear_anuncio(a))
pausa()
def main():
models.init_db()
while True:
op = menu_principal()
if op == 'l':
pantalla_listar()
elif op == 'c':
pantalla_filtrar_categoria()
elif op == 'b':
pantalla_buscar()
elif op == 'n':
pantalla_nuevo()
elif op == 'm':
pantalla_editar()
elif op == 'e':
pantalla_eliminar()
elif op == 'v':
pantalla_ver()
elif op == 's':
cabecera("Hasta luego!")
sys.exit(0)
else:
print(" Opcion no valida.")
pausa()
if __name__ == '__main__':
main()
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
set -e
# Arrancar servidor SSH
/usr/sbin/sshd
# Arrancar aplicacion web Flask
exec python3 /app/app.py
+422
View File
@@ -0,0 +1,422 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>{% if modo == 'ciudad' %}madcraig — {{ city }}{% else %}madcraig{% endif %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-body: #f5f7fa;
--bg-card: #ffffff;
--text-main: #1f2937;
--text-muted: #6b7280;
--border: #e5e7eb;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 1px 3px rgba(0,0,0,0.05), 0 1px 2px rgba(0,0,0,0.03);
--shadow-hover: 0 4px 6px rgba(0,0,0,0.05), 0 2px 4px rgba(0,0,0,0.03);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "JetBrains Mono", "JetBrainsMono Nerd Font", "Fira Code", monospace;
background: var(--bg-body);
color: var(--text-main);
margin: 0;
padding: 40px 24px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-y: scroll;
}
.container {
max-width: 860px;
margin: 0 auto;
}
h1 {
font-weight: 700;
font-size: 1.75rem;
margin: 0 0 6px 0;
letter-spacing: -0.5px;
color: #111827;
}
h1 a {
font-size: 1em;
}
.subtitle {
color: var(--text-muted);
font-size: 0.95rem;
margin: 0 0 20px 0;
}
h2 {
font-weight: 600;
font-size: 1.15rem;
margin: 32px 0 12px 0;
color: #111827;
}
/* City list */
.city-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
margin-top: 24px;
}
.city-card {
display: block;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
text-decoration: none;
color: var(--text-main);
font-weight: 600;
font-size: 0.95em;
box-shadow: var(--shadow);
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
}
.city-card:hover {
box-shadow: var(--shadow-hover);
transform: translateY(-1px);
border-color: #3b82f6;
text-decoration: none;
}
.city-card span {
color: var(--text-muted);
font-weight: 400;
font-size: 0.85em;
display: block;
margin-top: 4px;
}
/* Cards */
.anuncio {
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 4px solid #9ca3af;
border-radius: var(--radius);
padding: 16px 18px;
margin: 14px 0;
box-shadow: var(--shadow);
transition: box-shadow 0.2s ease, transform 0.2s ease;
font-size: 0.9em;
}
.anuncio:hover {
box-shadow: var(--shadow-hover);
transform: translateY(-1px);
}
.meta {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
font-size: 0.82em;
color: var(--text-muted);
}
.meta form {
display: inline;
margin: 0;
padding: 0;
border: none;
background: transparent;
}
.tag {
display: inline-block;
padding: 3px 8px;
border-radius: 6px;
font-size: 0.78em;
font-weight: 600;
background: #f3f4f6;
color: #4b5563;
border: none;
margin-right: 6px;
letter-spacing: 0.2px;
}
/* Colores por categoría */
.tag[data-categoria="general"] { background: #dbeafe; color: #1e40af; }
.tag[data-categoria="trabajo"] { background: #dcfce7; color: #166534; }
.tag[data-categoria="venta"] { background: #ffedd5; color: #9a3412; }
.tag[data-categoria="servicios"] { background: #f3e8ff; color: #6b21a8; }
.tag[data-categoria="alquiler"] { background: #ccfbf1; color: #0f766e; }
.tag[data-categoria="compra"] { background: #fce7f3; color: #9d174d; }
.tag[data-categoria="eventos"] { background: #fef9c3; color: #854d0e; }
.tag[data-categoria="tecnologia"] { background: #e0e7ff; color: #3730a3; }
.tag[data-categoria="hogar"] { background: #d1fae5; color: #065f46; }
.tag[data-categoria="ocio"] { background: #fee2e2; color: #991b1b; }
.tag[data-categoria="contactos"] { background: #cffafe; color: #155e75; }
.anuncio[data-categoria="general"] { border-left-color: #3b82f6; }
.anuncio[data-categoria="trabajo"] { border-left-color: #22c55e; }
.anuncio[data-categoria="venta"] { border-left-color: #f97316; }
.anuncio[data-categoria="servicios"] { border-left-color: #a855f7; }
.anuncio[data-categoria="alquiler"] { border-left-color: #14b8a6; }
.anuncio[data-categoria="compra"] { border-left-color: #ec4899; }
.anuncio[data-categoria="eventos"] { border-left-color: #eab308; }
.anuncio[data-categoria="tecnologia"] { border-left-color: #6366f1; }
.anuncio[data-categoria="hogar"] { border-left-color: #10b981; }
.anuncio[data-categoria="ocio"] { border-left-color: #ef4444; }
.anuncio[data-categoria="contactos"] { border-left-color: #06b6d4; }
.anuncio strong {
font-size: 1.02em;
font-weight: 600;
color: #111827;
}
.anuncio p {
margin: 6px 0 0 0;
color: #374151;
white-space: pre-wrap;
font-size: 0.92em;
line-height: 1.5;
}
/* Botones */
button {
font-family: inherit;
background: #111827;
color: #fff;
border: none;
border-radius: var(--radius-sm);
padding: 8px 14px;
cursor: pointer;
font-weight: 500;
font-size: 0.85em;
transition: background 0.15s ease, transform 0.1s ease;
}
button:hover {
background: #374151;
}
button:active {
transform: scale(0.98);
}
.meta button {
background: transparent;
color: #ef4444;
border: 1px solid #fecaca;
padding: 4px 10px;
font-size: 0.78em;
}
.meta button:hover {
background: #fef2f2;
}
/* Formularios */
form {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-top: 16px;
box-shadow: var(--shadow);
}
.filtros {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: flex-end;
}
.filtros > div {
flex: 1;
min-width: 160px;
}
.filtros > div:last-child {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
}
label {
display: block;
font-size: 0.85em;
font-weight: 500;
color: #4b5563;
margin-bottom: 6px;
}
input, textarea, select {
font-family: inherit;
width: 100%;
border: 1px solid #d1d5db;
border-radius: var(--radius-sm);
padding: 8px 10px;
background: #fff;
color: #1f2937;
font-size: 0.95em;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
}
.filtros input, .filtros select {
margin-bottom: 0;
}
a {
color: #2563eb;
text-decoration: none;
font-weight: 500;
font-size: 0.9em;
}
a:hover {
text-decoration: underline;
}
hr {
border: none;
border-top: 1px solid var(--border);
margin: 28px 0;
}
.empty {
color: var(--text-muted);
font-size: 0.95em;
margin: 12px 0;
}
.footer {
font-size: 0.8em;
color: var(--text-muted);
text-align: center;
margin-top: 8px;
}
.back-link {
display: inline-block;
margin-bottom: 16px;
font-size: 0.85em;
}
</style>
</head>
<body>
<div class="container">
<h1>{% if modo == 'ciudad' %}<a href="/" style="color:inherit;text-decoration:none;">madcraig</a> <span style="color:var(--text-muted);font-weight:400;">// {{ city }}</span>{% else %}madcraig{% endif %}</h1>
{% if modo == 'inicio' %}
<p class="subtitle">Selecciona una ciudad para ver los anuncios.</p>
<div class="city-list">
{% for c in ciudades %}
<a class="city-card" href="/c/{{ c }}">
{{ c|replace('-', ' ') }}
<span>/c/{{ c }}</span>
</a>
{% endfor %}
</div>
{% else %}
<h2>Buscar anuncios</h2>
<form method="get" action="/c/{{ city }}" class="filtros">
<div>
<label for="q">Busqueda</label>
<input type="text" id="q" name="q" value="{{ busqueda_actual }}" placeholder="Titulo o contenido...">
</div>
<div>
<label for="categoria">Categoria</label>
<select id="categoria" name="categoria">
<option value="">Todas</option>
{% for cat in categorias %}
<option value="{{ cat }}" {% if categoria_actual == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
</div>
<div>
<button type="submit">Filtrar</button>
{% if categoria_actual or busqueda_actual %}
<a href="/c/{{ city }}">Limpiar</a>
{% endif %}
</div>
</form>
<hr>
<h2>Anuncios</h2>
{% for a in anuncios %}
<div class="anuncio" data-categoria="{{ a.categoria|lower }}" data-id="{{ a.id }}">
<div class="meta">
<span>
<span class="tag" data-categoria="{{ a.categoria|lower }}">{{ a.categoria }}</span>
<span>{{ a.fecha }} &middot; {{ a.contacto }}</span>
</span>
<form class="borrar-form" action="/borrar/{{ a.id }}" method="post" onsubmit="return confirm('Borrar este anuncio?');" style="display:none">
<input type="hidden" name="token" class="token-input">
<button type="submit">Borrar</button>
</form>
</div>
<strong>{{ a.titulo }}</strong>
<p>{{ a.contenido }}</p>
</div>
{% else %}
<p class="empty">No hay anuncios todavia.</p>
{% endfor %}
{% endif %}
<hr>
<p class="footer">madcraig — No rights reserved.</p>
</div>
<script>
(function() {
const KEY = 'mc_tokens';
function getTokens() {
try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch(e) { return {}; }
}
function setTokens(obj) {
localStorage.setItem(KEY, JSON.stringify(obj));
}
// Guardar token de un anuncio recién creado (viene por query string)
const params = new URLSearchParams(location.search);
const nuevoId = params.get('nuevo');
const nuevoToken = params.get('token');
if (nuevoId && nuevoToken) {
const t = getTokens();
t[nuevoId] = nuevoToken;
setTokens(t);
// Limpiar query string sin recargar
history.replaceState({}, '', location.pathname + location.hash);
}
// Mostrar botón borrar solo para anuncios propios
const tokens = getTokens();
document.querySelectorAll('.anuncio').forEach(function(card) {
const id = card.getAttribute('data-id');
const form = card.querySelector('.borrar-form');
const input = card.querySelector('.token-input');
if (form && tokens[id]) {
input.value = tokens[id];
form.style.display = 'inline';
}
});
})();
</script>
</body>
</html>