- Rol «externas» para la asamblea de externas: pide sin cupo, siempre con el colectivo de fuera y su contacto, y sus solicitudes salen en naranja hasta que la asamblea de la Enre las confirma. - /pedir/ explica en una línea que los colectivos externos piden fecha en la asamblea de externas, los lunes de 19:00 a 20:00. - Seguridad: argon2 limitado a dos cálculos a la vez y el intento se cuenta antes de calcular (60 accesos simultáneos llevaban el proceso a 3,8 GB); bloqueo por cuenta a 20 fallos para que no se pueda dejar fuera a la asamblea; avisos por sesión en vez de por URL; sig sin «..»; eventos de 31 días como mucho; parámetros de argon2 acotados. - Títulos pegados arriba en todas las páginas; la franja de la enredadera recupera ese alto para seguir llenando la pantalla. - despliegue/: instalar.sh, cuenta.sh, servicio de systemd con el proceso encerrado, fragmento de nginx que además deja de servir .git y las notas internas, y configuración de ejemplo. Ninguna contraseña en el repositorio.
1074 lines
32 KiB
Go
1074 lines
32 KiB
Go
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:])
|
|
}
|