📜 feeds is a multi-protocol / multi-platform feed aggregation service that produces twtxt feeds for consumption by twtxt clients.
https://feeds.twtxt.net
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
242 lines
5.1 KiB
242 lines
5.1 KiB
package main |
|
|
|
import ( |
|
"encoding/base32" |
|
"errors" |
|
"fmt" |
|
"image" |
|
"image/png" |
|
"io" |
|
"io/ioutil" |
|
"net/http" |
|
"os" |
|
"path/filepath" |
|
"strings" |
|
"time" |
|
|
|
// Blank import so we can handle image/* |
|
_ "image/gif" |
|
_ "image/jpeg" |
|
|
|
"github.com/h2non/filetype" |
|
"github.com/nfnt/resize" |
|
log "github.com/sirupsen/logrus" |
|
"golang.org/x/crypto/blake2b" |
|
) |
|
|
|
var ( |
|
ErrInvalidImage = errors.New("error: invalid image") |
|
) |
|
|
|
// CleanTwt cleans a twt's text, replacing new lines with spaces and |
|
// stripping surrounding spaces. |
|
func CleanTwt(text string) string { |
|
text = strings.TrimSpace(text) |
|
text = strings.ReplaceAll(text, "\r\n", "\n") |
|
text = strings.ReplaceAll(text, "\n", "\u2028") |
|
return text |
|
} |
|
|
|
func RotateFile(fn string) error { |
|
now := time.Now().Unix() |
|
newFn := fmt.Sprintf("%s.%d", fn, now) |
|
if err := os.Rename(fn, newFn); err != nil { |
|
log.WithError(err).Errorf("error renaming %s", fn) |
|
return fmt.Errorf("error renaming %s to %s: %w", fn, newFn, err) |
|
} |
|
return os.WriteFile(fn, nil, 0644) |
|
} |
|
|
|
func Exists(name string) bool { |
|
if _, err := os.Stat(name); err != nil { |
|
if os.IsNotExist(err) { |
|
return false |
|
} |
|
} |
|
return true |
|
} |
|
|
|
func IsImage(fn string) bool { |
|
f, err := os.Open(fn) |
|
if err != nil { |
|
log.WithError(err).Warnf("error opening file %s", fn) |
|
return false |
|
} |
|
defer f.Close() |
|
|
|
head := make([]byte, 261) |
|
if _, err := f.Read(head); err != nil { |
|
log.WithError(err).Warnf("error reading from file %s", fn) |
|
return false |
|
} |
|
|
|
if filetype.IsImage(head) { |
|
return true |
|
} |
|
|
|
return false |
|
} |
|
|
|
type ImageOptions struct { |
|
Resize bool |
|
ResizeW int |
|
ResizeH int |
|
} |
|
|
|
func DownloadImage(conf *Config, url string, filename string, opts *ImageOptions) error { |
|
res, err := http.Get(url) |
|
if err != nil { |
|
log.WithError(err).Errorf("error downloading image from %s", url) |
|
return err |
|
} |
|
defer res.Body.Close() |
|
|
|
tf, err := ioutil.TempFile("", "feeds-*") |
|
if err != nil { |
|
log.WithError(err).Error("error creating temporary file") |
|
return err |
|
} |
|
defer tf.Close() |
|
defer os.Remove(tf.Name()) |
|
|
|
if _, err := io.Copy(tf, res.Body); err != nil { |
|
log.WithError(err).Error("error writng temporary file") |
|
return err |
|
} |
|
|
|
if _, err := tf.Seek(0, io.SeekStart); err != nil { |
|
log.WithError(err).Error("error seeking temporary file") |
|
return err |
|
} |
|
|
|
if !IsImage(tf.Name()) { |
|
return ErrInvalidImage |
|
} |
|
|
|
if _, err := tf.Seek(0, io.SeekStart); err != nil { |
|
log.WithError(err).Error("error seeking temporary file") |
|
return err |
|
} |
|
|
|
if _, err := tf.Seek(0, io.SeekStart); err != nil { |
|
log.WithError(err).Error("error seeking temporary file") |
|
return err |
|
} |
|
|
|
img, _, err := image.Decode(tf) |
|
if err != nil { |
|
log.WithError(err).Error("jpeg.Decode failed") |
|
return err |
|
} |
|
|
|
newImg := img |
|
|
|
if opts != nil { |
|
if opts.Resize && (opts.ResizeW+opts.ResizeH > 0) && (opts.ResizeH > 0 || img.Bounds().Size().X > opts.ResizeW) { |
|
newImg = resize.Resize(uint(opts.ResizeW), uint(opts.ResizeH), img, resize.Lanczos3) |
|
} |
|
} |
|
|
|
fn := filepath.Join(conf.DataDir, filename) |
|
|
|
of, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE, 0644) |
|
if err != nil { |
|
log.WithError(err).Error("error opening output file") |
|
return err |
|
} |
|
defer of.Close() |
|
|
|
// Encode uses a Writer, use a Buffer if you need the raw []byte |
|
if err := png.Encode(of, newImg); err != nil { |
|
log.WithError(err).Error("error reencoding image") |
|
return err |
|
} |
|
|
|
return nil |
|
} |
|
|
|
func AppendTwt(w io.Writer, text string, args ...interface{}) error { |
|
text = strings.TrimSpace(text) |
|
if text == "" { |
|
return fmt.Errorf("cowardly refusing to twt empty text, or only spaces") |
|
} |
|
|
|
// Support replacing/editing an existing Twt whilst preserving Created Timestamp |
|
// or posting a Twt with a custom Timestamp. |
|
now := time.Now().UTC() |
|
if len(args) == 1 { |
|
if t, ok := args[0].(time.Time); ok { |
|
now = t.UTC() |
|
} |
|
} |
|
|
|
line := fmt.Sprintf( |
|
"%s\t%s\n", |
|
now.Format(time.RFC3339), |
|
text, |
|
) |
|
|
|
if _, err := w.Write([]byte(line)); err != nil { |
|
return fmt.Errorf("error writing twt to writer: %w", err) |
|
} |
|
|
|
return nil |
|
} |
|
|
|
func URLForFeed(conf *Config, name string) string { |
|
return fmt.Sprintf( |
|
"%s/%s/twtxt.txt", |
|
strings.TrimSuffix(conf.BaseURL, "/"), |
|
name, |
|
) |
|
} |
|
|
|
func WalkMatch(root, pattern string) ([]string, error) { |
|
var matches []string |
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { |
|
if err != nil { |
|
return err |
|
} |
|
if info.IsDir() { |
|
return nil |
|
} |
|
if matched, err := filepath.Match(pattern, filepath.Base(path)); err != nil { |
|
return err |
|
} else if matched { |
|
matches = append(matches, path) |
|
} |
|
return nil |
|
}) |
|
if err != nil { |
|
return nil, err |
|
} |
|
return matches, nil |
|
} |
|
|
|
func BaseWithoutExt(filename string) string { |
|
base := filepath.Base(filename) |
|
ext := filepath.Ext(base) |
|
return strings.TrimSuffix(base, ext) |
|
} |
|
|
|
func FastHash(data []byte) string { |
|
sum := blake2b.Sum256(data) |
|
|
|
// Base32 is URL-safe, unlike Base64, and shorter than hex. |
|
encoding := base32.StdEncoding.WithPadding(base32.NoPadding) |
|
hash := strings.ToLower(encoding.EncodeToString(sum[:])) |
|
|
|
return hash |
|
} |
|
|
|
func FastHashString(s string) string { |
|
return FastHash([]byte(s)) |
|
} |
|
|
|
func FastHashFile(fn string) (string, error) { |
|
data, err := os.ReadFile(fn) |
|
if err != nil { |
|
return "", err |
|
} |
|
return FastHash(data), nil |
|
}
|
|
|