Sección «Pedir evento» con gestor de reservas y calendario legible en móvil
- /pedir/: calendario de fechas pedidas (negro) y confirmadas (verde), findes tranqui y con ruido, solo findes por defecto con opción de ver la semana entera. Se pincha un día libre y, sin sesión, se pide entrar y se vuelve al formulario con la fecha puesta. - Gestor en Go (gestor/): cuentas por colectivo con argon2id, sesiones y CSRF, cupo y ventana de la asamblea, caducidades, registro append-only. - La cuenta de la asamblea confirma, rechaza, cambia turnos de finde y en «Todas las fechas» quita o modifica cualquier evento. - Orden «cuenta» y «clave» con -clave y -definitiva para fijar contraseñas desde el servidor; ninguna contraseña va en el repositorio. - Botón «Pedir evento» arriba de la barra lateral en todas las páginas. - Calendario semanal en móvil: lista de días con nombre y hora de cada cita. - Fuentes servidas desde fuentes/: fuera Google Fonts en todas las páginas.
This commit is contained in:
parent
c3b51be992
commit
ce3127baff
38 changed files with 4029 additions and 52 deletions
6
404.html
6
404.html
|
|
@ -15,9 +15,6 @@
|
|||
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
|
|
@ -31,6 +28,9 @@
|
|||
<div class="container">
|
||||
|
||||
<aside class="sidebar">
|
||||
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
|
||||
menú, y la asamblea lo quiso arriba del todo. -->
|
||||
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
|
||||
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="192" height="192">
|
||||
<!-- Es la marca del panel, no un encabezado de sección: como el panel
|
||||
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@
|
|||
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
|
|
@ -32,6 +29,9 @@
|
|||
<div class="container">
|
||||
|
||||
<aside class="sidebar">
|
||||
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
|
||||
menú, y la asamblea lo quiso arriba del todo. -->
|
||||
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
|
||||
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="96" height="96">
|
||||
<!-- Es la marca del panel, no un encabezado de sección: como el panel
|
||||
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@
|
|||
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
|
|
@ -61,6 +58,9 @@
|
|||
<div class="container">
|
||||
|
||||
<aside class="sidebar">
|
||||
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
|
||||
menú, y la asamblea lo quiso arriba del todo. -->
|
||||
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
|
||||
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="96" height="96">
|
||||
<!-- Es la marca del panel, no un encabezado de sección: como el panel
|
||||
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@
|
|||
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
|
|
@ -33,6 +30,9 @@
|
|||
<div class="container">
|
||||
|
||||
<aside class="sidebar">
|
||||
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
|
||||
menú, y la asamblea lo quiso arriba del todo. -->
|
||||
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
|
||||
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="96" height="96">
|
||||
<!-- Es la marca del panel, no un encabezado de sección: como el panel
|
||||
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
|
||||
|
|
|
|||
BIN
fuentes/anton-latin.woff2
Normal file
BIN
fuentes/anton-latin.woff2
Normal file
Binary file not shown.
BIN
fuentes/rocksalt-latin.woff2
Normal file
BIN
fuentes/rocksalt-latin.woff2
Normal file
Binary file not shown.
BIN
fuentes/specialelite-latin.woff2
Normal file
BIN
fuentes/specialelite-latin.woff2
Normal file
Binary file not shown.
238
gestor/almacen.go
Normal file
238
gestor/almacen.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Apunte es una linea del registro. El registro solo crece: cada cambio se
|
||||
// escribe al final y nunca se reescribe nada. Al arrancar se relee entero para
|
||||
// reconstruir el estado en memoria, asi que el fichero es a la vez la base de
|
||||
// datos y el acta de la asamblea.
|
||||
type Apunte struct {
|
||||
Ts time.Time `json:"ts"`
|
||||
Tipo string `json:"tipo"`
|
||||
Por string `json:"por,omitempty"`
|
||||
Cuenta *Cuenta `json:"cuenta,omitempty"`
|
||||
Evento *Evento `json:"evento,omitempty"`
|
||||
Excepcion *Excepcion `json:"excepcion,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
ApunteCuenta = "cuenta"
|
||||
ApunteEvento = "evento"
|
||||
ApunteExcepcion = "excepcion"
|
||||
)
|
||||
|
||||
// Almacen aisla el resto del programa de como se guardan los datos. Hoy es un
|
||||
// registro de texto; si algun dia hace falta SQL, se cambia solo esta pieza.
|
||||
type Almacen interface {
|
||||
Cuentas() []Cuenta
|
||||
BuscarCuenta(usuario string) (Cuenta, bool)
|
||||
GuardarCuenta(por string, c Cuenta) error
|
||||
|
||||
Eventos() []Evento
|
||||
BuscarEvento(id string) (Evento, bool)
|
||||
GuardarEvento(por string, e Evento) error
|
||||
|
||||
Excepciones() map[Dia]Turno
|
||||
GuardarExcepcion(por string, x Excepcion) error
|
||||
}
|
||||
|
||||
type Registro struct {
|
||||
mu sync.RWMutex
|
||||
f *os.File
|
||||
ruta string
|
||||
cuentas map[string]Cuenta
|
||||
eventos map[string]Evento
|
||||
excepciones map[Dia]Turno
|
||||
}
|
||||
|
||||
func AbrirRegistro(dir string) (*Registro, error) {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ruta := filepath.Join(dir, "registro.jsonl")
|
||||
r := &Registro{
|
||||
ruta: ruta,
|
||||
cuentas: map[string]Cuenta{},
|
||||
eventos: map[string]Evento{},
|
||||
excepciones: map[Dia]Turno{},
|
||||
}
|
||||
if err := r.releer(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(ruta, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.f = f
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *Registro) releer() error {
|
||||
f, err := os.Open(r.ruta)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
n := 0
|
||||
for sc.Scan() {
|
||||
n++
|
||||
linea := sc.Bytes()
|
||||
if len(linea) == 0 {
|
||||
continue
|
||||
}
|
||||
var a Apunte
|
||||
if err := json.Unmarshal(linea, &a); err != nil {
|
||||
return fmt.Errorf("%s linea %d: %w", r.ruta, n, err)
|
||||
}
|
||||
r.aplicar(a)
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
|
||||
func (r *Registro) aplicar(a Apunte) {
|
||||
switch a.Tipo {
|
||||
case ApunteCuenta:
|
||||
if a.Cuenta != nil {
|
||||
r.cuentas[a.Cuenta.Usuario] = *a.Cuenta
|
||||
}
|
||||
case ApunteEvento:
|
||||
if a.Evento == nil {
|
||||
return
|
||||
}
|
||||
if _, err := a.Evento.Desde.Time(); err != nil {
|
||||
log.Printf("evento %q ignorado: %v", a.Evento.ID, err)
|
||||
return
|
||||
}
|
||||
if _, err := a.Evento.Hasta.Time(); err != nil {
|
||||
log.Printf("evento %q ignorado: %v", a.Evento.ID, err)
|
||||
return
|
||||
}
|
||||
r.eventos[a.Evento.ID] = *a.Evento
|
||||
case ApunteExcepcion:
|
||||
if a.Excepcion != nil {
|
||||
if _, err := a.Excepcion.Viernes.Time(); err != nil {
|
||||
log.Printf("excepcion de finde ignorada: %v", err)
|
||||
return
|
||||
}
|
||||
if a.Excepcion.Turno == "" {
|
||||
// Turno vacio significa volver a la alternancia automatica.
|
||||
delete(r.excepciones, a.Excepcion.Viernes)
|
||||
} else {
|
||||
r.excepciones[a.Excepcion.Viernes] = a.Excepcion.Turno
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// anotar escribe el apunte al final del fichero y lo sincroniza a disco antes
|
||||
// de dar la operacion por buena. Con el candado ya tomado por quien llama.
|
||||
func (r *Registro) anotar(a Apunte) error {
|
||||
a.Ts = time.Now()
|
||||
b, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
if _, err := r.f.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.f.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.aplicar(a)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registro) Cerrar() error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.f == nil {
|
||||
return nil
|
||||
}
|
||||
return r.f.Close()
|
||||
}
|
||||
|
||||
func (r *Registro) Cuentas() []Cuenta {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]Cuenta, 0, len(r.cuentas))
|
||||
for _, c := range r.cuentas {
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Usuario < out[j].Usuario })
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Registro) BuscarCuenta(usuario string) (Cuenta, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
c, ok := r.cuentas[usuario]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
func (r *Registro) GuardarCuenta(por string, c Cuenta) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.anotar(Apunte{Tipo: ApunteCuenta, Por: por, Cuenta: &c})
|
||||
}
|
||||
|
||||
func (r *Registro) Eventos() []Evento {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]Evento, 0, len(r.eventos))
|
||||
for _, e := range r.eventos {
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Desde != out[j].Desde {
|
||||
return out[i].Desde < out[j].Desde
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Registro) BuscarEvento(id string) (Evento, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
e, ok := r.eventos[id]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
func (r *Registro) GuardarEvento(por string, e Evento) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.anotar(Apunte{Tipo: ApunteEvento, Por: por, Evento: &e})
|
||||
}
|
||||
|
||||
func (r *Registro) Excepciones() map[Dia]Turno {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make(map[Dia]Turno, len(r.excepciones))
|
||||
for k, v := range r.excepciones {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *Registro) GuardarExcepcion(por string, x Excepcion) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.anotar(Apunte{Tipo: ApunteExcepcion, Por: por, Excepcion: &x})
|
||||
}
|
||||
355
gestor/auth.go
Normal file
355
gestor/auth.go
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Parametros de argon2id. 64 MiB por intento es suficiente para que probar
|
||||
// claves a lo bruto salga caro y poco para el servidor, que atiende a treinta
|
||||
// cuentas.
|
||||
const (
|
||||
argonMemoria = 64 * 1024
|
||||
argonIteraciones = 3
|
||||
argonHilos = 4
|
||||
argonLargo = 32
|
||||
argonSal = 16
|
||||
)
|
||||
|
||||
func aleatorio(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("sin fuente de aleatoriedad: " + err.Error())
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func nuevoID() string { return hex.EncodeToString(aleatorio(16)) }
|
||||
|
||||
// HashClave devuelve la clave cifrada en el formato codificado de argon2, que
|
||||
// lleva dentro los parametros con los que se genero.
|
||||
func HashClave(clave string) string {
|
||||
sal := aleatorio(argonSal)
|
||||
h := argon2.IDKey([]byte(clave), sal, argonIteraciones, argonMemoria, argonHilos, argonLargo)
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argonMemoria, argonIteraciones, argonHilos,
|
||||
base64.RawStdEncoding.EncodeToString(sal),
|
||||
base64.RawStdEncoding.EncodeToString(h))
|
||||
}
|
||||
|
||||
// hashInutil se usa para gastar el mismo tiempo cuando la cuenta no existe, de
|
||||
// forma que no se pueda averiguar que usuarios hay midiendo la respuesta.
|
||||
var hashInutil = HashClave("cuenta que no existe")
|
||||
|
||||
func VerificarClave(codificado, clave string) bool {
|
||||
partes := strings.Split(codificado, "$")
|
||||
if len(partes) != 6 || partes[1] != "argon2id" {
|
||||
return false
|
||||
}
|
||||
var version, memoria, iteraciones int
|
||||
var hilos int
|
||||
if _, err := fmt.Sscanf(partes[2], "v=%d", &version); err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := fmt.Sscanf(partes[3], "m=%d,t=%d,p=%d", &memoria, &iteraciones, &hilos); err != nil {
|
||||
return false
|
||||
}
|
||||
sal, err := base64.RawStdEncoding.DecodeString(partes[4])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
esperado, err := base64.RawStdEncoding.DecodeString(partes[5])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
calculado := argon2.IDKey([]byte(clave), sal, uint32(iteraciones), uint32(memoria),
|
||||
uint8(hilos), uint32(len(esperado)))
|
||||
return subtle.ConstantTimeCompare(calculado, esperado) == 1
|
||||
}
|
||||
|
||||
// ValidarClave es la politica de contrasenas: larga antes que retorcida, que es
|
||||
// lo que de verdad aguanta un ataque por diccionario.
|
||||
func ValidarClave(c string) error {
|
||||
if len([]rune(c)) < 12 {
|
||||
return fmt.Errorf("la contraseña tiene que tener al menos 12 caracteres")
|
||||
}
|
||||
if len(c) > 256 {
|
||||
return fmt.Errorf("la contraseña no puede pasar de 256 caracteres")
|
||||
}
|
||||
distintos := map[rune]bool{}
|
||||
for _, r := range c {
|
||||
distintos[r] = true
|
||||
}
|
||||
if len(distintos) < 5 {
|
||||
return fmt.Errorf("la contraseña repite demasiado los mismos caracteres")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClaveTemporal genera la clave de un solo uso que se entrega en mano al dar de
|
||||
// alta un colectivo. Sin caracteres que se confundan al copiarla a papel.
|
||||
func ClaveTemporal() string {
|
||||
const alfabeto = "abcdefghjkmnpqrstuvwxyz23456789"
|
||||
b := aleatorio(20)
|
||||
var sb strings.Builder
|
||||
for i, v := range b {
|
||||
if i > 0 && i%5 == 0 {
|
||||
sb.WriteByte('-')
|
||||
}
|
||||
sb.WriteByte(alfabeto[int(v)%len(alfabeto)])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func ValidarUsuario(u string) error {
|
||||
if len(u) < 3 || len(u) > 32 {
|
||||
return fmt.Errorf("el usuario tiene entre 3 y 32 caracteres")
|
||||
}
|
||||
for _, r := range u {
|
||||
if !unicode.IsLower(r) && !unicode.IsDigit(r) && r != '_' && r != '-' {
|
||||
return fmt.Errorf("el usuario solo admite minúsculas, números, guion y guion bajo")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sesiones viven solo en memoria: se pueden revocar al instante y un reinicio
|
||||
// del servicio cierra todas las sesiones abiertas, que es lo que interesa.
|
||||
type Sesiones struct {
|
||||
mu sync.Mutex
|
||||
m map[string]*Sesion
|
||||
maxInactivo time.Duration
|
||||
}
|
||||
|
||||
func NuevasSesiones(maxInactivo time.Duration) *Sesiones {
|
||||
return &Sesiones{m: map[string]*Sesion{}, maxInactivo: maxInactivo}
|
||||
}
|
||||
|
||||
func (s *Sesiones) Crear(usuario string) *Sesion {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ses := &Sesion{
|
||||
ID: hex.EncodeToString(aleatorio(32)),
|
||||
Usuario: usuario,
|
||||
CSRF: hex.EncodeToString(aleatorio(32)),
|
||||
Creada: time.Now(),
|
||||
Vista: time.Now(),
|
||||
}
|
||||
s.m[ses.ID] = ses
|
||||
return ses
|
||||
}
|
||||
|
||||
func (s *Sesiones) Buscar(id string) (*Sesion, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ses, ok := s.m[id]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(ses.Vista) > s.maxInactivo {
|
||||
delete(s.m, id)
|
||||
return nil, false
|
||||
}
|
||||
ses.Vista = time.Now()
|
||||
return ses, true
|
||||
}
|
||||
|
||||
// PonerClave guarda la clave temporal recien generada para ensenarla una sola
|
||||
// vez en la siguiente pagina, sin que pase por la URL.
|
||||
func (s *Sesiones) PonerClave(id, usuario, clave string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if ses, ok := s.m[id]; ok {
|
||||
ses.ClaveDe, ses.ClaveNueva = usuario, clave
|
||||
}
|
||||
}
|
||||
|
||||
// TomarClave la devuelve y la borra: no se vuelve a mostrar.
|
||||
func (s *Sesiones) TomarClave(id string) (string, string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ses, ok := s.m[id]
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
usuario, clave := ses.ClaveDe, ses.ClaveNueva
|
||||
ses.ClaveDe, ses.ClaveNueva = "", ""
|
||||
return usuario, clave
|
||||
}
|
||||
|
||||
func (s *Sesiones) Borrar(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.m, id)
|
||||
}
|
||||
|
||||
// BorrarDeUsuario cierra todas las sesiones de una cuenta: al cambiarle la
|
||||
// clave o al desactivarla.
|
||||
func (s *Sesiones) BorrarDeUsuario(usuario string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for id, ses := range s.m {
|
||||
if ses.Usuario == usuario {
|
||||
delete(s.m, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Sesiones) Limpiar() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for id, ses := range s.m {
|
||||
if time.Since(ses.Vista) > s.maxInactivo {
|
||||
delete(s.m, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cerrojo cuenta intentos fallidos por IP y por cuenta. Es la primera barrera,
|
||||
// dentro de la propia aplicacion; fail2ban es la segunda, en el cortafuegos.
|
||||
type Cerrojo struct {
|
||||
mu sync.Mutex
|
||||
fallos map[string][]time.Time
|
||||
maxIntentos int
|
||||
ventana time.Duration
|
||||
espera time.Duration
|
||||
}
|
||||
|
||||
func NuevoCerrojo(maxIntentos int, ventana, espera time.Duration) *Cerrojo {
|
||||
return &Cerrojo{
|
||||
fallos: map[string][]time.Time{},
|
||||
maxIntentos: maxIntentos,
|
||||
ventana: ventana,
|
||||
espera: espera,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cerrojo) Bloqueado(claves ...string) (bool, time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
ahora := time.Now()
|
||||
for _, k := range claves {
|
||||
recientes := c.podar(k, ahora)
|
||||
if len(recientes) >= c.maxIntentos {
|
||||
ultimo := recientes[len(recientes)-1]
|
||||
if falta := c.espera - ahora.Sub(ultimo); falta > 0 {
|
||||
return true, falta
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
func (c *Cerrojo) Fallo(claves ...string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
ahora := time.Now()
|
||||
for _, k := range claves {
|
||||
c.fallos[k] = append(c.podar(k, ahora), ahora)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cerrojo) Limpiar(claves ...string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, k := range claves {
|
||||
delete(c.fallos, k)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cerrojo) podar(k string, ahora time.Time) []time.Time {
|
||||
var out []time.Time
|
||||
for _, t := range c.fallos[k] {
|
||||
if ahora.Sub(t) <= c.ventana {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
delete(c.fallos, k)
|
||||
} else {
|
||||
c.fallos[k] = out
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AuthLog escribe el registro que lee fail2ban. Formato fijo y de una sola
|
||||
// linea por intento.
|
||||
type AuthLog struct {
|
||||
mu sync.Mutex
|
||||
f *os.File
|
||||
}
|
||||
|
||||
func AbrirAuthLog(ruta string) (*AuthLog, error) {
|
||||
if ruta == "" {
|
||||
return &AuthLog{}, nil
|
||||
}
|
||||
f, err := os.OpenFile(ruta, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AuthLog{f: f}, nil
|
||||
}
|
||||
|
||||
// limpiarCampo evita que un usuario inventado meta saltos de linea o espacios
|
||||
// en el log: si no, se podria falsificar una linea AUTH_FAIL con la IP de otro
|
||||
// y hacer que fail2ban banee a quien no toca.
|
||||
func limpiarCampo(s string) string {
|
||||
if s == "" {
|
||||
return "-"
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, r := range s {
|
||||
if r > unicode.MaxASCII || unicode.IsSpace(r) || !unicode.IsPrint(r) {
|
||||
sb.WriteByte('.')
|
||||
continue
|
||||
}
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' {
|
||||
sb.WriteRune(r)
|
||||
} else {
|
||||
sb.WriteByte('.')
|
||||
}
|
||||
if sb.Len() >= 32 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "-"
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (l *AuthLog) Escribir(evento, ip, usuario, motivo string) {
|
||||
linea := fmt.Sprintf("%s %s ip=%s usuario=%s motivo=%s\n",
|
||||
time.Now().Format(time.RFC3339), evento,
|
||||
limpiarCampo(ip), limpiarCampo(usuario), limpiarCampo(motivo))
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.f != nil {
|
||||
l.f.WriteString(linea)
|
||||
return
|
||||
}
|
||||
os.Stderr.WriteString(linea)
|
||||
}
|
||||
|
||||
// iguales compara dos cadenas sin filtrar por el tiempo cuanto coinciden.
|
||||
func iguales(a, b string) bool {
|
||||
return len(a) == len(b) && subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
|
||||
func (l *AuthLog) Cerrar() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.f != nil {
|
||||
l.f.Close()
|
||||
}
|
||||
}
|
||||
94
gestor/config.go
Normal file
94
gestor/config.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// Red
|
||||
Escucha string `json:"escucha"` // siempre local: nginx hace de puerta
|
||||
BaseURL string `json:"base_url"` // https://www.laenre.net, para comprobar el Origen
|
||||
|
||||
// Rutas
|
||||
DirDatos string `json:"dir_datos"` // registro.jsonl
|
||||
DirPlantillas string `json:"dir_plantillas"` // plantillas de /pedir/
|
||||
DirEstatico string `json:"dir_estatico"` // raiz de la web, solo en modo local
|
||||
LogAuth string `json:"log_auth"` // el registro que lee fail2ban
|
||||
|
||||
// Comportamiento
|
||||
CookieSegura bool `json:"cookie_segura"` // false solo para probar sin TLS
|
||||
Local bool `json:"local"` // sirve tambien la web estatica
|
||||
MesesVista int `json:"meses_vista"` // meses que pinta /pedir/
|
||||
|
||||
Reglas Reglas `json:"reglas"`
|
||||
}
|
||||
|
||||
func ConfigPorDefecto() Config {
|
||||
return Config{
|
||||
Escucha: "127.0.0.1:8090",
|
||||
BaseURL: "https://www.laenre.net",
|
||||
DirDatos: "/var/lib/enrecal",
|
||||
DirPlantillas: "/var/www/laenre/gestor/plantillas",
|
||||
DirEstatico: "/var/www/laenre",
|
||||
LogAuth: "/var/log/enrecal/auth.log",
|
||||
CookieSegura: true,
|
||||
MesesVista: 4,
|
||||
Reglas: ReglasPorDefecto(),
|
||||
}
|
||||
}
|
||||
|
||||
func CargarConfig(ruta string) (Config, error) {
|
||||
c := ConfigPorDefecto()
|
||||
if ruta == "" {
|
||||
return c, nil
|
||||
}
|
||||
b, err := os.ReadFile(ruta)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&c); err != nil {
|
||||
return c, fmt.Errorf("%s: %w", ruta, err)
|
||||
}
|
||||
return c, c.Validar()
|
||||
}
|
||||
|
||||
func (c Config) Validar() error {
|
||||
if c.Reglas.CupoDias < 1 {
|
||||
return fmt.Errorf("cupo_dias tiene que ser 1 o mas")
|
||||
}
|
||||
if c.Reglas.MesesVentana < 1 {
|
||||
return fmt.Errorf("meses_ventana tiene que ser 1 o mas")
|
||||
}
|
||||
if c.Reglas.DiasMinimoConfirmar < 0 {
|
||||
return fmt.Errorf("dias_minimo_confirmar no puede ser negativo")
|
||||
}
|
||||
if c.Reglas.DiasMaximoPendiente < 1 {
|
||||
return fmt.Errorf("dias_maximo_pendiente tiene que ser 1 o mas")
|
||||
}
|
||||
if len(c.Reglas.DiasReservables) == 0 {
|
||||
return fmt.Errorf("dias_reservables no puede estar vacio")
|
||||
}
|
||||
if _, err := c.Reglas.AnclaFinde.Time(); err != nil {
|
||||
return fmt.Errorf("ancla_finde: %w", err)
|
||||
}
|
||||
if c.Reglas.TurnoAncla != Tranqui && c.Reglas.TurnoAncla != Ruido {
|
||||
return fmt.Errorf("turno_ancla solo puede ser %q o %q", Tranqui, Ruido)
|
||||
}
|
||||
if c.MesesVista < 1 {
|
||||
return fmt.Errorf("meses_vista tiene que ser 1 o mas")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
inactividadSesion = 8 * time.Hour
|
||||
maxIntentos = 5
|
||||
ventanaIntentos = 10 * time.Minute
|
||||
esperaBloqueo = 15 * time.Minute
|
||||
)
|
||||
7
gestor/go.mod
Normal file
7
gestor/go.mod
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
module laenre.net/enrecal
|
||||
|
||||
go 1.22
|
||||
|
||||
require golang.org/x/crypto v0.26.0
|
||||
|
||||
require golang.org/x/sys v0.26.0 // indirect
|
||||
4
gestor/go.sum
Normal file
4
gestor/go.sum
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
352
gestor/main.go
Normal file
352
gestor/main.go
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
// 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
|
||||
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 o interno")
|
||||
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),
|
||||
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) vista() Vista {
|
||||
return Vista{
|
||||
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 != RolEnre && rol != RolInterno {
|
||||
return fmt.Errorf("el rol solo puede ser %q o %q", RolEnre, RolInterno)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
171
gestor/modelo.go
Normal file
171
gestor/modelo.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Dia es una fecha civil en formato ISO "2006-01-02". Se guarda como texto
|
||||
// porque asi se ordena y se compara igual que como se lee en el registro.
|
||||
type Dia string
|
||||
|
||||
const formatoDia = "2006-01-02"
|
||||
|
||||
func DiaDe(t time.Time) Dia { return Dia(t.Format(formatoDia)) }
|
||||
|
||||
func (d Dia) Time() (time.Time, error) {
|
||||
t, err := time.ParseInLocation(formatoDia, string(d), time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("fecha %q no válida (formato AAAA-MM-DD)", string(d))
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// MustTime solo se usa sobre fechas ya validadas al entrar en el sistema.
|
||||
func (d Dia) MustTime() time.Time {
|
||||
t, err := d.Time()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (d Dia) Mas(dias int) Dia { return DiaDe(d.MustTime().AddDate(0, 0, dias)) }
|
||||
|
||||
// DiaISO devuelve 1 (lunes) a 7 (domingo).
|
||||
func (d Dia) DiaISO() int { return int(d.MustTime().Weekday()+6)%7 + 1 }
|
||||
|
||||
func (d Dia) Legible() string {
|
||||
t := d.MustTime()
|
||||
return fmt.Sprintf("%s %d de %s", diasSemana[d.DiaISO()-1], t.Day(), mesesMin[int(t.Month())-1])
|
||||
}
|
||||
|
||||
var (
|
||||
diasSemana = []string{"lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"}
|
||||
meses = []string{"Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio",
|
||||
"Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"}
|
||||
mesesMin = []string{"enero", "febrero", "marzo", "abril", "mayo", "junio", "julio",
|
||||
"agosto", "septiembre", "octubre", "noviembre", "diciembre"}
|
||||
)
|
||||
|
||||
// Rango de dias que ocupa un evento, ambos extremos incluidos.
|
||||
func Rango(desde, hasta Dia) []Dia {
|
||||
if hasta < desde {
|
||||
return nil
|
||||
}
|
||||
var out []Dia
|
||||
for d := desde; d <= hasta; d = d.Mas(1) {
|
||||
out = append(out, d)
|
||||
if len(out) > 400 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type Rol string
|
||||
|
||||
const (
|
||||
RolEnre Rol = "enre" // la asamblea: confirma y rechaza
|
||||
RolInterno Rol = "interno" // colectivo de la casa: solicita y amadrina
|
||||
)
|
||||
|
||||
type Cuenta struct {
|
||||
Usuario string `json:"usuario"`
|
||||
Nombre string `json:"nombre"` // nombre visible del colectivo
|
||||
Rol Rol `json:"rol"`
|
||||
Hash string `json:"hash"` // argon2id codificado
|
||||
Temporal bool `json:"temporal,omitempty"` // obliga a cambiar la clave al entrar
|
||||
Colectivo string `json:"colectivo,omitempty"` // clave en datos/horarios.json, para el color
|
||||
Activa bool `json:"activa"`
|
||||
Creada time.Time `json:"creada"`
|
||||
}
|
||||
|
||||
type Estado string
|
||||
|
||||
const (
|
||||
Solicitado Estado = "solicitado"
|
||||
Confirmado Estado = "confirmado"
|
||||
Rechazado Estado = "rechazado"
|
||||
Caducado Estado = "caducado"
|
||||
Cancelado Estado = "cancelado"
|
||||
)
|
||||
|
||||
// Ocupa indica si el estado reserva la fecha frente a otras solicitudes.
|
||||
func (e Estado) Ocupa() bool { return e == Solicitado || e == Confirmado }
|
||||
|
||||
func (e Estado) Etiqueta() string {
|
||||
switch e {
|
||||
case Solicitado:
|
||||
return "SOLICITADO"
|
||||
case Confirmado:
|
||||
return "CONFIRMADO"
|
||||
case Rechazado:
|
||||
return "RECHAZADO"
|
||||
case Caducado:
|
||||
return "CADUCADO"
|
||||
case Cancelado:
|
||||
return "CANCELADO"
|
||||
}
|
||||
return string(e)
|
||||
}
|
||||
|
||||
type Evento struct {
|
||||
ID string `json:"id"`
|
||||
Titulo string `json:"titulo"`
|
||||
Solicitante string `json:"solicitante"` // usuario de la cuenta
|
||||
Amadrinado string `json:"amadrinado,omitempty"` // colectivo externo, vacio si es propio
|
||||
Contacto string `json:"contacto,omitempty"` // contacto del externo
|
||||
Desde Dia `json:"desde"`
|
||||
Hasta Dia `json:"hasta"`
|
||||
Hora string `json:"hora,omitempty"`
|
||||
Notas string `json:"notas,omitempty"`
|
||||
Estado Estado `json:"estado"`
|
||||
Creado time.Time `json:"creado"`
|
||||
Resuelto time.Time `json:"resuelto,omitempty"`
|
||||
ResueltoPor string `json:"resuelto_por,omitempty"`
|
||||
Motivo string `json:"motivo,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Evento) Dias() []Dia { return Rango(e.Desde, e.Hasta) }
|
||||
|
||||
func (e *Evento) NumDias() int { return len(e.Dias()) }
|
||||
|
||||
// Externo indica si el evento lo organiza un colectivo de fuera amadrinado.
|
||||
func (e *Evento) Externo() bool { return e.Amadrinado != "" }
|
||||
|
||||
type Turno string
|
||||
|
||||
const (
|
||||
Tranqui Turno = "tranqui"
|
||||
Ruido Turno = "ruido"
|
||||
)
|
||||
|
||||
func (t Turno) Etiqueta() string {
|
||||
switch t {
|
||||
case Tranqui:
|
||||
return "FINDE TRANQUI"
|
||||
case Ruido:
|
||||
return "FINDE CON RUIDO"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Excepcion fija a mano el turno de un finde concreto, identificado por su viernes.
|
||||
type Excepcion struct {
|
||||
Viernes Dia `json:"viernes"`
|
||||
Turno Turno `json:"turno"`
|
||||
}
|
||||
|
||||
type Sesion struct {
|
||||
ID string
|
||||
Usuario string
|
||||
CSRF string
|
||||
Creada time.Time
|
||||
Vista time.Time
|
||||
|
||||
// Clave temporal recien generada, para ensenarla una sola vez sin que pase
|
||||
// por la URL y acabe en el historial o en el log de nginx.
|
||||
ClaveDe string
|
||||
ClaveNueva string
|
||||
}
|
||||
1042
gestor/pedir.go
Normal file
1042
gestor/pedir.go
Normal file
File diff suppressed because it is too large
Load diff
23
gestor/pedir_test.go
Normal file
23
gestor/pedir_test.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDestinoSeguro(t *testing.T) {
|
||||
casos := map[string]string{
|
||||
"/pedir/nueva?fecha=2026-10-09": "/pedir/nueva?fecha=2026-10-09",
|
||||
"/pedir/mis-fechas": "/pedir/mis-fechas",
|
||||
"": "",
|
||||
"/": "",
|
||||
"/index.html": "",
|
||||
"//evil.example/pedir/": "",
|
||||
"https://evil.example/pedir/": "",
|
||||
"/\\evil.example/pedir/": "",
|
||||
"/pedir/\r\nSet-Cookie: x=1": "",
|
||||
"javascript:alert(1)": "",
|
||||
}
|
||||
for entrada, quiero := range casos {
|
||||
if got := destinoSeguro(entrada); got != quiero {
|
||||
t.Errorf("destinoSeguro(%q) = %q, quiero %q", entrada, got, quiero)
|
||||
}
|
||||
}
|
||||
}
|
||||
92
gestor/plantillas/_marco.tmpl.html
Normal file
92
gestor/plantillas/_marco.tmpl.html
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{{define "arriba"}}<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Titulo}} · La Enredadera de Tetuán</title>
|
||||
<meta name="description" content="Fechas pedidas y confirmadas en el CSO La Enredadera de Tetuán, y cómo pedir una.">
|
||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<input type="checkbox" id="nav-toggle" class="sr-only">
|
||||
<label for="nav-toggle" class="sb-toggle" aria-label="Abrir o cerrar el menú">☰</label>
|
||||
<label for="nav-toggle" class="sb-overlay" aria-hidden="true"></label>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<aside class="sidebar">
|
||||
<a class="sb-pedir" href="/pedir/" aria-current="page">Pedir evento</a>
|
||||
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="96" height="96">
|
||||
<p class="sb-marca">La Enredadera</p>
|
||||
<p class="lema">Centro Social Autogestionado en Tetuán. Haciendo barrio desde 2009.</p>
|
||||
<nav class="sidebar-nav" aria-label="Secciones">
|
||||
<ul>
|
||||
<li><a href="/colectivos.html">Colectivos</a></li>
|
||||
<li><a href="/calendario.html">Calendario</a></li>
|
||||
<li><a href="/eventos.html">Eventos</a></li>
|
||||
<li><a href="/subdir/campa.html">Campañas</a></li>
|
||||
<li><a href="/subdir/sobre_nosotrxs.html">La Enre</a></li>
|
||||
<li><a href="/subdir/contacto.html">Contacto</a></li>
|
||||
<li><a href="https://okupanel.laenre.net">Actividades</a></li>
|
||||
<li><a href="https://www.instagram.com/la_enredadera_de_tetuan/">Instagram</a></li>
|
||||
<li><a href="https://masto.es/@csolaenre">Mastodon</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
|
||||
<header class="cabecera">
|
||||
<h1>{{.Titulo}}</h1>
|
||||
{{- if .Sub}}<p class="sub">{{.Sub}}</p>{{end}}
|
||||
</header>
|
||||
|
||||
<a class="volver" href="/index.html">← Volver a la enredadera</a>
|
||||
{{- if .Dentro}}
|
||||
|
||||
<nav class="pedir-menu" aria-label="Reservas">
|
||||
<a href="/pedir/"{{if eq .Actual "calendario"}} aria-current="page"{{end}}>Calendario</a>
|
||||
<a href="/pedir/mis-fechas"{{if eq .Actual "mis-fechas"}} aria-current="page"{{end}}>Vuestras fechas</a>
|
||||
<a href="/pedir/nueva"{{if eq .Actual "nueva"}} aria-current="page"{{end}}>Pedir fecha</a>
|
||||
{{- if .EsEnre}}
|
||||
<a href="/pedir/todas"{{if eq .Actual "todas"}} aria-current="page"{{end}}>Todas las fechas</a>
|
||||
<a href="/pedir/pendientes"{{if eq .Actual "pendientes"}} aria-current="page"{{end}}>Pendientes</a>
|
||||
<a href="/pedir/findes"{{if eq .Actual "findes"}} aria-current="page"{{end}}>Findes</a>
|
||||
<a href="/pedir/cuentas"{{if eq .Actual "cuentas"}} aria-current="page"{{end}}>Cuentas</a>
|
||||
{{- end}}
|
||||
<a href="/pedir/clave"{{if eq .Actual "clave"}} aria-current="page"{{end}}>Contraseña</a>
|
||||
<form class="pedir-salir" method="post" action="/pedir/salir">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button type="submit">Salir · {{.Nombre}}</button>
|
||||
</form>
|
||||
</nav>
|
||||
{{- end}}
|
||||
{{- if .Aviso}}
|
||||
|
||||
<p class="pedir-aviso" role="status">{{.Aviso}}</p>
|
||||
{{- end}}
|
||||
{{- if .Error}}
|
||||
|
||||
<p class="pedir-error" role="alert">{{.Error}}</p>
|
||||
{{- end}}
|
||||
{{end}}
|
||||
|
||||
{{define "abajo"}}
|
||||
<footer class="pie-pagina">
|
||||
<p>
|
||||
Calle de la Coruña nº 5, Madrid ·
|
||||
<a href="mailto:enredadera@riseup.net">enredadera@riseup.net</a> ·
|
||||
<a href="https://masto.es/@csolaenre">@csolaenre</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
42
gestor/plantillas/_mes.tmpl.html
Normal file
42
gestor/plantillas/_mes.tmpl.html
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{{define "mes"}}
|
||||
<h3 class="cal-mes">{{.Nombre}} {{.Anio}}</h3>
|
||||
<div class="cal cal-fechas">
|
||||
<div class="cal-row cal-head">
|
||||
<div class="cal-cell cal-head-cell"><abbr title="lunes">L</abbr></div>
|
||||
<div class="cal-cell cal-head-cell"><abbr title="martes">M</abbr></div>
|
||||
<div class="cal-cell cal-head-cell"><abbr title="miércoles">X</abbr></div>
|
||||
<div class="cal-cell cal-head-cell"><abbr title="jueves">J</abbr></div>
|
||||
<div class="cal-cell cal-head-cell"><abbr title="viernes">V</abbr></div>
|
||||
<div class="cal-cell cal-head-cell"><abbr title="sábado">S</abbr></div>
|
||||
<div class="cal-cell cal-head-cell"><abbr title="domingo">D</abbr></div>
|
||||
</div>
|
||||
{{- range .Semanas}}
|
||||
<div class="cal-row">
|
||||
{{- range .Dias}}
|
||||
{{- if .Vacia}}
|
||||
<div class="cal-cell cal-empty"></div>
|
||||
{{- else}}
|
||||
<div class="cal-cell{{if .Hoy}} cal-today{{end}}{{if .Pasado}} cal-pasado{{end}}{{if .Turno}} cal-{{.Turno}}{{end}}{{if .Libre}} cal-libre{{end}}">
|
||||
<span class="cal-daynum">{{.Numero}}</span>
|
||||
{{- if .EtiquetaTurno}}<span class="cal-turno">{{.EtiquetaTurno}}</span>{{end}}
|
||||
{{- if .Libre}}
|
||||
<a class="cal-pedir" href="/pedir/nueva?fecha={{.Dia}}" aria-label="Pedir el {{.Dia.Legible}}">Pedir</a>
|
||||
{{- end}}
|
||||
{{- if .Eventos}}
|
||||
<div class="cal-pills">
|
||||
{{- range .Eventos}}
|
||||
<div class="cal-ev cal-ev-{{.Estado}}">
|
||||
<span class="cal-ev-cab">{{if .Hora}}<b class="cal-ev-hora">{{.Hora}}</b>{{end}}<span class="cal-ev-estado">{{.Etiqueta}}</span></span>
|
||||
<span class="cal-ev-nom">{{.Texto}}{{if .Sigue}} (sigue){{end}}</span>
|
||||
<span class="cal-ev-quien">{{.Quien}}</span>
|
||||
</div>
|
||||
{{- end}}
|
||||
</div>
|
||||
{{- end}}
|
||||
</div>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
</div>
|
||||
{{- end}}
|
||||
</div>
|
||||
{{end}}
|
||||
31
gestor/plantillas/clave.tmpl.html
Normal file
31
gestor/plantillas/clave.tmpl.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{{template "arriba" .}}
|
||||
|
||||
<section class="pantalla" aria-label="Cambiar la contraseña">
|
||||
<h2>Cambiar la contraseña</h2>
|
||||
{{- if .Temporal}}
|
||||
<p class="pedir-aviso">
|
||||
Estáis usando la contraseña temporal que os dieron en la asamblea. Cambiadla ahora:
|
||||
hasta que no lo hagáis no se puede pedir ninguna fecha.
|
||||
</p>
|
||||
{{- end}}
|
||||
<form class="pedir-form" method="post" action="/pedir/clave" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
{{- if .Sig}}<input type="hidden" name="sig" value="{{.Sig}}">{{end}}
|
||||
<p>
|
||||
<label for="actual">Contraseña actual</label>
|
||||
<input id="actual" name="actual" type="password" required maxlength="256">
|
||||
</p>
|
||||
<p>
|
||||
<label for="nueva">Contraseña nueva</label>
|
||||
<input id="nueva" name="nueva" type="password" required minlength="12" maxlength="256">
|
||||
<span class="pista">Mínimo 12 caracteres. Una frase larga aguanta más que una palabra retorcida.</span>
|
||||
</p>
|
||||
<p>
|
||||
<label for="repetida">Repetid la nueva</label>
|
||||
<input id="repetida" name="repetida" type="password" required minlength="12" maxlength="256">
|
||||
</p>
|
||||
<p><button type="submit">Cambiar la contraseña</button></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{template "abajo" .}}
|
||||
87
gestor/plantillas/cuentas.tmpl.html
Normal file
87
gestor/plantillas/cuentas.tmpl.html
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
{{template "arriba" .}}
|
||||
|
||||
{{- if .Clave}}
|
||||
<section class="pantalla" aria-label="Clave temporal">
|
||||
<h2>Clave temporal de «{{.DeQuien}}»</h2>
|
||||
<p class="clave-nueva">{{.Clave}}</p>
|
||||
<p class="pie">
|
||||
Apuntadla y entregadla en mano. No se vuelve a mostrar: de la contraseña solo se
|
||||
guarda su huella, no la contraseña. Quien entre con ella tendrá que cambiarla antes
|
||||
de poder hacer nada.
|
||||
</p>
|
||||
</section>
|
||||
{{- end}}
|
||||
|
||||
<section class="pantalla" aria-label="Cuentas">
|
||||
<h2>Cuentas</h2>
|
||||
<div class="tabla-envoltorio">
|
||||
<table class="pedir-tabla">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Usuario</th>
|
||||
<th scope="col">Colectivo</th>
|
||||
<th scope="col">Rol</th>
|
||||
<th scope="col">Estado</th>
|
||||
<th scope="col">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{- range .Cuentas}}
|
||||
<tr>
|
||||
<th scope="row">{{.Usuario}}</th>
|
||||
<td>{{.Nombre}}</td>
|
||||
<td>{{.Rol}}</td>
|
||||
<td>
|
||||
{{- if .Activa}}activa{{else}}desactivada{{end}}
|
||||
{{- if .Temporal}} <span class="pista">clave temporal sin cambiar</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<form class="en-linea" method="post" action="/pedir/cuentas">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="usuario" value="{{.Usuario}}">
|
||||
<button type="submit" name="accion" value="reset" class="pedir-boton-flojo">Resetear clave</button>
|
||||
{{- if .Activa}}
|
||||
<button type="submit" name="accion" value="desactivar" class="pedir-boton-flojo">Desactivar</button>
|
||||
{{- else}}
|
||||
<button type="submit" name="accion" value="activar" class="pedir-boton-flojo">Activar</button>
|
||||
{{- end}}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pantalla" aria-label="Dar de alta un colectivo">
|
||||
<h2>Dar de alta un colectivo de la casa</h2>
|
||||
<form class="pedir-form" method="post" action="/pedir/cuentas" autocomplete="off">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="accion" value="crear">
|
||||
<p>
|
||||
<label for="nuevo-usuario">Usuario</label>
|
||||
<input id="nuevo-usuario" name="usuario" type="text" required maxlength="32"
|
||||
autocapitalize="none" spellcheck="false">
|
||||
<span class="pista">Minúsculas, números, guion y guion bajo.</span>
|
||||
</p>
|
||||
<p>
|
||||
<label for="nuevo-nombre">Nombre del colectivo</label>
|
||||
<input id="nuevo-nombre" name="nombre" type="text" required maxlength="60">
|
||||
</p>
|
||||
<p>
|
||||
<label for="nuevo-rol">Rol</label>
|
||||
<select id="nuevo-rol" name="rol">
|
||||
<option value="interno">interno: pide fechas y amadrina</option>
|
||||
<option value="enre">enre: además confirma y rechaza</option>
|
||||
</select>
|
||||
</p>
|
||||
<p><button type="submit">Crear la cuenta</button></p>
|
||||
</form>
|
||||
<p class="pie">
|
||||
Los colectivos de fuera no tienen cuenta: sus eventos los pide un colectivo de la casa
|
||||
que los amadrina. Cuantas menos contraseñas haya guardadas, menos hay que perder.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{{template "abajo" .}}
|
||||
49
gestor/plantillas/editar.tmpl.html
Normal file
49
gestor/plantillas/editar.tmpl.html
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{{template "arriba" .}}
|
||||
|
||||
<section class="pantalla" aria-label="Modificar evento">
|
||||
<h2>Modificar evento</h2>
|
||||
<p class="pie">Lo pidió <strong>{{if .Quien}}{{.Quien}}{{else}}{{.Evento.Solicitante}}{{end}}</strong>. La asamblea no tiene cupo ni ventana: solo se comprueba que no pise otro evento.</p>
|
||||
|
||||
<form class="pedir-form" method="post" action="/pedir/editar">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.Evento.ID}}">
|
||||
<p>
|
||||
<label for="titulo">Nombre del evento</label>
|
||||
<input id="titulo" name="titulo" type="text" required maxlength="80" value="{{.Evento.Titulo}}">
|
||||
</p>
|
||||
<p>
|
||||
<label for="estado">Estado</label>
|
||||
<select id="estado" name="estado">
|
||||
<option value="solicitado"{{if eq .Evento.Estado "solicitado"}} selected{{end}}>Solicitado</option>
|
||||
<option value="confirmado"{{if eq .Evento.Estado "confirmado"}} selected{{end}}>Confirmado</option>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<label for="desde">Primer día</label>
|
||||
<input id="desde" name="desde" type="date" required value="{{.Evento.Desde}}">
|
||||
</p>
|
||||
<p>
|
||||
<label for="hasta">Último día</label>
|
||||
<input id="hasta" name="hasta" type="date" value="{{.Evento.Hasta}}">
|
||||
</p>
|
||||
<p>
|
||||
<label for="hora">Hora de inicio</label>
|
||||
<input id="hora" name="hora" type="time" value="{{.Evento.Hora}}">
|
||||
</p>
|
||||
<p>
|
||||
<label for="amadrinado">Colectivo de fuera amadrinado</label>
|
||||
<input id="amadrinado" name="amadrinado" type="text" maxlength="80" value="{{.Evento.Amadrinado}}">
|
||||
</p>
|
||||
<p>
|
||||
<label for="contacto">Contacto de ese colectivo</label>
|
||||
<input id="contacto" name="contacto" type="text" maxlength="120" value="{{.Evento.Contacto}}">
|
||||
</p>
|
||||
<p>
|
||||
<label for="notas">Notas</label>
|
||||
<textarea id="notas" name="notas" rows="4" maxlength="500">{{.Evento.Notas}}</textarea>
|
||||
</p>
|
||||
<p><button type="submit">Guardar cambios</button> <a class="pedir-boton-flojo" href="/pedir/todas">Cancelar</a></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{template "abajo" .}}
|
||||
9
gestor/plantillas/fallo.tmpl.html
Normal file
9
gestor/plantillas/fallo.tmpl.html
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{{template "arriba" .}}
|
||||
|
||||
<section class="pantalla" aria-label="Error">
|
||||
<h2>No se puede</h2>
|
||||
<p class="pedir-error" role="alert">{{.Mensaje}}</p>
|
||||
<p><a class="pedir-boton" href="/pedir/">Volver al calendario</a></p>
|
||||
</section>
|
||||
|
||||
{{template "abajo" .}}
|
||||
45
gestor/plantillas/findes.tmpl.html
Normal file
45
gestor/plantillas/findes.tmpl.html
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{{template "arriba" .}}
|
||||
|
||||
<section class="pantalla" aria-label="Turno de los findes">
|
||||
<h2>Turno de los findes</h2>
|
||||
<p class="pie">
|
||||
Los findes se alternan solos, semana a semana. Aquí se cambia uno concreto cuando la
|
||||
asamblea lo decida, por ejemplo para poner dos tranquilos seguidos. El turno se marca
|
||||
sobre el viernes y vale para todo el finde.
|
||||
</p>
|
||||
<div class="tabla-envoltorio">
|
||||
<table class="pedir-tabla">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Finde</th>
|
||||
<th scope="col">Turno</th>
|
||||
<th scope="col">Cambiar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{- range .Filas}}
|
||||
<tr>
|
||||
<th scope="row">{{.Legible}}</th>
|
||||
<td>
|
||||
<span class="estado finde-{{.Turno}}">{{.Etiqueta}}</span>
|
||||
{{- if .AMano}} <span class="pista">puesto a mano</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<form class="en-linea" method="post" action="/pedir/findes">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="viernes" value="{{.Viernes}}">
|
||||
<button type="submit" name="turno" value="tranqui" class="pedir-boton-flojo">Tranqui</button>
|
||||
<button type="submit" name="turno" value="ruido" class="pedir-boton-flojo">Con ruido</button>
|
||||
{{- if .AMano}}
|
||||
<button type="submit" name="turno" value="" class="pedir-boton-flojo">Automático</button>
|
||||
{{- end}}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{template "abajo" .}}
|
||||
64
gestor/plantillas/mis-fechas.tmpl.html
Normal file
64
gestor/plantillas/mis-fechas.tmpl.html
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{{template "arriba" .}}
|
||||
|
||||
<section class="pantalla" aria-label="Vuestro cupo">
|
||||
<h2>Vuestro cupo</h2>
|
||||
<p class="pie">
|
||||
Os quedan <strong>{{.Restantes}}</strong> de {{.Reglas.CupoDias}} días dentro de los
|
||||
próximos {{.Reglas.MesesVentana}} meses. Se pueden pedir fechas hasta el {{.Hasta}}.
|
||||
{{- if .DiasUsados}} Ahora mismo ocupáis {{.DiasUsados}}.{{end}}
|
||||
</p>
|
||||
<p><a class="pedir-boton" href="/pedir/nueva">Pedir una fecha</a></p>
|
||||
</section>
|
||||
|
||||
<section class="pantalla" aria-label="Vuestras fechas">
|
||||
<h2>Vuestras fechas</h2>
|
||||
{{- if .Mias}}
|
||||
<div class="tabla-envoltorio">
|
||||
<table class="pedir-tabla">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Fecha</th>
|
||||
<th scope="col">Evento</th>
|
||||
<th scope="col">Estado</th>
|
||||
<th scope="col">Caduca</th>
|
||||
<th scope="col"><span class="sr-only">Acciones</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{- range .Mias}}
|
||||
<tr>
|
||||
<td>
|
||||
{{.Legible}}{{if gt .Dias 1}} <span class="pista">({{.Dias}} días)</span>{{end}}
|
||||
{{- if .Evento.Hora}}<br><span class="pista">a las {{.Evento.Hora}}</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{.Evento.Titulo}}
|
||||
{{- if .Evento.Externo}}<br><span class="pista">amadrináis a {{.Evento.Amadrinado}}</span>{{end}}
|
||||
</td>
|
||||
<td><span class="estado estado-{{.Evento.Estado}}">{{.Etiqueta}}</span></td>
|
||||
<td>{{if .Caduca}}{{.Caduca}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{- if .Liberable}}
|
||||
<form method="post" action="/pedir/liberar">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.Evento.ID}}">
|
||||
<button type="submit" class="pedir-boton-flojo">Liberar</button>
|
||||
</form>
|
||||
{{- end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="pie">
|
||||
Una solicitud sin confirmar se borra sola a los {{.Reglas.DiasMaximoPendiente}} días,
|
||||
o cuando falten menos de {{.Reglas.DiasMinimoConfirmar}} para la fecha. La columna
|
||||
«caduca» dice cuándo, y al caducar la fecha queda libre para el resto.
|
||||
</p>
|
||||
{{- else}}
|
||||
<p class="pie">No tenéis ninguna fecha pedida.</p>
|
||||
{{- end}}
|
||||
</section>
|
||||
|
||||
{{template "abajo" .}}
|
||||
51
gestor/plantillas/nueva.tmpl.html
Normal file
51
gestor/plantillas/nueva.tmpl.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{{template "arriba" .}}
|
||||
|
||||
<section class="pantalla" aria-label="Pedir una fecha">
|
||||
<h2>Pedir una fecha</h2>
|
||||
<ul class="pedir-reglas">
|
||||
<li>Entre el <strong>{{.Primero.Legible}}</strong> y el <strong>{{.Ultimo.Legible}}</strong>.</li>
|
||||
<li>Como mucho <strong>{{.Reglas.CupoDias}} días por colectivo</strong> en esos {{.Reglas.MesesVentana}} meses. Os quedan {{.Restantes}}.</li>
|
||||
<li>Las fechas se piden para el finde: viernes, sábado o domingo.</li>
|
||||
<li>Si el evento es de un colectivo de fuera, decid cuál: vuestro colectivo lo amadrina y responde por él.</li>
|
||||
</ul>
|
||||
|
||||
<form class="pedir-form" method="post" action="/pedir/nueva">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<p>
|
||||
<label for="titulo">Nombre del evento</label>
|
||||
<input id="titulo" name="titulo" type="text" required maxlength="80" value="{{.Sol.Titulo}}">
|
||||
</p>
|
||||
<p>
|
||||
<label for="desde">Primer día</label>
|
||||
<input id="desde" name="desde" type="date" required
|
||||
min="{{.Primero}}" max="{{.Ultimo}}" value="{{.Sol.Desde}}">
|
||||
</p>
|
||||
<p>
|
||||
<label for="hasta">Último día</label>
|
||||
<input id="hasta" name="hasta" type="date"
|
||||
min="{{.Primero}}" max="{{.Ultimo}}" value="{{.Sol.Hasta}}">
|
||||
<span class="pista">Dejadlo vacío si es un solo día.</span>
|
||||
</p>
|
||||
<p>
|
||||
<label for="hora">Hora de inicio</label>
|
||||
<input id="hora" name="hora" type="time" value="{{.Sol.Hora}}">
|
||||
</p>
|
||||
<p>
|
||||
<label for="amadrinado">Colectivo de fuera al que amadrináis</label>
|
||||
<input id="amadrinado" name="amadrinado" type="text" maxlength="80" value="{{.Sol.Amadrinado}}">
|
||||
<span class="pista">Vacío si el evento es vuestro.</span>
|
||||
</p>
|
||||
<p>
|
||||
<label for="contacto">Contacto de ese colectivo</label>
|
||||
<input id="contacto" name="contacto" type="text" maxlength="120" value="{{.Sol.Contacto}}">
|
||||
<span class="pista">Correo o teléfono de quien responde por ellos.</span>
|
||||
</p>
|
||||
<p>
|
||||
<label for="notas">Notas para la asamblea</label>
|
||||
<textarea id="notas" name="notas" rows="4" maxlength="500">{{.Sol.Notas}}</textarea>
|
||||
</p>
|
||||
<p><button type="submit">Pedir la fecha</button></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{{template "abajo" .}}
|
||||
35
gestor/plantillas/pendientes.tmpl.html
Normal file
35
gestor/plantillas/pendientes.tmpl.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{{template "arriba" .}}
|
||||
|
||||
<section class="pantalla" aria-label="Solicitudes pendientes">
|
||||
<h2>Esperando a la asamblea</h2>
|
||||
{{- if .Filas}}
|
||||
{{- range .Filas}}
|
||||
<article class="solicitud">
|
||||
<h3>{{.Evento.Titulo}}</h3>
|
||||
<dl class="solicitud-datos">
|
||||
<dt>Fecha</dt>
|
||||
<dd>{{.Legible}}{{if gt .Dias 1}} ({{.Dias}} días){{end}}{{if .Evento.Hora}}, a las {{.Evento.Hora}}{{end}}</dd>
|
||||
<dt>Pide</dt><dd>{{.Quien}}</dd>
|
||||
{{- if .Evento.Contacto}}<dt>Contacto</dt><dd>{{.Evento.Contacto}}</dd>{{end}}
|
||||
{{- if .Turno}}<dt>Ese finde</dt><dd>{{.Turno}}</dd>{{end}}
|
||||
<dt>Faltan</dt><dd>{{.Faltan}} días</dd>
|
||||
{{- if .Caduca}}<dt>Caduca</dt><dd>{{.Caduca}}</dd>{{end}}
|
||||
{{- if .Evento.Notas}}<dt>Notas</dt><dd>{{.Evento.Notas}}</dd>{{end}}
|
||||
</dl>
|
||||
<form class="solicitud-resolver" method="post" action="/pedir/resolver">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.Evento.ID}}">
|
||||
<label for="motivo-{{.Evento.ID}}">Acuerdo de la asamblea</label>
|
||||
<input id="motivo-{{.Evento.ID}}" name="motivo" type="text" maxlength="200"
|
||||
placeholder="opcional">
|
||||
<button type="submit" name="accion" value="confirmar" class="pedir-boton">Confirmar</button>
|
||||
<button type="submit" name="accion" value="rechazar" class="pedir-boton-flojo">Rechazar</button>
|
||||
</form>
|
||||
</article>
|
||||
{{- end}}
|
||||
{{- else}}
|
||||
<p class="pie">No hay nada pendiente.</p>
|
||||
{{- end}}
|
||||
</section>
|
||||
|
||||
{{template "abajo" .}}
|
||||
65
gestor/plantillas/portada.tmpl.html
Normal file
65
gestor/plantillas/portada.tmpl.html
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{{template "arriba" .}}
|
||||
|
||||
<section class="pantalla" aria-label="Fechas pedidas y confirmadas">
|
||||
<h2>Qué findes están cogidos</h2>
|
||||
<p class="pie">
|
||||
Pincha en un día libre del finde para pedirlo.
|
||||
En <strong>negro</strong>, fechas que un colectivo ha pedido y esperan a que la
|
||||
asamblea las confirme. En <strong>verde chillón</strong>, fechas ya confirmadas. Los findes se
|
||||
turnan entre tranquilos y con ruido. Hoy se pueden pedir fechas hasta el
|
||||
{{.Hasta}}, con un máximo de {{.Reglas.CupoDias}} días por colectivo.
|
||||
Los horarios fijos de cada semana están en <a href="/calendario.html">Calendario</a>.
|
||||
</p>
|
||||
|
||||
<div class="leyenda-pedir">
|
||||
<span class="cal-ev cal-ev-solicitado leyenda-muestra">Solicitado</span>
|
||||
<span class="cal-ev cal-ev-confirmado leyenda-muestra">Confirmado</span>
|
||||
<span class="leyenda-finde cal-tranqui">Finde tranqui</span>
|
||||
<span class="leyenda-finde cal-ruido">Finde con ruido</span>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" id="ver-semana" class="ver-semana sr-only">
|
||||
<label for="ver-semana" class="ver-semana-boton pedir-boton-flojo"><span class="no">Ver la semana entera</span><span class="si">Ver solo los findes</span></label>
|
||||
|
||||
{{- range .Meses}}
|
||||
{{template "mes" .}}
|
||||
{{- end}}
|
||||
</section>
|
||||
|
||||
{{- if not .Dentro}}
|
||||
<section class="pantalla" id="entrar" aria-label="Cómo se pide un evento">
|
||||
<h2>Cómo se pide</h2>
|
||||
<p class="pie">
|
||||
Si tu colectivo <strong>no es de la casa</strong>, la fecha se pide en persona: ven a
|
||||
la <strong>asamblea de externas, los lunes a las 19:00</strong>, en la Enredadera,
|
||||
calle de la Coruña nº 5. Allí se habla del evento y un colectivo de la casa lo
|
||||
amadrina y se hace responsable.
|
||||
</p>
|
||||
<p class="pie">
|
||||
Si tu colectivo <strong>ya tiene credenciales</strong>, entra aquí y pide la fecha.
|
||||
Desde aquí se piden también las de los colectivos de fuera a los que amadrinéis.
|
||||
</p>
|
||||
|
||||
<form class="pedir-form" method="post" action="/pedir/entrar" autocomplete="off">
|
||||
{{- if .Sig}}<input type="hidden" name="sig" value="{{.Sig}}">{{end}}
|
||||
<p>
|
||||
<label for="usuario">Colectivo</label>
|
||||
<input id="usuario" name="usuario" type="text" required maxlength="32"
|
||||
autocapitalize="none" spellcheck="false">
|
||||
</p>
|
||||
<p>
|
||||
<label for="clave">Contraseña</label>
|
||||
<input id="clave" name="clave" type="password" required maxlength="256">
|
||||
</p>
|
||||
<p><button type="submit">Entrar</button></p>
|
||||
</form>
|
||||
|
||||
<p class="pie">
|
||||
No hay recuperación de contraseña por correo, a propósito: así no hay ninguna
|
||||
dirección guardada ni ningún formulario que atacar. Si la habéis perdido, se resetea
|
||||
en la asamblea.
|
||||
</p>
|
||||
</section>
|
||||
{{- end}}
|
||||
|
||||
{{template "abajo" .}}
|
||||
53
gestor/plantillas/todas.tmpl.html
Normal file
53
gestor/plantillas/todas.tmpl.html
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{{template "arriba" .}}
|
||||
|
||||
<section class="pantalla" aria-label="Todas las fechas">
|
||||
<h2>Todas las fechas que vienen</h2>
|
||||
{{- if .Filas}}
|
||||
<div class="tabla-envoltorio">
|
||||
<table class="pedir-tabla">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Fecha</th>
|
||||
<th scope="col">Evento</th>
|
||||
<th scope="col">Estado</th>
|
||||
<th scope="col"><span class="sr-only">Acciones</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{- range .Filas}}
|
||||
<tr>
|
||||
<td>
|
||||
{{.Legible}}{{if gt .Dias 1}} <span class="pista">({{.Dias}} días)</span>{{end}}
|
||||
{{- if .Evento.Hora}}<br><span class="pista">a las {{.Evento.Hora}}</span>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{.Evento.Titulo}}<br><span class="pista">{{.Quien}}</span>
|
||||
</td>
|
||||
<td><span class="estado estado-{{.Evento.Estado}}">{{.Etiqueta}}</span></td>
|
||||
<td>
|
||||
<div class="acciones">
|
||||
<a class="pedir-boton-flojo" href="/pedir/editar?id={{.Evento.ID}}">Modificar</a>
|
||||
<form method="post" action="/pedir/liberar">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.Evento.ID}}">
|
||||
<input type="hidden" name="volver" value="todas">
|
||||
<button type="submit" class="pedir-boton-flojo">Quitar</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{- end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="pie">
|
||||
Quitar deja la fecha libre para el resto; el evento queda en el registro como
|
||||
cancelado. Las solicitudes que esperan confirmación están también en
|
||||
<a href="/pedir/pendientes">Pendientes</a>.
|
||||
</p>
|
||||
{{- else}}
|
||||
<p class="pie">No hay ningún evento en las próximas fechas.</p>
|
||||
{{- end}}
|
||||
</section>
|
||||
|
||||
{{template "abajo" .}}
|
||||
252
gestor/reglas.go
Normal file
252
gestor/reglas.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Reglas reune los limites que acordo la asamblea. Los valores vienen de
|
||||
// /etc/enrecal/config.json para poder ajustarlos sin recompilar.
|
||||
type Reglas struct {
|
||||
MesesVentana int `json:"meses_ventana"` // no se puede pedir mas alla de hoy + N meses
|
||||
CupoDias int `json:"cupo_dias"` // dias por colectivo dentro de la ventana
|
||||
DiasMinimoConfirmar int `json:"dias_minimo_confirmar"` // por debajo de esto, una solicitud sin confirmar caduca
|
||||
DiasMaximoPendiente int `json:"dias_maximo_pendiente"` // una solicitud no puede esperar mas que esto
|
||||
DiasReservables []int `json:"dias_reservables"` // 1=lunes .. 7=domingo
|
||||
AnclaFinde Dia `json:"ancla_finde"` // un viernes de referencia
|
||||
TurnoAncla Turno `json:"turno_ancla"` // que turno le toca a ese finde
|
||||
}
|
||||
|
||||
func ReglasPorDefecto() Reglas {
|
||||
return Reglas{
|
||||
MesesVentana: 3,
|
||||
CupoDias: 3,
|
||||
DiasMinimoConfirmar: 7,
|
||||
DiasMaximoPendiente: 30,
|
||||
DiasReservables: []int{5, 6, 7},
|
||||
AnclaFinde: "2026-09-25",
|
||||
TurnoAncla: Ruido,
|
||||
}
|
||||
}
|
||||
|
||||
// Ventana devuelve el primer y el ultimo dia que se pueden pedir hoy.
|
||||
func (rg Reglas) Ventana(hoy Dia) (Dia, Dia) {
|
||||
primero := hoy.Mas(rg.DiasMinimoConfirmar)
|
||||
ultimo := DiaDe(hoy.MustTime().AddDate(0, rg.MesesVentana, 0))
|
||||
return primero, ultimo
|
||||
}
|
||||
|
||||
func (rg Reglas) reservable(d Dia) bool {
|
||||
iso := d.DiaISO()
|
||||
for _, v := range rg.DiasReservables {
|
||||
if v == iso {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Solicitud es lo que llega del formulario, ya recortado pero sin validar.
|
||||
type Solicitud struct {
|
||||
Solicitante string
|
||||
Rol Rol
|
||||
Titulo string
|
||||
Amadrinado string
|
||||
Contacto string
|
||||
Desde Dia
|
||||
Hasta Dia
|
||||
Hora string
|
||||
Notas string
|
||||
Excluir string // ID del evento que se está modificando: no choca consigo mismo
|
||||
}
|
||||
|
||||
// Ocupacion mapea cada dia ocupado con el titulo del evento que lo ocupa.
|
||||
func Ocupacion(eventos []Evento, excluir string) map[Dia]Evento {
|
||||
out := map[Dia]Evento{}
|
||||
for _, e := range eventos {
|
||||
if e.ID == excluir || !e.Estado.Ocupa() {
|
||||
continue
|
||||
}
|
||||
for _, d := range e.Dias() {
|
||||
out[d] = e
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CupoUsado cuenta los dias que un colectivo ya tiene ocupados dentro de la
|
||||
// ventana de reserva, sumando solicitados y confirmados.
|
||||
func (rg Reglas) CupoUsado(usuario string, eventos []Evento, hoy Dia, excluir string) []Dia {
|
||||
_, ultimo := rg.Ventana(hoy)
|
||||
var dias []Dia
|
||||
for _, e := range eventos {
|
||||
if e.ID == excluir || e.Solicitante != usuario || !e.Estado.Ocupa() {
|
||||
continue
|
||||
}
|
||||
for _, d := range e.Dias() {
|
||||
if d >= hoy && d <= ultimo {
|
||||
dias = append(dias, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(dias, func(i, j int) bool { return dias[i] < dias[j] })
|
||||
return dias
|
||||
}
|
||||
|
||||
// Validar aplica todas las reglas a una solicitud nueva. Devuelve un error con
|
||||
// el motivo escrito para mostrarlo tal cual en el formulario.
|
||||
func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error {
|
||||
if strings.TrimSpace(s.Titulo) == "" {
|
||||
return fmt.Errorf("falta el nombre del evento")
|
||||
}
|
||||
if len([]rune(s.Titulo)) > 80 {
|
||||
return fmt.Errorf("el nombre del evento no puede pasar de 80 caracteres")
|
||||
}
|
||||
if s.Amadrinado != "" && strings.TrimSpace(s.Contacto) == "" {
|
||||
return fmt.Errorf("si amadrinas a un colectivo de fuera hace falta un contacto suyo")
|
||||
}
|
||||
if _, err := s.Desde.Time(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.Hasta.Time(); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Hasta < s.Desde {
|
||||
return fmt.Errorf("la fecha de fin es anterior a la de inicio")
|
||||
}
|
||||
if s.Hora != "" {
|
||||
if _, err := time.Parse("15:04", s.Hora); err != nil {
|
||||
return fmt.Errorf("la hora %q no vale, se escribe como 20:00", s.Hora)
|
||||
}
|
||||
}
|
||||
|
||||
dias := Rango(s.Desde, s.Hasta)
|
||||
libre := s.Rol == RolEnre // la asamblea de la casa no tiene cupo ni ventana
|
||||
|
||||
if !libre {
|
||||
primero, ultimo := rg.Ventana(hoy)
|
||||
if s.Desde < primero {
|
||||
return fmt.Errorf("hay que pedirlo con al menos %d días de antelación: "+
|
||||
"la primera fecha libre es el %s. Con menos margen la solicitud caducaría sin que "+
|
||||
"la asamblea llegue a verla",
|
||||
rg.DiasMinimoConfirmar, primero.Legible())
|
||||
}
|
||||
if s.Hasta > ultimo {
|
||||
return fmt.Errorf("solo se pueden pedir fechas hasta %s, que son %d meses vista",
|
||||
ultimo.Legible(), rg.MesesVentana)
|
||||
}
|
||||
if len(dias) > rg.CupoDias {
|
||||
return fmt.Errorf("un evento no puede ocupar más de %d días y estás pidiendo %d",
|
||||
rg.CupoDias, len(dias))
|
||||
}
|
||||
for _, d := range dias {
|
||||
if !rg.reservable(d) {
|
||||
return fmt.Errorf("el %s no es día reservable: las fechas se piden para %s. "+
|
||||
"Para otro día, habladlo en la asamblea", d.Legible(), rg.nombresReservables())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ocupados := Ocupacion(eventos, s.Excluir)
|
||||
for _, d := range dias {
|
||||
if e, ok := ocupados[d]; ok {
|
||||
return fmt.Errorf("el %s ya está cogido por %q (%s)", d.Legible(), e.Titulo,
|
||||
strings.ToLower(e.Estado.Etiqueta()))
|
||||
}
|
||||
}
|
||||
|
||||
if !libre {
|
||||
usados := rg.CupoUsado(s.Solicitante, eventos, hoy, s.Excluir)
|
||||
if len(usados)+len(dias) > rg.CupoDias {
|
||||
return fmt.Errorf("te pasáis del cupo: %d días por colectivo en los próximos %d meses. "+
|
||||
"Ya tenéis %s y pedís %d día(s) más. Cancelad una de esas fechas o esperad a que pasen",
|
||||
rg.CupoDias, rg.MesesVentana, listaDias(usados), len(dias))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rg Reglas) nombresReservables() string {
|
||||
var out []string
|
||||
for _, v := range rg.DiasReservables {
|
||||
if v >= 1 && v <= 7 {
|
||||
out = append(out, diasSemana[v-1])
|
||||
}
|
||||
}
|
||||
return enumerar(out)
|
||||
}
|
||||
|
||||
// enumerar une una lista en castellano: "viernes, sábado y domingo".
|
||||
func enumerar(partes []string) string {
|
||||
switch len(partes) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return partes[0]
|
||||
}
|
||||
return strings.Join(partes[:len(partes)-1], ", ") + " y " + partes[len(partes)-1]
|
||||
}
|
||||
|
||||
func listaDias(dias []Dia) string {
|
||||
var out []string
|
||||
for _, d := range dias {
|
||||
out = append(out, d.Legible())
|
||||
}
|
||||
return enumerar(out)
|
||||
}
|
||||
|
||||
// Caducidad devuelve el motivo por el que un evento debe caducar, o "" si
|
||||
// todavia no toca.
|
||||
func (rg Reglas) Caducidad(e Evento, ahora time.Time) string {
|
||||
if e.Estado != Solicitado {
|
||||
return ""
|
||||
}
|
||||
hoy := DiaDe(ahora)
|
||||
if e.Desde < hoy.Mas(rg.DiasMinimoConfirmar) {
|
||||
return fmt.Sprintf("sin confirmar a menos de %d días de la fecha", rg.DiasMinimoConfirmar)
|
||||
}
|
||||
if ahora.Sub(e.Creado) > time.Duration(rg.DiasMaximoPendiente)*24*time.Hour {
|
||||
return fmt.Sprintf("más de %d días esperando confirmación", rg.DiasMaximoPendiente)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ViernesDe devuelve el viernes del finde al que pertenece un dia, o "" si el
|
||||
// dia no cae en finde.
|
||||
func ViernesDe(d Dia) Dia {
|
||||
iso := d.DiaISO()
|
||||
if iso < 5 {
|
||||
return ""
|
||||
}
|
||||
return d.Mas(5 - iso)
|
||||
}
|
||||
|
||||
// TurnoDe resuelve si un finde es tranqui o con ruido: primero mira las
|
||||
// excepciones puestas a mano y si no alterna semana a semana desde el ancla.
|
||||
func (rg Reglas) TurnoDe(d Dia, exc map[Dia]Turno) Turno {
|
||||
viernes := ViernesDe(d)
|
||||
if viernes == "" {
|
||||
return ""
|
||||
}
|
||||
if t, ok := exc[viernes]; ok {
|
||||
return t
|
||||
}
|
||||
ancla := ViernesDe(rg.AnclaFinde)
|
||||
if ancla == "" {
|
||||
ancla = rg.AnclaFinde
|
||||
}
|
||||
dif := int(viernes.MustTime().Sub(ancla.MustTime()).Hours() / 24 / 7)
|
||||
if dif < 0 {
|
||||
dif = -dif
|
||||
}
|
||||
otro := Tranqui
|
||||
if rg.TurnoAncla == Tranqui {
|
||||
otro = Ruido
|
||||
}
|
||||
if dif%2 == 0 {
|
||||
return rg.TurnoAncla
|
||||
}
|
||||
return otro
|
||||
}
|
||||
250
gestor/reglas_test.go
Normal file
250
gestor/reglas_test.go
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const hoy = Dia("2026-09-22") // martes
|
||||
|
||||
func solicitud(usuario string, desde, hasta Dia) Solicitud {
|
||||
return Solicitud{
|
||||
Solicitante: usuario,
|
||||
Rol: RolInterno,
|
||||
Titulo: "Concierto de prueba",
|
||||
Desde: desde,
|
||||
Hasta: hasta,
|
||||
Hora: "20:00",
|
||||
}
|
||||
}
|
||||
|
||||
func evento(id, usuario string, desde, hasta Dia, estado Estado) Evento {
|
||||
return Evento{
|
||||
ID: id,
|
||||
Titulo: "Evento " + id,
|
||||
Solicitante: usuario,
|
||||
Desde: desde,
|
||||
Hasta: hasta,
|
||||
Estado: estado,
|
||||
Creado: hoy.MustTime(),
|
||||
}
|
||||
}
|
||||
|
||||
func TestVentanaTresMeses(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
|
||||
// 2026-10-02 es viernes, dentro de los tres meses.
|
||||
if err := rg.Validar(solicitud("hacklab", "2026-10-02", "2026-10-02"), nil, hoy); err != nil {
|
||||
t.Fatalf("una fecha dentro de la ventana deberia valer: %v", err)
|
||||
}
|
||||
// 2027-01-01 es viernes y cae fuera: hoy + 3 meses es 2026-12-22.
|
||||
err := rg.Validar(solicitud("hacklab", "2027-01-01", "2027-01-01"), nil, hoy)
|
||||
if err == nil || !strings.Contains(err.Error(), "3 meses") {
|
||||
t.Fatalf("una fecha a mas de 3 meses tiene que fallar por la ventana, dio: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAntelacionMinima(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
// 2026-09-25 es el viernes de esta semana: a 3 dias, caducaria al instante.
|
||||
err := rg.Validar(solicitud("hacklab", "2026-09-25", "2026-09-25"), nil, hoy)
|
||||
if err == nil || !strings.Contains(err.Error(), "antelación") {
|
||||
t.Fatalf("pedir con menos de 7 dias tiene que fallar, dio: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCupoTresDiasEnLaVentana(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
|
||||
// Tres dias ya cogidos por el mismo colectivo: uno confirmado, dos solicitados.
|
||||
eventos := []Evento{
|
||||
evento("a", "hacklab", "2026-10-02", "2026-10-02", Confirmado),
|
||||
evento("b", "hacklab", "2026-10-10", "2026-10-11", Solicitado),
|
||||
}
|
||||
|
||||
err := rg.Validar(solicitud("hacklab", "2026-10-16", "2026-10-16"), eventos, hoy)
|
||||
if err == nil || !strings.Contains(err.Error(), "cupo") {
|
||||
t.Fatalf("el cuarto dia tiene que fallar por cupo, dio: %v", err)
|
||||
}
|
||||
// El cupo es por colectivo: otro colectivo si puede coger ese dia.
|
||||
if err := rg.Validar(solicitud("feminismos", "2026-10-16", "2026-10-16"), eventos, hoy); err != nil {
|
||||
t.Fatalf("otro colectivo no deberia arrastrar el cupo del primero: %v", err)
|
||||
}
|
||||
// Los dias rechazados o caducados no cuentan.
|
||||
eventos[1].Estado = Caducado
|
||||
if err := rg.Validar(solicitud("hacklab", "2026-10-16", "2026-10-17"), eventos, hoy); err != nil {
|
||||
t.Fatalf("un evento caducado no deberia gastar cupo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCupoNoCuentaFechasPasadas(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
viejos := []Evento{evento("v", "hacklab", "2026-08-07", "2026-08-09", Confirmado)}
|
||||
if err := rg.Validar(solicitud("hacklab", "2026-10-02", "2026-10-04"), viejos, hoy); err != nil {
|
||||
t.Fatalf("lo que ya paso no gasta cupo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSinSolapes(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
eventos := []Evento{evento("a", "hacklab", "2026-10-03", "2026-10-03", Solicitado)}
|
||||
|
||||
err := rg.Validar(solicitud("feminismos", "2026-10-02", "2026-10-04"), eventos, hoy)
|
||||
if err == nil || !strings.Contains(err.Error(), "ya está cogido") {
|
||||
t.Fatalf("un dia solicitado por otro tiene que bloquear, dio: %v", err)
|
||||
}
|
||||
// Al caducar se libera la fecha.
|
||||
eventos[0].Estado = Caducado
|
||||
if err := rg.Validar(solicitud("feminismos", "2026-10-02", "2026-10-04"), eventos, hoy); err != nil {
|
||||
t.Fatalf("una fecha caducada tiene que quedar libre: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoloFindesSalvoLaEnre(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
// 2026-10-06 es martes.
|
||||
err := rg.Validar(solicitud("hacklab", "2026-10-06", "2026-10-06"), nil, hoy)
|
||||
if err == nil || !strings.Contains(err.Error(), "no es día reservable") {
|
||||
t.Fatalf("un martes no es reservable para un colectivo, dio: %v", err)
|
||||
}
|
||||
s := solicitud("enre", "2026-10-06", "2026-10-06")
|
||||
s.Rol = RolEnre
|
||||
if err := rg.Validar(s, nil, hoy); err != nil {
|
||||
t.Fatalf("la enre si puede poner cualquier dia: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaEnreNoTieneCupoNiVentana(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
eventos := []Evento{
|
||||
evento("a", "enre", "2026-10-02", "2026-10-04", Confirmado),
|
||||
evento("b", "enre", "2026-10-09", "2026-10-11", Confirmado),
|
||||
}
|
||||
s := solicitud("enre", "2027-06-04", "2027-06-06")
|
||||
s.Rol = RolEnre
|
||||
if err := rg.Validar(s, eventos, hoy); err != nil {
|
||||
t.Fatalf("la enre no deberia topar con cupo ni ventana: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmadrinadoPideContacto(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
s := solicitud("hacklab", "2026-10-02", "2026-10-02")
|
||||
s.Amadrinado = "Colectivo de fuera"
|
||||
err := rg.Validar(s, nil, hoy)
|
||||
if err == nil || !strings.Contains(err.Error(), "contacto") {
|
||||
t.Fatalf("amadrinar sin contacto tiene que fallar, dio: %v", err)
|
||||
}
|
||||
s.Contacto = "fuera@riseup.net"
|
||||
if err := rg.Validar(s, nil, hoy); err != nil {
|
||||
t.Fatalf("con contacto deberia valer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaducidad(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
ahora := hoy.MustTime()
|
||||
|
||||
cerca := evento("a", "hacklab", "2026-09-27", "2026-09-27", Solicitado)
|
||||
if m := rg.Caducidad(cerca, ahora); m == "" || !strings.Contains(m, "menos de 7") {
|
||||
t.Fatalf("a 5 dias sin confirmar tiene que caducar, dio %q", m)
|
||||
}
|
||||
|
||||
lejos := evento("b", "hacklab", "2026-11-06", "2026-11-06", Solicitado)
|
||||
if m := rg.Caducidad(lejos, ahora); m != "" {
|
||||
t.Fatalf("una solicitud reciente y lejana no caduca, dio %q", m)
|
||||
}
|
||||
|
||||
vieja := lejos
|
||||
vieja.Creado = ahora.AddDate(0, 0, -31)
|
||||
if m := rg.Caducidad(vieja, ahora); m == "" || !strings.Contains(m, "esperando") {
|
||||
t.Fatalf("31 dias esperando tiene que caducar, dio %q", m)
|
||||
}
|
||||
|
||||
confirmada := cerca
|
||||
confirmada.Estado = Confirmado
|
||||
if m := rg.Caducidad(confirmada, ahora); m != "" {
|
||||
t.Fatalf("un evento confirmado no caduca nunca, dio %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTurnoDeFinde(t *testing.T) {
|
||||
rg := ReglasPorDefecto() // ancla 2026-09-25, turno ruido
|
||||
|
||||
casos := []struct {
|
||||
dia Dia
|
||||
quer Turno
|
||||
}{
|
||||
{"2026-09-25", Ruido}, // viernes ancla
|
||||
{"2026-09-26", Ruido}, // sabado del mismo finde
|
||||
{"2026-09-27", Ruido}, // domingo del mismo finde
|
||||
{"2026-10-02", Tranqui}, // finde siguiente
|
||||
{"2026-10-09", Ruido},
|
||||
{"2026-10-16", Tranqui},
|
||||
{"2026-09-18", Tranqui}, // hacia atras tambien alterna
|
||||
}
|
||||
for _, c := range casos {
|
||||
if got := rg.TurnoDe(c.dia, nil); got != c.quer {
|
||||
t.Errorf("%s: turno %q, esperaba %q", c.dia, got, c.quer)
|
||||
}
|
||||
}
|
||||
|
||||
// Un dia de entre semana no tiene turno.
|
||||
if got := rg.TurnoDe("2026-10-06", nil); got != "" {
|
||||
t.Errorf("un martes no tiene turno de finde, dio %q", got)
|
||||
}
|
||||
|
||||
// La excepcion escrita a mano manda sobre la alternancia.
|
||||
exc := map[Dia]Turno{"2026-10-02": Ruido}
|
||||
if got := rg.TurnoDe("2026-10-03", exc); got != Ruido {
|
||||
t.Errorf("la excepcion del viernes tiene que valer para todo el finde, dio %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangoYDiaISO(t *testing.T) {
|
||||
dias := Rango("2026-10-02", "2026-10-04")
|
||||
if len(dias) != 3 || dias[0] != "2026-10-02" || dias[2] != "2026-10-04" {
|
||||
t.Fatalf("rango mal calculado: %v", dias)
|
||||
}
|
||||
if Rango("2026-10-04", "2026-10-02") != nil {
|
||||
t.Fatal("un rango invertido tiene que dar nada")
|
||||
}
|
||||
if got := Dia("2026-10-02").DiaISO(); got != 5 {
|
||||
t.Fatalf("2026-10-02 es viernes, iso 5, dio %d", got)
|
||||
}
|
||||
if got := ViernesDe("2026-10-04"); got != "2026-10-02" {
|
||||
t.Fatalf("el viernes del domingo 4 es el 2, dio %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVentanaDevuelveTresMeses(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
primero, ultimo := rg.Ventana(hoy)
|
||||
if primero != "2026-09-29" {
|
||||
t.Errorf("primera fecha pedible %q, esperaba 2026-09-29", primero)
|
||||
}
|
||||
if ultimo != "2026-12-22" {
|
||||
t.Errorf("ultima fecha pedible %q, esperaba 2026-12-22", ultimo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModificarNoChocaConsigoMismo(t *testing.T) {
|
||||
rg := ReglasPorDefecto()
|
||||
eventos := []Evento{
|
||||
evento("a", "hacklab", "2026-10-02", "2026-10-03", Confirmado),
|
||||
evento("b", "vivienda", "2026-10-09", "2026-10-09", Solicitado),
|
||||
}
|
||||
|
||||
// La asamblea mueve la hora del evento «a» sin cambiarle los días.
|
||||
s := solicitud("hacklab", "2026-10-02", "2026-10-03")
|
||||
s.Rol, s.Excluir = RolEnre, "a"
|
||||
if err := rg.Validar(s, eventos, hoy); err != nil {
|
||||
t.Fatalf("modificar un evento en sus propios días deberia valer: %v", err)
|
||||
}
|
||||
|
||||
// Pero no puede llevarlo encima del evento «b».
|
||||
s.Desde, s.Hasta = "2026-10-09", "2026-10-09"
|
||||
if err := rg.Validar(s, eventos, hoy); err == nil || !strings.Contains(err.Error(), "cogido") {
|
||||
t.Fatalf("mover «a» encima de «b» deberia fallar por solape, dio %v", err)
|
||||
}
|
||||
}
|
||||
152
gestor/vista.go
Normal file
152
gestor/vista.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FilaEvento es un evento ya resuelto para pintarlo en una celda del mes.
|
||||
type FilaEvento struct {
|
||||
Hora string
|
||||
Texto string
|
||||
Quien string
|
||||
Estado Estado
|
||||
Etiqueta string
|
||||
Sigue bool // dia intermedio o final de un evento de varios dias
|
||||
}
|
||||
|
||||
type Celda struct {
|
||||
Dia Dia
|
||||
Numero int
|
||||
Vacia bool
|
||||
Hoy bool
|
||||
Pasado bool
|
||||
Libre bool // se puede pedir: dia reservable, dentro de la ventana y sin ocupar
|
||||
Turno Turno
|
||||
EtiquetaTurno string // solo en el viernes, para no repetirla tres veces
|
||||
Eventos []FilaEvento
|
||||
}
|
||||
|
||||
type Semana struct {
|
||||
Dias []Celda
|
||||
}
|
||||
|
||||
type Mes struct {
|
||||
Nombre string
|
||||
Anio int
|
||||
Semanas []Semana
|
||||
}
|
||||
|
||||
// Vista arma los meses que se pintan, a partir del estado del registro.
|
||||
// Aqui no hay horarios semanales: esos siguen en calendario.html, que se dibuja
|
||||
// con su propio guion y no se toca.
|
||||
type Vista struct {
|
||||
reglas Reglas
|
||||
exc map[Dia]Turno
|
||||
eventos []Evento
|
||||
nombres map[string]string
|
||||
hoy Dia
|
||||
}
|
||||
|
||||
func (v Vista) eventosDe(d Dia) []FilaEvento {
|
||||
var out []FilaEvento
|
||||
for _, e := range v.eventos {
|
||||
if !e.Estado.Ocupa() || d < e.Desde || d > e.Hasta {
|
||||
continue
|
||||
}
|
||||
quien := v.nombres[e.Solicitante]
|
||||
if quien == "" {
|
||||
quien = e.Solicitante
|
||||
}
|
||||
if e.Externo() {
|
||||
quien = fmt.Sprintf("%s · amadrina %s", e.Amadrinado, quien)
|
||||
}
|
||||
out = append(out, FilaEvento{
|
||||
Hora: e.Hora,
|
||||
Texto: e.Titulo,
|
||||
Quien: quien,
|
||||
Estado: e.Estado,
|
||||
Etiqueta: e.Estado.Etiqueta(),
|
||||
Sigue: d != e.Desde,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (v Vista) celda(d Dia, numero int) Celda {
|
||||
turno := v.reglas.TurnoDe(d, v.exc)
|
||||
etiqueta := ""
|
||||
if turno != "" && d.DiaISO() == 5 {
|
||||
etiqueta = turno.Etiqueta()
|
||||
}
|
||||
primero, ultimo := v.reglas.Ventana(v.hoy)
|
||||
eventos := v.eventosDe(d)
|
||||
return Celda{
|
||||
Dia: d,
|
||||
Numero: numero,
|
||||
Hoy: d == v.hoy,
|
||||
Pasado: d < v.hoy,
|
||||
Libre: len(eventos) == 0 && d >= primero && d <= ultimo && v.reglas.reservable(d),
|
||||
Turno: turno,
|
||||
EtiquetaTurno: etiqueta,
|
||||
Eventos: eventos,
|
||||
}
|
||||
}
|
||||
|
||||
func (v Vista) Mes(anio, mes int) Mes {
|
||||
primero := fechaMes(anio, mes)
|
||||
total := diasDelMes(anio, mes)
|
||||
hueco := (int(primero.Weekday()) + 6) % 7 // celdas vacias antes del dia 1
|
||||
|
||||
m := Mes{Nombre: meses[mes-1], Anio: anio}
|
||||
semana := Semana{}
|
||||
for i := 0; i < hueco; i++ {
|
||||
semana.Dias = append(semana.Dias, Celda{Vacia: true})
|
||||
}
|
||||
for dia := 1; dia <= total; dia++ {
|
||||
d := DiaDe(primero.AddDate(0, 0, dia-1))
|
||||
semana.Dias = append(semana.Dias, v.celda(d, dia))
|
||||
if len(semana.Dias) == 7 {
|
||||
m.Semanas = append(m.Semanas, semana)
|
||||
semana = Semana{}
|
||||
}
|
||||
}
|
||||
if len(semana.Dias) > 0 {
|
||||
for len(semana.Dias) < 7 {
|
||||
semana.Dias = append(semana.Dias, Celda{Vacia: true})
|
||||
}
|
||||
m.Semanas = append(m.Semanas, semana)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Meses devuelve el mes que corre y los siguientes, que son los que se pueden
|
||||
// pedir.
|
||||
func (v Vista) Meses(cuantos int) []Mes {
|
||||
t := v.hoy.MustTime()
|
||||
var out []Mes
|
||||
for i := 0; i < cuantos; i++ {
|
||||
m := t.AddDate(0, i, 0)
|
||||
out = append(out, v.Mes(m.Year(), int(m.Month())))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Proximas lista los eventos que vienen, para la tabla de debajo del mes.
|
||||
func (v Vista) Proximas() []Evento {
|
||||
var out []Evento
|
||||
for _, e := range v.eventos {
|
||||
if e.Estado.Ocupa() && e.Hasta >= v.hoy {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func diasDelMes(anio, mes int) int {
|
||||
return fechaMes(anio, mes).AddDate(0, 1, -1).Day()
|
||||
}
|
||||
|
||||
func fechaMes(anio, mes int) time.Time {
|
||||
return time.Date(anio, time.Month(mes), 1, 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
434
index.css
434
index.css
|
|
@ -97,6 +97,22 @@
|
|||
font-weight:normal; font-style:normal; font-display:swap;
|
||||
}
|
||||
|
||||
/* Anton y Rock Salt, servidas desde aquí. Antes las traía cada página con un
|
||||
<link> a fonts.googleapis.com: eso son dos conexiones más antes de pintar
|
||||
nada y, sobre todo, la IP de cada persona que entra en la web de un centro
|
||||
social okupado pasando por Google. Son los subconjuntos latinos, 77 KB entre
|
||||
las dos, los mismos ficheros que servía Google: se ve exactamente igual. */
|
||||
@font-face{
|
||||
font-family:'Anton';
|
||||
src:url('/fuentes/anton-latin.woff2') format('woff2');
|
||||
font-weight:400; font-style:normal; font-display:swap;
|
||||
}
|
||||
@font-face{
|
||||
font-family:'Rock Salt';
|
||||
src:url('/fuentes/rocksalt-latin.woff2') format('woff2');
|
||||
font-weight:400; font-style:normal; font-display:swap;
|
||||
}
|
||||
|
||||
/* ============================ 2. BASE ============================ */
|
||||
*,*::before,*::after{ box-sizing:border-box; }
|
||||
html,body{ margin:0; padding:0; overflow-x:hidden; }
|
||||
|
|
@ -1009,30 +1025,78 @@ gancio-calendar{
|
|||
#redes-toggle:checked ~ .brotes > li{ visibility:visible; }
|
||||
|
||||
/* En pantallas muy estrechas, siete columnas dan 55px cada una y ahí no cabe
|
||||
ningún texto útil: "Autodefensa 20:00" quedaba en "Autod...". Se cambia por
|
||||
una barra del color del colectivo, que la leyenda de debajo explica. Sigue
|
||||
siendo pulsable y el nombre completo queda en el title para lectores. */
|
||||
ningún texto útil: "Autodefensa 20:00" quedaba en "Autod...".
|
||||
|
||||
Durante un tiempo la salida fue quitarle el texto a la píldora
|
||||
—`font-size:0`— y dejar sólo una barra del color del colectivo, con la
|
||||
leyenda de debajo para descifrarla. La asamblea lo rechazó: en el móvil hay
|
||||
que leer la hora y el nombre, no interpretar colores.
|
||||
|
||||
Así que por debajo de 560px la rejilla deja de ser rejilla y se convierte en
|
||||
una LISTA DE DÍAS, uno debajo de otro y a todo el ancho. Ahí el texto entra
|
||||
entero. Se hace sólo con CSS: el guión del calendario sigue dibujando
|
||||
exactamente el mismo DOM y no se toca ni una línea de él.
|
||||
|
||||
En ordenador y en tableta no cambia nada. */
|
||||
@media (max-width:560px){
|
||||
/* La barra se VE de 10px pero el elemento MIDE 24: el relleno vertical
|
||||
agranda el área a la que llega el dedo y `background-clip:content-box`
|
||||
deja el color sólo en la franja del medio. Con 10px de verdad, tocarla en
|
||||
un móvil era imposible, y la página dice «pulsa cualquier etiqueta».
|
||||
24px es el mínimo de WCAG 2.5.8. */
|
||||
/* El marco de la rejilla sobra: ahora cada día es su propia tarjeta. */
|
||||
.cal{ border:none; background:transparent; overflow:visible; border-radius:0; }
|
||||
|
||||
/* Los rótulos L M X J V S D sólo tienen sentido en siete columnas. */
|
||||
.cal-row.cal-head{ display:none; }
|
||||
|
||||
/* `contents` disuelve la fila: las celdas pasan a ser hijas de .cal y caen
|
||||
una debajo de otra. La regla de más arriba que esconde la semana entera
|
||||
vacía tiene más especificidad, así que sigue mandando. */
|
||||
.cal-row{ display:contents; }
|
||||
|
||||
/* La celda deja de ser columna flexible y pasa a bloque, para que el día de
|
||||
la semana y el número salgan en la misma línea y las píldoras debajo. */
|
||||
.cal-cell{
|
||||
display:block; min-height:0;
|
||||
margin:0 0 8px; padding:10px 12px 12px;
|
||||
border:var(--pluma) solid var(--rosa-tenue); border-radius:var(--curva);
|
||||
background:var(--verde-humo);
|
||||
}
|
||||
|
||||
/* Los días sin nada no pintan nada en una lista, y un mes entero son treinta
|
||||
tarjetas. Se quedan los que tienen actividad y el de hoy, aunque esté
|
||||
vacío. Donde no haya :has() salen todos: se ve más largo, no roto. */
|
||||
.cal-cell:not(:has(.cal-pills)):not(:has(.cal-turno)):not(.cal-today){ display:none; }
|
||||
.cal-cell.cal-empty{ display:none; }
|
||||
|
||||
/* El nombre del día lo pone el CSS a partir de la posición dentro de la
|
||||
fila: la columna 1 es lunes. `display:contents` no toca el árbol, así que
|
||||
:nth-child sigue contando igual. */
|
||||
.cal-cell::before{
|
||||
font-family:var(--titular); font-size:.82rem; letter-spacing:.12em;
|
||||
text-transform:uppercase; color:var(--rosa-legible); margin-right:7px;
|
||||
}
|
||||
.cal-row > .cal-cell:nth-child(1)::before{ content:"Lunes"; }
|
||||
.cal-row > .cal-cell:nth-child(2)::before{ content:"Martes"; }
|
||||
.cal-row > .cal-cell:nth-child(3)::before{ content:"Miércoles"; }
|
||||
.cal-row > .cal-cell:nth-child(4)::before{ content:"Jueves"; }
|
||||
.cal-row > .cal-cell:nth-child(5)::before{ content:"Viernes"; }
|
||||
.cal-row > .cal-cell:nth-child(6)::before{ content:"Sábado"; }
|
||||
.cal-row > .cal-cell:nth-child(7)::before{ content:"Domingo"; }
|
||||
|
||||
.cal-daynum{ display:inline; font-size:1.05rem; color:var(--tinta); }
|
||||
|
||||
.cal-pills{ gap:5px; margin-top:8px; }
|
||||
|
||||
/* La píldora recupera su texto y ocupa el ancho de la tarjeta. Con 1.35 de
|
||||
interlínea y 8px arriba y abajo mide 35px de alto, por encima de los 24
|
||||
que pide WCAG 2.5.8 para tocarla con el dedo. */
|
||||
.cal-pill{
|
||||
font-size:0; line-height:0; color:transparent;
|
||||
/* content-box: con el border-box global el relleno se comía la altura y
|
||||
la barra desaparecía. Así los 10px son los del color y el relleno los
|
||||
suma por fuera hasta 24. */
|
||||
box-sizing:content-box;
|
||||
height:10px; padding:7px 0; border-radius:3px;
|
||||
background-clip:content-box; box-shadow:none;
|
||||
font-size:.92rem; line-height:1.35; color:var(--tinta);
|
||||
padding:8px 14px; border-radius:999px;
|
||||
white-space:normal; text-overflow:clip; overflow:visible;
|
||||
background-clip:border-box;
|
||||
}
|
||||
@supports (background: color-mix(in srgb, red 10%, transparent)){
|
||||
.cal-pill{ background:color-mix(in srgb, var(--bg) 60%, transparent);
|
||||
background-clip:content-box; box-shadow:none; }
|
||||
}
|
||||
.cal-cell{ gap:2px; }
|
||||
.cal-pills{ gap:2px; }
|
||||
/* En reposo la píldora no se mueve; el desplazamiento del :hover de
|
||||
escritorio se queda para el toque, que es donde se agradece. */
|
||||
.cal-pill:hover, .cal-pill:active{ transform:none; }
|
||||
|
||||
.cal-legend{ margin-top:18px; }
|
||||
.cal-legend-item{ font-size:.78rem; }
|
||||
}
|
||||
|
|
@ -1112,3 +1176,331 @@ gancio-calendar{
|
|||
.sidebar-nav li{ margin-bottom:2px; }
|
||||
.sidebar .sb-marca{ margin-bottom:22px; }
|
||||
}
|
||||
|
||||
/* ========================== 10. PEDIR EVENTO ==========================
|
||||
La sección /pedir/: el calendario de fechas pedidas y confirmadas, y los
|
||||
formularios con los que se piden y se confirman. Las sirve el gestor, no son
|
||||
ficheros de esta carpeta, pero comparten esta hoja y sus colores.
|
||||
|
||||
El sitio no tenía ni un estilo de formulario —sólo las dos flechas del
|
||||
calendario—, así que todo lo de aquí es nuevo. Los contrastes van medidos
|
||||
contra el mismo umbral que el resto: --rosa a secas NUNCA para texto (3.1:1),
|
||||
--rosa-legible sí, tanto de tinta como de fondo con texto claro encima. */
|
||||
|
||||
/* ---- El botón de la barra lateral ----
|
||||
Va encima del logo, que es lo primero que se ve al abrir el menú con el botón
|
||||
de arriba a la izquierda. */
|
||||
.sb-pedir{
|
||||
display:block; margin:0 0 18px; padding:13px 12px;
|
||||
background:var(--rosa-legible); color:var(--verde-humo);
|
||||
font-family:var(--titular); font-size:.95rem; letter-spacing:.14em;
|
||||
text-transform:uppercase; text-align:center; text-decoration:none;
|
||||
border:var(--pluma) solid var(--rosa); border-radius:var(--curva);
|
||||
transition:background .2s, color .2s;
|
||||
}
|
||||
.sb-pedir:hover, .sb-pedir:active{ background:var(--rosa); color:var(--negro); }
|
||||
|
||||
/* ---- El menú de la sección ---- */
|
||||
.pedir-menu{
|
||||
display:flex; flex-wrap:wrap; align-items:center; gap:4px 6px;
|
||||
margin:0 clamp(16px,5vw,60px) 22px; padding:8px;
|
||||
border:var(--pluma) solid var(--verde-vivo); border-radius:var(--curva);
|
||||
background:var(--verde-humo);
|
||||
}
|
||||
.pedir-menu a, .pedir-salir button{
|
||||
display:flex; align-items:center; min-height:44px; padding:0 14px;
|
||||
font-family:var(--titular); font-size:.8rem; letter-spacing:.1em;
|
||||
text-transform:uppercase; text-decoration:none;
|
||||
color:var(--rosa-legible); background:transparent;
|
||||
border:none; border-radius:var(--curva); cursor:pointer;
|
||||
}
|
||||
.pedir-menu a:hover, .pedir-menu a:active,
|
||||
.pedir-salir button:hover, .pedir-salir button:active{
|
||||
background:var(--rosa-legible); color:var(--verde-humo);
|
||||
}
|
||||
.pedir-menu a[aria-current="page"]{
|
||||
color:var(--tinta); box-shadow:inset 0 -3px 0 var(--rosa);
|
||||
}
|
||||
.pedir-salir{ margin-left:auto; }
|
||||
.pedir-salir button{ color:var(--verde-hoja); }
|
||||
|
||||
/* ---- Avisos ---- */
|
||||
.pedir-aviso, .pedir-error{
|
||||
margin:0 clamp(16px,5vw,60px) 22px; padding:14px 18px;
|
||||
border-radius:var(--curva); font-family:var(--lectura); line-height:1.6;
|
||||
}
|
||||
.pedir-aviso{ border:var(--pluma) solid var(--verde-hoja); background:#e7f2e9; color:var(--tinta); }
|
||||
.pedir-error{ border:var(--pluma) solid var(--rosa); background:#fdeaf5; color:var(--rosa-legible); font-weight:700; }
|
||||
|
||||
/* ---- Textos de apoyo ---- */
|
||||
.pista{ display:block; margin-top:4px; font-size:.85rem; color:var(--tinta-suave); }
|
||||
.pedir-reglas{ margin:0 0 26px; padding-left:22px; line-height:1.7; max-width:70ch; }
|
||||
|
||||
/* ---- Formularios ---- */
|
||||
.pedir-form{ max-width:34rem; }
|
||||
.pedir-form p{ margin:0 0 18px; }
|
||||
.pedir-form label{
|
||||
display:block; margin-bottom:6px;
|
||||
font-family:var(--titular); font-size:.82rem; letter-spacing:.1em;
|
||||
text-transform:uppercase; color:var(--tinta);
|
||||
}
|
||||
.pedir-form input[type="text"], .pedir-form input[type="password"],
|
||||
.pedir-form input[type="date"], .pedir-form input[type="time"],
|
||||
.pedir-form select, .pedir-form textarea,
|
||||
.solicitud-resolver input[type="text"]{
|
||||
width:100%; box-sizing:border-box; min-height:46px; padding:10px 13px;
|
||||
font-family:var(--lectura); font-size:1rem;
|
||||
color:var(--tinta); background:var(--verde-humo);
|
||||
border:var(--pluma) solid var(--verde-hoja); border-radius:var(--curva);
|
||||
}
|
||||
.pedir-form textarea{ min-height:110px; line-height:1.6; resize:vertical; }
|
||||
.pedir-form input:focus-visible, .pedir-form select:focus-visible,
|
||||
.pedir-form textarea:focus-visible{ outline:3px solid var(--rosa); outline-offset:2px; }
|
||||
|
||||
/* ---- Botones ----
|
||||
El relleno deja los 44px de alto que pide el sitio para lo que se toca. */
|
||||
.pedir-boton, .pedir-boton-flojo, .pedir-form button{
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
min-height:44px; padding:0 22px;
|
||||
font-family:var(--titular); font-size:.9rem; letter-spacing:.12em;
|
||||
text-transform:uppercase; text-decoration:none; text-align:center;
|
||||
border-radius:var(--curva); cursor:pointer;
|
||||
transition:background .2s, color .2s;
|
||||
}
|
||||
.pedir-boton, .pedir-form button{
|
||||
color:var(--verde-humo); background:var(--rosa-legible);
|
||||
border:var(--pluma) solid var(--rosa-legible);
|
||||
}
|
||||
.pedir-boton:hover, .pedir-boton:active,
|
||||
.pedir-form button:hover, .pedir-form button:active{
|
||||
background:var(--rosa); color:var(--negro); border-color:var(--rosa);
|
||||
}
|
||||
.pedir-boton-flojo{
|
||||
min-height:44px; padding:0 15px; font-size:.78rem;
|
||||
color:var(--tinta); background:transparent;
|
||||
border:var(--pluma) solid var(--verde-hoja);
|
||||
}
|
||||
.pedir-boton-flojo:hover, .pedir-boton-flojo:active{
|
||||
background:var(--verde-hoja); color:var(--verde-humo);
|
||||
}
|
||||
.en-linea{ display:flex; flex-wrap:wrap; gap:6px; }
|
||||
|
||||
/* ---- Tablas ----
|
||||
El envoltorio deja deslizar la tabla a lo ancho en vez de desbordar la
|
||||
página, que es lo que mide el banco de pruebas. */
|
||||
.tabla-envoltorio{ overflow-x:auto; }
|
||||
.pedir-tabla{
|
||||
width:100%; border-collapse:collapse;
|
||||
font-family:var(--lectura); font-size:.95rem;
|
||||
}
|
||||
.pedir-tabla th[scope="col"]{
|
||||
font-family:var(--titular); font-size:.78rem; letter-spacing:.12em;
|
||||
text-transform:uppercase; text-align:left; padding:10px 12px;
|
||||
color:var(--rosa-legible);
|
||||
border-bottom:var(--pluma) solid var(--rosa-tenue);
|
||||
}
|
||||
.pedir-tabla td, .pedir-tabla th[scope="row"]{
|
||||
padding:12px; text-align:left; vertical-align:top;
|
||||
border-bottom:var(--pluma) solid var(--rosa-papel);
|
||||
}
|
||||
.pedir-tabla th[scope="row"]{ font-family:var(--titular); letter-spacing:.04em; font-weight:400; }
|
||||
|
||||
/* Modificar y Quitar, uno junto al otro en la última columna de la tabla. */
|
||||
.acciones{ display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
|
||||
.acciones form{ margin:0; }
|
||||
|
||||
/* ---- Estados ---- */
|
||||
.estado{
|
||||
display:inline-block; padding:4px 11px; border-radius:999px;
|
||||
font-family:var(--titular); font-size:.72rem; letter-spacing:.1em;
|
||||
text-transform:uppercase; white-space:nowrap; color:var(--tinta);
|
||||
}
|
||||
/* Solicitado en negro y confirmado en verde radiactivo: se distinguen de un
|
||||
vistazo entre sí y de los dos fondos de finde, que son verde apagado y rosa. */
|
||||
.estado-solicitado{ background:var(--negro); color:#fff; }
|
||||
.estado-confirmado{ background:#39ff14; color:#000; box-shadow:inset 0 0 0 1.4px #000; }
|
||||
.finde-tranqui{ background:#a3d1b0; box-shadow:inset 0 0 0 1.4px var(--verde-hoja); }
|
||||
.finde-ruido{ background:#fbdcef; box-shadow:inset 0 0 0 1.4px var(--rosa); }
|
||||
|
||||
/* ---- Solicitudes pendientes ---- */
|
||||
.solicitud{
|
||||
border:var(--pluma) solid var(--verde-vivo);
|
||||
border-left:7px solid var(--rosa);
|
||||
border-radius:var(--curva);
|
||||
padding:18px 20px; margin:0 0 20px; max-width:60rem;
|
||||
}
|
||||
.solicitud h3{
|
||||
font-family:var(--titular); font-size:1.15rem; letter-spacing:.04em;
|
||||
margin:0 0 12px; color:var(--tinta);
|
||||
}
|
||||
.solicitud-datos{ margin:0 0 16px; font-family:var(--lectura); }
|
||||
.solicitud-datos dt{
|
||||
font-family:var(--titular); font-size:.74rem; letter-spacing:.12em;
|
||||
text-transform:uppercase; color:var(--verde-hoja); margin-top:10px;
|
||||
}
|
||||
.solicitud-datos dd{ margin:2px 0 0; line-height:1.6; }
|
||||
.solicitud-resolver{
|
||||
display:flex; flex-wrap:wrap; align-items:center; gap:10px;
|
||||
padding-top:14px; border-top:var(--pluma) dashed var(--rosa-tenue);
|
||||
}
|
||||
.solicitud-resolver label{
|
||||
font-family:var(--titular); font-size:.74rem; letter-spacing:.12em;
|
||||
text-transform:uppercase; color:var(--tinta);
|
||||
}
|
||||
.solicitud-resolver input[type="text"]{ flex:1 1 15rem; min-height:44px; }
|
||||
|
||||
/* ---- La clave temporal, que sólo se enseña una vez ---- */
|
||||
.clave-nueva{
|
||||
font-family:ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size:1.5rem; letter-spacing:.12em; overflow-wrap:anywhere;
|
||||
padding:16px 18px; margin:0 0 14px; color:var(--tinta);
|
||||
background:var(--verde-hondo);
|
||||
border:var(--pluma) dashed var(--verde-hoja); border-radius:var(--curva);
|
||||
}
|
||||
|
||||
/* ---- Leyenda del calendario de fechas ---- */
|
||||
.leyenda-pedir{ display:flex; flex-wrap:wrap; gap:10px 16px; margin:0 0 26px; }
|
||||
/* Las muestras de estado miden lo mismo que las de finde, para que la
|
||||
leyenda sea una sola fila pareja. Va con .leyenda-pedir delante porque
|
||||
.cal-ev, más abajo, les pondría su propio relleno y su esquina. */
|
||||
.leyenda-pedir .leyenda-muestra{
|
||||
display:inline-flex; align-items:center; padding:6px 14px;
|
||||
border-radius:999px; font-size:.85rem;
|
||||
}
|
||||
.leyenda-finde{
|
||||
display:inline-flex; align-items:center; padding:6px 14px;
|
||||
border:var(--pluma) solid var(--verde-hoja); border-radius:999px;
|
||||
font-family:var(--lectura); font-size:.85rem; color:var(--tinta);
|
||||
}
|
||||
.leyenda-finde.cal-tranqui{ background:#a3d1b0; }
|
||||
.leyenda-finde.cal-ruido{ background:#fbdff0; border-color:var(--rosa); }
|
||||
|
||||
/* ---- El calendario de fechas ----
|
||||
Reaprovecha la rejilla del calendario semanal (.cal, .cal-row, .cal-cell), y
|
||||
con ella el paso a lista de días en el móvil. Lo que cambia es lo que va
|
||||
dentro de la celda: aquí un evento necesita tres líneas —hora y estado,
|
||||
nombre, y quién lo pide—, así que no puede ser una .cal-pill de una línea. */
|
||||
.cal-fechas .cal-cell:not(.cal-head-cell){ min-height:clamp(74px,10vw,132px); }
|
||||
.cal-mes{
|
||||
font-family:var(--titular); font-size:clamp(1rem,.85rem + .7vw,1.35rem);
|
||||
text-transform:uppercase; letter-spacing:.08em;
|
||||
color:var(--rosa-legible); margin:26px 0 10px;
|
||||
}
|
||||
.cal-fechas + .cal-mes, .cal + .cal-mes{ margin-top:34px; }
|
||||
|
||||
.cal-turno{
|
||||
display:inline-block; margin-top:2px; padding:1px 7px;
|
||||
font-family:var(--titular); font-size:.56rem; letter-spacing:.08em;
|
||||
text-transform:uppercase; white-space:nowrap;
|
||||
color:var(--tinta); background:var(--verde-humo);
|
||||
border:1px solid var(--verde-hoja); border-radius:999px;
|
||||
}
|
||||
|
||||
/* El fondo del finde. Va después de la regla del sábado y el domingo que pinta
|
||||
.cal-cell:nth-child(6) y (7), y con la misma especificidad, así que manda
|
||||
este por orden: en la sección de fechas el turno pesa más que «es finde». */
|
||||
.cal-row:not(.cal-head) > .cal-cell.cal-tranqui{ background:#a3d1b0; }
|
||||
.cal-row:not(.cal-head) > .cal-cell.cal-ruido{ background:#fbdff0; }
|
||||
|
||||
.cal-ev{
|
||||
display:block; padding:5px 9px; border-radius:var(--curva);
|
||||
font-family:var(--lectura); font-size:.72rem; line-height:1.35;
|
||||
color:var(--tinta); overflow-wrap:anywhere;
|
||||
}
|
||||
.cal-ev-solicitado{ background:var(--negro); color:#fff; }
|
||||
.cal-ev-confirmado{ background:#39ff14; color:#000; box-shadow:inset 0 0 0 1.4px #000; }
|
||||
.cal-ev-cab{ display:flex; flex-wrap:wrap; align-items:baseline; gap:3px 8px; }
|
||||
.cal-ev-hora{ font-family:var(--titular); font-size:.78rem; letter-spacing:.04em; }
|
||||
.cal-ev-estado{
|
||||
font-family:var(--titular); font-size:.58rem; letter-spacing:.1em;
|
||||
text-transform:uppercase; margin-left:auto;
|
||||
}
|
||||
.cal-ev-solicitado .cal-ev-estado{ color:var(--rosa-vivo); } /* 6.7:1 sobre el negro */
|
||||
.cal-ev-confirmado .cal-ev-estado{ color:#000; }
|
||||
.cal-ev-nom{ display:block; font-weight:700; }
|
||||
.cal-ev-quien{ display:block; font-size:.66rem; color:var(--tinta-suave); }
|
||||
.cal-ev-solicitado .cal-ev-quien{ color:#d0d4c8; }
|
||||
.cal-ev-confirmado .cal-ev-quien{ color:var(--tinta); }
|
||||
.cal-pasado{ opacity:.55; }
|
||||
|
||||
/* ---- Findes o semana entera ----
|
||||
Por defecto solo se ven viernes, sábado y domingo, que es cuando se hacen
|
||||
los eventos: en el móvil la lista queda en la mitad. La casilla #ver-semana
|
||||
va antes de los meses, así que con ~ decide qué se pinta, sin JavaScript,
|
||||
igual que el #nav-toggle del menú. Todo va con :not(:checked): marcada, se
|
||||
aplican tal cual las reglas de siempre de la rejilla y de la lista. */
|
||||
.ver-semana-boton{ display:inline-flex; align-items:center; margin:0 0 18px; cursor:pointer; }
|
||||
.ver-semana-boton .si{ display:none; }
|
||||
.ver-semana:checked + .ver-semana-boton .no{ display:none; }
|
||||
.ver-semana:checked + .ver-semana-boton .si{ display:inline; }
|
||||
.ver-semana:focus-visible + .ver-semana-boton{ outline:3px solid var(--rosa); outline-offset:2px; }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-row{ grid-template-columns:repeat(3,minmax(0,1fr)); }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-row > .cal-cell:nth-child(-n+4){ display:none; }
|
||||
/* La última semana de un mes que acaba en jueves no tiene finde: fuera. */
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-row:not(.cal-head):not(:has(> .cal-cell:nth-child(n+5):not(.cal-empty))){ display:none; }
|
||||
|
||||
/* ---- Pinchar un día libre ----
|
||||
Cada día que se puede pedir lleva un enlace a /pedir/nueva?fecha=…. El
|
||||
::after lo estira sobre toda la celda, así que se pincha en cualquier parte
|
||||
del día y no solo en la palabra. Sin sesión, el gestor manda a la entrada y
|
||||
vuelve al formulario con la fecha puesta. */
|
||||
.cal-fechas .cal-cell.cal-libre{ position:relative; }
|
||||
.cal-pedir{
|
||||
align-self:flex-start; margin-top:auto; padding:2px 9px;
|
||||
font-family:var(--titular); font-size:.62rem; letter-spacing:.1em;
|
||||
text-transform:uppercase; text-decoration:none;
|
||||
color:var(--rosa-legible); border:1px solid var(--rosa-legible); border-radius:999px;
|
||||
}
|
||||
.cal-pedir::after{ content:""; position:absolute; inset:0; }
|
||||
.cal-fechas .cal-cell.cal-libre:hover, .cal-fechas .cal-cell.cal-libre:active{
|
||||
box-shadow:inset 0 0 0 2.5px var(--rosa-legible);
|
||||
}
|
||||
.cal-libre:hover .cal-pedir, .cal-libre:active .cal-pedir{
|
||||
background:var(--rosa-legible); color:#fff;
|
||||
}
|
||||
.cal-pedir:focus-visible{ outline:3px solid var(--rosa); outline-offset:2px; }
|
||||
|
||||
/* ---- Letra grande con solo tres columnas ----
|
||||
En ordenador, con solo el finde, cada columna mide unos 380px: las medidas
|
||||
de la rejilla de siete (número a .78rem, eventos a .72rem) se quedaban
|
||||
perdidas en la casilla. Con la semana entera vuelven las de siempre. */
|
||||
@media (min-width:561px){
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-cell:not(.cal-head-cell){
|
||||
padding:12px 14px 14px; gap:8px; min-height:clamp(110px,12vw,150px);
|
||||
}
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell{ padding:16px 2px; }
|
||||
/* Con tres columnas caben los nombres enteros: la letra suelta se esconde
|
||||
y el nombre lo pone el CSS según la columna, como en la lista del móvil. */
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell abbr{ font-size:0; text-decoration:none; }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell abbr::after{ font-size:1.05rem; letter-spacing:.14em; }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell:nth-child(5) abbr::after{ content:"Viernes"; }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell:nth-child(6) abbr::after{ content:"Sábado"; }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-head-cell:nth-child(7) abbr::after{ content:"Domingo"; }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-daynum{ font-size:1.5rem; color:var(--tinta); }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-turno{ font-size:.74rem; padding:3px 12px; align-self:flex-start; }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-pedir{ font-size:.9rem; padding:6px 16px; }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-ev{ font-size:1rem; padding:10px 14px; }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-ev-hora{ font-size:1.1rem; }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-ev-estado{ font-size:.74rem; }
|
||||
.ver-semana:not(:checked) ~ .cal-fechas .cal-ev-quien{ font-size:.88rem; }
|
||||
}
|
||||
|
||||
@media (max-width:560px){
|
||||
.pedir-menu{ margin:0 clamp(16px,5vw,60px) 16px; }
|
||||
.pedir-menu a, .pedir-salir button{ font-size:.74rem; padding:0 10px; }
|
||||
.pedir-salir{ margin-left:0; }
|
||||
/* En lista, la altura la pone el contenido: un finde libre es una tarjeta de
|
||||
una línea, no un hueco de 132px. */
|
||||
.cal-fechas .cal-cell:not(.cal-head-cell){ min-height:0; }
|
||||
/* La lista esconde los días vacíos, pero uno libre para pedir no está vacío:
|
||||
tiene su botón. Misma especificidad que la regla que los esconde y va
|
||||
después, así que manda esta. */
|
||||
.cal-fechas .cal-row > .cal-cell.cal-libre{ display:block; }
|
||||
.cal-pedir{ float:right; font-size:.74rem; padding:4px 12px; }
|
||||
.cal-ev{ font-size:.92rem; padding:8px 12px; }
|
||||
.cal-ev-hora{ font-size:1rem; }
|
||||
.cal-ev-estado{ font-size:.66rem; }
|
||||
.cal-ev-quien{ font-size:.82rem; }
|
||||
.cal-turno{ font-size:.66rem; margin-left:6px; }
|
||||
.clave-nueva{ font-size:1.15rem; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@
|
|||
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
|
|
@ -67,6 +64,9 @@
|
|||
<div class="container">
|
||||
|
||||
<aside class="sidebar">
|
||||
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
|
||||
menú, y la asamblea lo quiso arriba del todo. -->
|
||||
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
|
||||
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="96" height="96">
|
||||
<!-- Es la marca del panel, no un encabezado de sección: como el panel
|
||||
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
|
||||
|
|
|
|||
15
panel.html
15
panel.html
|
|
@ -15,15 +15,24 @@
|
|||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'">
|
||||
<title>Tattoo Circus · Panel</title>
|
||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<style>
|
||||
/* Este micrositio es oscuro por diseño. */
|
||||
:root{ color-scheme: dark; }
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Anton&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&display=swap');
|
||||
/* Fuentes servidas desde laenre.net, sin Google Fonts. */
|
||||
@font-face {
|
||||
font-family: 'Anton';
|
||||
src: url('/fuentes/anton-latin.woff2') format('woff2');
|
||||
font-weight: 400; font-style: normal; font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Special Elite';
|
||||
src: url('/fuentes/specialelite-latin.woff2') format('woff2');
|
||||
font-weight: 400; font-style: normal; font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Code462WPink';
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@
|
|||
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
|
|
@ -31,6 +28,9 @@
|
|||
<div class="container">
|
||||
|
||||
<aside class="sidebar">
|
||||
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
|
||||
menú, y la asamblea lo quiso arriba del todo. -->
|
||||
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
|
||||
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="192" height="192">
|
||||
<!-- Es la marca del panel, no un encabezado de sección: como el panel
|
||||
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@
|
|||
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
|
|
@ -31,6 +28,9 @@
|
|||
<div class="container">
|
||||
|
||||
<aside class="sidebar">
|
||||
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
|
||||
menú, y la asamblea lo quiso arriba del todo. -->
|
||||
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
|
||||
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="192" height="192">
|
||||
<!-- Es la marca del panel, no un encabezado de sección: como el panel
|
||||
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
|
||||
|
|
|
|||
|
|
@ -15,9 +15,6 @@
|
|||
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Anton&family=Rock+Salt&display=swap">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
|
|
@ -31,6 +28,9 @@
|
|||
<div class="container">
|
||||
|
||||
<aside class="sidebar">
|
||||
<!-- El botón va antes que el logo: es lo primero que se ve al abrir el
|
||||
menú, y la asamblea lo quiso arriba del todo. -->
|
||||
<a class="sb-pedir" href="/pedir/">Pedir evento</a>
|
||||
<img src="/INFO/logo_enre_negro.png" alt="" class="logo" width="192" height="192">
|
||||
<!-- Es la marca del panel, no un encabezado de sección: como el panel
|
||||
va ANTES del <main>, un <h2> aquí hacía que el primer encabezado
|
||||
|
|
|
|||
21
tatus.html
21
tatus.html
|
|
@ -14,7 +14,7 @@
|
|||
<meta property="og:image:alt" content="Fachada del CSO La Enredadera: el cartel «La Enredadera» pintado a mano letra a letra sobre la puerta, y encima el mural «El último tesoro que le queda a la ciudad, es el barrio»">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="referrer" content="no-referrer-when-downgrade">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'none'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'">
|
||||
<title>Tattoo Circus · La Enredadera</title>
|
||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
|
|
@ -23,9 +23,22 @@
|
|||
/* Este micrositio es oscuro por diseño. */
|
||||
:root{ color-scheme: dark; }
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Rock+Salt&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Anton&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&display=swap');
|
||||
/* Fuentes servidas desde laenre.net, sin Google Fonts. */
|
||||
@font-face {
|
||||
font-family: 'Rock Salt';
|
||||
src: url('/fuentes/rocksalt-latin.woff2') format('woff2');
|
||||
font-weight: 400; font-style: normal; font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Anton';
|
||||
src: url('/fuentes/anton-latin.woff2') format('woff2');
|
||||
font-weight: 400; font-style: normal; font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Special Elite';
|
||||
src: url('/fuentes/specialelite-latin.woff2') format('woff2');
|
||||
font-weight: 400; font-style: normal; font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Code462WPink';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue