feat: initial upload, basic read+manage functionality
This commit is contained in:
parent
307f1194c2
commit
8427ae6aaa
12 changed files with 419 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -21,3 +21,4 @@
|
||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
datastore.json
|
||||||
|
|
22
Dockerfile
Normal file
22
Dockerfile
Normal 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
42
api.go
Normal 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
60
datastore.go
Normal 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
34
feed_fetch.go
Normal 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
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module rss
|
||||||
|
|
||||||
|
go 1.22.2
|
100
main.go
Normal file
100
main.go
Normal 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
42
rss.go
Normal 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
34
static/main.css
Normal 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
22
templates/about.html.tmpl
Normal 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
27
templates/index.html.tmpl
Normal 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>
|
32
templates/manage.html.tmpl
Normal file
32
templates/manage.html.tmpl
Normal 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>
|
Loading…
Reference in a new issue