// Package research serves the shared, public paper corpus. Account data and // private collections do not belong to these anonymous read-only endpoints. package research import ( "context" "database/sql" "encoding/json" "errors" "net/http" "net/url" "regexp" "strconv" "strings" "time" "unicode/utf8" ) type Source struct { Label string `json:"label"` URL string `json:"url"` ExternalID string `json:"externalId"` } type Publication struct { Venue string `json:"venue"` Year int `json:"year"` Track string `json:"track"` Status string `json:"status"` EvidenceURL string `json:"evidenceUrl,omitempty"` } type Paper struct { ID string `json:"id"` Title string `json:"title"` Abstract *string `json:"abstract"` Authors []string `json:"authors"` PublishedAt *string `json:"publishedAt"` Topics []string `json:"topics"` Sources []Source `json:"sources"` Publications []Publication `json:"publications,omitempty"` DOI *string `json:"doi,omitempty"` Journal *string `json:"journal,omitempty"` Comments *string `json:"comments,omitempty"` } type Filters struct { Query string SearchIn string Topic string Sort string Cursor string PageSize int } type Page struct { Items []Paper `json:"items"` NextCursor string `json:"nextCursor"` PreviousCursor string `json:"previousCursor"` Topics []string `json:"topics"` PageSize int `json:"pageSize"` } // Repository implementations must use a read-only transaction for each List, // so its metadata and page describe the same database snapshot. type Repository interface { List(context.Context, Filters) (Page, error) Get(context.Context, int64) (Paper, error) } type service struct { repository Repository projects map[string]string slots chan struct{} timeout time.Duration } // Register adds only GET endpoints; no credentials or database errors are sent // to anonymous callers. A nil repository leaves the existing server unchanged. func Register(mux *http.ServeMux, repository Repository, projects map[string]string) { if repository == nil { return } s := &service{repository: repository, projects: projects, slots: make(chan struct{}, 2), timeout: 8 * time.Second} mux.HandleFunc("GET /projects/{project}/research/papers", s.serve) mux.HandleFunc("GET /projects/{project}/research/papers/{id}", s.serve) } func reply(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) } func reject(w http.ResponseWriter, status int, code string) { reply(w, status, map[string]string{"error": code}) } var topicPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9.-]{0,79}$`) var positiveInteger = regexp.MustCompile(`^[1-9][0-9]*$`) func parseFilters(raw string) (Filters, error) { f := Filters{SearchIn: "text", Sort: "recent", PageSize: 8} values, err := url.ParseQuery(raw) if err != nil { return f, err } invalid := errors.New("invalid query") for key, value := range values { if len(value) != 1 { return f, invalid } switch key { case "q": if !utf8.ValidString(value[0]) || utf8.RuneCountInString(value[0]) > 200 || len(strings.Fields(value[0])) > 20 || strings.ContainsRune(value[0], 0) { return f, invalid } f.Query = strings.TrimSpace(value[0]) case "searchIn": if value[0] != "text" && value[0] != "author" { return f, invalid } f.SearchIn = value[0] case "topic": if value[0] != "" && !topicPattern.MatchString(value[0]) { return f, invalid } f.Topic = value[0] case "sort": if value[0] != "recent" && value[0] != "title" { return f, invalid } f.Sort = value[0] case "cursor": f.Cursor = value[0] case "pageSize": if !positiveInteger.MatchString(value[0]) { return f, invalid } n, err := strconv.Atoi(value[0]) if err != nil || n > 50 { return f, invalid } f.PageSize = n default: return f, invalid } } _, err = decodeCursor(f) return f, err } func (s *service) serve(w http.ResponseWriter, r *http.Request) { if _, exists := s.projects[r.PathValue("project")]; !exists { reject(w, 404, "not_found") return } var filters Filters var id int64 var err error if value := r.PathValue("id"); value != "" { if !positiveInteger.MatchString(value) || r.URL.RawQuery != "" { reject(w, 400, "invalid_request") return } id, err = strconv.ParseInt(value, 10, 64) } else { filters, err = parseFilters(r.URL.RawQuery) } if err != nil { reject(w, 400, "invalid_request") return } select { case s.slots <- struct{}{}: defer func() { <-s.slots }() default: w.Header().Set("Retry-After", "1") reject(w, 429, "research_busy") return } ctx, cancel := context.WithTimeout(r.Context(), s.timeout) defer cancel() var result any if id != 0 { result, err = s.repository.Get(ctx, id) } else { result, err = s.repository.List(ctx, filters) } if err == nil { reply(w, 200, result) return } var state interface{ SQLState() string } switch { case errors.Is(err, sql.ErrNoRows): reject(w, 404, "not_found") case errors.Is(ctx.Err(), context.DeadlineExceeded), errors.Is(err, context.DeadlineExceeded), errors.As(err, &state) && state.SQLState() == "57014": reject(w, 504, "research_timeout") default: reject(w, 503, "research_unavailable") } }