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 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="twitter:card" content="summary_large_image">
|
||||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
<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.svg" type="image/svg+xml">
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="/index.css">
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
|
@ -31,6 +28,9 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<aside class="sidebar">
|
<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">
|
<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
|
<!-- 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
|
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 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="twitter:card" content="summary_large_image">
|
||||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
<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.svg" type="image/svg+xml">
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="/index.css">
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
|
@ -32,6 +29,9 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<aside class="sidebar">
|
<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">
|
<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
|
<!-- 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
|
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 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="twitter:card" content="summary_large_image">
|
||||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
<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.svg" type="image/svg+xml">
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="/index.css">
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
|
@ -61,6 +58,9 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<aside class="sidebar">
|
<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">
|
<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
|
<!-- 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
|
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 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="twitter:card" content="summary_large_image">
|
||||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
<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.svg" type="image/svg+xml">
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="/index.css">
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
|
@ -33,6 +30,9 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<aside class="sidebar">
|
<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">
|
<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
|
<!-- 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
|
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;
|
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 ============================ */
|
/* ============================ 2. BASE ============================ */
|
||||||
*,*::before,*::after{ box-sizing:border-box; }
|
*,*::before,*::after{ box-sizing:border-box; }
|
||||||
html,body{ margin:0; padding:0; overflow-x:hidden; }
|
html,body{ margin:0; padding:0; overflow-x:hidden; }
|
||||||
|
|
@ -1009,30 +1025,78 @@ gancio-calendar{
|
||||||
#redes-toggle:checked ~ .brotes > li{ visibility:visible; }
|
#redes-toggle:checked ~ .brotes > li{ visibility:visible; }
|
||||||
|
|
||||||
/* En pantallas muy estrechas, siete columnas dan 55px cada una y ahí no cabe
|
/* 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
|
ningún texto útil: "Autodefensa 20:00" quedaba en "Autod...".
|
||||||
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. */
|
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){
|
@media (max-width:560px){
|
||||||
/* La barra se VE de 10px pero el elemento MIDE 24: el relleno vertical
|
/* El marco de la rejilla sobra: ahora cada día es su propia tarjeta. */
|
||||||
agranda el área a la que llega el dedo y `background-clip:content-box`
|
.cal{ border:none; background:transparent; overflow:visible; border-radius:0; }
|
||||||
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».
|
/* Los rótulos L M X J V S D sólo tienen sentido en siete columnas. */
|
||||||
24px es el mínimo de WCAG 2.5.8. */
|
.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{
|
.cal-pill{
|
||||||
font-size:0; line-height:0; color:transparent;
|
font-size:.92rem; line-height:1.35; color:var(--tinta);
|
||||||
/* content-box: con el border-box global el relleno se comía la altura y
|
padding:8px 14px; border-radius:999px;
|
||||||
la barra desaparecía. Así los 10px son los del color y el relleno los
|
white-space:normal; text-overflow:clip; overflow:visible;
|
||||||
suma por fuera hasta 24. */
|
background-clip:border-box;
|
||||||
box-sizing:content-box;
|
|
||||||
height:10px; padding:7px 0; border-radius:3px;
|
|
||||||
background-clip:content-box; box-shadow:none;
|
|
||||||
}
|
}
|
||||||
@supports (background: color-mix(in srgb, red 10%, transparent)){
|
/* En reposo la píldora no se mueve; el desplazamiento del :hover de
|
||||||
.cal-pill{ background:color-mix(in srgb, var(--bg) 60%, transparent);
|
escritorio se queda para el toque, que es donde se agradece. */
|
||||||
background-clip:content-box; box-shadow:none; }
|
.cal-pill:hover, .cal-pill:active{ transform:none; }
|
||||||
}
|
|
||||||
.cal-cell{ gap:2px; }
|
|
||||||
.cal-pills{ gap:2px; }
|
|
||||||
.cal-legend{ margin-top:18px; }
|
.cal-legend{ margin-top:18px; }
|
||||||
.cal-legend-item{ font-size:.78rem; }
|
.cal-legend-item{ font-size:.78rem; }
|
||||||
}
|
}
|
||||||
|
|
@ -1112,3 +1176,331 @@ gancio-calendar{
|
||||||
.sidebar-nav li{ margin-bottom:2px; }
|
.sidebar-nav li{ margin-bottom:2px; }
|
||||||
.sidebar .sb-marca{ margin-bottom:22px; }
|
.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 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="twitter:card" content="summary_large_image">
|
||||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
<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.svg" type="image/svg+xml">
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="/index.css">
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
|
@ -67,6 +64,9 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<aside class="sidebar">
|
<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">
|
<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
|
<!-- 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
|
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="twitter:card" content="summary_large_image">
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
<meta name="referrer" content="no-referrer">
|
<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>
|
<title>Tattoo Circus · Panel</title>
|
||||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
||||||
<style>
|
<style>
|
||||||
/* Este micrositio es oscuro por diseño. */
|
/* Este micrositio es oscuro por diseño. */
|
||||||
:root{ color-scheme: dark; }
|
:root{ color-scheme: dark; }
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Anton&display=swap');
|
/* Fuentes servidas desde laenre.net, sin Google Fonts. */
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&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-face {
|
||||||
font-family: 'Code462WPink';
|
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 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="twitter:card" content="summary_large_image">
|
||||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
<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.svg" type="image/svg+xml">
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="/index.css">
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
|
@ -31,6 +28,9 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<aside class="sidebar">
|
<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">
|
<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
|
<!-- 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
|
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 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="twitter:card" content="summary_large_image">
|
||||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
<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.svg" type="image/svg+xml">
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="/index.css">
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
|
@ -31,6 +28,9 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<aside class="sidebar">
|
<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">
|
<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
|
<!-- 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
|
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 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="twitter:card" content="summary_large_image">
|
||||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
<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.svg" type="image/svg+xml">
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
<link rel="stylesheet" href="/index.css">
|
<link rel="stylesheet" href="/index.css">
|
||||||
|
|
@ -31,6 +28,9 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<aside class="sidebar">
|
<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">
|
<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
|
<!-- 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
|
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 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="twitter:card" content="summary_large_image">
|
||||||
<meta name="referrer" content="no-referrer-when-downgrade">
|
<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>
|
<title>Tattoo Circus · La Enredadera</title>
|
||||||
<link rel="preload" href="/Code462WPink-Regular.ttf" as="font" type="font/ttf" crossorigin>
|
<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.svg" type="image/svg+xml">
|
||||||
|
|
@ -23,9 +23,22 @@
|
||||||
/* Este micrositio es oscuro por diseño. */
|
/* Este micrositio es oscuro por diseño. */
|
||||||
:root{ color-scheme: dark; }
|
:root{ color-scheme: dark; }
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Rock+Salt&display=swap');
|
/* Fuentes servidas desde laenre.net, sin Google Fonts. */
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Anton&display=swap');
|
@font-face {
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&display=swap');
|
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-face {
|
||||||
font-family: 'Code462WPink';
|
font-family: 'Code462WPink';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue