ENRE_WEB/gestor/pedir.go
SITO ce3127baff Sección «Pedir evento» con gestor de reservas y calendario legible en móvil
- /pedir/: calendario de fechas pedidas (negro) y confirmadas (verde),
  findes tranqui y con ruido, solo findes por defecto con opción de ver la
  semana entera. Se pincha un día libre y, sin sesión, se pide entrar y se
  vuelve al formulario con la fecha puesta.
- Gestor en Go (gestor/): cuentas por colectivo con argon2id, sesiones y
  CSRF, cupo y ventana de la asamblea, caducidades, registro append-only.
- La cuenta de la asamblea confirma, rechaza, cambia turnos de finde y en
  «Todas las fechas» quita o modifica cualquier evento.
- Orden «cuenta» y «clave» con -clave y -definitiva para fijar contraseñas
  desde el servidor; ninguna contraseña va en el repositorio.
- Botón «Pedir evento» arriba de la barra lateral en todas las páginas.
- Calendario semanal en móvil: lista de días con nombre y hora de cada cita.
- Fuentes servidas desde fuentes/: fuera Google Fonts en todas las páginas.
2026-09-23 11:32:25 +02:00

1042 lines
31 KiB
Go

package main
import (
"bytes"
"fmt"
"log"
"net"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
const cookieSesion = "enre_pedir"
func (a *App) rutas() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /pedir/{$}", a.verPortada)
mux.HandleFunc("POST /pedir/entrar", a.entrar)
mux.HandleFunc("POST /pedir/salir", a.salir)
mux.HandleFunc("GET /pedir/mis-fechas", a.conSesion(a.verMisFechas))
mux.HandleFunc("GET /pedir/nueva", a.conSesion(a.verNueva))
mux.HandleFunc("POST /pedir/nueva", a.conSesion(a.hacerNueva))
mux.HandleFunc("POST /pedir/liberar", a.conSesion(a.hacerLiberar))
mux.HandleFunc("GET /pedir/pendientes", a.soloEnre(a.verPendientes))
mux.HandleFunc("POST /pedir/resolver", a.soloEnre(a.hacerResolver))
mux.HandleFunc("GET /pedir/todas", a.soloEnre(a.verTodas))
mux.HandleFunc("GET /pedir/editar", a.soloEnre(a.verEditar))
mux.HandleFunc("POST /pedir/editar", a.soloEnre(a.hacerEditar))
mux.HandleFunc("GET /pedir/findes", a.soloEnre(a.verFindes))
mux.HandleFunc("POST /pedir/findes", a.soloEnre(a.hacerFinde))
mux.HandleFunc("GET /pedir/cuentas", a.soloEnre(a.verCuentas))
mux.HandleFunc("POST /pedir/cuentas", a.soloEnre(a.hacerCuenta))
mux.HandleFunc("GET /pedir/clave", a.conSesion(a.verClave))
mux.HandleFunc("POST /pedir/clave", a.conSesion(a.hacerClave))
if a.cfg.Local && a.cfg.DirEstatico != "" {
// Solo para probar y para pasar el banco de pruebas sin nginx: en el
// servidor las paginas estaticas las sirve nginx y esto no se enciende.
mux.Handle("GET /", http.FileServer(http.Dir(a.cfg.DirEstatico)))
}
return a.conCabeceras(mux)
}
// conCabeceras pone las cabeceras de seguridad y descarta los POST que no
// vengan de nuestro propio formulario.
func (a *App) conCabeceras(siguiente http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
enPedir := strings.HasPrefix(r.URL.Path, "/pedir/")
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
// same-origin y no no-referrer: con no-referrer el navegador manda
// «Origin: null» en los POST y origenValido rechaza el acceso. Así no
// sale nada hacia fuera y dentro de la web el Origen llega entero.
h.Set("Referrer-Policy", "same-origin")
if enPedir {
// Estas paginas no llevan ni una linea de JavaScript ni un estilo
// en linea, asi que el navegador puede tenerlo prohibido del todo.
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self'; "+
"img-src 'self'; font-src 'self'; form-action 'self'; "+
"frame-ancestors 'none'; base-uri 'none'; script-src 'none'")
h.Set("X-Frame-Options", "DENY")
h.Set("Cache-Control", "no-store")
}
if r.Method == http.MethodPost {
// nginx ya corta en 16k, pero el limite tiene que estar tambien
// aqui: el gestor no depende de que haya un proxy delante.
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
if !a.origenValido(r) {
log.Printf("origen rechazado: %s %s host=%q origin=%q referer=%q",
r.Method, r.URL.Path, r.Host, r.Header.Get("Origin"), r.Header.Get("Referer"))
a.fallo(w, r, http.StatusForbidden,
"El formulario no viene de esta web. Vuelve a entrar desde el principio.")
return
}
}
siguiente.ServeHTTP(w, r)
})
}
// origenValido compara el Origen del formulario con el de la web. Es la barrera
// contra el CSRF que no necesita sesion, y por eso protege tambien el acceso.
func (a *App) origenValido(r *http.Request) bool {
esperado := a.anfitrionEsperado()
comprobar := func(crudo string) (bool, bool) {
if crudo == "" {
return false, false
}
u, err := url.Parse(crudo)
if err != nil || u.Host == "" {
return false, true
}
return strings.EqualFold(u.Host, esperado) || strings.EqualFold(u.Host, r.Host), true
}
if ok, habia := comprobar(r.Header.Get("Origin")); habia {
return ok
}
if ok, habia := comprobar(r.Header.Get("Referer")); habia {
return ok
}
// Sin Origin ni Referer queda el testigo CSRF, que se comprueba aparte.
return true
}
func (a *App) anfitrionEsperado() string {
if u, err := url.Parse(a.cfg.BaseURL); err == nil && u.Host != "" {
return u.Host
}
return a.cfg.Escucha
}
// ipCliente devuelve la IP real. Solo se hace caso de las cabeceras del proxy
// cuando la conexion viene de la propia maquina, que es donde corre nginx.
func ipCliente(r *http.Request) string {
anfitrion, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
anfitrion = r.RemoteAddr
}
if ip := net.ParseIP(anfitrion); ip != nil && ip.IsLoopback() {
if v := r.Header.Get("X-Real-IP"); v != "" {
return v
}
if v := r.Header.Get("X-Forwarded-For"); v != "" {
partes := strings.Split(v, ",")
return strings.TrimSpace(partes[len(partes)-1])
}
}
return anfitrion
}
// ---------------------------------------------------------------- plantillas
// Base es lo que necesita el armazón de la página: el título, quién ha entrado
// y los avisos.
type Base struct {
Titulo string
Sub string
Actual string // qué entrada del menú de la sección va marcada
Dentro bool
Usuario string
Nombre string
EsEnre bool
CSRF string
Aviso string
Error string
Reglas Reglas
Hasta string
Sig string // adónde volver después de entrar, ya comprobado
}
// destinoSeguro deja pasar solo rutas de la propia sección: nada de otro
// dominio ni de «//host», para que el parámetro sig no sirva de redirección
// abierta.
func destinoSeguro(sig string) string {
u, err := url.Parse(sig)
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil ||
!strings.HasPrefix(u.Path, "/pedir/") || strings.ContainsAny(sig, "\\\r\n") ||
strings.HasPrefix(sig, "//") {
return ""
}
return u.RequestURI()
}
// conSig añade a una ruta el destino al que hay que volver, si lo hay.
func conSig(ruta, sig string) string {
if sig == "" {
return ruta
}
return ruta + "?sig=" + url.QueryEscape(sig)
}
func (a *App) base(titulo, sub, actual string, c Cuenta, ses *Sesion) Base {
_, ultimo := a.cfg.Reglas.Ventana(DiaDe(time.Now()))
b := Base{
Titulo: titulo, Sub: sub, Actual: actual,
Reglas: a.cfg.Reglas, Hasta: ultimo.Legible(),
}
if ses != nil {
b.Dentro = true
b.Usuario = c.Usuario
b.Nombre = c.Nombre
b.EsEnre = c.Rol == RolEnre
b.CSRF = ses.CSRF
}
return b
}
// pintar genera la pagina entera en memoria antes de mandarla, para no dejar a
// medias una respuesta si falla la plantilla.
func (a *App) pintar(w http.ResponseWriter, plantilla string, datos any) {
var buf bytes.Buffer
if err := a.plantillas.ExecuteTemplate(&buf, plantilla, datos); err != nil {
log.Printf("plantilla %s: %v", plantilla, err)
http.Error(w, "Error al montar la página", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(buf.Bytes())
}
func (a *App) fallo(w http.ResponseWriter, r *http.Request, codigo int, mensaje string) {
ses, c, _ := a.sesion(r)
datos := struct {
Base
Mensaje string
}{Base: a.base("No se puede", "", "", c, ses), Mensaje: mensaje}
w.WriteHeader(codigo)
a.pintar(w, "fallo.tmpl.html", datos)
}
// --------------------------------------------------------------- middlewares
func (a *App) sesion(r *http.Request) (*Sesion, Cuenta, bool) {
ck, err := r.Cookie(cookieSesion)
if err != nil {
return nil, Cuenta{}, false
}
ses, ok := a.ses.Buscar(ck.Value)
if !ok {
return nil, Cuenta{}, false
}
c, ok := a.alm.BuscarCuenta(ses.Usuario)
if !ok || !c.Activa {
a.ses.Borrar(ses.ID)
return nil, Cuenta{}, false
}
return ses, c, true
}
type manejador func(http.ResponseWriter, *http.Request, Cuenta, *Sesion)
func (a *App) conSesion(siguiente manejador) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ses, c, ok := a.sesion(r)
// Si alguien pincha una fecha sin haber entrado, se le lleva a la
// entrada y, al entrar, vuelve a donde iba.
sig := ""
if r.Method == http.MethodGet {
sig = destinoSeguro(r.URL.RequestURI())
}
if !ok {
http.Redirect(w, r, conSig("/pedir/", sig)+"#entrar", http.StatusSeeOther)
return
}
if r.Method == http.MethodPost && !a.csrfValido(ses, r) {
a.fallo(w, r, http.StatusForbidden,
"El formulario ha caducado. Recarga la página y vuelve a enviarlo.")
return
}
// Con clave temporal no se puede hacer nada mas que cambiarla.
if c.Temporal && r.URL.Path != "/pedir/clave" {
http.Redirect(w, r, conSig("/pedir/clave", sig), http.StatusSeeOther)
return
}
siguiente(w, r, c, ses)
}
}
func (a *App) soloEnre(siguiente manejador) http.HandlerFunc {
return a.conSesion(func(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
if c.Rol != RolEnre {
a.fallo(w, r, http.StatusForbidden,
"Esto solo lo puede hacer la cuenta de la Enredadera, que es la que decide en asamblea.")
return
}
siguiente(w, r, c, ses)
})
}
func (a *App) csrfValido(ses *Sesion, r *http.Request) bool {
if err := r.ParseForm(); err != nil {
return false
}
return iguales(r.PostFormValue("csrf"), ses.CSRF)
}
// ------------------------------------------------------------------ portada
func (a *App) verPortada(w http.ResponseWriter, r *http.Request) {
ses, c, dentro := a.sesion(r)
v := a.vista()
datos := struct {
Base
Meses []Mes
Proximas []Evento
Nombres map[string]string
}{
Base: a.base("PEDIR EVENTO", "Qué findes están cogidos y cómo se pide uno", "calendario", c, ses),
Meses: v.Meses(a.cfg.MesesVista),
Proximas: v.Proximas(),
Nombres: v.nombres,
}
sig := destinoSeguro(r.URL.Query().Get("sig"))
if dentro && c.Temporal {
http.Redirect(w, r, conSig("/pedir/clave", sig), http.StatusSeeOther)
return
}
if dentro && sig != "" {
http.Redirect(w, r, sig, http.StatusSeeOther)
return
}
datos.Sig = sig
datos.Aviso = r.URL.Query().Get("a")
if sig != "" {
datos.Aviso = "Para pedir esa fecha, entra con las credenciales de tu colectivo."
}
a.pintar(w, "portada.tmpl.html", datos)
}
func (a *App) entrar(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
a.fallo(w, r, http.StatusBadRequest, "Formulario ilegible.")
return
}
// Para el acceso se exige Origen conocido: sin sesion todavia no hay testigo
// CSRF que comprobar.
if r.Header.Get("Origin") == "" && r.Header.Get("Referer") == "" {
a.fallo(w, r, http.StatusForbidden,
"Entra desde la página de acceso, no desde un enlace de fuera.")
return
}
usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario")))
clave := r.PostFormValue("clave")
ip := ipCliente(r)
cerrojos := []string{"ip:" + ip, "usuario:" + usuario}
if bloqueado, falta := a.cer.Bloqueado(cerrojos...); bloqueado {
a.auth.Escribir("AUTH_BLOCK", ip, usuario, "demasiados_intentos")
a.errorAcceso(w, r, fmt.Sprintf("Demasiados intentos. Prueba otra vez en %d minutos.",
int(falta.Minutes())+1))
return
}
c, existe := a.alm.BuscarCuenta(usuario)
if !existe || !c.Activa {
// Se gasta el mismo tiempo que con una cuenta real para que no se pueda
// averiguar que usuarios existen.
VerificarClave(hashInutil, clave)
a.cer.Fallo(cerrojos...)
a.auth.Escribir("AUTH_FAIL", ip, usuario, "cuenta_desconocida")
a.errorAcceso(w, r, "Usuario o contraseña incorrectos.")
return
}
if !VerificarClave(c.Hash, clave) {
a.cer.Fallo(cerrojos...)
a.auth.Escribir("AUTH_FAIL", ip, usuario, "clave_incorrecta")
a.errorAcceso(w, r, "Usuario o contraseña incorrectos.")
return
}
a.cer.Limpiar(cerrojos...)
a.auth.Escribir("AUTH_OK", ip, usuario, "-")
ses := a.ses.Crear(c.Usuario)
a.ponerCookie(w, ses.ID, 0)
sig := destinoSeguro(r.PostFormValue("sig"))
destino := "/pedir/mis-fechas"
if sig != "" {
destino = sig
}
if c.Temporal {
destino = conSig("/pedir/clave", sig)
}
http.Redirect(w, r, destino, http.StatusSeeOther)
}
func (a *App) errorAcceso(w http.ResponseWriter, r *http.Request, mensaje string) {
v := a.vista()
datos := struct {
Base
Meses []Mes
Proximas []Evento
}{
Base: a.base("PEDIR EVENTO", "Qué findes están cogidos y cómo se pide uno", "calendario", Cuenta{}, nil),
Meses: v.Meses(a.cfg.MesesVista),
Proximas: v.Proximas(),
}
datos.Error = mensaje
datos.Sig = destinoSeguro(r.PostFormValue("sig"))
w.WriteHeader(http.StatusUnauthorized)
a.pintar(w, "portada.tmpl.html", datos)
}
func (a *App) ponerCookie(w http.ResponseWriter, valor string, maxEdad int) {
http.SetCookie(w, &http.Cookie{
Name: cookieSesion,
Value: valor,
Path: "/pedir",
HttpOnly: true,
Secure: a.cfg.CookieSegura,
SameSite: http.SameSiteStrictMode,
MaxAge: maxEdad,
})
}
func (a *App) salir(w http.ResponseWriter, r *http.Request) {
if ses, _, ok := a.sesion(r); ok {
if a.csrfValido(ses, r) {
a.ses.Borrar(ses.ID)
}
}
a.ponerCookie(w, "", -1)
http.Redirect(w, r, "/pedir/", http.StatusSeeOther)
}
// --------------------------------------------------------------- mis fechas
type FilaMia struct {
Evento Evento
Dias int
Legible string
Etiqueta string
Caduca string
Liberable bool
}
func (a *App) filaMia(e Evento) FilaMia {
f := FilaMia{
Evento: e, Dias: e.NumDias(), Legible: rangoLegible(e),
Etiqueta: e.Estado.Etiqueta(), Liberable: e.Estado.Ocupa(),
}
if e.Estado == Solicitado {
limite := e.Desde.Mas(-a.cfg.Reglas.DiasMinimoConfirmar)
porTiempo := DiaDe(e.Creado.AddDate(0, 0, a.cfg.Reglas.DiasMaximoPendiente))
if porTiempo < limite {
limite = porTiempo
}
f.Caduca = limite.Legible()
}
return f
}
func (a *App) verMisFechas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
hoy := DiaDe(time.Now())
eventos := a.alm.Eventos()
usados := a.cfg.Reglas.CupoUsado(c.Usuario, eventos, hoy, "")
var mias []FilaMia
for _, e := range eventos {
if e.Solicitante != c.Usuario || !e.Estado.Ocupa() || e.Hasta < hoy {
continue
}
mias = append(mias, a.filaMia(e))
}
datos := struct {
Base
Mias []FilaMia
Usados int
Restantes int
DiasUsados string
}{
Base: a.base("VUESTRAS FECHAS", c.Nombre, "mis-fechas", c, ses),
Mias: mias,
Usados: len(usados),
Restantes: max0(a.cfg.Reglas.CupoDias - len(usados)),
DiasUsados: listaDias(usados),
}
datos.Aviso = r.URL.Query().Get("a")
a.pintar(w, "mis-fechas.tmpl.html", datos)
}
func rangoLegible(e Evento) string {
if e.Desde == e.Hasta {
return e.Desde.Legible()
}
return fmt.Sprintf("del %s al %s", e.Desde.Legible(), e.Hasta.Legible())
}
func max0(n int) int {
if n < 0 {
return 0
}
return n
}
// -------------------------------------------------------------- pedir fecha
func (a *App) verNueva(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
s := Solicitud{}
// Viene de pinchar un día del calendario.
if f := Dia(r.URL.Query().Get("fecha")); f != "" {
if _, err := f.Time(); err == nil {
s.Desde = f
}
}
a.formularioNueva(w, c, ses, s, "")
}
func (a *App) formularioNueva(w http.ResponseWriter, c Cuenta, ses *Sesion, s Solicitud, fallo string) {
hoy := DiaDe(time.Now())
primero, ultimo := a.cfg.Reglas.Ventana(hoy)
usados := a.cfg.Reglas.CupoUsado(c.Usuario, a.alm.Eventos(), hoy, "")
datos := struct {
Base
Sol Solicitud
Primero Dia
Ultimo Dia
Restantes int
DiasUsados string
}{
Base: a.base("PEDIR UNA FECHA", c.Nombre, "nueva", c, ses),
Sol: s,
Primero: primero,
Ultimo: ultimo,
Restantes: max0(a.cfg.Reglas.CupoDias - len(usados)),
DiasUsados: listaDias(usados),
}
datos.Error = fallo
a.pintar(w, "nueva.tmpl.html", datos)
}
func (a *App) hacerNueva(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
s := Solicitud{
Solicitante: c.Usuario,
Rol: c.Rol,
Titulo: recortar(r.PostFormValue("titulo"), 80),
Amadrinado: recortar(r.PostFormValue("amadrinado"), 80),
Contacto: recortar(r.PostFormValue("contacto"), 120),
Desde: Dia(strings.TrimSpace(r.PostFormValue("desde"))),
Hasta: Dia(strings.TrimSpace(r.PostFormValue("hasta"))),
Hora: strings.TrimSpace(r.PostFormValue("hora")),
Notas: recortar(r.PostFormValue("notas"), 500),
}
if s.Hasta == "" {
s.Hasta = s.Desde
}
a.mu.Lock()
hoy := DiaDe(time.Now())
err := a.cfg.Reglas.Validar(s, a.alm.Eventos(), hoy)
var e Evento
if err == nil {
estado := Solicitado
if c.Rol == RolEnre {
estado = Confirmado // la asamblea de la casa no se pide permiso a si misma
}
e = Evento{
ID: nuevoID(), Titulo: s.Titulo, Solicitante: c.Usuario,
Amadrinado: s.Amadrinado, Contacto: s.Contacto,
Desde: s.Desde, Hasta: s.Hasta, Hora: s.Hora, Notas: s.Notas,
Estado: estado, Creado: time.Now(),
}
if estado == Confirmado {
e.Resuelto = e.Creado
e.ResueltoPor = c.Usuario
}
err = a.alm.GuardarEvento(c.Usuario, e)
}
a.mu.Unlock()
if err != nil {
a.formularioNueva(w, c, ses, s, primeraMayuscula(err.Error()))
return
}
log.Printf("%s pide %s (%s) como %s", c.Usuario, rangoLegible(e), e.Titulo, e.Estado)
http.Redirect(w, r, "/pedir/mis-fechas?a="+url.QueryEscape("Fecha apuntada: "+rangoLegible(e)),
http.StatusSeeOther)
}
func (a *App) hacerLiberar(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
id := r.PostFormValue("id")
a.mu.Lock()
e, ok := a.alm.BuscarEvento(id)
var err error
switch {
case !ok:
err = fmt.Errorf("esa fecha ya no existe")
case e.Solicitante != c.Usuario && c.Rol != RolEnre:
err = fmt.Errorf("esa fecha no es vuestra")
case !e.Estado.Ocupa():
err = fmt.Errorf("esa fecha ya estaba %s", strings.ToLower(e.Estado.Etiqueta()))
default:
e.Estado = Cancelado
e.Resuelto = time.Now()
e.ResueltoPor = c.Usuario
e.Motivo = "liberada por " + c.Nombre
err = a.alm.GuardarEvento(c.Usuario, e)
}
a.mu.Unlock()
if err != nil {
a.fallo(w, r, http.StatusBadRequest, primeraMayuscula(err.Error()))
return
}
destino := "/pedir/mis-fechas"
if r.PostFormValue("volver") == "todas" && c.Rol == RolEnre {
destino = "/pedir/todas"
}
http.Redirect(w, r, destino+"?a="+url.QueryEscape("Fecha liberada: "+rangoLegible(e)),
http.StatusSeeOther)
}
// ------------------------------------------------------------------- todas
// FilaTodas es un evento en la lista de la asamblea, con quién lo pide.
type FilaTodas struct {
FilaMia
Quien string
}
// verTodas enseña a la asamblea todos los eventos que vienen, de cualquier
// colectivo, para quitarlos o modificarlos. «Vuestras fechas» solo enseña los
// de la propia cuenta.
func (a *App) verTodas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
hoy := DiaDe(time.Now())
nombres := a.nombres()
var filas []FilaTodas
for _, e := range a.alm.Eventos() {
if !e.Estado.Ocupa() || e.Hasta < hoy {
continue
}
quien := nombres[e.Solicitante]
if quien == "" {
quien = e.Solicitante
}
if e.Externo() {
quien = fmt.Sprintf("%s · amadrina %s", e.Amadrinado, quien)
}
filas = append(filas, FilaTodas{FilaMia: a.filaMia(e), Quien: quien})
}
sort.Slice(filas, func(i, j int) bool { return filas[i].Evento.Desde < filas[j].Evento.Desde })
datos := struct {
Base
Filas []FilaTodas
}{
Base: a.base("TODAS LAS FECHAS", "Lo que viene, de todos los colectivos", "todas", c, ses),
Filas: filas,
}
datos.Aviso = r.URL.Query().Get("a")
a.pintar(w, "todas.tmpl.html", datos)
}
func (a *App) verEditar(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
e, ok := a.alm.BuscarEvento(r.URL.Query().Get("id"))
if !ok || !e.Estado.Ocupa() {
a.fallo(w, r, http.StatusNotFound, "Ese evento no existe o ya no ocupa ninguna fecha.")
return
}
a.formularioEditar(w, c, ses, e, "")
}
func (a *App) formularioEditar(w http.ResponseWriter, c Cuenta, ses *Sesion, e Evento, fallo string) {
datos := struct {
Base
Evento Evento
Quien string
}{
Base: a.base("MODIFICAR EVENTO", e.Titulo, "todas", c, ses),
Evento: e,
Quien: a.nombres()[e.Solicitante],
}
datos.Error = fallo
a.pintar(w, "editar.tmpl.html", datos)
}
// hacerEditar cambia un evento de cualquier colectivo. Pasa por Validar como
// la asamblea, que no tiene cupo ni ventana, pero sí respeta que no se pisen
// dos eventos en el mismo día.
func (a *App) hacerEditar(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
a.mu.Lock()
e, ok := a.alm.BuscarEvento(r.PostFormValue("id"))
if !ok || !e.Estado.Ocupa() {
a.mu.Unlock()
a.fallo(w, r, http.StatusNotFound, "Ese evento no existe o ya no ocupa ninguna fecha.")
return
}
nuevo := e
nuevo.Titulo = recortar(r.PostFormValue("titulo"), 80)
nuevo.Amadrinado = recortar(r.PostFormValue("amadrinado"), 80)
nuevo.Contacto = recortar(r.PostFormValue("contacto"), 120)
nuevo.Desde = Dia(strings.TrimSpace(r.PostFormValue("desde")))
nuevo.Hasta = Dia(strings.TrimSpace(r.PostFormValue("hasta")))
nuevo.Hora = strings.TrimSpace(r.PostFormValue("hora"))
nuevo.Notas = recortar(r.PostFormValue("notas"), 500)
if nuevo.Hasta == "" {
nuevo.Hasta = nuevo.Desde
}
switch Estado(r.PostFormValue("estado")) {
case Solicitado:
nuevo.Estado = Solicitado
case Confirmado:
nuevo.Estado = Confirmado
}
s := Solicitud{
Solicitante: e.Solicitante, Rol: RolEnre,
Titulo: nuevo.Titulo, Amadrinado: nuevo.Amadrinado, Contacto: nuevo.Contacto,
Desde: nuevo.Desde, Hasta: nuevo.Hasta, Hora: nuevo.Hora, Notas: nuevo.Notas,
Excluir: e.ID,
}
err := a.cfg.Reglas.Validar(s, a.alm.Eventos(), DiaDe(time.Now()))
if err == nil {
if nuevo.Estado != e.Estado {
nuevo.Resuelto = time.Now()
nuevo.ResueltoPor = c.Usuario
}
nuevo.Motivo = "modificado por " + c.Nombre
err = a.alm.GuardarEvento(c.Usuario, nuevo)
}
a.mu.Unlock()
if err != nil {
a.formularioEditar(w, c, ses, nuevo, primeraMayuscula(err.Error()))
return
}
http.Redirect(w, r, "/pedir/todas?a="+url.QueryEscape("Evento modificado: "+nuevo.Titulo),
http.StatusSeeOther)
}
// ---------------------------------------------------------------- pendientes
type FilaPendiente struct {
Evento Evento
Quien string
Legible string
Dias int
Faltan int
Caduca string
Turno string
}
func (a *App) verPendientes(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
hoy := DiaDe(time.Now())
nombres := a.nombres()
exc := a.alm.Excepciones()
var filas []FilaPendiente
for _, e := range a.alm.Eventos() {
if e.Estado != Solicitado {
continue
}
quien := nombres[e.Solicitante]
if quien == "" {
quien = e.Solicitante
}
if e.Externo() {
quien = fmt.Sprintf("%s, amadrinado por %s", e.Amadrinado, quien)
}
filas = append(filas, FilaPendiente{
Evento: e,
Quien: quien,
Legible: rangoLegible(e),
Dias: e.NumDias(),
Faltan: int(e.Desde.MustTime().Sub(hoy.MustTime()).Hours() / 24),
Caduca: a.filaMia(e).Caduca,
Turno: a.cfg.Reglas.TurnoDe(e.Desde, exc).Etiqueta(),
})
}
sort.Slice(filas, func(i, j int) bool { return filas[i].Evento.Desde < filas[j].Evento.Desde })
datos := struct {
Base
Filas []FilaPendiente
}{Base: a.base("PENDIENTES", "Lo que espera a la asamblea", "pendientes", c, ses), Filas: filas}
datos.Aviso = r.URL.Query().Get("a")
a.pintar(w, "pendientes.tmpl.html", datos)
}
func (a *App) hacerResolver(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
id := r.PostFormValue("id")
accion := r.PostFormValue("accion")
motivo := recortar(r.PostFormValue("motivo"), 200)
a.mu.Lock()
e, ok := a.alm.BuscarEvento(id)
var err error
switch {
case !ok:
err = fmt.Errorf("esa solicitud ya no existe")
case e.Estado != Solicitado:
err = fmt.Errorf("esa solicitud ya estaba %s", strings.ToLower(e.Estado.Etiqueta()))
case accion == "confirmar":
e.Estado = Confirmado
case accion == "rechazar":
e.Estado = Rechazado
default:
err = fmt.Errorf("acción desconocida")
}
if err == nil {
e.Resuelto = time.Now()
e.ResueltoPor = c.Usuario
e.Motivo = motivo
err = a.alm.GuardarEvento(c.Usuario, e)
}
a.mu.Unlock()
if err != nil {
a.fallo(w, r, http.StatusBadRequest, primeraMayuscula(err.Error()))
return
}
log.Printf("%s %s %q de %s", c.Usuario, e.Estado, e.Titulo, e.Solicitante)
http.Redirect(w, r, "/pedir/pendientes?a="+url.QueryEscape(
fmt.Sprintf("«%s» queda %s", e.Titulo, strings.ToLower(e.Estado.Etiqueta()))),
http.StatusSeeOther)
}
// -------------------------------------------------------------------- findes
type FilaFinde struct {
Viernes Dia
Legible string
Turno Turno
Etiqueta string
AMano bool
}
func (a *App) verFindes(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
exc := a.alm.Excepciones()
viernes := DiaDe(time.Now())
for viernes.DiaISO() != 5 {
viernes = viernes.Mas(1)
}
var filas []FilaFinde
for i := 0; i < 16; i++ {
v := viernes.Mas(7 * i)
t := a.cfg.Reglas.TurnoDe(v, exc)
_, aMano := exc[v]
filas = append(filas, FilaFinde{
Viernes: v, Legible: v.Legible(), Turno: t, Etiqueta: t.Etiqueta(), AMano: aMano,
})
}
datos := struct {
Base
Filas []FilaFinde
}{Base: a.base("TURNO DE LOS FINDES", "Tranquilo o con ruido", "findes", c, ses), Filas: filas}
datos.Aviso = r.URL.Query().Get("a")
a.pintar(w, "findes.tmpl.html", datos)
}
func (a *App) hacerFinde(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
v := Dia(strings.TrimSpace(r.PostFormValue("viernes")))
turno := Turno(r.PostFormValue("turno"))
if _, err := v.Time(); err != nil || v.DiaISO() != 5 {
a.fallo(w, r, http.StatusBadRequest, "Los turnos se marcan sobre el viernes del finde.")
return
}
if turno != Tranqui && turno != Ruido && turno != "" {
a.fallo(w, r, http.StatusBadRequest, "Turno desconocido.")
return
}
if err := a.alm.GuardarExcepcion(c.Usuario, Excepcion{Viernes: v, Turno: turno}); err != nil {
a.fallo(w, r, http.StatusInternalServerError, "No he podido guardarlo.")
return
}
aviso := fmt.Sprintf("El finde del %s queda como %s", v.Legible(), strings.ToLower(turno.Etiqueta()))
if turno == "" {
aviso = fmt.Sprintf("El finde del %s vuelve a la alternancia automática", v.Legible())
}
http.Redirect(w, r, "/pedir/findes?a="+url.QueryEscape(aviso), http.StatusSeeOther)
}
// ------------------------------------------------------------------- cuentas
func (a *App) verCuentas(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
datos := struct {
Base
Cuentas []Cuenta
Clave string
DeQuien string
}{
Base: a.base("CUENTAS", "Quién puede pedir fechas", "cuentas", c, ses),
Cuentas: a.alm.Cuentas(),
}
datos.DeQuien, datos.Clave = a.ses.TomarClave(ses.ID)
datos.Aviso = r.URL.Query().Get("a")
datos.Error = r.URL.Query().Get("e")
a.pintar(w, "cuentas.tmpl.html", datos)
}
func (a *App) hacerCuenta(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
accion := r.PostFormValue("accion")
usuario := strings.ToLower(strings.TrimSpace(r.PostFormValue("usuario")))
volver := func(params string) { http.Redirect(w, r, "/pedir/cuentas"+params, http.StatusSeeOther) }
problema := func(err error) { volver("?e=" + url.QueryEscape(primeraMayuscula(err.Error()))) }
switch accion {
case "crear":
nombre := recortar(r.PostFormValue("nombre"), 60)
rol := Rol(r.PostFormValue("rol"))
if err := ValidarUsuario(usuario); err != nil {
problema(err)
return
}
if nombre == "" {
problema(fmt.Errorf("falta el nombre del colectivo"))
return
}
if rol != RolEnre && rol != RolInterno {
problema(fmt.Errorf("rol desconocido"))
return
}
if _, existe := a.alm.BuscarCuenta(usuario); existe {
problema(fmt.Errorf("ya existe la cuenta %q", usuario))
return
}
clave := ClaveTemporal()
nueva := Cuenta{
Usuario: usuario, Nombre: nombre, Rol: rol,
Hash: HashClave(clave), Temporal: true, Activa: true, Creada: time.Now(),
}
if err := a.alm.GuardarCuenta(c.Usuario, nueva); err != nil {
problema(err)
return
}
a.ses.PonerClave(ses.ID, usuario, clave)
volver("")
case "reset", "activar", "desactivar":
otra, ok := a.alm.BuscarCuenta(usuario)
if !ok {
problema(fmt.Errorf("no existe la cuenta %q", usuario))
return
}
if accion == "reset" {
clave := ClaveTemporal()
otra.Hash = HashClave(clave)
otra.Temporal = true
if err := a.alm.GuardarCuenta(c.Usuario, otra); err != nil {
problema(err)
return
}
a.ses.BorrarDeUsuario(usuario)
a.ses.PonerClave(ses.ID, usuario, clave)
volver("")
return
}
if usuario == c.Usuario {
problema(fmt.Errorf("no te puedes desactivar a ti misma"))
return
}
otra.Activa = accion == "activar"
if err := a.alm.GuardarCuenta(c.Usuario, otra); err != nil {
problema(err)
return
}
if !otra.Activa {
a.ses.BorrarDeUsuario(usuario)
}
hecho := "desactivada"
if otra.Activa {
hecho = "activada"
}
volver("?a=" + url.QueryEscape(fmt.Sprintf("Cuenta «%s» %s", usuario, hecho)))
default:
a.fallo(w, r, http.StatusBadRequest, "Acción desconocida.")
}
}
// --------------------------------------------------------------------- clave
func (a *App) verClave(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
a.formularioClave(w, c, ses, "", http.StatusOK, destinoSeguro(r.URL.Query().Get("sig")))
}
func (a *App) formularioClave(w http.ResponseWriter, c Cuenta, ses *Sesion, fallo string, codigo int, sig string) {
datos := struct {
Base
Temporal bool
}{Base: a.base("CONTRASEÑA", c.Nombre, "clave", c, ses), Temporal: c.Temporal}
datos.Error = fallo
datos.Sig = sig
if codigo != http.StatusOK {
w.WriteHeader(codigo)
}
a.pintar(w, "clave.tmpl.html", datos)
}
func (a *App) hacerClave(w http.ResponseWriter, r *http.Request, c Cuenta, ses *Sesion) {
actual := r.PostFormValue("actual")
nueva := r.PostFormValue("nueva")
repetida := r.PostFormValue("repetida")
sig := destinoSeguro(r.PostFormValue("sig"))
mal := func(msg string) { a.formularioClave(w, c, ses, msg, http.StatusBadRequest, sig) }
if !VerificarClave(c.Hash, actual) {
a.auth.Escribir("AUTH_FAIL", ipCliente(r), c.Usuario, "clave_actual_incorrecta")
mal("La contraseña actual no es correcta.")
return
}
if nueva != repetida {
mal("Las dos contraseñas nuevas no coinciden.")
return
}
if err := ValidarClave(nueva); err != nil {
mal(primeraMayuscula(err.Error()))
return
}
if nueva == actual {
mal("La contraseña nueva tiene que ser distinta de la anterior.")
return
}
c.Hash = HashClave(nueva)
c.Temporal = false
if err := a.alm.GuardarCuenta(c.Usuario, c); err != nil {
mal("No he podido guardarla.")
return
}
// Cambiar la clave cierra las demas sesiones de esa cuenta.
a.ses.BorrarDeUsuario(c.Usuario)
nuevaSesion := a.ses.Crear(c.Usuario)
a.ponerCookie(w, nuevaSesion.ID, 0)
if sig != "" {
http.Redirect(w, r, sig, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/pedir/mis-fechas?a="+url.QueryEscape("Contraseña cambiada"),
http.StatusSeeOther)
}
// -------------------------------------------------------------------- varios
func recortar(s string, max int) string {
s = strings.TrimSpace(s)
r := []rune(s)
if len(r) > max {
return string(r[:max])
}
return s
}
func primeraMayuscula(s string) string {
if s == "" {
return s
}
r := []rune(s)
return strings.ToUpper(string(r[0])) + string(r[1:])
}