- /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.
238 lines
5.4 KiB
Go
238 lines
5.4 KiB
Go
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})
|
|
}
|