Gmail now connects. Authorisation-code flow with PKCE, the verifier kept server-side rather than in a cookie — the callback is a top-level navigation from Google, and a SameSite=Lax cookie is the kind of thing that works until a browser tightens its defaults. State is checked, the attempt expires after ten minutes, and the spent verifier is deleted rather than left at rest. Scope is gmail.readonly and the sync is one-way on purpose. Unstarring a message does not delete the task; completing the task does not touch the mailbox. Two-way would mean deciding what happens when both sides changed, and the only honest answer is a sync model far larger than the feature. The query carries newer_than:30d so connecting an account with a decade of starred mail does not create a decade of tasks. The update check is off unless the operator turns it on, asks GitHub at most every six hours, and sends nothing about the instance — no identifier, no version, no counts. A development build never reports an update: somebody running their own tree is ahead of every release, and telling them otherwise trains them to ignore the notice. Only administrators see it, because only they can act on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
303 lines
9.4 KiB
Go
303 lines
9.4 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kristianwind/verdande/internal/gmail"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
func (s *Server) gmailConfig() gmail.Config {
|
|
return gmail.Config{
|
|
ClientID: s.cfg.GmailClientID,
|
|
ClientSecret: s.cfg.GmailClientSecret,
|
|
RedirectURL: s.cfg.GmailRedirectURL(),
|
|
}
|
|
}
|
|
|
|
// handleGmailAuthorize starts the flow: it mints a PKCE pair, remembers it against
|
|
// the user, and hands back the Google URL to send the browser to.
|
|
//
|
|
// The verifier and state are stored server-side rather than put in a cookie,
|
|
// because the callback arrives as a top-level navigation from Google and a
|
|
// SameSite=Lax cookie is the kind of thing that works until a browser tightens its
|
|
// defaults.
|
|
func (s *Server) handleGmailAuthorize(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
cfg := s.gmailConfig()
|
|
|
|
if !cfg.Configured() {
|
|
writeError(w, http.StatusConflict, CodeConflict,
|
|
"Gmail er ikke sat op på denne server — administratoren skal registrere en OAuth-klient hos Google")
|
|
return
|
|
}
|
|
|
|
pkce, err := gmail.NewPKCE()
|
|
if err != nil {
|
|
s.internal(w, "gmail pkce", err)
|
|
return
|
|
}
|
|
|
|
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
|
if err != nil {
|
|
s.internal(w, "gmail settings", err)
|
|
return
|
|
}
|
|
settings["pkce_verifier"] = pkce.Verifier
|
|
settings["pkce_state"] = pkce.State
|
|
// The attempt expires: a stored verifier that lives forever is a replay window
|
|
// that lives forever.
|
|
settings["pkce_expires"] = time.Now().Add(10 * time.Minute).Unix()
|
|
|
|
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
|
s.internal(w, "gmail settings", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"url": cfg.AuthURL(pkce)})
|
|
}
|
|
|
|
// handleGmailCallback is where Google sends the browser back.
|
|
//
|
|
// It is a top-level navigation, so it answers with a redirect into the app rather
|
|
// than with JSON — the person is looking at a browser tab, not at a fetch response.
|
|
func (s *Server) handleGmailCallback(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
settingsURL := s.cfg.BaseURL + "/indstillinger"
|
|
|
|
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
|
// Somebody clicked "cancel" on the consent screen. Not an error worth a
|
|
// page of its own.
|
|
http.Redirect(w, r, settingsURL+"?gmail="+errParam, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
code := r.URL.Query().Get("code")
|
|
state := r.URL.Query().Get("state")
|
|
if code == "" || state == "" {
|
|
http.Redirect(w, r, settingsURL+"?gmail=invalid", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
settings, err := s.db.UserSettings(r.Context(), user.ID, "gmail")
|
|
if err != nil {
|
|
s.internal(w, "gmail settings", err)
|
|
return
|
|
}
|
|
verifier, _ := settings["pkce_verifier"].(string)
|
|
expected, _ := settings["pkce_state"].(string)
|
|
expires, _ := settings["pkce_expires"].(float64)
|
|
|
|
// The state has to match, and match in constant time is not needed here — it
|
|
// is a comparison of two values the server generated — but it does have to
|
|
// match at all, or this endpoint accepts a code from anywhere.
|
|
if verifier == "" || expected == "" || state != expected {
|
|
http.Redirect(w, r, settingsURL+"?gmail=state", http.StatusFound)
|
|
return
|
|
}
|
|
if int64(expires) < time.Now().Unix() {
|
|
http.Redirect(w, r, settingsURL+"?gmail=expired", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
token, err := s.gmailConfig().Exchange(r.Context(), code, gmail.PKCE{Verifier: verifier})
|
|
if err != nil {
|
|
s.log.Error("gmail exchange", "err", err, "user", user.ID)
|
|
http.Redirect(w, r, settingsURL+"?gmail=failed", http.StatusFound)
|
|
return
|
|
}
|
|
if token.RefreshToken == "" {
|
|
// Without one the connection dies in an hour. Google only withholds it
|
|
// when the consent prompt was skipped, which access_type and prompt are
|
|
// meant to prevent — so this means something is wrong with the client
|
|
// registration rather than with this request.
|
|
s.log.Error("gmail returned no refresh token", "user", user.ID)
|
|
http.Redirect(w, r, settingsURL+"?gmail=norefresh", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
email, err := gmail.NewClient(token.AccessToken).Profile(r.Context())
|
|
if err != nil {
|
|
s.log.Warn("gmail profile", "err", err)
|
|
}
|
|
|
|
// The one-time values are cleared: keeping a spent verifier serves nothing and
|
|
// is one more secret at rest.
|
|
delete(settings, "pkce_verifier")
|
|
delete(settings, "pkce_state")
|
|
delete(settings, "pkce_expires")
|
|
|
|
settings["refresh_token"] = token.RefreshToken
|
|
settings["access_token"] = token.AccessToken
|
|
settings["expires_at"] = token.ExpiresAt.Unix()
|
|
settings["email"] = email
|
|
if _, ok := settings["trigger"]; !ok {
|
|
settings["trigger"] = "starred"
|
|
}
|
|
|
|
if err := s.db.SetUserSettings(r.Context(), user.ID, "gmail", settings); err != nil {
|
|
s.internal(w, "save gmail tokens", err)
|
|
return
|
|
}
|
|
s.log.Info("gmail connected", "user", user.ID, "mailbox", email)
|
|
http.Redirect(w, r, settingsURL+"?gmail=connected", http.StatusFound)
|
|
}
|
|
|
|
// handleGmailSyncNow polls immediately, so somebody who has just connected can see
|
|
// it work rather than wondering whether it did.
|
|
func (s *Server) handleGmailSyncNow(w http.ResponseWriter, r *http.Request) {
|
|
user := userFrom(r.Context())
|
|
|
|
created, err := s.SyncGmail(r.Context(), user)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadGateway, CodeInternal, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int{"created": created})
|
|
}
|
|
|
|
// syncGmail creates a task for each matching message that has not produced one yet.
|
|
//
|
|
// Returns how many were made. Also used by the background job, which is why it
|
|
// takes a context and a user rather than a request.
|
|
func (s *Server) SyncGmail(ctx context.Context, user *store.User) (int, error) {
|
|
settings, err := s.db.UserSettings(ctx, user.ID, "gmail")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
str := func(key string) string {
|
|
v, _ := settings[key].(string)
|
|
return v
|
|
}
|
|
|
|
refresh := str("refresh_token")
|
|
if refresh == "" {
|
|
return 0, nil // not connected; nothing to do and nothing to complain about
|
|
}
|
|
query := gmail.Query(str("trigger"), str("label"))
|
|
if query == "" {
|
|
return 0, nil // connected but no trigger chosen
|
|
}
|
|
|
|
// The access token is refreshed only when it has actually expired.
|
|
access := str("access_token")
|
|
expires, _ := settings["expires_at"].(float64)
|
|
if access == "" || int64(expires) <= time.Now().Unix() {
|
|
token, err := s.gmailConfig().Refresh(ctx, refresh)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
access = token.AccessToken
|
|
settings["access_token"] = access
|
|
settings["expires_at"] = token.ExpiresAt.Unix()
|
|
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
client := gmail.NewClient(access)
|
|
ids, err := client.List(ctx, query, 25)
|
|
if err != nil {
|
|
if err == gmail.ErrUnauthorized {
|
|
// The person revoked access, or changed their password. Forget the
|
|
// tokens rather than retrying every ten minutes forever.
|
|
s.log.Info("gmail access was revoked; disconnecting", "user", user.ID)
|
|
_ = s.db.SetUserSettings(ctx, user.ID, "gmail", map[string]any{})
|
|
}
|
|
return 0, err
|
|
}
|
|
|
|
// Which messages have already become tasks. Kept as a list on the settings
|
|
// rather than as a table: it is bounded by the query's own 30-day window, and
|
|
// a table would be one more thing to migrate for a feature this small.
|
|
seen := map[string]bool{}
|
|
if raw, ok := settings["seen"].([]any); ok {
|
|
for _, v := range raw {
|
|
if id, ok := v.(string); ok {
|
|
seen[id] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
inbox, err := s.db.InboxID(ctx, user.ID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
projectID := str("project_id")
|
|
if projectID == "" {
|
|
projectID = inbox
|
|
} else if _, err := store.RequireProjectRole(ctx, s.db, projectID, user.ID, store.RoleEditor); err != nil {
|
|
projectID = inbox
|
|
}
|
|
|
|
created := 0
|
|
for _, id := range ids {
|
|
if seen[id] {
|
|
continue
|
|
}
|
|
msg, err := client.Get(ctx, id)
|
|
if err != nil {
|
|
s.log.Warn("gmail get message", "err", err, "id", id)
|
|
continue
|
|
}
|
|
|
|
subject := strings.TrimSpace(msg.Subject)
|
|
if subject == "" {
|
|
subject = "(uden emne)"
|
|
}
|
|
sender := gmail.SenderName(msg.From)
|
|
|
|
task := &store.Task{
|
|
ProjectID: projectID,
|
|
Content: sender + ": " + subject,
|
|
// The deep link is the point: the task is a pointer back to the mail,
|
|
// not a copy of it.
|
|
Description: msg.Snippet + "\n\n" + msg.Link,
|
|
Priority: 4,
|
|
CreatedBy: user.ID,
|
|
}
|
|
if err := s.db.CreateTask(ctx, task, nil); err != nil {
|
|
s.log.Warn("gmail create task", "err", err)
|
|
continue
|
|
}
|
|
seen[id] = true
|
|
created++
|
|
s.hub.Publish(projectID, "task.created", toTaskJSON(*task))
|
|
}
|
|
|
|
if created > 0 {
|
|
// Only the ids Gmail still returns are kept, so the list cannot grow
|
|
// without bound as messages fall out of the 30-day window.
|
|
keep := make([]any, 0, len(ids))
|
|
for _, id := range ids {
|
|
if seen[id] {
|
|
keep = append(keep, id)
|
|
}
|
|
}
|
|
settings["seen"] = keep
|
|
settings["last_sync"] = time.Now().Unix()
|
|
if err := s.db.SetUserSettings(ctx, user.ID, "gmail", settings); err != nil {
|
|
return created, err
|
|
}
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
// --- version and updates ------------------------------------------------------------
|
|
|
|
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
|
status := s.updates.Status(r.Context())
|
|
// The notice is for whoever would do something about it. Telling an ordinary
|
|
// member that the server is out of date is telling them about somebody else's
|
|
// job.
|
|
if user := userFrom(r.Context()); user == nil || !user.IsAdmin {
|
|
status.Available = false
|
|
status.Notes = ""
|
|
status.URL = ""
|
|
status.Latest = ""
|
|
}
|
|
writeJSON(w, http.StatusOK, status)
|
|
}
|