feat: initial upload, basic read+manage functionality

This commit is contained in:
Marc 2024-09-12 23:03:39 -04:00
parent 307f1194c2
commit 8427ae6aaa
Signed by: marc
GPG key ID: 048E042F22B5DC79
12 changed files with 419 additions and 0 deletions

1
.gitignore vendored
View file

@ -21,3 +21,4 @@
# Go workspace file # Go workspace file
go.work go.work
datastore.json

22
Dockerfile Normal file
View file

@ -0,0 +1,22 @@
FROM golang:1.22-alpine AS backend-build
WORKDIR /build
COPY *.go .
COPY go.mod .
RUN go build -ldflags "-s -w" -o /tmp/rss
FROM alpine:3.20 AS base
WORKDIR /app
COPY --from=backend-build /tmp/rss /app/rss
COPY go.mod .
COPY templates templates
COPY feeds.txt .
COPY static static
RUN chmod +x /app/rss
CMD ["/app/rss"]

42
api.go Normal file
View file

@ -0,0 +1,42 @@
package main
import (
"fmt"
"log/slog"
"net/http"
"time"
)
type Route struct {
Path string
Handler http.HandlerFunc
}
type API struct {
Routes []Route
StaticRoot string
}
func LoggingMiddleware(f http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
f(w, r)
slog.Info(fmt.Sprintf("%s - %s (%dms)", r.Method, r.URL, time.Now().Sub(start).Milliseconds()))
}
}
func (a API) Start(addr string) {
for _, route := range a.Routes {
http.HandleFunc(route.Path, LoggingMiddleware(route.Handler))
}
if a.StaticRoot != "" {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(a.StaticRoot))))
}
http.ListenAndServe(addr, nil)
}
func (a *API) AddRoute(path string, handler http.HandlerFunc) {
a.Routes = append(a.Routes, Route{Path: path, Handler: handler})
}

60
datastore.go Normal file
View file

@ -0,0 +1,60 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log/slog"
"os"
"strings"
"time"
)
type Datastore struct {
Data map[string]string `json:"data"`
LastUpdate time.Time `json:"updated"`
}
func FromFile(cachePath string) *Datastore {
bytes, err := ioutil.ReadFile(cachePath)
if err != nil {
return nil
}
var store Datastore
json.Unmarshal(bytes, &store)
return &store
}
func (d Datastore) List(namespace string) map[string]string {
slog.Debug(fmt.Sprintf("Listing entries with namespace: %s", namespace))
withinNamespace := map[string]string{}
for key, value := range d.Data {
if strings.HasPrefix(key, namespace) {
withinNamespace[key] = value
}
}
return withinNamespace
}
func (d Datastore) Get(key string) string {
slog.Debug(fmt.Sprintf("Getting entry with key: %s", key))
return d.Data[key]
}
func (d *Datastore) Set(key string, value string) {
slog.Debug(fmt.Sprintf("Setting entry for key %s", key))
d.Data[key] = value
os.WriteFile("datastore.json", []byte(d.Serialize()), 0755)
}
func (d Datastore) Serialize() string {
slog.Debug("Serialized state")
b, _ := json.Marshal(d)
return string(b)
}

34
feed_fetch.go Normal file
View file

@ -0,0 +1,34 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
)
func fetchFeed(url string) {
slog.Info((fmt.Sprintf("Fetching %s\n", url)))
re, err := http.Get(url)
if err != nil {
fmt.Printf("%+v", err)
return
}
defer re.Body.Close()
b, _ := io.ReadAll(re.Body)
d := ParseFeed(b)
b, _ = json.Marshal(d.Channel.ItemsToLinks())
cacheKey := fmt.Sprintf("feeds:%s", url)
SharedCache.Set(cacheKey, string(b))
}
func refreshFeeds() {
for _, url := range SharedCache.List("feedurl") {
fetchFeed(url)
}
}

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module rss
go 1.22.2

100
main.go Normal file
View file

