1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
// calweb serves the primary Google calendar of one account as text/llm.
//
// Reads go to Google live; there is no mirror. Writes are proposals that a
// person confirms on a small HTML page. Nothing calweb sends can invite or
// notify anybody. See calweb(1) for the reference and calweb(7) for the
// reasoning.
package main

import (
	"context"
	"database/sql"
	"errors"
	"flag"
	"fmt"
	"log"
	"net"
	"net/http"
	"os"
	"os/exec"
	"strings"
	"time"
)

type server struct {
	db  *sql.DB
	g   *google
	loc *time.Location
	// listen is the address the server is reachable at, used to build the
	// OAuth redirect so it lands back on this process.
	listen string
	now    func() time.Time
}

// runCmd runs a shell command and returns its first line of output. Used for
// the client id and secret, which live in pass(1) and never on the command
// line or in the process table.
func runCmd(flagName, cmd string) (string, error) {
	if cmd == "" {
		return "", fmt.Errorf("--%s is required", flagName)
	}
	out, err := exec.Command("sh", "-c", cmd).Output()
	if err != nil {
		return "", fmt.Errorf("--%s %q failed: %w", flagName, cmd, err)
	}
	line, _, _ := strings.Cut(string(out), "\n")
	line = strings.TrimSpace(line)
	if line == "" {
		return "", fmt.Errorf("--%s %q printed nothing", flagName, cmd)
	}
	return line, nil
}

func main() {
	listen := flag.String("listen", "127.0.0.1:8779", "Address to listen on. Loopback only: calweb has no authentication.")
	dbPath := flag.String("db", os.ExpandEnv("$HOME/.local/state/calweb/calweb.db"), "SQLite database for the OAuth grant and proposals")
	clientIDCmd := flag.String("client-id-cmd", "", "Shell command printing the OAuth client id (e.g. \"pass …/client-id\")")
	clientSecretCmd := flag.String("client-secret-cmd", "", "Shell command printing the OAuth client secret")
	tz := flag.String("timezone", "Europe/Berlin", "Time zone dates are shown in and read from forms in")
	flag.Parse()

	host, _, err := net.SplitHostPort(*listen)
	if err != nil {
		log.Fatalf("--listen %q: %v", *listen, err)
	}
	if ip := net.ParseIP(host); host != "localhost" && (ip == nil || !ip.IsLoopback()) {
		// A refusal rather than a warning: whoever reaches this address can
		// propose changes, and a proposal is one click from being written.
		log.Fatalf("--listen %q is not a loopback address; calweb has no authentication", *listen)
	}

	loc, err := time.LoadLocation(*tz)
	if err != nil {
		log.Fatalf("--timezone: %v", err)
	}
	clientID, err := runCmd("client-id-cmd", *clientIDCmd)
	if err != nil {
		log.Fatal(err)
	}
	clientSecret, err := runCmd("client-secret-cmd", *clientSecretCmd)
	if err != nil {
		log.Fatal(err)
	}
	db, err := openDB(*dbPath)
	if err != nil {
		log.Fatal(err)
	}

	s := &server{db: db, g: newGoogle(db, clientID, clientSecret), loc: loc, listen: *listen, now: time.Now}
	if _, err := loadGrant(db); errors.Is(err, errNoGrant) {
		log.Printf("not logged in yet: open http://%s/login", *listen)
	}
	log.Printf("calweb listening on http://%s", *listen)
	srv := &http.Server{Addr: *listen, Handler: s.routes(), ReadHeaderTimeout: 10 * time.Second}
	log.Fatal(srv.ListenAndServe())
}

func (s *server) routes() *http.ServeMux {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", s.handleAgenda)
	mux.HandleFunc("GET /event/{id}", s.handleEvent)
	mux.HandleFunc("GET /search", s.handleSearch)
	mux.HandleFunc("POST /events", s.handleProposeCreate)
	mux.HandleFunc("POST /event/{id}/edit", s.handleProposeEdit)
	mux.HandleFunc("POST /event/{id}/delete", s.handleProposeDelete)
	mux.HandleFunc("GET /proposals", s.handleProposals)
	mux.HandleFunc("GET /proposal/{token}", s.handleProposal)
	mux.HandleFunc("POST /proposal/{token}/apply", s.handleApply)
	mux.HandleFunc("POST /proposal/{token}/discard", s.handleDiscard)
	mux.HandleFunc("GET /login", s.handleLogin)
	mux.HandleFunc("GET /oauth/callback", s.handleCallback)
	return mux
}

func (s *server) ctx(r *http.Request) (context.Context, context.CancelFunc) {
	return context.WithTimeout(r.Context(), 60*time.Second)
}

// ============================================================================
// Login
// ============================================================================

func (s *server) redirectURI() string {
	return "http://" + s.listen + "/oauth/callback"
}

func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
	u, err := s.g.authCodeURL(s.redirectURI())
	if err != nil {
		s.fail(w, r, http.StatusInternalServerError, err)
		return
	}
	http.Redirect(w, r, u, http.StatusFound)
}

func (s *server) handleCallback(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	if e := q.Get("error"); e != "" {
		s.fail(w, r, http.StatusBadRequest, fmt.Errorf("google did not grant access: %s", e))
		return
	}
	ctx, cancel := s.ctx(r)
	defer cancel()
	gr, err := s.g.exchange(ctx, q.Get("state"), q.Get("code"))
	if err != nil {
		s.fail(w, r, http.StatusBadRequest, err)
		return
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	fmt.Fprintf(w, "calweb is logged in.\n\nscope: %s\nobtained: %s\n\nThe agenda is at http://%s/?view=llm\n",
		gr.Scope, gr.ObtainedAt.In(s.loc).Format("2006-01-02 15:04"), s.listen)
}

// ============================================================================
// Errors
// ============================================================================

// fail answers with an error in the representation that was asked for. A
// missing login is turned into instructions, since it is the one error whose
// remedy is always the same.
func (s *server) fail(w http.ResponseWriter, r *http.Request, status int, err error) {
	msg := err.Error()
	if errors.Is(err, errNoGrant) {
		status = http.StatusServiceUnavailable
		msg = fmt.Sprintf("%s. A person has to open http://%s/login in a browser once; "+
			"calweb cannot do it on its own.", err, s.listen)
	}
	var ae *apiError
	if errors.As(err, &ae) && ae.Status == http.StatusNotFound {
		status = http.StatusNotFound
	}
	log.Printf("%s %s: %v", r.Method, r.URL.Path, err)
	writeLLM(w, r, errorTmpl, errorData{Status: status, Message: msg}, status)
}