package main import ( "errors" "fmt" "net/http" "slices" "sort" "strconv" "strings" "time" "go.yarn.social/lextwt" "go.yarn.social/types" "go.sour.is/xt/internal/otel" ) func httpServer(c *console, app *appState) error { ctx, span := otel.Span(c) defer span.End() span.AddEvent("start http server") db, err := app.DB(ctx) if err != nil { span.RecordError(fmt.Errorf("%w: missing db", err)) return err } http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { _, span := otel.Span(r.Context()) defer span.End() w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write([]byte("ok")) }) http.HandleFunc("/api/plain/conv/{hash}", func(w http.ResponseWriter, r *http.Request) { ctx, span := otel.Span(r.Context()) defer span.End() hash := r.PathValue("hash") if (len(hash) < 6 || len(hash) > 8) && !notAny(hash, "abcdefghijklmnopqrstuvwxyz234567") { w.WriteHeader(http.StatusBadRequest) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") rows, err := db.QueryContext( ctx, `SELECT feed_id, hash, conv, nick, uri, text FROM twts JOIN ( SELECT feed_id, nick, uri FROM feeds ) using (feed_id) WHERE hash = $1 or conv = $1 order by ulid asc`, hash, ) if err != nil { span.RecordError(err) return } defer rows.Close() var twts []types.Twt for rows.Next() { var o struct { FeedID string Hash string Conv string Dt string Nick string URI string Text string } err = rows.Scan(&o.FeedID, &o.Hash, &o.Conv, &o.Nick, &o.URI, &o.Text) if err != nil { span.RecordError(err) return } twter := types.NewTwter(o.Nick, o.URI) o.Text = strings.ReplaceAll(o.Text, "\n", "\u2028") twt, _ := lextwt.ParseLine(o.Text, &twter) twts = append(twts, twt) } var preamble lextwt.Comments preamble = append(preamble, lextwt.NewComment("# self = /conv/"+hash)) reg := lextwt.NewTwtRegistry(preamble, twts) reg.WriteTo(w) }) http.HandleFunc("/api/plain/twt", func(w http.ResponseWriter, r *http.Request) { ctx, span := otel.Span(r.Context()) defer span.End() args := make([]any, 0, 3) uriarg := "" uri := r.URL.Query().Get("uri") if uri != "" { feed_id := urlNS.UUID5(uri) uriarg = "and feed_id = ?" args = append(args, feed_id) } limit := 100 if v, ok := strconv.Atoi(r.URL.Query().Get("limit")); ok == nil { limit = v } offset := 0 if v, ok := strconv.Atoi(r.URL.Query().Get("offset")); ok == nil { offset = v } args = append(args, limit, offset) w.Header().Set("Content-Type", "text/plain; charset=utf-8") rows, err := db.QueryContext( ctx, `SELECT feed_id, hash, conv, nick, uri, text FROM twts JOIN ( SELECT feed_id, nick, uri FROM feeds where state not in ('frozen', 'permanantly-dead') `+uriarg+` ) using (feed_id) order by ulid desc limit ? offset ? `, args..., ) if err != nil { span.RecordError(err) return } defer rows.Close() var twts []types.Twt for rows.Next() { var o struct { FeedID string Hash string Conv string Dt string Nick string URI string Text string } err = rows.Scan(&o.FeedID, &o.Hash, &o.Conv, &o.Nick, &o.URI, &o.Text) if err != nil { span.RecordError(err) return } twter := types.NewTwter(o.Nick, o.URI) o.Text = strings.ReplaceAll(o.Text, "\n", "\u2028") twt, _ := lextwt.ParseLine(o.Text, &twter) twts = append(twts, twt) } var preamble lextwt.Comments preamble = append(preamble, lextwt.NewComment("# I am the Watcher. I am your guide through this vast new twtiverse.")) preamble = append(preamble, lextwt.NewComment("# self = /api/plain/twts"+mkqry(uri, limit, offset))) preamble = append(preamble, lextwt.NewComment("# next = /api/plain/twts"+mkqry(uri, limit, offset+len(twts)))) if offset > 0 { preamble = append(preamble, lextwt.NewComment("# prev = /api/plain/twts"+mkqry(uri, limit, offset-limit))) } reg := lextwt.NewTwtRegistry(preamble, twts) reg.WriteTo(w) }) http.HandleFunc("/api/plain/users", func(w http.ResponseWriter, r *http.Request) { ctx, span := otel.Span(r.Context()) defer span.End() w.Header().Set("Content-Type", "text/plain; charset=utf-8") where := `where parent_id is null and state not in ('permanantly-dead', 'frozen') and last_twt_on is not null` args := make([]any, 0) if uri := r.URL.Query().Get("uri"); uri != "" { where = `where feed_id = ? or parent_id = ?` feed_id := urlNS.UUID5(uri) args = append(args, feed_id, feed_id) } rows, err := db.QueryContext( ctx, `SELECT feed_id, uri, nick, last_scan_on, last_twt_on FROM feeds left join ( select feed_id, max(strftime('%Y-%m-%dT%H:%M:%fZ', (substring(text, 1, instr(text, ' ')-1)))) last_twt_on from twts group by feed_id ) using (feed_id) `+where+` order by nick, uri `,args..., ) if err != nil { span.RecordError(err) return } defer rows.Close() var twts []types.Twt for rows.Next() { var o struct { FeedID string URI string Nick string Dt TwtTime LastTwtOn TwtTime } err = rows.Scan(&o.FeedID, &o.URI, &o.Nick, &o.Dt, &o.LastTwtOn) if err != nil { span.RecordError(err) return } twts = append(twts, lextwt.NewTwt( types.NewTwter(o.Nick, o.URI), lextwt.NewDateTime(o.Dt.Time, o.LastTwtOn.Time.Format(time.RFC3339)), nil, )) } reg := lextwt.NewTwtRegistry(nil, twts) reg.WriteTo(w) }) http.HandleFunc("/api/plain/queue", func(w http.ResponseWriter, r *http.Request) { lis := slices.Collect(app.queue.Iter()) sort.Slice(lis, func(i, j int) bool { return lis[i].NextScanOn.Time.Before(lis[j].LastScanOn.Time) }) for _, feed := range lis { fmt.Fprintln(w, feed.State, feed.NextScanOn.Time.Format(time.RFC3339), feed.Nick, feed.URI) } }) srv := &http.Server{ Addr: app.args.Listen, Handler: http.DefaultServeMux, } c.AddCancel(srv.Shutdown) err = srv.ListenAndServe() if !errors.Is(err, http.ErrServerClosed) { span.RecordError(err) return err } return nil } func notAny(s string, chars string) bool { for _, c := range s { if !strings.ContainsRune(chars, c) { return false } } return true } func mkqry(uri string, limit, offset int) string { qry := make([]string, 0, 3) if uri != "" { qry = append(qry, "uri=" + uri) } limit = min(100, max(1, limit)) if limit != 100 { qry = append(qry, fmt.Sprint("limit=", limit)) } offset = max(0, offset) if offset != 0 { qry = append(qry, fmt.Sprint("offset=", offset)) } if len(qry) == 0 { return "" } return "?" + strings.Join(qry, "&") }