Implemented asset storage and display. Closes #17 and #19.

This commit is contained in:
Nick Gerakines 2020-03-05 12:09:14 -05:00
parent e4eaaa6dfd
commit a25709646f
25 changed files with 1014 additions and 36 deletions

128
asset/agent.go Normal file
View File

@ -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
}

92
asset/command.go Normal file
View File

@ -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
}

15
asset/storage.go Normal file
View File

@ -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)
}

94
asset/storage_file.go Normal file
View File

@ -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)
}

43
config/assets.go Normal file
View 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"),
}
}

View File

@ -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
View File

@ -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
View File

@ -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=

81
job/asset.go Normal file
View File

@ -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
}

View File

@ -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]))

View File

@ -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))

7
public/clipboard.min.js vendored Normal file

File diff suppressed because one or more lines are too long

156
storage/asset.go Normal file
View File

@ -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
}

View File

@ -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,

View File

@ -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 }}

View File

@ -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>

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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>

View File

@ -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 {

View File

@ -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) {

View File

@ -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)
}

View File

@ -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
}

140
web/handler_image.go Normal file
View File

@ -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)
}

View File

@ -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
}