@ -0,0 +1,100 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"text/template"
"time"
)
var SharedCache *Datastore
type Link struct {
Url string `json:"url"`
PublishedDate string `json:"publishedDate"`
Title string `json:"title"`
}
func healthcheck(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
}
func about(w http.ResponseWriter, r *http.Request) {
tmpl, _ := template.New("about.html.tmpl").ParseFiles("templates/about.html.tmpl")
tmpl.Execute(w, nil)
}
func listContent(w http.ResponseWriter, r *http.Request) {
links := []Link{}
for _, feed := range SharedCache.List("feeds") {
var formattedItems []Link
json.Unmarshal([]byte(feed), &formattedItems)
links = append(links, formattedItems...)
}
tmpl, _ := template.New("index.html.tmpl").ParseFiles("templates/index.html.tmpl")
tmpl.Execute(w, links)
}
func manageContent(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
r.ParseForm()
urlValue := r.PostFormValue("url")
if _, err := url.Parse(urlValue); err != nil {
w.WriteHeader(400)
return
}
cacheKey := fmt.Sprintf("feedurl:%s", urlValue)
SharedCache.Set(cacheKey, urlValue)
go fetchFeed(urlValue)
w.WriteHeader(201)
}
allFeeds := SharedCache.List("feedurl")
type ManageTmplData struct {
Feeds map[string]string
}
tmpl, _ := template.New("manage.html.tmpl").ParseFiles("templates/manage.html.tmpl")
tmpl.Execute(w, ManageTmplData{Feeds: allFeeds})
}
var routeMap = map[string]http.HandlerFunc{
"/": listContent,
"/about": about,
"/manage": manageContent,
"/ping": healthcheck,
}
func main() {
SharedCache = &Datastore{
Data: map[string]string{},
}
if existingStore := FromFile("datastore.json"); existingStore != nil {
SharedCache = existingStore
}
api := API{StaticRoot: "./static"}
for route, handler := range routeMap {
api.AddRoute(route, handler)
}
go func() {
for {
refreshFeeds()
time.Sleep(10 * time.Minute)
}
}()
api.Start(":9000")
}

42
rss.go Normal file
View file

@ -0,0 +1,42 @@
package main
import (
"encoding/xml"
)
type Document struct {
Channel Channel `xml:"channel"`
}
type Item struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
PublishedDate string `xml:"pubDate"`
}
type Channel struct {
Title string `xml:"title"`
Description string `xml:"description"`
Items []Item `xml:"item"`
}
func (c Channel) ItemsToLinks() []Link {
formattedItems := []Link{}
for _, feedItem := range c.Items {
formattedItems = append(formattedItems, Link{Url: feedItem.Link, Title: feedItem.Title, PublishedDate: feedItem.PublishedDate})
}
return formattedItems
}
func ParseFeed(raw []byte) Document {
var doc Document
if err := xml.Unmarshal(raw, &doc); err != nil {
return Document{}
}
return doc
}

34
static/main.css Normal file
View file

@ -0,0 +1,34 @@
body {
margin: 0;
}
header {
padding: 10px;
}
header > h1 {
margin: 0;
font-size: 1.2em;
}
header ul {
list-style: none;
display: flex;
gap: 5px;
margin: 0;
padding: 5px;
}
#items {
list-style: none;
padding-left: 0;
margin: 5px;
}
#items > li {
padding: 5px;
margin: 2px;
background-color: #eef0ff;
display: flex;
flex-direction: column;
}

22
templates/about.html.tmpl Normal file
View file

@ -0,0 +1,22 @@
<html>
<head>
<title>☕ Morning coffee</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/static/main.css", rel="stylesheet">
</head>
<body>
<header>
<h1>☕ Morning coffee</h1>
<nav>
<ul>
<li><a href="/about">About</a></li>
<li><a href="/">Feeds</a></li>
<li><a href="/manage">Manage</a></li>
</ul>
</nav>
</header>
<main>
<em>Morning Coffee</em> is a minimalist static-page RSS & interesting links reader written in Go.
</main>
</body>
</html>

27
templates/index.html.tmpl Normal file
View file

@ -0,0 +1,27 @@
<html>
<head>
<title>☕ Morning coffee</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/static/main.css", rel="stylesheet">
</head>
<body>
<header>
<h1>☕ Morning coffee</h1>
<nav>
<ul>
<li><a href="/about">About</a></li>
<li><a href="/">Feeds</a></li>
<li><a href="/manage">Manage</a></li>
</ul>
</nav>
</header>
<ul id="items">
{{ range . }}
<li>
<a href="{{ .Url }}">{{ .Title }}</a>
<span>{{ .PublishedDate }}</span>
</li>
{{ end }}
</ul>
</body>
</html>

View file

@ -0,0 +1,32 @@
<html>
<head>
<title>☕ Morning coffee</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/static/main.css", rel="stylesheet">
</head>
<body>
<header>
<h1>☕ Morning coffee</h1>
<nav>
<ul>
<li><a href="/about">About</a></li>
<li><a href="/">Feeds</a></li>
<li><a href="/manage">Manage</a></li>
</ul>
</nav>
</header>
<main>
<form action="/manage" method="post">
<label for="feed">Url</label>
<input name="url" type="url" />
<input type="submit" value="Save" />
</form>
<h1>Subscriptions</h1>
<ul>
{{ range .Feeds }}
<li>{{ . }}</li>
{{ end }}
</ul>
</main>
</body>
</html>