package main // The distribution's news since the last upgrade (novox/hq research 026/05: "an upgrade with the // news first"). Arch posts what an upgrade needs a person to do — a manual intervention, a replaced // package — in its news feed, and an upgrade that ignores it is how a machine breaks. Fetched over // https; no network is an answer, never a failure. import ( "encoding/xml" "fmt" "io" "net/http" "regexp" "strings" "time" ) // NewsFeed is the distribution's news, as RSS. const NewsFeed = "https://archlinux.org/feeds/news/" // fetch is how the feed is read; a test replaces it. var fetch = func(url string) ([]byte, error) { client := http.Client{Timeout: 10 * time.Second} res, err := client.Get(url) if err != nil { return nil, err } defer res.Body.Close() if res.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s answered %s", url, res.Status) } return io.ReadAll(io.LimitReader(res.Body, 4<<20)) } // NewsItem is one post. type NewsItem struct { Title string `json:"title"` Link string `json:"link"` Published string `json:"published"` Summary string `json:"summary"` } type rss struct { Items []struct { Title string `xml:"title"` Link string `xml:"link"` PubDate string `xml:"pubDate"` Description string `xml:"description"` } `xml:"channel>item"` } var tags = regexp.MustCompile(`<[^>]*>`) // ParseNews reads the feed's posts published after a time, newest first as the feed has them. func ParseNews(body []byte, since time.Time) ([]NewsItem, error) { var feed rss if err := xml.Unmarshal(body, &feed); err != nil { return nil, err } items := []NewsItem{} for _, it := range feed.Items { t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)) if err != nil { t, err = time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)) } if err != nil || !t.After(since) { continue } summary := strings.Join(strings.Fields(tags.ReplaceAllString(it.Description, " ")), " ") if len(summary) > 600 { summary = summary[:600] + "…" } items = append(items, NewsItem{Title: it.Title, Link: it.Link, Published: t.Format(time.RFC3339), Summary: summary}) } return items, nil } // News is the posts since a day (YYYY-MM-DD), or since the last full upgrade the log records. func (m *Machine) News(since string) map[string]any { out := map[string]any{"feed": NewsFeed, "items": []NewsItem{}} var from time.Time if since != "" { t, err := time.ParseInLocation("2006-01-02", since, time.Local) if err != nil { out["error"] = fmt.Sprintf("since %q is not a day as YYYY-MM-DD", since) return out } from = t } else if last, err := m.LastUpgrade(); err == nil && !last.IsZero() { from = last out["since_last_full_upgrade"] = true } else { from = m.Now().AddDate(0, 0, -90) } out["since"] = from.Format(time.RFC3339) body, err := fetch(NewsFeed) if err != nil { out["reachable"] = false out["error"] = err.Error() return out } out["reachable"] = true items, err := ParseNews(body, from) if err != nil { out["error"] = "the feed could not be read: " + err.Error() return out } out["items"] = items return out }