package main import ( "bytes" "fmt" "log" "net" "net/http" "net/url" "path" "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 EsExternas 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 "" } // Nada de «/pedir/../»: la ruta tiene que estar ya limpia. limpia := path.Clean(u.Path) if strings.HasSuffix(u.Path, "/") && limpia != "/" { limpia += "/" } if limpia != u.Path || !strings.HasPrefix(limpia, "/pedir/") { 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.EsExternas = c.Rol == RolExternas 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) } // avisar deja un aviso para la página siguiente y redirige a ella. func (a *App) avisar(w http.ResponseWriter, r *http.Request, ses *Sesion, ruta, aviso string) { a.ses.PonerAviso(ses.ID, aviso, "") http.Redirect(w, r, ruta, http.StatusSeeOther) } // ------------------------------------------------------------------ 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 if sig != "" { datos.Aviso = "Para pedir esa fecha, entra con las credenciales de tu colectivo. " + "Colectivos externos: se pide en la asamblea de externas, los lunes de 19:00 a 20:00." } 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) // Un usuario con forma imposible cae siempre en el mismo cajón: si no, // cada nombre inventado (hasta 64 KB) sería una entrada más en memoria. llaveU := usuario if ValidarUsuario(usuario) != nil { llaveU = "?" } // El intento se apunta ANTES de calcular la clave: ver Cerrojo.Intentar. pasa, falta := a.cer.Intentar(ip) if pasa { pasa, falta = a.cerU.Intentar(llaveU) } if !pasa { 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.auth.Escribir("AUTH_FAIL", ip, usuario, "cuenta_desconocida") a.errorAcceso(w, r, "Usuario o contraseña incorrectos.") return } if !VerificarClave(c.Hash, clave) { a.auth.Escribir("AUTH_FAIL", ip, usuario, "clave_incorrecta") a.errorAcceso(w, r, "Usuario o contraseña incorrectos.") return } a.cer.Limpiar(ip) a.cerU.Limpiar(llaveU) 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 Externas bool // la pidió la asamblea de externas: va en naranja } 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 c, ok := a.alm.BuscarCuenta(e.Solicitante); ok && c.Rol == RolExternas { f.Externas = true f.Etiqueta = etiquetaEstado(e.Estado, true) } 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, datos.Error = a.ses.TomarAviso(ses.ID) 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 Enre 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) a.avisar(w, r, ses, "/pedir/mis-fechas", "Fecha apuntada: "+rangoLegible(e)) } 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" } a.avisar(w, r, ses, destino, "Fecha liberada: "+rangoLegible(e)) } // ------------------------------------------------------------------- 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 } fila := a.filaMia(e) switch { case fila.Externas: quien = e.Amadrinado + " · asamblea de externas" case e.Externo(): quien = fmt.Sprintf("%s · amadrina %s", e.Amadrinado, quien) } filas = append(filas, FilaTodas{FilaMia: fila, 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, datos.Error = a.ses.TomarAviso(ses.ID) 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 } a.avisar(w, r, ses, "/pedir/todas", "Evento modificado: "+nuevo.Titulo) } // ---------------------------------------------------------------- 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, datos.Error = a.ses.TomarAviso(ses.ID) 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) a.avisar(w, r, ses, "/pedir/pendientes", fmt.Sprintf("«%s» queda %s", e.Titulo, strings.ToLower(e.Estado.Etiqueta()))) } // -------------------------------------------------------------------- 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, datos.Error = a.ses.TomarAviso(ses.ID) 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()) } a.avisar(w, r, ses, "/pedir/findes", aviso) } // ------------------------------------------------------------------- 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, datos.Error = a.ses.TomarAviso(ses.ID) 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(aviso string) { a.avisar(w, r, ses, "/pedir/cuentas", aviso) } problema := func(err error) { a.ses.PonerAviso(ses.ID, "", primeraMayuscula(err.Error())) http.Redirect(w, r, "/pedir/cuentas", http.StatusSeeOther) } 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.Valido() { 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(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 } a.avisar(w, r, nuevaSesion, "/pedir/mis-fechas", "Contraseña cambiada") } // -------------------------------------------------------------------- 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:]) }