mirror of https://gitlab.com/ngerakines/tavern.git
parent
e4eaaa6dfd
commit
a25709646f
|
@ -0,0 +1,128 @@
|
|||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/buckket/go-blurhash"
|
||||
|
||||
"github.com/ngerakines/tavern/common"
|
||||
"github.com/ngerakines/tavern/storage"
|
||||
)
|
||||
|
||||
type Agent struct {
|
||||
AssetStorage Storage
|
||||
DataStorage storage.Storage
|
||||
HTTPClient common.HTTPClient
|
||||
}
|
||||
|
||||
func (a Agent) HandleImage(ctx context.Context, location string) (storage.ImageAsset, error) {
|
||||
// A whole lot of wrong can happen here. Need to log errors and handle cleanup gracefully.
|
||||
|
||||
existingImages, err := a.DataStorage.GetImagesByAlias(ctx, []string{location})
|
||||
if err != nil {
|
||||
return storage.ImageAsset{}, err
|
||||
}
|
||||
if len(existingImages) == 1 {
|
||||
return existingImages[0], nil
|
||||
}
|
||||
|
||||
tmpfile, err := ioutil.TempFile("", "image")
|
||||
if err != nil {
|
||||
return storage.ImageAsset{}, err
|
||||
}
|
||||
|
||||
hasher := sha256.New()
|
||||
|
||||
w := io.MultiWriter(tmpfile, hasher)
|
||||
|
||||
sourceReader, err := a.download(ctx, location)
|
||||
if err != nil {
|
||||
return storage.ImageAsset{}, err
|
||||
}
|
||||
|
||||
lsr := io.LimitReader(sourceReader, 5000001) // 5 megs
|
||||
|
||||
written, err := io.Copy(w, lsr)
|
||||
if err != nil {
|
||||
return storage.ImageAsset{}, err
|
||||
}
|
||||
if written > 5000000 {
|
||||
return storage.ImageAsset{}, fmt.Errorf("file size limit of 5 megabytes reached")
|
||||
}
|
||||
|
||||
tmpFileName := tmpfile.Name()
|
||||
|
||||
checksum := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
err = tmpfile.Close()
|
||||
if err != nil {
|
||||
return storage.ImageAsset{}, err
|
||||
}
|
||||
|
||||
bounds, ff, blurHash, err := a.imageBounds(ctx, tmpFileName)
|
||||
if err != nil {
|
||||
return storage.ImageAsset{}, err
|
||||
}
|
||||
|
||||
contentType := storage.ContentTypeUnknown
|
||||
switch ff {
|
||||
case "png":
|
||||
contentType = storage.ContentTypePNG
|
||||
case "jpg", "jpeg":
|
||||
contentType = storage.ContentTypeJPG
|
||||
case "svg":
|
||||
// This is put in here as a placeholder to do some basic parsing and detection of SVG images.
|
||||
contentType = storage.ContentTypeSVG
|
||||
}
|
||||
|
||||
fullLocation, err := a.AssetStorage.Upload(ctx, checksum, tmpFileName)
|
||||
if err != nil {
|
||||
return storage.ImageAsset{}, err
|
||||
}
|
||||
|
||||
return a.DataStorage.CreateImage(ctx, fullLocation, checksum, blurHash, int(written), contentType, bounds.Max.Y, bounds.Max.X, []string{location})
|
||||
}
|
||||
|
||||
func (a Agent) download(ctx context.Context, location string) (io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", location, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := a.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unexpected response code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
func (a Agent) imageBounds(ctx context.Context, tempFileLocation string) (image.Rectangle, string, string, error) {
|
||||
file, err := os.Open(tempFileLocation)
|
||||
if err != nil {
|
||||
return image.Rectangle{}, "", "", err
|
||||
}
|
||||
defer file.Close()
|
||||
img, ff, err := image.Decode(file)
|
||||
if err != nil {
|
||||
return image.Rectangle{}, "", "", err
|
||||
}
|
||||
|
||||
blurHash, err := blurhash.Encode(4, 3, &img)
|
||||
if err != nil {
|
||||
return image.Rectangle{}, "", "", err
|
||||
}
|
||||
|
||||
return img.Bounds(), ff, blurHash, nil
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
"github.com/kr/pretty"
|
||||
"github.com/urfave/cli/v2"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ngerakines/tavern/common"
|
||||
"github.com/ngerakines/tavern/config"
|
||||
"github.com/ngerakines/tavern/g"
|
||||
"github.com/ngerakines/tavern/storage"
|
||||
)
|
||||
|
||||
var Command = cli.Command{
|
||||
Name: "file",
|
||||
Usage: "Prepare a remote file",
|
||||
Flags: []cli.Flag{
|
||||
&config.EnvironmentFlag,
|
||||
&config.ListenFlag,
|
||||
&config.DomainFlag,
|
||||
&config.DatabaseFlag,
|
||||
},
|
||||
Action: fileCommandAction,
|
||||
}
|
||||
|
||||
func fileCommandAction(cliCtx *cli.Context) error {
|
||||
logger, err := config.Logger(cliCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
domain := cliCtx.String("domain")
|
||||
siteBase := fmt.Sprintf("https://%s", domain)
|
||||
|
||||
logger.Info("Starting",
|
||||
zap.String("command", cliCtx.Command.Name),
|
||||
zap.String("GOOS", runtime.GOOS),
|
||||
zap.String("site", siteBase),
|
||||
zap.String("env", cliCtx.String("environment")))
|
||||
|
||||
sentryConfig, err := config.NewSentryConfig(cliCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sentryConfig.Enabled {
|
||||
err = sentry.Init(sentry.ClientOptions{
|
||||
Dsn: sentryConfig.Key,
|
||||
Environment: cliCtx.String("environment"),
|
||||
Release: fmt.Sprintf("%s-%s", g.ReleaseCode, g.GitCommit),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sentry.ConfigureScope(func(scope *sentry.Scope) {
|
||||
scope.SetTags(map[string]string{"container": "server"})
|
||||
})
|
||||
defer sentry.Recover()
|
||||
}
|
||||
|
||||
db, dbClose, err := config.DB(cliCtx, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer dbClose()
|
||||
|
||||
s := storage.DefaultStorage(db)
|
||||
|
||||
a := Agent{
|
||||
AssetStorage: FileStorage{Base: "./assets/"},
|
||||
DataStorage: s,
|
||||
HTTPClient: common.DefaultHTTPClient(),
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
for _, arg := range cliCtx.Args().Slice() {
|
||||
img, err := a.HandleImage(ctx, arg)
|
||||
if err != nil {
|
||||
logger.Error("unable to process image", zap.String("image", arg), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
pretty.Println(img)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
Close() error
|
||||
Create(ctx context.Context, location string, reader io.Reader) (string, error)
|
||||
Delete(ctx context.Context, location string) error
|
||||
Exists(ctx context.Context, location string) (bool, error)
|
||||
Read(ctx context.Context, location string) (io.ReadCloser, error)
|
||||
Upload(ctx context.Context, checksum string, source string) (string, error)
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
package asset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FileStorage struct {
|
||||
Base string
|
||||
}
|
||||
|
||||
func NewFileStorage(base string) Storage {
|
||||
return &FileStorage{
|
||||
Base: base,
|
||||
}
|
||||
}
|
||||
|
||||
var _ Storage = FileStorage{}
|
||||
|
||||
func (f FileStorage) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f FileStorage) Create(ctx context.Context, fileName string, reader io.Reader) (string, error) {
|
||||
fullPath := filepath.Join(f.Base, fileName)
|
||||
|
||||
exists, err := f.Exists(ctx, fileName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists {
|
||||
return "", fmt.Errorf("file exists")
|
||||
}
|
||||
|
||||
out, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, reader)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("file://%s", fullPath), err
|
||||
}
|
||||
|
||||
func (f FileStorage) Delete(ctx context.Context, location string) error {
|
||||
if strings.HasPrefix(location, fmt.Sprintf("file://%s", f.Base)) {
|
||||
return fmt.Errorf("invalid file storage location: %s", location)
|
||||
}
|
||||
location = strings.TrimPrefix(location, "file://")
|
||||
|
||||
return os.Remove(location)
|
||||
}
|
||||
|
||||
func (f FileStorage) Exists(ctx context.Context, location string) (bool, error) {
|
||||
if strings.HasPrefix(location, fmt.Sprintf("file://%s", f.Base)) {
|
||||
return false, fmt.Errorf("invalid file storage location: %s", location)
|
||||
}
|
||||
location = strings.TrimPrefix(location, "file://")
|
||||
|
||||
_, err := os.Stat(location)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f FileStorage) Read(ctx context.Context, location string) (io.ReadCloser, error) {
|
||||
if strings.HasPrefix(location, fmt.Sprintf("file://%s", f.Base)) {
|
||||
return nil, fmt.Errorf("invalid file storage location: %s", location)
|
||||
}
|
||||
location = strings.TrimPrefix(location, "file://")
|
||||
|
||||
return os.Open(location)
|
||||
}
|
||||
|
||||
func (f FileStorage) Upload(ctx context.Context, checksum string, source string) (string, error) {
|
||||
file, err := os.Open(source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// TODO: handle this error
|
||||
defer file.Close()
|
||||
return f.Create(ctx, checksum, file)
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
var AssetStorageFlag = cli.StringFlag{
|
||||
Name: "asset-storage",
|
||||
Usage: "The type of asset storage system to use",
|
||||
Value: "file",
|
||||
EnvVars: []string{"ASSET_STORAGE",},
|
||||
}
|
||||
|
||||
var AssetStorageFileBaseFlag = cli.StringFlag{
|
||||
Name: "asset-storage-file-base",
|
||||
Usage: "The base path used for file asset storage.",
|
||||
Value: "./assets/",
|
||||
EnvVars: []string{"ASSET_STORAGE_FILE_BASE",},
|
||||
}
|
||||
|
||||
type AssetStore interface {
|
||||
Close() error
|
||||
Create(ctx context.Context, location string, reader io.Reader) (string, error)
|
||||
Delete(ctx context.Context, location string) error
|
||||
Exists(ctx context.Context, location string) (bool, error)
|
||||
Read(ctx context.Context, location string) (io.ReadCloser, error)
|
||||
Upload(ctx context.Context, checksum string, source string) (string, error)
|
||||
}
|
||||
|
||||
type AssetStorageConfig struct {
|
||||
Type string
|
||||
FileBasePath string
|
||||
}
|
||||
|
||||
func AssetStorage(c *cli.Context) AssetStorageConfig {
|
||||
return AssetStorageConfig{
|
||||
Type: c.String("asset-storage"),
|
||||
FileBasePath: c.String("asset-storage-file-base"),
|
||||
}
|
||||
}
|
|
@ -3,19 +3,22 @@ package fed
|
|||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"strings"
|
||||
|
||||
"github.com/yukimochi/httpsig"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ngerakines/tavern/asset"
|
||||
"github.com/ngerakines/tavern/common"
|
||||
"github.com/ngerakines/tavern/storage"
|
||||
)
|
||||
|
||||
type Crawler struct {
|
||||
HTTPClient common.HTTPClient
|
||||
Logger *zap.Logger
|
||||
Storage storage.Storage
|
||||
MaxDepth int
|
||||
HTTPClient common.HTTPClient
|
||||
Logger *zap.Logger
|
||||
Storage storage.Storage
|
||||
MaxDepth int
|
||||
AssetStorage asset.Storage
|
||||
}
|
||||
|
||||
type Signer interface {
|
||||
|
@ -46,6 +49,8 @@ func (c Crawler) Start(sa Signer, seed string) ([]string, []string, error) {
|
|||
|
||||
actorQueue := &SeenStringQueue{Seen: make(map[string]bool), Queue: make([]string, 0), Taken: make([]string, 0)}
|
||||
|
||||
mediaQueue := &SeenStringQueue{Seen: make(map[string]bool), Queue: make([]string, 0), Taken: make([]string, 0)}
|
||||
|
||||
counter := 0
|
||||
|
||||
for !activityQueue.Empty() {
|
||||
|
@ -93,6 +98,19 @@ func (c Crawler) Start(sa Signer, seed string) ([]string, []string, error) {
|
|||
actorQueue.Add(activityActor)
|
||||
}
|
||||
|
||||
attachments, ok := storage.JSONMapList(payload, "attachment")
|
||||
if ok {
|
||||
for _, attachment := range attachments {
|
||||
mediaType, hasMediaType := storage.JSONString(attachment, "mediaType")
|
||||
url, hasURL := storage.JSONString(attachment, "url")
|
||||
if hasMediaType && hasURL && strings.HasPrefix(url, "https://") {
|
||||
if mediaType == "image/jpeg" || mediaType == "image/png" {
|
||||
mediaQueue.Add(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
counter++
|
||||
}
|
||||
|
||||
|
@ -118,5 +136,33 @@ func (c Crawler) Start(sa Signer, seed string) ([]string, []string, error) {
|
|||
counter++
|
||||
}
|
||||
|
||||
counter = 0
|
||||
|
||||
a := &asset.Agent{
|
||||
AssetStorage: c.AssetStorage,
|
||||
DataStorage: c.Storage,
|
||||
HTTPClient: c.HTTPClient,
|
||||
}
|
||||
|
||||
for !mediaQueue.Empty() {
|
||||
if counter > (c.MaxDepth * 10) {
|
||||
break
|
||||
}
|
||||
|
||||
location, ok := mediaQueue.Take()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
c.Logger.Info("invoking media downloader", zap.String("location", location))
|
||||
|
||||
_, err = a.HandleImage(ctx, location)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
counter++
|
||||
}
|
||||
|
||||
return actorQueue.Taken, activityQueue.Taken, nil
|
||||
}
|
||||
|
|
2
go.mod
2
go.mod
|
@ -3,6 +3,7 @@ module github.com/ngerakines/tavern
|
|||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/buckket/go-blurhash v1.0.3
|
||||
github.com/foolin/goview v0.2.0
|
||||
github.com/getsentry/sentry-go v0.4.0
|
||||
github.com/gin-contrib/sessions v0.0.3
|
||||
|
@ -16,6 +17,7 @@ require (
|
|||
github.com/lib/pq v1.3.0
|
||||
github.com/lucasb-eyer/go-colorful v1.0.3
|
||||
github.com/microcosm-cc/bluemonday v1.0.2
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
|
||||
github.com/oklog/run v1.0.0
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sslhound/herr v1.4.1 // indirect
|
||||
|
|
4
go.sum
4
go.sum
|
@ -15,6 +15,8 @@ github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go
|
|||
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
|
||||
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
|
||||
github.com/buckket/go-blurhash v1.0.3 h1:zCSPYlKYWxF+3I/JJT2GrF4ut6wRaifz89JdsdZClpw=
|
||||
github.com/buckket/go-blurhash v1.0.3/go.mod h1:BUt9nlD6V+23blJqm6Vn/423xpTnP1OLA9yv+y4l44U=
|
||||
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
|
@ -158,6 +160,8 @@ github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOA
|
|||
github.com/nats-io/nats.go v1.8.1/go.mod h1:BrFz9vVn0fU3AcH9Vn4Kd7W0NpJ651tD5omQ3M8LwxM=
|
||||
github.com/nats-io/nkeys v0.0.2/go.mod h1:dab7URMsZm6Z/jp9Z5UGa87Uutgc2mVpXLC4B7TDb/4=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
|
||||
github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
|
||||
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ngerakines/tavern/asset"
|
||||
"github.com/ngerakines/tavern/common"
|
||||
"github.com/ngerakines/tavern/storage"
|
||||
)
|
||||
|
||||
type assetDownload struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger *zap.Logger
|
||||
queue storage.StringQueue
|
||||
storage storage.Storage
|
||||
assetStorage asset.Storage
|
||||
httpClient common.HTTPClient
|
||||
}
|
||||
|
||||
func NewAssetDownloadJob(logger *zap.Logger, queue storage.StringQueue, storage storage.Storage, assetStorage asset.Storage) Job {
|
||||
return &assetDownload{
|
||||
logger: logger,
|
||||
queue: queue,
|
||||
storage: storage,
|
||||
assetStorage: assetStorage,
|
||||
httpClient: common.DefaultHTTPClient(),
|
||||
}
|
||||
}
|
||||
|
||||
func (job *assetDownload) Run(parent context.Context) error {
|
||||
job.ctx, job.cancel = context.WithCancel(parent)
|
||||
defer job.cancel()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-time.After(time.Second):
|
||||
err := job.work()
|
||||
if err != nil {
|
||||
job.logger.Error("error processing work", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
case <-job.ctx.Done():
|
||||
return ignoreCanceled(job.ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (job *assetDownload) Shutdown(parent context.Context) error {
|
||||
job.cancel()
|
||||
select {
|
||||
case <-parent.Done():
|
||||
return parent.Err()
|
||||
case <-job.ctx.Done():
|
||||
return job.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (job *assetDownload) work() error {
|
||||
work, err := job.queue.Take()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(work) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
a := &asset.Agent{
|
||||
AssetStorage: job.assetStorage,
|
||||
DataStorage: job.storage,
|
||||
HTTPClient: job.httpClient,
|
||||
}
|
||||
_, err = a.HandleImage(job.ctx, work)
|
||||
if err != nil {
|
||||
job.logger.Warn("error getting asset", zap.Error(err), zap.String("location", work))
|
||||
}
|
||||
return nil
|
||||
}
|
|
@ -9,26 +9,29 @@ import (
|
|||
"github.com/gofrs/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ngerakines/tavern/asset"
|
||||
"github.com/ngerakines/tavern/common"
|
||||
"github.com/ngerakines/tavern/fed"
|
||||
"github.com/ngerakines/tavern/storage"
|
||||
)
|
||||
|
||||
type crawl struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger *zap.Logger
|
||||
queue storage.StringQueue
|
||||
storage storage.Storage
|
||||
httpClient common.HTTPClient
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
logger *zap.Logger
|
||||
queue storage.StringQueue
|
||||
storage storage.Storage
|
||||
httpClient common.HTTPClient
|
||||
assetStorage asset.Storage
|
||||
}
|
||||
|
||||
func NewCrawlWorker(logger *zap.Logger, queue storage.StringQueue, storage storage.Storage) Job {
|
||||
func NewCrawlWorker(logger *zap.Logger, queue storage.StringQueue, storage storage.Storage, assetStorage asset.Storage) Job {
|
||||
return &crawl{
|
||||
logger: logger,
|
||||
queue: queue,
|
||||
storage: storage,
|
||||
httpClient: common.DefaultHTTPClient(),
|
||||
logger: logger,
|
||||
queue: queue,
|
||||
storage: storage,
|
||||
assetStorage: assetStorage,
|
||||
httpClient: common.DefaultHTTPClient(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -84,10 +87,11 @@ func (job *crawl) work() error {
|
|||
}
|
||||
|
||||
crawler := &fed.Crawler{
|
||||
HTTPClient: job.httpClient,
|
||||
Logger: job.logger,
|
||||
Storage: job.storage,
|
||||
MaxDepth: fed.CrawlerDefaultMaxCount,
|
||||
HTTPClient: job.httpClient,
|
||||
Logger: job.logger,
|
||||
Storage: job.storage,
|
||||
MaxDepth: fed.CrawlerDefaultMaxCount,
|
||||
AssetStorage: job.assetStorage,
|
||||
}
|
||||
if _, _, err = crawler.Start(user, parts[1]); err != nil {
|
||||
job.logger.Warn("error crawling", zap.Error(err), zap.String("location", parts[1]))
|
||||
|
|
2
main.go
2
main.go
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"github.com/ngerakines/tavern/asset"
|
||||
"github.com/ngerakines/tavern/g"
|
||||
"github.com/ngerakines/tavern/start"
|
||||
"github.com/ngerakines/tavern/web"
|
||||
|
@ -44,6 +45,7 @@ func main() {
|
|||
app.Commands = []*cli.Command{
|
||||
&start.Command,
|
||||
&web.Command,
|
||||
&asset.Command,
|
||||
}
|
||||
|
||||
sort.Sort(cli.FlagsByName(app.Flags))
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,156 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/ngerakines/tavern/errors"
|
||||
)
|
||||
|
||||
type AssetStorage interface {
|
||||
CreateImage(ctx context.Context, location, checksum, blur string, size, contentType, height, width int, aliases []string) (ImageAsset, error)
|
||||
GetImagesByLocation(ctx context.Context, locations []string) ([]ImageAsset, error)
|
||||
GetImagesByAlias(ctx context.Context, aliases []string) ([]ImageAsset, error)
|
||||
GetImageByChecksum(ctx context.Context, checksum string) (ImageAsset, error)
|
||||
}
|
||||
|
||||
type ImageAsset struct {
|
||||
ID uuid.UUID
|
||||
Location string
|
||||
Checksum string
|
||||
Blur string
|
||||
Size int
|
||||
ContentType int
|
||||
Height int
|
||||
Width int
|
||||
Alias string
|
||||
}
|
||||
|
||||
var _ AssetStorage = &pgStorage{}
|
||||
|
||||
const (
|
||||
ContentTypeUnknown = 0
|
||||
ContentTypePNG = 1
|
||||
ContentTypeJPG = 2
|
||||
ContentTypeSVG = 3
|
||||
)
|
||||
|
||||
func (s pgStorage) CreateImage(ctx context.Context, location, checksum, blur string, size, contentType, height, width int, aliases []string) (ImageAsset, error) {
|
||||
var img ImageAsset
|
||||
now := s.now()
|
||||
txErr := runTransactionWithOptions(s.db, func(tx *sql.Tx) error {
|
||||
imageRowID := NewV4()
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO images (id, location, checksum, content_type, blur, height, width, "size", created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, imageRowID, location, checksum, contentType, blur, height, width, size, now)
|
||||
if err != nil {
|
||||
return errors.WrapInsertQueryFailedError(err)
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO image_aliases (id, image_id, alias, created_at) VALUES ($1, $2, $3, $4)`, NewV4(), imageRowID, alias, now)
|
||||
if err != nil {
|
||||
return errors.WrapInsertQueryFailedError(err)
|
||||
}
|
||||
}
|
||||
img, err = s.getImage(tx, ctx, imageRowID)
|
||||
return errors.WrapInsertQueryFailedError(err)
|
||||
})
|
||||
if txErr != nil {
|
||||
return ImageAsset{}, txErr
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (s pgStorage) GetImage(ctx context.Context, id uuid.UUID) (ImageAsset, error) {
|
||||
images, err := s.getImagesQuery(s.db, ctx, `SELECT id, location, checksum, blur, size, content_type, height, width, '' FROM images WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return ImageAsset{}, err
|
||||
}
|
||||
if len(images) == 0 {
|
||||
return ImageAsset{}, errors.NewNotFoundError(nil)
|
||||
}
|
||||
return images[0], nil
|
||||
}
|
||||
|
||||
func (s pgStorage) GetImageByChecksum(ctx context.Context, checksum string) (ImageAsset, error) {
|
||||
images, err := s.getImagesQuery(s.db, ctx, `SELECT id, location, checksum, blur, size, content_type, height, width, '' FROM images WHERE checksum = $1`, checksum)
|
||||
if err != nil {
|
||||
return ImageAsset{}, err
|
||||
}
|
||||
if len(images) == 0 {
|
||||
return ImageAsset{}, errors.NewNotFoundError(nil)
|
||||
}
|
||||
return images[0], nil
|
||||
}
|
||||
|
||||
func (s pgStorage) getImage(qc QueryContext, ctx context.Context, id uuid.UUID) (ImageAsset, error) {
|
||||
images, err := s.getImagesQuery(qc, ctx, `SELECT id, location, checksum, blur, size, content_type, height, width, '' FROM images WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return ImageAsset{}, err
|
||||
}
|
||||
if len(images) == 0 {
|
||||
return ImageAsset{}, errors.NewNotFoundError(nil)
|
||||
}
|
||||
return images[0], nil
|
||||
}
|
||||
|
||||
func (s pgStorage) GetImagesByLocation(ctx context.Context, locations []string) ([]ImageAsset, error) {
|
||||
if len(locations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
params := make([]string, len(locations))
|
||||
args := make([]interface{}, len(locations))
|
||||
for i, id := range locations {
|
||||
params[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`SELECT id, location, checksum, blur, size, content_type, height, width, '' FROM images WHERE location IN (%s)`, strings.Join(params, ", "))
|
||||
return s.getImagesQuery(s.db, ctx, query, args...)
|
||||
}
|
||||
|
||||
func (s pgStorage) GetImagesByAlias(ctx context.Context, aliases []string) ([]ImageAsset, error) {
|
||||
if len(aliases) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
params := make([]string, len(aliases))
|
||||
args := make([]interface{}, len(aliases))
|
||||
for i, id := range aliases {
|
||||
params[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`SELECT im.id, im.location, im.checksum, im.blur, im.size, im.content_type, im.height, im.width, alias FROM images im INNER JOIN image_aliases ima ON im.id = ima.image_id WHERE ima.alias IN (%s)`, strings.Join(params, ", "))
|
||||
return s.getImagesQuery(s.db, ctx, query, args...)
|
||||
}
|
||||
|
||||
func (s pgStorage) getImagesQuery(qc QueryContext, ctx context.Context, query string, args ...interface{}) ([]ImageAsset, error) {
|
||||
var results []ImageAsset
|
||||
|
||||
rows, err := qc.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.NewSelectQueryFailedError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var imageAsset ImageAsset
|
||||
if err := rows.Scan(
|
||||
&imageAsset.ID,
|
||||
&imageAsset.Location,
|
||||
&imageAsset.Checksum,
|
||||
&imageAsset.Blur,
|
||||
&imageAsset.Size,
|
||||
&imageAsset.ContentType,
|
||||
&imageAsset.Height,
|
||||
&imageAsset.Width,
|
||||
&imageAsset.Alias,
|
||||
); err != nil {
|
||||
return nil, errors.NewSelectQueryFailedError(err)
|
||||
}
|
||||
results = append(results, imageAsset)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
|
@ -20,6 +20,8 @@ type Storage interface {
|
|||
PeerStorage
|
||||
ThreadStorage
|
||||
ObjectStorage
|
||||
|
||||
AssetStorage
|
||||
}
|
||||
|
||||
type QueryExecute interface {
|
||||
|
@ -30,6 +32,10 @@ type QueryRowCount interface {
|
|||
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
type QueryContext interface {
|
||||
QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
func DefaultStorage(db *sql.DB) Storage {
|
||||
return pgStorage{
|
||||
db: db,
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
</ul>
|
||||
<div class="row pt-3 ">
|
||||
<div class="col">
|
||||
{{template "activity_feed" (dict "feed" .feed "actors" .actors "announcements" .announcements) }}
|
||||
{{template "activity_feed" (dict "feed" .feed "actors" .actors "announcements" .announcements "media" .media) }}
|
||||
</div>
|
||||
</div>
|
||||
{{ if gt .page_count 1 }}
|
||||
|
|
|
@ -59,6 +59,7 @@
|
|||
integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="/public/bootstrap.min.js"></script>
|
||||
<script src="/public/clipboard.min.js"></script>
|
||||
{{ template "footer_script" . }}
|
||||
</body>
|
||||
</html>
|
|
@ -1,6 +1,7 @@
|
|||
{{ define "activity_announce_note" }}
|
||||
{{ $actors := .actors }}
|
||||
{{ $announcements := .announcements }}
|
||||
{{ $media := .media }}
|
||||
{{ with $item := .item }}
|
||||
{{ with $boost := $item.announce_note}}
|
||||
{{ with $note := $boost.note }}
|
||||
|
@ -8,7 +9,7 @@
|
|||
<i class="fas fa-megaphone align-self-start fa-2x mr-3 text-secondary"></i>
|
||||
<div class="media-body">
|
||||
<p>
|
||||
<img src="{{- $actors.Lookup "icon" $note.author -}}?size=14" alt="icon" />
|
||||
<img src="{{- $actors.Lookup "icon" $note.author -}}?size=14" alt="icon"/>
|
||||
Note by
|
||||
<a href="{{ $note.author }}">
|
||||
{{- $actors.Lookup "name" $note.author -}}
|
||||
|
@ -51,6 +52,21 @@
|
|||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ if $note.media }}
|
||||
<h3>Media </h3>
|
||||
<ul class="pt-1 list-unstyled">
|
||||
{{ range $m := $note.media }}
|
||||
{{ $thumb := ($media.Lookup "blurthumbnail" $m) }}
|
||||
{{ if $thumb }}
|
||||
<li>
|
||||
<a href="{{ $media.Lookup "image" $m }}" target="_blank">
|
||||
<img class="img-thumbnail" src="{{ $thumb }}" alt="blurred thumbnail of media" />
|
||||
</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
|
|
|
@ -1,13 +1,14 @@
|
|||
{{ define "activity_create_note" }}
|
||||
{{ $actors := .actors }}
|
||||
{{ $announcements := .announcements }}
|
||||
{{ $media := .media }}
|
||||
{{ with $item := .item }}
|
||||
{{ with $note := $item.create_note}}
|
||||
<li class="media mb-3 border-top border-secondary pt-2">
|
||||
<i class="fas fa-comment align-self-start fa-2x mr-3 text-secondary"></i>
|
||||
<div class="media-body">
|
||||
<p>
|
||||
<img src="{{- $actors.Lookup "icon" $note.author -}}?size=14" alt="icon" />
|
||||
<img src="{{- $actors.Lookup "icon" $note.author -}}?size=14" alt="icon"/>
|
||||
{{ if $note.in_reply_to }}
|
||||
<a href="{{ $note.author }}">
|
||||
{{- $actors.Lookup "name" $note.author -}}
|
||||
|
@ -54,10 +55,26 @@
|
|||
data-object="{{ $note.object_id }}">Announce</a>
|
||||
{{ end }}
|
||||
{{- if $note.conversation -}}
|
||||
<a href="{{ url "compose" }}?inReplyTo={{ urlEncode $note.object_id }}" class="btn btn-primary btn-sm">Reply</a>
|
||||
<a href="{{ url "compose" }}?inReplyTo={{ urlEncode $note.object_id }}"
|
||||
class="btn btn-primary btn-sm">Reply</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ if $note.media }}
|
||||
<h3>Media </h3>
|
||||
<ul class="pt-1 list-unstyled">
|
||||
{{ range $m := $note.media }}
|
||||
{{ $thumb := ($media.Lookup "blurthumbnail" $m) }}
|
||||
{{ if $thumb }}
|
||||
<li>
|
||||
<a href="{{ $media.Lookup "image" $m }}" target="_blank">
|
||||
<img class="img-thumbnail" src="{{ $thumb }}" alt="blurred thumbnail of media" />
|
||||
</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
{{ define "activity_feed" }}
|
||||
{{ $actors := .actors }}
|
||||
{{ $announcements := .announcements }}
|
||||
{{ $media := .media }}
|
||||
{{ if .feed }}
|
||||
<ul class="list-unstyled activity-feed">
|
||||
{{ range $item := .feed }}
|
||||
{{ if $item.create_note }}
|
||||
{{template "activity_create_note" (dict "item" $item "actors" $actors "announcements" $announcements) }}
|
||||
{{template "activity_create_note" (dict "item" $item "actors" $actors "announcements" $announcements "media" $media) }}
|
||||
{{ end }}
|
||||
{{ if $item.announce_note }}
|
||||
{{template "activity_announce_note" (dict "item" $item "actors" $actors "announcements" $announcements) }}
|
||||
{{template "activity_announce_note" (dict "item" $item "actors" $actors "announcements" $announcements "media" $media) }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
|
|
|
@ -26,6 +26,7 @@ import (
|
|||
"github.com/urfave/cli/v2"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ngerakines/tavern/asset"
|
||||
"github.com/ngerakines/tavern/config"
|
||||
"github.com/ngerakines/tavern/g"
|
||||
"github.com/ngerakines/tavern/job"
|
||||
|
@ -50,6 +51,8 @@ var Command = cli.Command{
|
|||
},
|
||||
&config.EnableSVGerFlag,
|
||||
&config.SVGerEndpointFlag,
|
||||
&config.AssetStorageFlag,
|
||||
&config.AssetStorageFileBaseFlag,
|
||||
},
|
||||
Action: serverCommandAction,
|
||||
}
|
||||
|
@ -105,10 +108,24 @@ func serverCommandAction(cliCtx *cli.Context) error {
|
|||
|
||||
utrans, err := config.Trans(cliCtx)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
return err
|
||||
}
|
||||
|
||||
assetStorageConfig := config.AssetStorage(cliCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var assetStorage asset.Storage
|
||||
switch assetStorageConfig.Type {
|
||||
case "file":
|
||||
assetStorage = asset.FileStorage{
|
||||
Base: assetStorageConfig.FileBasePath,
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown asset storage type: %s", assetStorageConfig.Type)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
if sentryConfig.Enabled {
|
||||
r.Use(sentrygin.New(sentrygin.Options{
|
||||
|
@ -173,6 +190,7 @@ func serverCommandAction(cliCtx *cli.Context) error {
|
|||
|
||||
webFingerQueue := storage.NewStringQueue()
|
||||
crawlQueue := storage.NewStringQueue()
|
||||
assetQueue := storage.NewStringQueue()
|
||||
|
||||
var svgConv SVGConverter
|
||||
if svgerConfig.Enabled {
|
||||
|
@ -186,9 +204,11 @@ func serverCommandAction(cliCtx *cli.Context) error {
|
|||
sentryConfig: sentryConfig,
|
||||
webFingerQueue: webFingerQueue,
|
||||
crawlQueue: crawlQueue,
|
||||
assetQueue: assetQueue,
|
||||
adminUser: cliCtx.String("admin-name"),
|
||||
url: tmplUrlGen(siteBase),
|
||||
svgConverter: svgConv,
|
||||
assetStorage: assetStorage,
|
||||
}
|
||||
|
||||
configI18nMiddleware(sentryConfig, logger, utrans, domain, r)
|
||||
|
@ -210,6 +230,11 @@ func serverCommandAction(cliCtx *cli.Context) error {
|
|||
root.GET("/object/:object", h.getObject)
|
||||
root.GET("/tags/:tag", h.getTaggedObjects)
|
||||
|
||||
root.GET("/asset/image/:checksum", h.viewAsset)
|
||||
root.GET("/asset/thumbnail/:checksum", h.viewThumbnail)
|
||||
root.GET("/asset/blur/:checksum", h.viewBlur)
|
||||
root.GET("/asset/blurthumbnail/:checksum", h.viewThumbnailBlur)
|
||||
|
||||
root.GET("/avatar/svg/:domain/:name", h.avatarSVG)
|
||||
if svgerConfig.Enabled {
|
||||
root.GET("/avatar/png/:domain/:name", h.avatarPNG)
|
||||
|
@ -276,9 +301,12 @@ func serverCommandAction(cliCtx *cli.Context) error {
|
|||
webFingerJob := job.NewWebFingerWorker(logger, webFingerQueue, s)
|
||||
job.RunWorker(&group, logger, webFingerJob, parentCtx)
|
||||
|
||||
crawlJob := job.NewCrawlWorker(logger, crawlQueue, s)
|
||||
crawlJob := job.NewCrawlWorker(logger, crawlQueue, s, assetStorage)
|
||||
job.RunWorker(&group, logger, crawlJob, parentCtx)
|
||||
|
||||
assetJob := job.NewAssetDownloadJob(logger, assetQueue, s, assetStorage)
|
||||
job.RunWorker(&group, logger, assetJob, parentCtx)
|
||||
|
||||
quit := make(chan os.Signal)
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
group.Add(func() error {
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ngerakines/tavern/asset"
|
||||
"github.com/ngerakines/tavern/config"
|
||||
"github.com/ngerakines/tavern/errors"
|
||||
"github.com/ngerakines/tavern/storage"
|
||||
|
@ -26,6 +27,8 @@ type handler struct {
|
|||
adminUser string
|
||||
url func(parts ...interface{}) string
|
||||
svgConverter SVGConverter
|
||||
assetStorage asset.Storage
|
||||
assetQueue storage.StringQueue
|
||||
}
|
||||
|
||||
func (h handler) hardFail(ctx *gin.Context, err error, fields ...zap.Field) {
|
||||
|
|
|
@ -168,12 +168,18 @@ func (h handler) actorInboxFollow(c *gin.Context, user *storage.User, payload st
|
|||
return
|
||||
}
|
||||
|
||||
followerActor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, common.DefaultHTTPClient(), target)
|
||||
if err != nil {
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.verifySignature(c); err != nil {
|
||||
h.unauthorizedJSON(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
err := h.storage.CreatePendingFollower(ctx, user.ID, target, string(body))
|
||||
err = h.storage.CreatePendingFollower(ctx, user.ID, target, string(body))
|
||||
if err != nil {
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
|
@ -181,12 +187,6 @@ func (h handler) actorInboxFollow(c *gin.Context, user *storage.User, payload st
|
|||
|
||||
if user.AcceptFollowers {
|
||||
|
||||
followerActor, err := fed.GetOrFetchActor(ctx, h.storage, h.logger, common.DefaultHTTPClient(), target)
|
||||
if err != nil {
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response := storage.EmptyPayload()
|
||||
acceptActivityID := storage.NewV4()
|
||||
response["@context"] = "https://www.w3.org/ns/activitystreams"
|
||||
|
@ -340,6 +340,24 @@ func (h handler) actorInboxCreate(c *gin.Context, user *storage.User, payload st
|
|||
h.logger.Warn("error queueing crawl work", zap.Error(err))
|
||||
}
|
||||
|
||||
obj, ok := storage.JSONMap(activityObject, "object")
|
||||
if ok {
|
||||
attachments, ok := storage.JSONMapList(obj, "attachment")
|
||||
if ok {
|
||||
for _, attachment := range attachments {
|
||||
mediaType, hasMediaType := storage.JSONString(attachment, "mediaType")
|
||||
url, hasURL := storage.JSONString(attachment, "url")
|
||||
if hasMediaType && hasURL && strings.HasPrefix(url, "https://") {
|
||||
if mediaType == "image/jpeg" || mediaType == "image/png" {
|
||||
if err = h.assetQueue.Add(url); err != nil {
|
||||
h.logger.Warn("error queueing asset download work", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
@ -135,6 +137,14 @@ func (h handler) viewFeed(c *gin.Context) {
|
|||
|
||||
data["actors"] = actorLookup{h.domain, allActors}
|
||||
|
||||
ml := &mediaLookup{h.domain, make(map[string]string)}
|
||||
err = ml.load(ctx, h.storage, vf.mediaURLs)
|
||||
if err != nil {
|
||||
h.hardFail(c, err)
|
||||
}
|
||||
|
||||
data["media"] = ml
|
||||
|
||||
if cont = h.saveSession(c, session); !cont {
|
||||
return
|
||||
}
|
||||
|
@ -225,6 +235,14 @@ func (h handler) viewMyFeed(c *gin.Context) {
|
|||
data["pages"] = pages
|
||||
data["actors"] = actorLookup{h.domain, allActors}
|
||||
|
||||
ml := &mediaLookup{h.domain, make(map[string]string)}
|
||||
err = ml.load(ctx, h.storage, vf.mediaURLs)
|
||||
if err != nil {
|
||||
h.hardFail(c, err)
|
||||
}
|
||||
|
||||
data["media"] = ml
|
||||
|
||||
if cont = h.saveSession(c, session); !cont {
|
||||
return
|
||||
}
|
||||
|
@ -305,3 +323,41 @@ func (al actorLookup) Lookup(focus string, actorID string) string {
|
|||
return fmt.Sprintf("unknown key: %s", focus)
|
||||
}
|
||||
}
|
||||
|
||||
type mediaLookup struct {
|
||||
domain string
|
||||
payloads map[string]string
|
||||
}
|
||||
|
||||
func (ml *mediaLookup) Lookup(size string, href string) string {
|
||||
data, ok := ml.payloads[fmt.Sprintf("%s,%s", size, href)]
|
||||
if ok {
|
||||
return data
|
||||
}
|
||||
return fmt.Sprintf("https://%s/asset/placeholder/%s", ml.domain, size)
|
||||
}
|
||||
|
||||
func (ml *mediaLookup) load(ctx context.Context, s storage.Storage, hrefs []string) error {
|
||||
domainPrefix := fmt.Sprintf("https://%s/asset/image/", ml.domain)
|
||||
for _, href := range hrefs {
|
||||
if strings.HasPrefix(href, domainPrefix) {
|
||||
checksum := strings.TrimPrefix(href, domainPrefix)
|
||||
ml.payloads[fmt.Sprintf("image,%s", href)] = fmt.Sprintf("https://%s/asset/image/%s", ml.domain, checksum)
|
||||
ml.payloads[fmt.Sprintf("thumbnail,%s", href)] = fmt.Sprintf("https://%s/asset/thumbnail/%s", ml.domain, checksum)
|
||||
ml.payloads[fmt.Sprintf("blur,%s", href)] = fmt.Sprintf("https://%s/asset/blur/%s", ml.domain, checksum)
|
||||
ml.payloads[fmt.Sprintf("blurthumbnail,%s", href)] = fmt.Sprintf("https://%s/asset/blurthumbnail/%s", ml.domain, checksum)
|
||||
continue
|
||||
}
|
||||
imgs, err := s.GetImagesByAlias(ctx, []string{href})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(imgs) > 0 {
|
||||
ml.payloads[fmt.Sprintf("image,%s", href)] = fmt.Sprintf("https://%s/asset/image/%s", ml.domain, imgs[0].Checksum)
|
||||
ml.payloads[fmt.Sprintf("thumbnail,%s", href)] = fmt.Sprintf("https://%s/asset/thumbnail/%s", ml.domain, imgs[0].Checksum)
|
||||
ml.payloads[fmt.Sprintf("blur,%s", href)] = fmt.Sprintf("https://%s/asset/blur/%s", ml.domain, imgs[0].Checksum)
|
||||
ml.payloads[fmt.Sprintf("blurthumbnail,%s", href)] = fmt.Sprintf("https://%s/asset/blurthumbnail/%s", ml.domain, imgs[0].Checksum)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -0,0 +1,140 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
|
||||
"github.com/buckket/go-blurhash"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/nfnt/resize"
|
||||
|
||||
"github.com/ngerakines/tavern/errors"
|
||||
"github.com/ngerakines/tavern/storage"
|
||||
)
|
||||
|
||||
func (h handler) viewAsset(c *gin.Context) {
|
||||
checksum := c.Param("checksum")
|
||||
img, err := h.storage.GetImageByChecksum(c.Request.Context(), checksum)
|
||||
if err != nil {
|
||||
if errors.Is(err, errors.NewNotFoundError(nil)) {
|
||||
h.notFoundJSON(c, nil)
|
||||
return
|
||||
}
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
readerCloser, err := h.assetStorage.Read(c.Request.Context(), img.Location)
|
||||
if err != nil {
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var contentType = "application/octet-stream"
|
||||
// TODO: Handle other content types.
|
||||
switch img.ContentType {
|
||||
case storage.ContentTypeJPG:
|
||||
contentType = "image/jpeg"
|
||||
case storage.ContentTypePNG:
|
||||
contentType = "image/png"
|
||||
}
|
||||
|
||||
extraHeaders := map[string]string{
|
||||
"Cache-Control": "max-age=7200",
|
||||
}
|
||||
c.DataFromReader(http.StatusOK, int64(img.Size), contentType, readerCloser, extraHeaders)
|
||||
}
|
||||
|
||||
func (h handler) viewThumbnail(c *gin.Context) {
|
||||
checksum := c.Param("checksum")
|
||||
img, err := h.storage.GetImageByChecksum(c.Request.Context(), checksum)
|
||||
if err != nil {
|
||||
if errors.Is(err, errors.NewNotFoundError(nil)) {
|
||||
h.notFoundJSON(c, nil)
|
||||
return
|
||||
}
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
readerCloser, err := h.assetStorage.Read(c.Request.Context(), img.Location)
|
||||
if err != nil {
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Log this error
|
||||
defer readerCloser.Close()
|
||||
|
||||
readImage, _, err := image.Decode(readerCloser)
|
||||
if err != nil {
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
}
|
||||
m := resize.Thumbnail(40, 40, readImage, resize.NearestNeighbor)
|
||||
|
||||
// TODO: Handle other content types.
|
||||
switch img.ContentType {
|
||||
case storage.ContentTypeJPG:
|
||||
c.Writer.Header().Add("Cache-Control", "max-age=7200")
|
||||
c.Writer.Header().Add("Content-Type", "image/jpeg")
|
||||
// TODO: handle this error.
|
||||
jpeg.Encode(c.Writer, m, nil)
|
||||
case storage.ContentTypePNG:
|
||||
c.Writer.Header().Add("Cache-Control", "max-age=7200")
|
||||
c.Writer.Header().Add("Content-Type", "image/png")
|
||||
// TODO: handle this error.
|
||||
png.Encode(c.Writer, m)
|
||||
default:
|
||||
h.internalServerErrorJSON(c, fmt.Errorf("unable to process image thumbnail"))
|
||||
}
|
||||
}
|
||||
|
||||
func (h handler) viewBlur(c *gin.Context) {
|
||||
checksum := c.Param("checksum")
|
||||
img, err := h.storage.GetImageByChecksum(c.Request.Context(), checksum)
|
||||
if err != nil {
|
||||
if errors.Is(err, errors.NewNotFoundError(nil)) {
|
||||
h.notFoundJSON(c, err)
|
||||
return
|
||||
}
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
blurImg, err := blurhash.Decode(img.Blur, img.Width, img.Height, 1)
|
||||
if err != nil {
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
}
|
||||
c.Writer.Header().Add("Cache-Control", "max-age=7200")
|
||||
c.Writer.Header().Add("Content-Type", "image/png")
|
||||
// Todo: handle this error
|
||||
png.Encode(c.Writer, blurImg)
|
||||
}
|
||||
|
||||
func (h handler) viewThumbnailBlur(c *gin.Context) {
|
||||
checksum := c.Param("checksum")
|
||||
img, err := h.storage.GetImageByChecksum(c.Request.Context(), checksum)
|
||||
if err != nil {
|
||||
if errors.Is(err, errors.NewNotFoundError(nil)) {
|
||||
h.notFoundJSON(c, err)
|
||||
return
|
||||
}
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
blurImg, err := blurhash.Decode(img.Blur, 40, 40, 1)
|
||||
if err != nil {
|
||||
h.internalServerErrorJSON(c, err)
|
||||
return
|
||||
}
|
||||
c.Writer.Header().Add("Cache-Control", "max-age=7200")
|
||||
c.Writer.Header().Add("Content-Type", "image/png")
|
||||
// Todo: handle this error
|
||||
png.Encode(c.Writer, blurImg)
|
||||
}
|
|
@ -2,6 +2,7 @@ package web
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
@ -11,8 +12,9 @@ import (
|
|||
)
|
||||
|
||||
type viewFeed struct {
|
||||
feed []map[string]interface{}
|
||||
actorIDs []string
|
||||
feed []map[string]interface{}
|
||||
actorIDs []string
|
||||
mediaURLs []string
|
||||
}
|
||||
|
||||
func (v *viewFeed) populate(userFeed []storage.UserFeed, pairs map[uuid.UUID]storage.ObjectEventPair) error {
|
||||
|
@ -141,12 +143,28 @@ func (v *viewFeed) createNoteViewFromPayload(pair storage.ObjectEventPair, p sto
|
|||
}
|
||||
}
|
||||
|
||||
var media []string
|
||||
|
||||
attachments, ok := storage.JSONMapList(p, "attachment")
|
||||
if ok {
|
||||
for _, attachment := range attachments {
|
||||
url, hasUrl := storage.JSONString(attachment, "url")
|
||||
if hasUrl && strings.HasPrefix(url, "https://") {
|
||||
v.mediaURLs = append(v.mediaURLs, url)
|
||||
media = append(media, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(tags) > 0 {
|
||||
createNote["tags"] = tags
|
||||
}
|
||||
if len(mentions) > 0 {
|
||||
createNote["mentions"] = mentions
|
||||
}
|
||||
if len(media) > 0 {
|
||||
createNote["media"] = media
|
||||
}
|
||||
|
||||
return createNote
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue