621 lines
16 KiB
Go
621 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"owl-blogs/app/repository"
|
|
"owl-blogs/config"
|
|
"owl-blogs/domain/model"
|
|
entrytypes "owl-blogs/entry_types"
|
|
"owl-blogs/interactions"
|
|
"owl-blogs/render"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
vocab "github.com/go-ap/activitypub"
|
|
"github.com/go-ap/jsonld"
|
|
"github.com/go-fed/httpsig"
|
|
)
|
|
|
|
type ActivityPubConfig struct {
|
|
PreferredUsername string
|
|
PublicKeyPem string
|
|
PrivateKeyPem string
|
|
}
|
|
|
|
// Form implements app.AppConfig.
|
|
func (cfg *ActivityPubConfig) Form(binSvc model.BinaryStorageInterface) string {
|
|
f, _ := render.RenderTemplateToString("forms/ActivityPubConfig", cfg)
|
|
return f
|
|
}
|
|
|
|
// ParseFormData implements app.AppConfig.
|
|
func (cfg *ActivityPubConfig) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
|
|
cfg.PreferredUsername = data.FormValue("PreferredUsername")
|
|
cfg.PublicKeyPem = data.FormValue("PublicKeyPem")
|
|
cfg.PrivateKeyPem = data.FormValue("PrivateKeyPem")
|
|
return nil
|
|
}
|
|
|
|
func (cfg *ActivityPubConfig) PrivateKey() *rsa.PrivateKey {
|
|
block, _ := pem.Decode([]byte(cfg.PrivateKeyPem))
|
|
privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
slog.Error("error x509.ParsePKCS1PrivateKey", "err", err)
|
|
}
|
|
return privKey
|
|
}
|
|
|
|
type ActivityPubService struct {
|
|
followersRepo repository.FollowerRepository
|
|
configRepo repository.ConfigRepository
|
|
interactionRepository repository.InteractionRepository
|
|
entryService *EntryService
|
|
siteConfigServcie *SiteConfigService
|
|
binService *BinaryService
|
|
}
|
|
|
|
func NewActivityPubService(
|
|
followersRepo repository.FollowerRepository,
|
|
configRepo repository.ConfigRepository,
|
|
interactionRepository repository.InteractionRepository,
|
|
entryService *EntryService,
|
|
siteConfigServcie *SiteConfigService,
|
|
binService *BinaryService,
|
|
bus *EventBus,
|
|
) *ActivityPubService {
|
|
service := &ActivityPubService{
|
|
followersRepo: followersRepo,
|
|
configRepo: configRepo,
|
|
interactionRepository: interactionRepository,
|
|
entryService: entryService,
|
|
binService: binService,
|
|
siteConfigServcie: siteConfigServcie,
|
|
}
|
|
|
|
bus.Subscribe(service)
|
|
|
|
return service
|
|
}
|
|
|
|
func (svc *ActivityPubService) defaultConfig() ActivityPubConfig {
|
|
privKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
pubKey := privKey.Public().(*rsa.PublicKey)
|
|
|
|
pubKeyPem := pem.EncodeToMemory(
|
|
&pem.Block{
|
|
Type: "RSA PUBLIC KEY",
|
|
Bytes: x509.MarshalPKCS1PublicKey(pubKey),
|
|
},
|
|
)
|
|
|
|
privKeyPrm := pem.EncodeToMemory(
|
|
&pem.Block{
|
|
Type: "RSA PRIVATE KEY",
|
|
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
|
},
|
|
)
|
|
|
|
return ActivityPubConfig{
|
|
PreferredUsername: "blog",
|
|
PublicKeyPem: string(pubKeyPem),
|
|
PrivateKeyPem: string(privKeyPrm),
|
|
}
|
|
}
|
|
|
|
func (svc *ActivityPubService) GetApConfig() (ActivityPubConfig, error) {
|
|
apConfig := ActivityPubConfig{}
|
|
err := svc.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
|
|
if err != nil {
|
|
println("ERROR IN ACTIVITY PUB CONFIG")
|
|
return ActivityPubConfig{}, err
|
|
}
|
|
if reflect.ValueOf(apConfig).IsZero() {
|
|
cfg := svc.defaultConfig()
|
|
svc.configRepo.Update(config.ACT_PUB_CONF_NAME, cfg)
|
|
return cfg, nil
|
|
}
|
|
return apConfig, nil
|
|
}
|
|
|
|
func (svc *ActivityPubService) ActorUrl() string {
|
|
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
return cfg.FullUrl
|
|
}
|
|
func (svc *ActivityPubService) MainKeyUri() string {
|
|
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
return cfg.FullUrl + "#main-key"
|
|
}
|
|
func (svc *ActivityPubService) InboxUrl() string {
|
|
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
return cfg.FullUrl + "/activitypub/inbox"
|
|
}
|
|
func (svc *ActivityPubService) OutboxUrl() string {
|
|
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
return cfg.FullUrl + "/activitypub/outbox"
|
|
}
|
|
func (svc *ActivityPubService) FollowersUrl() string {
|
|
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
return cfg.FullUrl + "/activitypub/followers"
|
|
}
|
|
func (svc *ActivityPubService) AcccepId() string {
|
|
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
return cfg.FullUrl + "#accept-" + strconv.FormatInt(time.Now().UnixNano(), 16)
|
|
}
|
|
|
|
func (svc *ActivityPubService) HashtagId(hashtag string) string {
|
|
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
return cfg.FullUrl + "/tags/" + strings.ReplaceAll(hashtag, "#", "")
|
|
}
|
|
|
|
func (svc *ActivityPubService) ActorName() string {
|
|
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
return cfg.Title
|
|
}
|
|
|
|
func (svc *ActivityPubService) ActorIcon() vocab.Image {
|
|
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
u := cfg.AvatarUrl
|
|
pUrl, _ := url.Parse(u)
|
|
parts := strings.Split(pUrl.Path, ".")
|
|
fullUrl, _ := url.JoinPath(cfg.FullUrl, u)
|
|
return vocab.Image{
|
|
Type: vocab.ImageType,
|
|
MediaType: vocab.MimeType("image/" + parts[len(parts)-1]),
|
|
URL: vocab.IRI(fullUrl),
|
|
}
|
|
}
|
|
|
|
func (svc *ActivityPubService) ActorSummary() string {
|
|
cfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
return cfg.SubTitle
|
|
}
|
|
|
|
func (s *ActivityPubService) AddFollower(follower string) error {
|
|
return s.followersRepo.Add(follower)
|
|
}
|
|
|
|
func (s *ActivityPubService) RemoveFollower(follower string) error {
|
|
return s.followersRepo.Remove(follower)
|
|
}
|
|
|
|
func (s *ActivityPubService) AllFollowers() ([]string, error) {
|
|
return s.followersRepo.All()
|
|
}
|
|
|
|
func (s *ActivityPubService) sign(privateKey *rsa.PrivateKey, pubKeyId string, body []byte, r *http.Request) error {
|
|
prefs := []httpsig.Algorithm{httpsig.RSA_SHA256}
|
|
digestAlgorithm := httpsig.DigestSha256
|
|
// The "Date" and "Digest" headers must already be set on r, as well as r.URL.
|
|
headersToSign := []string{httpsig.RequestTarget, "host", "date"}
|
|
if body != nil {
|
|
headersToSign = append(headersToSign, "digest")
|
|
}
|
|
signer, _, err := httpsig.NewSigner(prefs, digestAlgorithm, headersToSign, httpsig.Signature, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// To sign the digest, we need to give the signer a copy of the body...
|
|
// ...but it is optional, no digest will be signed if given "nil"
|
|
// If r were a http.ResponseWriter, call SignResponse instead.
|
|
err = signer.SignRequest(privateKey, pubKeyId, r, body)
|
|
|
|
slog.Info("Signed Request", "req", r.Header)
|
|
return err
|
|
}
|
|
|
|
func (s *ActivityPubService) GetActor(reqUrl string) (vocab.Actor, error) {
|
|
|
|
siteConfig := model.SiteConfig{}
|
|
apConfig := ActivityPubConfig{}
|
|
s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
|
|
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
|
|
|
|
c := http.Client{}
|
|
|
|
parsedUrl, err := url.Parse(reqUrl)
|
|
if err != nil {
|
|
slog.Error("parse error", "err", err)
|
|
return vocab.Actor{}, err
|
|
}
|
|
|
|
req, _ := http.NewRequest("GET", reqUrl, nil)
|
|
req.Header.Set("Accept", "application/ld+json")
|
|
req.Header.Set("Date", time.Now().Format(http.TimeFormat))
|
|
req.Header.Set("Host", parsedUrl.Host)
|
|
|
|
err = s.sign(apConfig.PrivateKey(), s.MainKeyUri(), nil, req)
|
|
if err != nil {
|
|
slog.Error("Signing error", "err", err)
|
|
return vocab.Actor{}, err
|
|
}
|
|
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
slog.Error("failed to retrieve sender actor", "err", err, "url", reqUrl)
|
|
return vocab.Actor{}, err
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return vocab.Actor{}, err
|
|
}
|
|
|
|
item, err := vocab.UnmarshalJSON(data)
|
|
if err != nil {
|
|
return vocab.Actor{}, err
|
|
}
|
|
|
|
var actor vocab.Actor
|
|
|
|
err = vocab.OnActor(item, func(o *vocab.Actor) error {
|
|
actor = *o
|
|
return nil
|
|
})
|
|
|
|
return actor, err
|
|
}
|
|
|
|
func (s *ActivityPubService) VerifySignature(r *http.Request, sender string) error {
|
|
siteConfig := model.SiteConfig{}
|
|
apConfig := ActivityPubConfig{}
|
|
s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
|
|
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
|
|
|
|
slog.Info("verifying for", "sender", sender, "retriever", s.ActorUrl())
|
|
|
|
actor, err := s.GetActor(sender)
|
|
// actor does not have a pub key -> don't verify
|
|
if actor.PublicKey.PublicKeyPem == "" {
|
|
return nil
|
|
}
|
|
|
|
if err != nil {
|
|
slog.Error("unable to retrieve actor for sig verification", "sender", sender)
|
|
return err
|
|
}
|
|
block, _ := pem.Decode([]byte(actor.PublicKey.PublicKeyPem))
|
|
pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
|
|
if err != nil {
|
|
slog.Error("unable to decode pub key pem", "pubKeyPem", actor.PublicKey.PublicKeyPem)
|
|
return err
|
|
}
|
|
slog.Info("retrieved pub key of sender", "actor", actor, "pubKey", pubKey)
|
|
|
|
verifier, err := httpsig.NewVerifier(r)
|
|
if err != nil {
|
|
slog.Error("invalid signature", "err", err)
|
|
return err
|
|
}
|
|
return verifier.Verify(pubKey, httpsig.RSA_SHA256)
|
|
}
|
|
|
|
func (s *ActivityPubService) Accept(act *vocab.Activity) error {
|
|
actor, err := s.GetActor(act.Actor.GetID().String())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
accept := vocab.AcceptNew(vocab.IRI(s.AcccepId()), act)
|
|
data, err := jsonld.WithContext(
|
|
jsonld.IRI(vocab.ActivityBaseURI),
|
|
).Marshal(accept)
|
|
|
|
if err != nil {
|
|
slog.Error("marshalling error", "err", err)
|
|
return err
|
|
}
|
|
|
|
return s.sendObject(actor, data)
|
|
}
|
|
|
|
func (s *ActivityPubService) AddLike(sender string, liked string, likeId string) error {
|
|
entry, err := s.entryService.FindByUrl(liked)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
actor, err := s.GetActor(sender)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var like *interactions.Like
|
|
interaction, err := s.interactionRepository.FindById(likeId)
|
|
if err != nil {
|
|
interaction = &interactions.Like{}
|
|
}
|
|
like, ok := interaction.(*interactions.Like)
|
|
if !ok {
|
|
return errors.New("existing interaction with same id is not a like")
|
|
}
|
|
existing := like.ID() != ""
|
|
|
|
likeMeta := interactions.LikeMetaData{
|
|
SenderUrl: sender,
|
|
SenderName: actor.Name.String(),
|
|
}
|
|
like.SetID(likeId)
|
|
like.SetMetaData(&likeMeta)
|
|
like.SetEntryID(entry.ID())
|
|
like.SetCreatedAt(time.Now())
|
|
if !existing {
|
|
return s.interactionRepository.Create(like)
|
|
} else {
|
|
return s.interactionRepository.Update(like)
|
|
}
|
|
}
|
|
|
|
func (s *ActivityPubService) RemoveLike(id string) error {
|
|
interaction, err := s.interactionRepository.FindById(id)
|
|
if err != nil {
|
|
interaction = &interactions.Like{}
|
|
}
|
|
return s.interactionRepository.Delete(interaction)
|
|
}
|
|
|
|
func (s *ActivityPubService) AddRepost(sender string, reposted string, respostId string) error {
|
|
entry, err := s.entryService.FindByUrl(reposted)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
actor, err := s.GetActor(sender)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var repost *interactions.Repost
|
|
interaction, err := s.interactionRepository.FindById(respostId)
|
|
if err != nil {
|
|
interaction = &interactions.Repost{}
|
|
}
|
|
repost, ok := interaction.(*interactions.Repost)
|
|
if !ok {
|
|
return errors.New("existing interaction with same id is not a like")
|
|
}
|
|
existing := repost.ID() != ""
|
|
|
|
repostMeta := interactions.RepostMetaData{
|
|
SenderUrl: sender,
|
|
SenderName: actor.Name.String(),
|
|
}
|
|
repost.SetID(respostId)
|
|
repost.SetMetaData(&repostMeta)
|
|
repost.SetEntryID(entry.ID())
|
|
repost.SetCreatedAt(time.Now())
|
|
if !existing {
|
|
return s.interactionRepository.Create(repost)
|
|
} else {
|
|
return s.interactionRepository.Update(repost)
|
|
}
|
|
}
|
|
|
|
func (s *ActivityPubService) RemoveRepost(id string) error {
|
|
interaction, err := s.interactionRepository.FindById(id)
|
|
if err != nil {
|
|
interaction = &interactions.Repost{}
|
|
}
|
|
return s.interactionRepository.Delete(interaction)
|
|
}
|
|
|
|
func (s *ActivityPubService) sendObject(to vocab.Actor, data []byte) error {
|
|
siteConfig := model.SiteConfig{}
|
|
apConfig := ActivityPubConfig{}
|
|
s.configRepo.Get(config.ACT_PUB_CONF_NAME, &apConfig)
|
|
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
|
|
|
|
if to.Inbox == nil {
|
|
slog.Error("actor has no inbox", "actor", to)
|
|
return errors.New("actor has no inbox")
|
|
}
|
|
|
|
actorUrl, err := url.Parse(to.Inbox.GetID().String())
|
|
if err != nil {
|
|
slog.Error("parse error", "err", err)
|
|
return err
|
|
}
|
|
|
|
c := http.Client{}
|
|
req, _ := http.NewRequest("POST", to.Inbox.GetID().String(), bytes.NewReader(data))
|
|
req.Header.Set("Accept", "application/ld+json")
|
|
req.Header.Set("Date", time.Now().Format(http.TimeFormat))
|
|
req.Header.Set("Host", actorUrl.Host)
|
|
err = s.sign(apConfig.PrivateKey(), s.MainKeyUri(), data, req)
|
|
if err != nil {
|
|
slog.Error("Signing error", "err", err)
|
|
return err
|
|
}
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
slog.Error("Sending error", "url", req.URL, "err", err)
|
|
return err
|
|
}
|
|
slog.Info("Request", "host", resp.Request.Header)
|
|
|
|
if resp.StatusCode > 299 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
slog.Error("Error sending Note", "method", resp.Request.Method, "url", resp.Request.URL, "status", resp.Status, "body", string(body))
|
|
return err
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
slog.Info("Sent Body", "body", string(data))
|
|
slog.Info("Retrieved", "status", resp.Status, "body", string(body))
|
|
return nil
|
|
}
|
|
|
|
/*
|
|
* Notifiers
|
|
*/
|
|
|
|
func (svc *ActivityPubService) NotifyEntryCreated(entry model.Entry) {
|
|
slog.Info("Processing Entry Create for ActivityPub")
|
|
followers, err := svc.AllFollowers()
|
|
if err != nil {
|
|
slog.Error("Cannot retrieve followers")
|
|
}
|
|
|
|
object, err := svc.entryToObject(entry)
|
|
if err != nil {
|
|
slog.Error("Cannot convert object", "err", err)
|
|
}
|
|
|
|
create := vocab.CreateNew(object.ID, object)
|
|
create.Actor = object.AttributedTo
|
|
create.To = object.To
|
|
create.Published = object.Published
|
|
data, err := jsonld.WithContext(
|
|
jsonld.IRI(vocab.ActivityBaseURI),
|
|
jsonld.Context{
|
|
jsonld.ContextElement{
|
|
Term: "toot",
|
|
IRI: jsonld.IRI("http://joinmastodon.org/ns#"),
|
|
},
|
|
},
|
|
).Marshal(create)
|
|
if err != nil {
|
|
slog.Error("marshalling error", "err", err)
|
|
}
|
|
|
|
for _, follower := range followers {
|
|
actor, err := svc.GetActor(follower)
|
|
if err != nil {
|
|
slog.Error("Unable to retrieve follower actor", "err", err)
|
|
}
|
|
svc.sendObject(actor, data)
|
|
}
|
|
}
|
|
|
|
func (svc *ActivityPubService) NotifyEntryUpdated(entry model.Entry) {
|
|
|
|
}
|
|
|
|
func (svc *ActivityPubService) NotifyEntryDeleted(entry model.Entry) {
|
|
obj, err := svc.entryToObject(entry)
|
|
if err != nil {
|
|
slog.Error("error converting to object", "err", err)
|
|
return
|
|
}
|
|
|
|
followers, err := svc.AllFollowers()
|
|
if err != nil {
|
|
slog.Error("Cannot retrieve followers")
|
|
}
|
|
|
|
delete := vocab.DeleteNew(obj.ID, obj)
|
|
delete.Actor = obj.AttributedTo
|
|
delete.To = obj.To
|
|
delete.Published = time.Now()
|
|
data, err := jsonld.WithContext(
|
|
jsonld.IRI(vocab.ActivityBaseURI),
|
|
).Marshal(delete)
|
|
if err != nil {
|
|
slog.Error("marshalling error", "err", err)
|
|
}
|
|
|
|
for _, follower := range followers {
|
|
actor, err := svc.GetActor(follower)
|
|
if err != nil {
|
|
slog.Error("Unable to retrieve follower actor", "err", err)
|
|
}
|
|
svc.sendObject(actor, data)
|
|
}
|
|
|
|
}
|
|
|
|
func (svc *ActivityPubService) entryToObject(entry model.Entry) (vocab.Object, error) {
|
|
// limit to notes for now
|
|
|
|
if noteEntry, ok := entry.(*entrytypes.Note); ok {
|
|
return svc.noteToObject(noteEntry), nil
|
|
}
|
|
if imageEntry, ok := entry.(*entrytypes.Image); ok {
|
|
return svc.imageToObject(imageEntry), nil
|
|
}
|
|
slog.Warn("entry type not yet supported for activity pub")
|
|
return vocab.Object{}, errors.New("entry type not supported")
|
|
}
|
|
|
|
func (svc *ActivityPubService) noteToObject(noteEntry *entrytypes.Note) vocab.Object {
|
|
|
|
siteCfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
content := noteEntry.Content()
|
|
r := regexp.MustCompile("#[a-z0-9_]+")
|
|
matches := r.FindAllString(string(content), -1)
|
|
tags := vocab.ItemCollection{}
|
|
for _, hashtag := range matches {
|
|
tags.Append(vocab.Object{
|
|
ID: vocab.ID(svc.HashtagId(hashtag)),
|
|
Name: vocab.NaturalLanguageValues{{Value: vocab.Content(hashtag)}},
|
|
})
|
|
}
|
|
|
|
note := vocab.Note{
|
|
ID: vocab.ID(noteEntry.FullUrl(siteCfg)),
|
|
Type: "Note",
|
|
To: vocab.ItemCollection{
|
|
vocab.PublicNS,
|
|
vocab.IRI(svc.FollowersUrl()),
|
|
},
|
|
Published: *noteEntry.PublishedAt(),
|
|
AttributedTo: vocab.ID(svc.ActorUrl()),
|
|
Content: vocab.NaturalLanguageValues{
|
|
{Value: vocab.Content(content)},
|
|
},
|
|
Tag: tags,
|
|
}
|
|
return note
|
|
|
|
}
|
|
|
|
func (svc *ActivityPubService) imageToObject(imageEntry *entrytypes.Image) vocab.Object {
|
|
siteCfg, _ := svc.siteConfigServcie.GetSiteConfig()
|
|
content := imageEntry.Content()
|
|
|
|
imgPath := imageEntry.ImageUrl()
|
|
fullImageUrl, _ := url.JoinPath(siteCfg.FullUrl, imgPath)
|
|
binaryFile, err := svc.binService.FindById(imageEntry.MetaData().(*entrytypes.ImageMetaData).ImageId)
|
|
if err != nil {
|
|
slog.Error("cannot get image file")
|
|
}
|
|
|
|
attachments := vocab.ItemCollection{}
|
|
attachments = append(attachments, vocab.Document{
|
|
Type: vocab.DocumentType,
|
|
MediaType: vocab.MimeType(binaryFile.Mime()),
|
|
URL: vocab.ID(fullImageUrl),
|
|
})
|
|
|
|
image := vocab.Note{
|
|
ID: vocab.ID(imageEntry.FullUrl(siteCfg)),
|
|
Type: "Note",
|
|
To: vocab.ItemCollection{
|
|
vocab.PublicNS,
|
|
vocab.IRI(svc.FollowersUrl()),
|
|
},
|
|
Published: *imageEntry.PublishedAt(),
|
|
AttributedTo: vocab.ID(svc.ActorUrl()),
|
|
Name: vocab.NaturalLanguageValues{
|
|
{Value: vocab.Content(imageEntry.Title())},
|
|
},
|
|
Content: vocab.NaturalLanguageValues{
|
|
{Value: vocab.Content(content)},
|
|
},
|
|
Attachment: attachments,
|
|
// Tag: tags,
|
|
}
|
|
return image
|
|
|
|
}
|