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:]) }