- 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.
363 lines
9.4 KiB
Go
363 lines
9.4 KiB
Go
// enrecal: reservas de fechas del CSO La Enredadera de Tetuán.
|
|
//
|
|
// Sirve la sección /pedir/ de laenre.net: el calendario de fechas pedidas y
|
|
// confirmadas, el formulario para pedirlas y el panel de la asamblea. Sin una
|
|
// línea de JavaScript en el navegador.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
type App struct {
|
|
cfg Config
|
|
alm Almacen
|
|
ses *Sesiones
|
|
cer *Cerrojo // por IP
|
|
cerU *Cerrojo // por cuenta
|
|
auth *AuthLog
|
|
|
|
// mu serializa las escrituras de eventos: comprobar las reglas y guardar
|
|
// tienen que ser una sola operacion, o dos colectivos podrian colarse en la
|
|
// misma fecha a la vez.
|
|
mu sync.Mutex
|
|
|
|
plantillas *template.Template
|
|
}
|
|
|
|
func main() {
|
|
log.SetFlags(0)
|
|
log.SetPrefix("enrecal: ")
|
|
|
|
var (
|
|
rutaConfig = flag.String("config", "", "fichero de configuracion en JSON")
|
|
escucha = flag.String("escucha", "", "direccion donde escuchar, por ejemplo 127.0.0.1:8090")
|
|
dirDatos = flag.String("datos", "", "directorio del registro")
|
|
dirPlant = flag.String("plantillas", "", "directorio de plantillas")
|
|
dirEstat = flag.String("estatico", "", "raiz de la web, para el modo local")
|
|
logAuth = flag.String("logauth", "", "registro de accesos; \"-\" para sacarlo por stderr")
|
|
insegura = flag.Bool("insegura", false, "cookie sin marca Secure, solo para probar sin TLS")
|
|
local = flag.Bool("local", false, "sirve tambien la web estatica, solo para probar sin nginx")
|
|
usuario = flag.String("usuario", "", "usuario, para las ordenes cuenta y clave")
|
|
nombre = flag.String("nombre", "", "nombre visible del colectivo, para la orden cuenta")
|
|
rol = flag.String("rol", string(RolInterno), "rol de la cuenta: enre, interno o externas")
|
|
claveFija = flag.String("clave", "", "clave temporal para cuenta y clave; sin ella se genera una al azar")
|
|
definitiva = flag.Bool("definitiva", false, "con -clave: la clave no caduca al entrar (minimo 12 caracteres)")
|
|
)
|
|
flag.Usage = uso
|
|
flag.Parse()
|
|
|
|
cfg, err := CargarConfig(*rutaConfig)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
for destino, valor := range map[*string]string{
|
|
&cfg.Escucha: *escucha, &cfg.DirDatos: *dirDatos,
|
|
&cfg.DirPlantillas: *dirPlant, &cfg.DirEstatico: *dirEstat,
|
|
} {
|
|
if valor != "" {
|
|
*destino = valor
|
|
}
|
|
}
|
|
switch *logAuth {
|
|
case "":
|
|
case "-":
|
|
cfg.LogAuth = "" // a stderr, para probar en local
|
|
default:
|
|
cfg.LogAuth = *logAuth
|
|
}
|
|
if *insegura {
|
|
cfg.CookieSegura = false
|
|
}
|
|
if *local {
|
|
cfg.Local = true
|
|
}
|
|
if err := cfg.Validar(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
app, err := NuevaApp(cfg)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer app.Cerrar()
|
|
|
|
orden := flag.Arg(0)
|
|
if orden == "" {
|
|
orden = "servir"
|
|
}
|
|
switch orden {
|
|
case "servir":
|
|
if err := app.Servir(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
case "cuenta":
|
|
if err := app.AltaCuenta(*usuario, *nombre, Rol(*rol), *claveFija, *definitiva); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
case "clave":
|
|
if err := app.ResetClave(*usuario, *claveFija, *definitiva); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
case "caducar":
|
|
n, err := app.Caducar()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
fmt.Printf("%d solicitudes caducadas\n", n)
|
|
default:
|
|
uso()
|
|
os.Exit(2)
|
|
}
|
|
}
|
|
|
|
func uso() {
|
|
fmt.Fprint(os.Stderr, `uso: enrecal [opciones] <orden>
|
|
|
|
ordenes:
|
|
servir levanta la seccion /pedir/ (por defecto)
|
|
cuenta da de alta una cuenta e imprime su clave temporal (-clave para fijarla)
|
|
enrecal cuenta -usuario hacklab -nombre "Hacklab La Raiz" -rol interno
|
|
clave resetea la clave de una cuenta e imprime la nueva temporal
|
|
caducar pasa a caducadas las solicitudes que toque y sale
|
|
|
|
opciones:
|
|
`)
|
|
flag.PrintDefaults()
|
|
}
|
|
|
|
func NuevaApp(cfg Config) (*App, error) {
|
|
reg, err := AbrirRegistro(cfg.DirDatos)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("abriendo el registro: %w", err)
|
|
}
|
|
al, err := AbrirAuthLog(cfg.LogAuth)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("abriendo el registro de accesos: %w", err)
|
|
}
|
|
patron := filepath.Join(cfg.DirPlantillas, "*.tmpl.html")
|
|
plantillas, err := template.New("pedir").ParseGlob(patron)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("plantillas (%s): %w", patron, err)
|
|
}
|
|
return &App{
|
|
cfg: cfg,
|
|
alm: reg,
|
|
ses: NuevasSesiones(inactividadSesion),
|
|
cer: NuevoCerrojo(maxIntentos, ventanaIntentos, esperaBloqueo),
|
|
cerU: NuevoCerrojo(maxIntentosCuenta, ventanaIntentos, esperaBloqueo),
|
|
auth: al,
|
|
plantillas: plantillas,
|
|
}, nil
|
|
}
|
|
|
|
func (a *App) Cerrar() {
|
|
if r, ok := a.alm.(*Registro); ok {
|
|
r.Cerrar()
|
|
}
|
|
a.auth.Cerrar()
|
|
}
|
|
|
|
// nombres asocia cada usuario con el nombre visible de su colectivo.
|
|
func (a *App) nombres() map[string]string {
|
|
out := map[string]string{}
|
|
for _, c := range a.alm.Cuentas() {
|
|
out[c.Usuario] = c.Nombre
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *App) roles() map[string]Rol {
|
|
out := map[string]Rol{}
|
|
for _, c := range a.alm.Cuentas() {
|
|
out[c.Usuario] = c.Rol
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (a *App) vista() Vista {
|
|
return Vista{
|
|
roles: a.roles(),
|
|
reglas: a.cfg.Reglas,
|
|
exc: a.alm.Excepciones(),
|
|
eventos: a.alm.Eventos(),
|
|
nombres: a.nombres(),
|
|
hoy: DiaDe(time.Now()),
|
|
}
|
|
}
|
|
|
|
// claveTemporal devuelve la clave pedida o, si no se pide ninguna, una al azar.
|
|
// Sea cual sea, la cuenta queda marcada como temporal: al entrar por primera
|
|
// vez no deja hacer nada hasta cambiarla por una de 12 caracteres o más.
|
|
func claveTemporal(fija string) string {
|
|
if fija != "" {
|
|
return fija
|
|
}
|
|
return ClaveTemporal()
|
|
}
|
|
|
|
func avisoClave(clave string, definitiva bool) {
|
|
if definitiva {
|
|
fmt.Println("clave definitiva: no se pide cambiarla al entrar")
|
|
return
|
|
}
|
|
fmt.Printf("clave temporal: %s\nSe entrega en mano y hay que cambiarla al entrar por primera vez.\n", clave)
|
|
}
|
|
|
|
// claveDefinitiva comprueba que una clave que no se va a cambiar al entrar
|
|
// cumple lo mismo que se exige al cambiarla desde la web.
|
|
func claveDefinitiva(fija string) error {
|
|
if fija == "" {
|
|
return errors.New("-definitiva necesita -clave")
|
|
}
|
|
if len([]rune(fija)) < 12 {
|
|
return errors.New("una clave definitiva tiene al menos 12 caracteres")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) AltaCuenta(usuario, nombre string, rol Rol, fija string, definitiva bool) error {
|
|
if err := ValidarUsuario(usuario); err != nil {
|
|
return err
|
|
}
|
|
if nombre == "" {
|
|
return errors.New("falta -nombre: el nombre visible del colectivo")
|
|
}
|
|
if !rol.Valido() {
|
|
return fmt.Errorf("el rol solo puede ser %q, %q o %q", RolEnre, RolInterno, RolExternas)
|
|
}
|
|
if _, existe := a.alm.BuscarCuenta(usuario); existe {
|
|
return fmt.Errorf("la cuenta %q ya existe; para cambiarle la clave usa la orden clave", usuario)
|
|
}
|
|
if definitiva {
|
|
if err := claveDefinitiva(fija); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
clave := claveTemporal(fija)
|
|
c := Cuenta{
|
|
Usuario: usuario, Nombre: nombre, Rol: rol,
|
|
Hash: HashClave(clave), Temporal: !definitiva, Activa: true, Creada: time.Now(),
|
|
}
|
|
if err := a.alm.GuardarCuenta("consola", c); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("cuenta %q (%s) creada con rol %s\n", usuario, nombre, rol)
|
|
avisoClave(clave, definitiva)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) ResetClave(usuario, fija string, definitiva bool) error {
|
|
c, ok := a.alm.BuscarCuenta(usuario)
|
|
if !ok {
|
|
return fmt.Errorf("no existe la cuenta %q", usuario)
|
|
}
|
|
if definitiva {
|
|
if err := claveDefinitiva(fija); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
clave := claveTemporal(fija)
|
|
c.Hash = HashClave(clave)
|
|
c.Temporal = !definitiva
|
|
if err := a.alm.GuardarCuenta("consola", c); err != nil {
|
|
return err
|
|
}
|
|
a.ses.BorrarDeUsuario(usuario)
|
|
fmt.Printf("clave de %q reseteada\n", usuario)
|
|
avisoClave(clave, definitiva)
|
|
return nil
|
|
}
|
|
|
|
// Caducar pasa a caducadas las solicitudes que se han quedado sin margen o sin
|
|
// respuesta, liberando las fechas.
|
|
func (a *App) Caducar() (int, error) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
ahora := time.Now()
|
|
n := 0
|
|
for _, e := range a.alm.Eventos() {
|
|
motivo := a.cfg.Reglas.Caducidad(e, ahora)
|
|
if motivo == "" {
|
|
continue
|
|
}
|
|
e.Estado = Caducado
|
|
e.Resuelto = ahora
|
|
e.ResueltoPor = "automatico"
|
|
e.Motivo = motivo
|
|
if err := a.alm.GuardarEvento("automatico", e); err != nil {
|
|
return n, err
|
|
}
|
|
log.Printf("caducada %q de %s: %s", e.Titulo, e.Solicitante, motivo)
|
|
n++
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (a *App) Servir() error {
|
|
if _, err := a.Caducar(); err != nil {
|
|
return err
|
|
}
|
|
|
|
srv := &http.Server{
|
|
Addr: a.cfg.Escucha,
|
|
Handler: a.rutas(),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 20 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
MaxHeaderBytes: 16 * 1024,
|
|
}
|
|
|
|
paro := make(chan os.Signal, 1)
|
|
signal.Notify(paro, syscall.SIGINT, syscall.SIGTERM)
|
|
go a.tareasPeriodicas(paro)
|
|
|
|
errores := make(chan error, 1)
|
|
go func() {
|
|
log.Printf("escuchando en %s", a.cfg.Escucha)
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
errores <- err
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case err := <-errores:
|
|
return err
|
|
case <-paro:
|
|
log.Print("parando")
|
|
ctx, cancelar := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancelar()
|
|
return srv.Shutdown(ctx)
|
|
}
|
|
}
|
|
|
|
// tareasPeriodicas repasa cada hora las solicitudes caducables y limpia las
|
|
// sesiones abandonadas.
|
|
func (a *App) tareasPeriodicas(paro <-chan os.Signal) {
|
|
t := time.NewTicker(time.Hour)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-t.C:
|
|
if _, err := a.Caducar(); err != nil {
|
|
log.Printf("repaso de caducidades: %v", err)
|
|
}
|
|
a.ses.Limpiar()
|
|
case <-paro:
|
|
return
|
|
}
|
|
}
|
|
}
|