Skip to content
Snippets Groups Projects
Unverified Commit e54982ca authored by Janne Mareike Koschinski's avatar Janne Mareike Koschinski
Browse files

initial commit

parents
No related branches found
No related tags found
No related merge requests found
### Go template
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
go.work
go.work.sum
.env
/.idea/
/cache/
*.iml
*.ipr
*.iws
FROM golang:1.23-alpine AS builder
WORKDIR /go/src/app
COPY go.* ./
ENV GOPROXY=https://proxy.golang.org
RUN go mod download
COPY . ./
RUN go build -o app .
FROM alpine
RUN addgroup -g 1000 -S app && \
adduser -u 1000 -G app -S app
COPY --from=builder /go/src/app /
USER app
VOLUME /cache
ENTRYPOINT ["/app", "--cachedir", "/cache"]
cache.go 0 → 100644
package main
import (
"crypto/sha256"
"fmt"
"hash"
"io"
"os"
"path"
"regexp"
"rss-cache-proxy/util"
"time"
)
type Cache struct {
cacheDir string
writerFactory func(file io.WriteCloser, digest hash.Hash) io.WriteCloser
}
type CacheEntry struct {
Checksum string
LastModified time.Time
Content io.ReadCloser
}
func (cacheEntry CacheEntry) LastModifiedString() string {
return cacheEntry.LastModified.Format("Mon, 02 Jan 2006 15:04:05 GMT")
}
func NewCache(cacheDir string) *Cache {
return &Cache{
cacheDir: cacheDir,
writerFactory: func(file io.WriteCloser, digest hash.Hash) io.WriteCloser {
return util.MultiWriteCloser(file, digest)
},
}
}
func NewCacheWithFilter(cacheDir string, pattern *regexp.Regexp) *Cache {
return &Cache{
cacheDir: cacheDir,
writerFactory: func(file io.WriteCloser, digest hash.Hash) io.WriteCloser {
return util.MultiWriteCloser(file, util.NewFilteredWriter(digest, pattern))
},
}
}
func (c *Cache) writeWithHash(writer io.WriteCloser, reader io.ReadCloser) (string, error) {
hasher := sha256.New()
multiWriter := c.writerFactory(writer, hasher)
if _, err := io.Copy(multiWriter, reader); err != nil {
return "", err
}
if err := multiWriter.Close(); err != nil {
return "", err
}
if err := reader.Close(); err != nil {
return "", err
}
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
}
func (c *Cache) LastModified() (time.Time, error) {
indexFile, err := os.Stat(path.Join(c.cacheDir, "current"))
if err != nil {
return time.UnixMicro(0), err
}
return indexFile.ModTime(), nil
}
func (c *Cache) Write(content io.ReadCloser) error {
contentFile, err := os.CreateTemp(c.cacheDir, "tmp-content")
if err != nil {
return err
}
hasher := sha256.New()
multiWriter := c.writerFactory(contentFile, hasher)
if _, err = io.Copy(multiWriter, content); err != nil {
return err
}
_ = multiWriter.Close()
_ = content.Close()
checksum := fmt.Sprintf("%x", hasher.Sum(nil))
filePath := path.Join(c.cacheDir, checksum)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
err = os.Rename(contentFile.Name(), filePath)
if err != nil {
return err
}
} else {
err = os.Remove(contentFile.Name())
if err != nil {
return err
}
}
indexFile, err := os.CreateTemp(c.cacheDir, "tmp-index")
if err != nil {
return err
}
_, err = io.WriteString(indexFile, checksum)
_ = indexFile.Close()
if err != nil {
return err
}
indexPath := path.Join(c.cacheDir, "current")
err = os.Rename(indexFile.Name(), indexPath)
if err != nil {
return err
}
return nil
}
func (c *Cache) Read() (CacheEntry, error) {
indexFile, err := os.Open(path.Join(c.cacheDir, "current"))
if err != nil {
return CacheEntry{}, err
}
indexContent, err := io.ReadAll(indexFile)
_ = indexFile.Close()
if err != nil {
return CacheEntry{}, err
}
currentVersion := string(indexContent)
currentFile, err := os.Open(path.Join(c.cacheDir, currentVersion))
if err != nil {
return CacheEntry{}, err
}
currentInfo, err := currentFile.Stat()
if err != nil {
_ = currentFile.Close()
return CacheEntry{}, err
}
return CacheEntry{
Checksum: currentVersion,
LastModified: currentInfo.ModTime(),
Content: currentFile,
}, nil
}
go.mod 0 → 100644
module rss-cache-proxy
go 1.23.0
require github.com/alexflint/go-arg v1.5.1
require github.com/alexflint/go-scalar v1.2.0 // indirect
main.go 0 → 100644
package main
import (
"context"
"fmt"
arg "github.com/alexflint/go-arg"
"io"
"log"
"net/http"
"os"
"regexp"
"rss-cache-proxy/util"
"strings"
"time"
)
type Options struct {
CacheDir string `arg:"required"`
Url string `arg:"required"`
UserAgent string `default:"Mozilla/5.0 (compatible; rss-cache-proxy/0.1.0; +https://git.kuschku.de/justjanne/rss-cache-proxy)"`
Interval time.Duration `default:"60m"`
Pattern string `default:""`
}
func respondError(writer http.ResponseWriter, request *http.Request, err error) {
if os.IsNotExist(err) {
log.Printf("error processing request for %s: %s\n", request.URL, err.Error())
writer.WriteHeader(404)
_, _ = fmt.Fprintf(writer, "Not Found\n")
} else if err != nil {
log.Printf("error processing request for %s: %s\n", request.URL, err.Error())
writer.WriteHeader(502)
_, err = fmt.Fprintf(writer, "Internal Server Error: %s\n", err)
}
}
func main() {
var options Options
arg.MustParse(&options)
var cache *Cache
if len(options.Pattern) > 0 {
cache = NewCacheWithFilter(options.CacheDir, regexp.MustCompile(options.Pattern))
} else {
cache = NewCache(options.CacheDir)
}
lastModified, err := cache.LastModified()
if err != nil {
lastModified = time.UnixMicro(0)
}
stopTimer := util.IntervalTimer(context.Background(), lastModified, options.Interval, func(ctx context.Context) {
request, err := http.NewRequestWithContext(ctx, "GET", options.Url, nil)
if err != nil {
log.Printf("error building request for %s: %s\n", options.Url, err.Error())
return
}
request.Header.Set("User-Agent", options.UserAgent)
log.Printf("fetching content for %s…\n", options.Url)
response, err := http.DefaultClient.Do(request)
if err != nil {
log.Printf("error fetching content for %s: %s\n", options.Url, err.Error())
return
}
err = cache.Write(response.Body)
if err != nil {
log.Printf("error updating cache for %s: %s\n", options.Url, err.Error())
return
}
log.Printf("finished updating cache for %s\n", options.Url)
})
defer stopTimer()
http.HandleFunc("/rss.xml", func(writer http.ResponseWriter, request *http.Request) {
cacheEntry, err := cache.Read()
if err != nil {
respondError(writer, request, err)
return
}
for _, value := range request.Header.Values("If-Modified-Since") {
if value == cacheEntry.LastModifiedString() {
writer.WriteHeader(304)
_ = cacheEntry.Content.Close()
return
}
}
for _, value := range request.Header.Values("If-None-Match") {
for _, etag := range strings.Split(value, ",") {
etag = strings.Trim(strings.TrimSpace(etag), "\"")
if cacheEntry.Checksum == etag {
writer.WriteHeader(304)
_ = cacheEntry.Content.Close()
return
}
}
}
writer.Header().Set("Last-Modified", cacheEntry.LastModifiedString())
writer.Header().Set("ETag", cacheEntry.Checksum)
_, _ = io.Copy(writer, cacheEntry.Content)
_ = cacheEntry.Content.Close()
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
package util
import (
"bytes"
"io"
"regexp"
)
type FilteredWriter struct {
buffer *bytes.Buffer
writer io.Writer
pattern *regexp.Regexp
}
func (w FilteredWriter) processChunk(chunk []byte) error {
index := 0
for index < len(chunk) {
n, err := w.writer.Write(chunk[index:])
if err != nil {
return err
}
index += n
}
return nil
}
func (w FilteredWriter) Flush() error {
i := 0
for {
i++
line, err := w.buffer.ReadBytes('\n')
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if !w.pattern.Match(line) {
index := 0
for index < len(line) {
n, err := w.writer.Write(line[index:])
index += n
if err != nil {
return err
}
}
}
}
}
func (w FilteredWriter) Close() error {
if err := w.Flush(); err != nil {
return err
}
if writeCloser, ok := w.writer.(io.WriteCloser); ok {
return writeCloser.Close()
}
return nil
}
func (w FilteredWriter) Write(p []byte) (n int, err error) {
if w.buffer.Available() < len(p) {
err = w.Flush()
if err != nil {
return
}
}
n, err = w.buffer.Write(p)
return
}
func NewFilteredWriter(writer io.Writer, pattern *regexp.Regexp) FilteredWriter {
return FilteredWriter{
buffer: bytes.NewBuffer(make([]byte, 65536)),
writer: writer,
pattern: pattern,
}
}
package util
import (
"context"
"fmt"
"time"
)
func IntervalTimer(ctx context.Context, lastRun time.Time, interval time.Duration, function func(ctx context.Context)) context.CancelFunc {
childContext, cancelFunc := context.WithCancel(ctx)
go func() {
nextRun := lastRun.Add(interval)
if nextRun.After(time.Now()) {
fmt.Printf("Updating RSS feed every %s starting at %s\n", interval, nextRun)
select {
case <-childContext.Done():
return
case <-time.After(time.Until(nextRun)):
}
} else {
fmt.Printf("Updating RSS feed every %s starting at %s\n", interval, time.Now())
}
ticker := time.NewTicker(interval)
function(childContext)
for {
select {
case <-childContext.Done():
ticker.Stop()
return
case <-ticker.C:
function(childContext)
}
}
}()
return cancelFunc
}
package util
import (
"fmt"
"io"
)
type multiCloser struct {
a io.Closer
b io.Closer
}
func MultiCloser(a io.Closer, b io.Closer) io.Closer {
return multiCloser{a, b}
}
func (c multiCloser) Close() error {
errA := c.a.Close()
errB := c.b.Close()
if errA != nil && errB != nil {
return fmt.Errorf("multiple errors: %w, %w", errA, errB)
} else if errA != nil {
return errA
} else if errB != nil {
return errB
} else {
return nil
}
}
package util
import "io"
type multiWriteCloser struct {
io.Writer
io.Closer
}
func MultiWriteCloser(a io.Writer, b io.Writer) io.WriteCloser {
aCloser, aOk := a.(io.Closer)
bCloser, bOk := b.(io.Closer)
if aOk && bOk {
return multiWriteCloser{
io.MultiWriter(a, b),
MultiCloser(aCloser, bCloser),
}
} else if aOk {
return multiWriteCloser{
io.MultiWriter(a, b),
aCloser,
}
} else if bOk {
return multiWriteCloser{
io.MultiWriter(a, b),
bCloser,
}
} else {
return multiWriteCloser{
io.MultiWriter(a, b),
nil,
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment