Compare commits

...

54 Commits

Author SHA1 Message Date
Niko Abeler da06541e11 spacing var 2024-05-18 22:05:44 +02:00
Niko Abeler 7b047a609f powered by 2024-05-18 22:04:23 +02:00
Niko Abeler a5f24427a1 cleanup + primary color usage 2024-05-18 21:59:32 +02:00
Niko Abeler ec13fffbe9 release flow 3 2024-05-18 21:11:03 +02:00
Niko Abeler 686cd72ec2 release flow 2 2024-05-18 21:06:42 +02:00
Niko Abeler 29e875d2e5 release flow 2024-05-18 20:49:07 +02:00
Niko Abeler f958fa36fd fix test 2024-05-18 20:31:50 +02:00
Niko Abeler 1b347bcdac CI setup 4 2024-05-18 20:20:00 +02:00
Niko Abeler c8b759a834 CI setup 3 2024-05-18 20:16:47 +02:00
Niko Abeler 943bc10eaf CI setup 2 2024-05-18 20:00:19 +02:00
Niko Abeler 4543e448ed update requirements 2024-05-18 19:57:15 +02:00
Niko Abeler e0d6f4f223 CI setup 1 2024-05-18 19:54:58 +02:00
Niko Abeler 49a602f68b
Create test.yml 2024-05-18 19:53:06 +02:00
Niko Abeler 596ab0047e CI prep + README 2024-05-18 19:49:55 +02:00
Niko Abeler 5c05f48be3 prevent publishing of drafts + support for recipes 2024-05-18 19:34:44 +02:00
Niko Abeler 3c924ac8a4 support for images 2024-05-18 16:12:35 +02:00
Niko Abeler 26737ea21d remove files 2024-05-18 15:21:12 +02:00
Niko Abeler 29ae7e717f actor details 2024-05-18 13:10:14 +02:00
Niko Abeler 741ccfac73 support announce, publish deletions, add 'hashtags' to notes 2024-05-18 12:42:49 +02:00
Niko Abeler 9cfbf0b9b7 process likes from ActivityPub 2024-05-17 22:37:18 +02:00
Niko Abeler cba57ba708 first working toot 2024-05-17 21:05:13 +02:00
Niko Abeler ced3907880 move actor to main url 2024-05-17 20:23:38 +02:00
Niko Abeler 3cbf952ae6 sending accepts 2024-05-16 21:11:02 +02:00
Niko Abeler 0c8779def7 test follow/unfollow 2024-05-16 20:27:08 +02:00
Niko Abeler 652f81805d WIP mock amsto + tests 2024-05-14 21:23:37 +02:00
Niko Abeler 624f19a1d9 WIP copying code from fedi-games 2024-05-13 19:03:24 +02:00
Niko Abeler 10ca2bdcd9 add follower repository 2024-05-12 21:45:04 +02:00
Niko Abeler 633c0991e9 add me links again 2024-05-11 21:21:39 +02:00
Niko Abeler 2bf2e409b6 fixes for small screens 2024-05-11 19:46:47 +02:00
Niko Abeler 390a58b404 using owl styling 2024-05-11 16:33:29 +02:00
Niko Abeler d7e5df2a95 tables 2024-05-09 16:52:13 +02:00
Niko Abeler 3a3655b587 remove picocss files 2024-05-09 16:11:35 +02:00
Niko Abeler cba07e961d actions and forms 2024-05-09 16:11:13 +02:00
Niko Abeler 1524820d5e extending own stylesheet to remove pico 2024-05-09 14:15:19 +02:00
Niko Abeler 91b82f0e57 package updates 2024-05-09 11:13:42 +02:00
Niko Abeler 2a4b76ee03 add indication of upload error 2024-05-09 11:02:41 +02:00
Niko Abeler 4bdb920c71 add animation while uploading a dropped file 2024-05-09 10:58:32 +02:00
Niko Abeler ff10f6c5eb raise upload limit 2024-05-09 10:47:10 +02:00
Niko Abeler bc7b146e91 add @context to actor 2024-02-26 20:17:53 +01:00
Niko Abeler 7eb3bf0b44 update README with build and run 2024-02-25 20:29:41 +01:00
Niko Abeler 5cc55a79ff refactoring to have a dedicated site config service. Default config for new setups 2024-02-25 14:01:55 +01:00
Niko Abeler be62bcd627 use static favicon 2024-02-24 21:53:39 +01:00
Niko Abeler d794ad0865 remove empty tag 2024-02-24 21:22:23 +01:00
Niko Abeler f5946ea823 Open Graph Meta data 2024-02-24 21:21:48 +01:00
Niko Abeler 8200e3384c fix for interactions 2024-02-24 20:11:09 +01:00
Niko Abeler 0bf7c492c9 WIP interaction list 2024-02-24 19:53:49 +01:00
Niko Abeler 765698b1a6 compact file site in admin 2024-02-23 13:02:13 +01:00
Niko Abeler 1485a7efbe update to pico 2 2024-02-23 12:57:43 +01:00
Niko Abeler bc50388f58 filter for binary files 2024-02-23 12:34:05 +01:00
Niko Abeler 7ceb00799a add drop file to more forms 2024-02-23 10:15:06 +01:00
Niko Abeler 6d115dd74e finish drop file and moved into function for reusablility 2024-02-23 10:10:07 +01:00
Niko Abeler 4941b5d027 drag files into article 2024-02-21 21:21:58 +01:00
Niko Abeler 2f81bf8678 unified interface Formable 2024-02-21 20:24:18 +01:00
h4kor c36b9abbcf Merge pull request 'Better Editor Forms' (#56) from better_forms into main
Reviewed-on: #56
2024-02-21 19:05:07 +00:00
100 changed files with 3293 additions and 652 deletions

View File

@ -1,11 +1,11 @@
root = "." root = "."
testdata_dir = "testdata" testdata_dir = "testdata"
tmp_dir = "tmp" tmp_dir = "/tmp"
[build] [build]
args_bin = ["web"] args_bin = ["web"]
bin = "./tmp/main" bin = "/tmp/main"
cmd = "go build -o ./tmp/main owl-blogs/cmd/owl" cmd = "go build -buildvcs=false -o /tmp/main owl-blogs/cmd/owl"
delay = 1000 delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = [] exclude_file = []

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
e2e_tests/
tmp/
*.db

45
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,45 @@
on:
release:
types: [created]
permissions:
contents: write
packages: write
jobs:
release-linux-amd64:
name: release linux/amd64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
- name: E2E Test
run: |
cd e2e_tests
docker compose -f docker-compose.ci.yml up -d
pip install -r requirements.txt
pytest
- name: Build Release
env:
CGO_ENABLED: 1
GOOS: linux
GOARCH: amd64
GH_TOKEN: ${{ github.token }}
run: |
go build -o owl-linux-amd64 ./cmd/owl
gh release upload ${{github.event.release.tag_name}} owl-linux-amd64

38
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,38 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
name: Go
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
- name: E2E Test
run: |
cd e2e_tests
docker compose -f docker-compose.ci.yml up -d
pip install -r requirements.txt
pytest

3
.gitignore vendored
View File

@ -28,3 +28,6 @@ users/
*.db *.db
tmp/ tmp/
venv/
*.pyc

View File

@ -1,7 +1,7 @@
## ##
## Build Container ## Build Container
## ##
FROM golang:1.21-alpine as build FROM golang:1.22-alpine as build
RUN apk add --no-cache --update git gcc g++ RUN apk add --no-cache --update git gcc g++

View File

@ -2,7 +2,60 @@
# Owl Blogs # Owl Blogs
A simple web server for blogs generated from Markdown files. Owl-blogs is a blogging software focused on simplicity with IndieWeb and Fediverse support.
**_This project is not yet stable. Expect frequent breaking changes! Only use this if you are willing to regularly adjust your project accordingly._** # Usage
## Run
To run the web server use the command:
```
owl web
```
The blog will run on port 3000 (http://localhost:3000)
To create a new account:
```
owl new-author -u <name> -p <password>
```
To retrieve a list of all commands run:
```
owl -h
```
# Development
## Build
```
CGO_ENABLED=1 go build -o owl ./cmd/owl
```
For development with live reload use `air` ([has to be install first](https://github.com/cosmtrek/air))
## Tests
The project has two test suites; "unit tests" written in go and "end-to-end tests" written in python.
### Unit Tests
```
go test ./...
```
### End-to-End tests
- Start the docker compose setup in the `e2e_tests` directory.
- Install the python dependencies into a virtualenv
```
cd e2e_tests
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
```
- Run the e2e_tests with `pytest`

712
app/activity_pub_service.go Normal file
View File

@ -0,0 +1,712 @@
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) {
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)
}
update := vocab.UpdateNew(object.ID, object)
update.Actor = object.AttributedTo
update.To = object.To
update.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(update)
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) 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
}
if articleEntry, ok := entry.(*entrytypes.Article); ok {
return svc.articleToObject(articleEntry), nil
}
if recipeEntry, ok := entry.(*entrytypes.Recipe); ok {
return svc.recipeToObject(recipeEntry), 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),
Name: vocab.NaturalLanguageValues{
{Value: vocab.Content(content)},
},
})
image := vocab.Image{
ID: vocab.ID(imageEntry.FullUrl(siteCfg)),
Type: "Image",
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(imageEntry.Title() + "<br><br>" + string(content))},
},
Attachment: attachments,
// Tag: tags,
}
return image
}
func (svc *ActivityPubService) articleToObject(articleEntry *entrytypes.Article) vocab.Object {
siteCfg, _ := svc.siteConfigServcie.GetSiteConfig()
content := articleEntry.Content()
image := vocab.Article{
ID: vocab.ID(articleEntry.FullUrl(siteCfg)),
Type: "Article",
To: vocab.ItemCollection{
vocab.PublicNS,
vocab.IRI(svc.FollowersUrl()),
},
Published: *articleEntry.PublishedAt(),
AttributedTo: vocab.ID(svc.ActorUrl()),
Name: vocab.NaturalLanguageValues{
{Value: vocab.Content(articleEntry.Title())},
},
Content: vocab.NaturalLanguageValues{
{Value: vocab.Content(string(content))},
},
}
return image
}
func (svc *ActivityPubService) recipeToObject(recipeEntry *entrytypes.Recipe) vocab.Object {
siteCfg, _ := svc.siteConfigServcie.GetSiteConfig()
content := recipeEntry.Content()
image := vocab.Article{
ID: vocab.ID(recipeEntry.FullUrl(siteCfg)),
Type: "Article",
To: vocab.ItemCollection{
vocab.PublicNS,
vocab.IRI(svc.FollowersUrl()),
},
Published: *recipeEntry.PublishedAt(),
AttributedTo: vocab.ID(svc.ActorUrl()),
Name: vocab.NaturalLanguageValues{
{Value: vocab.Content(recipeEntry.Title())},
},
Content: vocab.NaturalLanguageValues{
{Value: vocab.Content(string(content))},
},
}
return image
}

View File

@ -4,7 +4,6 @@ import (
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"owl-blogs/app/repository" "owl-blogs/app/repository"
"owl-blogs/config"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"strings" "strings"
@ -13,11 +12,11 @@ import (
type AuthorService struct { type AuthorService struct {
repo repository.AuthorRepository repo repository.AuthorRepository
siteConfigRepo repository.ConfigRepository siteConfigService *SiteConfigService
} }
func NewAuthorService(repo repository.AuthorRepository, siteConfigRepo repository.ConfigRepository) *AuthorService { func NewAuthorService(repo repository.AuthorRepository, siteConfigService *SiteConfigService) *AuthorService {
return &AuthorService{repo: repo, siteConfigRepo: siteConfigRepo} return &AuthorService{repo: repo, siteConfigService: siteConfigService}
} }
func hashPassword(password string) (string, error) { func hashPassword(password string) (string, error) {
@ -65,14 +64,13 @@ func (s *AuthorService) Authenticate(name string, password string) bool {
} }
func (s *AuthorService) getSecretKey() string { func (s *AuthorService) getSecretKey() string {
siteConfig := model.SiteConfig{} siteConfig, err := s.siteConfigService.GetSiteConfig()
err := s.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if siteConfig.Secret == "" { if siteConfig.Secret == "" {
siteConfig.Secret = RandStringRunes(64) siteConfig.Secret = RandStringRunes(64)
err = s.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) err = s.siteConfigService.UpdateSiteConfig(siteConfig)
if err != nil { if err != nil {
panic(err) panic(err)
} }

View File

@ -30,7 +30,7 @@ func (c *testConfigRepo) Update(name string, result interface{}) error {
func getAutherService() *app.AuthorService { func getAutherService() *app.AuthorService {
db := test.NewMockDb() db := test.NewMockDb()
authorRepo := infra.NewDefaultAuthorRepo(db) authorRepo := infra.NewDefaultAuthorRepo(db)
authorService := app.NewAuthorService(authorRepo, &testConfigRepo{}) authorService := app.NewAuthorService(authorRepo, app.NewSiteConfigService(&testConfigRepo{}))
return authorService return authorService
} }

View File

@ -25,8 +25,11 @@ func (s *BinaryService) FindById(id string) (*model.BinaryFile, error) {
return s.repo.FindById(id) return s.repo.FindById(id)
} }
func (s *BinaryService) ListIds() ([]string, error) { // ListIds list all ids of binary files
return s.repo.ListIds() // if filter is not empty, the list will be filter to all ids which include the filter filter substring
// ids and filters are compared in lower case
func (s *BinaryService) ListIds(filter string) ([]string, error) {
return s.repo.ListIds(filter)
} }
func (s *BinaryService) Delete(binary *model.BinaryFile) error { func (s *BinaryService) Delete(binary *model.BinaryFile) error {

View File

@ -3,8 +3,7 @@ package app
import "owl-blogs/domain/model" import "owl-blogs/domain/model"
type AppConfig interface { type AppConfig interface {
Form(binSvc model.BinaryStorageInterface) string model.Formable
ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) (AppConfig, error)
} }
type ConfigRegister struct { type ConfigRegister struct {

View File

@ -1,6 +1,7 @@
package app package app
import ( import (
"errors"
"fmt" "fmt"
"owl-blogs/app/repository" "owl-blogs/app/repository"
"owl-blogs/domain/model" "owl-blogs/domain/model"
@ -10,15 +11,18 @@ import (
type EntryService struct { type EntryService struct {
EntryRepository repository.EntryRepository EntryRepository repository.EntryRepository
siteConfigServcie *SiteConfigService
Bus *EventBus Bus *EventBus
} }
func NewEntryService( func NewEntryService(
entryRepository repository.EntryRepository, entryRepository repository.EntryRepository,
siteConfigServcie *SiteConfigService,
bus *EventBus, bus *EventBus,
) *EntryService { ) *EntryService {
return &EntryService{ return &EntryService{
EntryRepository: entryRepository, EntryRepository: entryRepository,
siteConfigServcie: siteConfigServcie,
Bus: bus, Bus: bus,
} }
} }
@ -44,7 +48,13 @@ func (s *EntryService) Create(entry model.Entry) error {
if err != nil { if err != nil {
return err return err
} }
// only notify if the publishing date is set
// otherwise this is a draft.
// listeners might publish the entry to other services/platforms
// this should only happen for publshed content
if entry.PublishedAt() != nil && !entry.PublishedAt().IsZero() {
s.Bus.NotifyCreated(entry) s.Bus.NotifyCreated(entry)
}
return nil return nil
} }
@ -53,7 +63,13 @@ func (s *EntryService) Update(entry model.Entry) error {
if err != nil { if err != nil {
return err return err
} }
// only notify if the publishing date is set
// otherwise this is a draft.
// listeners might publish the entry to other services/platforms
// this should only happen for publshed content
if entry.PublishedAt() != nil && !entry.PublishedAt().IsZero() {
s.Bus.NotifyUpdated(entry) s.Bus.NotifyUpdated(entry)
}
return nil return nil
} }
@ -62,6 +78,9 @@ func (s *EntryService) Delete(entry model.Entry) error {
if err != nil { if err != nil {
return err return err
} }
// deletes should always be notfied
// a published entry might be converted to a draft before deletion
// omitting the deletion in this case would prevent deletion on other platforms
s.Bus.NotifyDeleted(entry) s.Bus.NotifyDeleted(entry)
return nil return nil
} }
@ -70,6 +89,19 @@ func (s *EntryService) FindById(id string) (model.Entry, error) {
return s.EntryRepository.FindById(id) return s.EntryRepository.FindById(id)
} }
func (s *EntryService) FindByUrl(url string) (model.Entry, error) {
cfg, _ := s.siteConfigServcie.GetSiteConfig()
if !strings.HasPrefix(url, cfg.FullUrl) {
return nil, errors.New("url does not belong to blog")
}
if strings.HasSuffix(url, "/") {
url = url[:len(url)-1]
}
parts := strings.Split(url, "/")
id := parts[len(parts)-1]
return s.FindById(id)
}
func (s *EntryService) filterEntries(entries []model.Entry, published bool, drafts bool) []model.Entry { func (s *EntryService) filterEntries(entries []model.Entry, published bool, drafts bool) []model.Entry {
filteredEntries := make([]model.Entry, 0) filteredEntries := make([]model.Entry, 0)
for _, entry := range entries { for _, entry := range entries {

View File

@ -14,7 +14,9 @@ func setupService() *app.EntryService {
register := app.NewEntryTypeRegistry() register := app.NewEntryTypeRegistry()
register.Register(&test.MockEntry{}) register.Register(&test.MockEntry{})
repo := infra.NewEntryRepository(db, register) repo := infra.NewEntryRepository(db, register)
service := app.NewEntryService(repo, app.NewEventBus()) cfgRepo := infra.NewConfigRepo(db)
cfgService := app.NewSiteConfigService(cfgRepo)
service := app.NewEntryService(repo, cfgService, app.NewEventBus())
return service return service
} }

View File

@ -0,0 +1,14 @@
package app
import (
"owl-blogs/app/repository"
"owl-blogs/domain/model"
)
type InteractionService struct {
repo repository.InteractionRepository
}
func (s *InteractionService) ListInteractions() ([]model.Interaction, error) {
return s.repo.ListAllInteractions()
}

View File

@ -19,7 +19,10 @@ type BinaryRepository interface {
Create(name string, data []byte, entry model.Entry) (*model.BinaryFile, error) Create(name string, data []byte, entry model.Entry) (*model.BinaryFile, error)
FindById(id string) (*model.BinaryFile, error) FindById(id string) (*model.BinaryFile, error)
FindByNameForEntry(name string, entry model.Entry) (*model.BinaryFile, error) FindByNameForEntry(name string, entry model.Entry) (*model.BinaryFile, error)
ListIds() ([]string, error) // ListIds list all ids of binary files
// if filter is not empty, the list will be filter to all ids which include the filter filter substring
// ids and filters are compared in lower case
ListIds(filter string) ([]string, error)
Delete(binary *model.BinaryFile) error Delete(binary *model.BinaryFile) error
} }
@ -45,4 +48,12 @@ type InteractionRepository interface {
Delete(interaction model.Interaction) error Delete(interaction model.Interaction) error
FindById(id string) (model.Interaction, error) FindById(id string) (model.Interaction, error)
FindAll(entryId string) ([]model.Interaction, error) FindAll(entryId string) ([]model.Interaction, error)
// ListAllInteractions lists all interactions, sorted by creation date (descending)
ListAllInteractions() ([]model.Interaction, error)
}
type FollowerRepository interface {
Add(follower string) error
Remove(follower string) error
All() ([]string, error)
} }

View File

@ -0,0 +1,58 @@
package app
import (
"owl-blogs/app/repository"
"owl-blogs/config"
"owl-blogs/domain/model"
"reflect"
)
// SiteConfigService is a service to retrieve and store the site config
// Even though the site config is a standard config, it is handle by an extra service
// as it is used in many places.
// The SiteConfig contains global settings require by multiple parts of the app
type SiteConfigService struct {
repo repository.ConfigRepository
}
func NewSiteConfigService(repo repository.ConfigRepository) *SiteConfigService {
return &SiteConfigService{
repo: repo,
}
}
func (svc *SiteConfigService) defaultConfig() model.SiteConfig {
return model.SiteConfig{
Title: "My Owl-Blog",
SubTitle: "A freshly created blog",
PrimaryColor: "#d37f12",
AuthorName: "",
Me: []model.MeLinks{},
Lists: []model.EntryList{},
PrimaryListInclude: []string{},
HeaderMenu: []model.MenuItem{},
FooterMenu: []model.MenuItem{},
Secret: "",
AvatarUrl: "",
FullUrl: "http://localhost:3000",
HtmlHeadExtra: "",
FooterExtra: "",
}
}
func (svc *SiteConfigService) GetSiteConfig() (model.SiteConfig, error) {
siteConfig := model.SiteConfig{}
err := svc.repo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil {
println("ERROR IN SITE CONFIG")
return model.SiteConfig{}, err
}
if reflect.ValueOf(siteConfig).IsZero() {
return svc.defaultConfig(), nil
}
return siteConfig, nil
}
func (svc *SiteConfigService) UpdateSiteConfig(cfg model.SiteConfig) error {
return svc.repo.Update(config.SITE_CONFIG, cfg)
}

View File

@ -5,28 +5,27 @@ import (
"net/url" "net/url"
"owl-blogs/app/owlhttp" "owl-blogs/app/owlhttp"
"owl-blogs/app/repository" "owl-blogs/app/repository"
"owl-blogs/config"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"owl-blogs/interactions" "owl-blogs/interactions"
"time" "time"
) )
type WebmentionService struct { type WebmentionService struct {
ConfigRepo repository.ConfigRepository siteConfigService *SiteConfigService
InteractionRepository repository.InteractionRepository InteractionRepository repository.InteractionRepository
EntryRepository repository.EntryRepository EntryRepository repository.EntryRepository
Http owlhttp.HttpClient Http owlhttp.HttpClient
} }
func NewWebmentionService( func NewWebmentionService(
configRepo repository.ConfigRepository, siteConfigService *SiteConfigService,
interactionRepository repository.InteractionRepository, interactionRepository repository.InteractionRepository,
entryRepository repository.EntryRepository, entryRepository repository.EntryRepository,
http owlhttp.HttpClient, http owlhttp.HttpClient,
bus *EventBus, bus *EventBus,
) *WebmentionService { ) *WebmentionService {
svc := &WebmentionService{ svc := &WebmentionService{
ConfigRepo: configRepo, siteConfigService: siteConfigService,
InteractionRepository: interactionRepository, InteractionRepository: interactionRepository,
EntryRepository: entryRepository, EntryRepository: entryRepository,
Http: http, Http: http,
@ -104,8 +103,7 @@ func (s *WebmentionService) ScanForLinks(entry model.Entry) ([]string, error) {
} }
func (s *WebmentionService) FullEntryUrl(entry model.Entry) string { func (s *WebmentionService) FullEntryUrl(entry model.Entry) string {
siteConfig := model.SiteConfig{} siteConfig, _ := s.siteConfigService.GetSiteConfig()
s.ConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
url, _ := url.JoinPath( url, _ := url.JoinPath(
siteConfig.FullUrl, siteConfig.FullUrl,

View File

@ -62,7 +62,7 @@ func getWebmentionService() *app.WebmentionService {
http := infra.OwlHttpClient{} http := infra.OwlHttpClient{}
return app.NewWebmentionService( return app.NewWebmentionService(
configRepo, interactionRepo, entryRepo, &http, bus, app.NewSiteConfigService(configRepo), interactionRepo, entryRepo, &http, bus,
) )
} }

View File

@ -92,7 +92,6 @@ var importCmd = &cobra.Command{
} }
v2Config.Title = v1Config.Title v2Config.Title = v1Config.Title
v2Config.SubTitle = v1Config.SubTitle v2Config.SubTitle = v1Config.SubTitle
v2Config.HeaderColor = v1Config.HeaderColor
v2Config.AuthorName = v1Config.AuthorName v2Config.AuthorName = v1Config.AuthorName
v2Config.Me = mes v2Config.Me = mes
v2Config.Lists = lists v2Config.Lists = lists

View File

@ -8,6 +8,7 @@ import (
"owl-blogs/infra" "owl-blogs/infra"
"owl-blogs/interactions" "owl-blogs/interactions"
"owl-blogs/plugings" "owl-blogs/plugings"
"owl-blogs/render"
"owl-blogs/web" "owl-blogs/web"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -40,6 +41,8 @@ func App(db infra.Database) *web.WebApp {
interactionRegister := app.NewInteractionTypeRegistry() interactionRegister := app.NewInteractionTypeRegistry()
interactionRegister.Register(&interactions.Webmention{}) interactionRegister.Register(&interactions.Webmention{})
interactionRegister.Register(&interactions.Like{})
interactionRegister.Register(&interactions.Repost{})
configRegister := app.NewConfigRegister() configRegister := app.NewConfigRegister()
@ -49,6 +52,7 @@ func App(db infra.Database) *web.WebApp {
authorRepo := infra.NewDefaultAuthorRepo(db) authorRepo := infra.NewDefaultAuthorRepo(db)
configRepo := infra.NewConfigRepo(db) configRepo := infra.NewConfigRepo(db)
interactionRepo := infra.NewInteractionRepo(db, interactionRegister) interactionRepo := infra.NewInteractionRepo(db, interactionRegister)
followersRepo := infra.NewFollowerRepository(db)
// Create External Services // Create External Services
httpClient := &infra.OwlHttpClient{} httpClient := &infra.OwlHttpClient{}
@ -57,12 +61,21 @@ func App(db infra.Database) *web.WebApp {
eventBus := app.NewEventBus() eventBus := app.NewEventBus()
// Create Services // Create Services
entryService := app.NewEntryService(entryRepo, eventBus) siteConfigService := app.NewSiteConfigService(configRepo)
entryService := app.NewEntryService(entryRepo, siteConfigService, eventBus)
binaryService := app.NewBinaryFileService(binRepo) binaryService := app.NewBinaryFileService(binRepo)
authorService := app.NewAuthorService(authorRepo, configRepo) authorService := app.NewAuthorService(authorRepo, siteConfigService)
webmentionService := app.NewWebmentionService( webmentionService := app.NewWebmentionService(
configRepo, interactionRepo, entryRepo, httpClient, eventBus, siteConfigService, interactionRepo, entryRepo, httpClient, eventBus,
) )
apService := app.NewActivityPubService(
followersRepo, configRepo, interactionRepo,
entryService, siteConfigService, binaryService,
eventBus,
)
// setup render functions
render.SiteConfigService = siteConfigService
// plugins // plugins
plugings.NewEcho(eventBus) plugings.NewEcho(eventBus)
@ -74,7 +87,8 @@ func App(db infra.Database) *web.WebApp {
return web.NewWebApp( return web.NewWebApp(
entryService, entryRegister, binaryService, entryService, entryRegister, binaryService,
authorService, configRepo, configRegister, authorService, configRepo, configRegister,
webmentionService, interactionRepo, siteConfigService, webmentionService, interactionRepo,
apService,
) )
} }

View File

@ -4,6 +4,7 @@ import "os"
const ( const (
SITE_CONFIG = "site_config" SITE_CONFIG = "site_config"
ACT_PUB_CONF_NAME = "activity_pub"
) )
type Config interface { type Config interface {

View File

@ -1,6 +1,7 @@
package model package model
import ( import (
"net/url"
"time" "time"
) )
@ -15,16 +16,18 @@ type Entry interface {
// Optional: can return empty string // Optional: can return empty string
Title() string Title() string
ImageUrl() string
SetID(id string) SetID(id string)
SetPublishedAt(publishedAt *time.Time) SetPublishedAt(publishedAt *time.Time)
SetMetaData(metaData EntryMetaData) SetMetaData(metaData EntryMetaData)
SetAuthorId(authorId string) SetAuthorId(authorId string)
FullUrl(cfg SiteConfig) string
} }
type EntryMetaData interface { type EntryMetaData interface {
Form(binSvc BinaryStorageInterface) string Formable
ParseFormData(data HttpFormData, binSvc BinaryStorageInterface) (EntryMetaData, error)
} }
type EntryBase struct { type EntryBase struct {
@ -41,6 +44,10 @@ func (e *EntryBase) PublishedAt() *time.Time {
return e.publishedAt return e.publishedAt
} }
func (e *EntryBase) ImageUrl() string {
return ""
}
func (e *EntryBase) SetID(id string) { func (e *EntryBase) SetID(id string) {
e.id = id e.id = id
} }
@ -56,3 +63,8 @@ func (e *EntryBase) AuthorId() string {
func (e *EntryBase) SetAuthorId(authorId string) { func (e *EntryBase) SetAuthorId(authorId string) {
e.authorId = authorId e.authorId = authorId
} }
func (e *EntryBase) FullUrl(cfg SiteConfig) string {
u, _ := url.JoinPath(cfg.FullUrl, "/posts/", e.ID(), "/")
return u
}

View File

@ -0,0 +1,34 @@
package model_test
import (
"owl-blogs/domain/model"
"testing"
"github.com/stretchr/testify/require"
)
func TestEntryFullUrl(t *testing.T) {
type testCase struct {
Id string
Url string
Want string
}
testCases := []testCase{
{Id: "foobar", Url: "https://example.com", Want: "https://example.com/posts/foobar/"},
{Id: "foobar", Url: "https://example.com/", Want: "https://example.com/posts/foobar/"},
{Id: "foobar", Url: "http://example.com", Want: "http://example.com/posts/foobar/"},
{Id: "foobar", Url: "http://example.com/", Want: "http://example.com/posts/foobar/"},
{Id: "bi-bar-buz", Url: "https://example.com", Want: "https://example.com/posts/bi-bar-buz/"},
{Id: "foobar", Url: "https://example.com/lol/", Want: "https://example.com/lol/posts/foobar/"},
}
for _, test := range testCases {
e := model.EntryBase{}
e.SetID(test.Id)
cfg := model.SiteConfig{FullUrl: test.Url}
require.Equal(t, e.FullUrl(cfg), test.Want)
}
}

View File

@ -2,6 +2,11 @@ package model
import "mime/multipart" import "mime/multipart"
type Formable interface {
Form(binSvc BinaryStorageInterface) string
ParseFormData(data HttpFormData, binSvc BinaryStorageInterface) error
}
type HttpFormData interface { type HttpFormData interface {
// FormFile returns the first file by key from a MultipartForm. // FormFile returns the first file by key from a MultipartForm.
FormFile(key string) (*multipart.FileHeader, error) FormFile(key string) (*multipart.FileHeader, error)

View File

@ -22,7 +22,6 @@ type MenuItem struct {
type SiteConfig struct { type SiteConfig struct {
Title string Title string
SubTitle string SubTitle string
HeaderColor string
PrimaryColor string PrimaryColor string
AuthorName string AuthorName string
Me []MeLinks Me []MeLinks

View File

@ -0,0 +1,6 @@
package model
type SiteConfigInterface interface {
GetSiteConfig() (SiteConfig, error)
UpdateSiteConfig(cfg SiteConfig) error
}

49
e2e_tests/conftest.py Normal file
View File

@ -0,0 +1,49 @@
import pytest
from requests import Session
from urllib.parse import urljoin
from tests.fixtures import ACCT_NAME
class LiveServerSession(Session):
def __init__(self, base_url=None):
super().__init__()
self.base_url = base_url
def request(self, method, url, *args, **kwargs):
joined_url = urljoin(self.base_url, url)
return super().request(method, joined_url, *args, **kwargs)
@pytest.fixture
def client():
return LiveServerSession("http://localhost:3000")
@pytest.fixture
def actor_url(client):
resp = client.get(f"/.well-known/webfinger?resource={ACCT_NAME}")
data = resp.json()
self_link = [x for x in data["links"] if x["rel"] == "self"][0]
return self_link["href"]
@pytest.fixture
def actor(client, actor_url):
resp = client.get(actor_url, headers={"Content-Type": "application/activity+json"})
assert resp.status_code == 200
return resp.json()
@pytest.fixture
def inbox_url(actor):
return actor["inbox"]
@pytest.fixture
def outbox_url(actor):
return actor["outbox"]
@pytest.fixture
def followers_url(actor):
return actor["followers"]

View File

@ -0,0 +1,12 @@
services:
web:
build:
context: ../
dockerfile: Dockerfile
command: web
ports:
- "3000:3000"
mock_masto:
build: mock_masto
ports:
- 8000:8000

View File

@ -0,0 +1,24 @@
services:
web:
build:
context: ../
dockerfile: Dockerfile.test
volumes:
- ../app:/go/owl/app
- ../assets:/go/owl/assets
- ../cmd:/go/owl/cmd
- ../config:/go/owl/config
- ../domain:/go/owl/domain
- ../entry_types:/go/owl/entry_types
- ../importer:/go/owl/importer
- ../infra:/go/owl/infra
- ../interactions:/go/owl/interactions
- ../plugings:/go/owl/plugings
- ../render:/go/owl/render
- ../web:/go/owl/web
ports:
- "3000:3000"
mock_masto:
build: mock_masto
ports:
- 8000:8000

View File

@ -0,0 +1,6 @@
FROM python:3.11
COPY . .
RUN pip install -r requirements.txt
CMD [ "python", "main.py" ]

View File

@ -0,0 +1,214 @@
import json
from flask import Flask, request
app = Flask(__name__)
PRIV_KEY_PEM = """-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCni8P4bvkC/3Sx
NTrDw1qw0vWtJKZMsyJ3Mcs4+1apoVqOQhujUqGqFSiRT7Vmc7OEhB0vikdiTkCk
1PcoTd/rOa/0WbG8385JcLzaJfTIG+rrRtHwZ1TwxwBju43jpGEZjpbA0dqoXMkr
J1MyD7aPLoAiVe0ikw2czSZumv4ncemOtk0VG3b2TnIxo3CMKtUOWu8xT08MMIuo
3cZRnpI6Xr/ULzvjv8e3EjIpwRJqMPECtGsfdcjFmR0yFIrjrlmkZTiW31z/Dk7i
xRGD0ADy3WEQ3lA4l3mNZeyG4S0Wi4iYe9/wegESMZcakLoME7ks+KNS388Mdbcd
DKy9NmWvAgMBAAECggEABLQAA0hHhdWv6+Lc9xkpFuTvxTV4fuyvCf4u1eGlnstg
ZF/nW1/6w8XQ8WCgbJ4mKuZz1J14FYKxfoRaj8S9MA2Ff+wd+M77gRpAuDWajRzO
LQk8OW2yd7POXKkAzvln9F9eofkCFKR4zSpPGTenCJaQkuYrQEOKfUf7oofdRzQi
w9kmp3wAxM/EseHZpknYDCgDQV7MDQAaMD7kbynL2WfXPxebktwpRlKUwgtGrevj
gagQL8J/GX6wO3ymw9sln4BhlI2+3LuiMXQdQc1tamkXFCguCuOZCu/2VRdCHmiS
nnpu+FMspBHbvxO+RXo3Cu/S6jjJgoQxD2WZTE0gqQKBgQDM6AQdqBYjISdkI9Gl
6ZLLjwZRJSYpopujtX7pun61l9kUwQevaR2Z39rMWxX62DD6arazi/ygIUBw6Kgp
s/qBEb29ec+0cESdC8aJYb3dGvDzh/8C05p7ozxj8JZQcxq5W5jql/BELlSsUONO
jfqQv8RGZNSkD9uy6TxOr4eWIwKBgQDRUuO/XRDLt8Mp10mTshxTznSQ3gAJYKeG
0WfEC3kPEukHBQb8huqFcQDiQ71oBWuEdOQWgT3aBS6L+nIMyZMT5u+BejQm7/E5
pMM+z0VRpfFSsIrCvU8yKam0aemQGlKQAfhTct1gCg+wKnYsSQMlNHKWEfDbw9I/
cns/IN+dBQKBgQC6/Of0oFVDTZgC3GUPAO3C8QwUtM/0or1hUdk1Nck3shCZzeVT
f5tRtmSWpHCUbwGTJBsCEjdBcda6srXzCJkLe8Moy6ZtxR34KqzM5fM7eMB1nJ9s
Vunc9gPAN+cUF1ZF3H7ZZjoOHjGK5m3oW8xSl41np9Acv5P/2rP8Ilaa/QKBgQDJ
YwISfitGk8mEW8hB/L4cMykapztJyl/i6Vz31EHoKr1fL4sFMZg4QfwjtCBqD6zd
hshajoU/WHTr30wS2WxTXX9YBoZeX8KpPsdJioiagRioAYm+yfuDu2m2VZ+MMIb2
Xa7YOk6Zs5RcXL3M5YHNLaSAlUoxZTjGKhJBLhN1MQKBgQCbo3ngBl7Qjjx4WJ93
2WEEKvSDCv69eecNQDuKWKEiFqBN23LheNrN8DXMWFTtE4miY106dzQ0dUMh418x
K98rXSX3VvY4w48AznvPMKVLqesFjcvwnBdvk/NqXod20CMSpOEVj6W/nGoTBQt2
0PuW3IUym9KvO0WX9E+1Qw8mbw==
-----END PRIVATE KEY-----"""
PUB_KEY_PEM = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp4vD+G75Av90sTU6w8Na
sNL1rSSmTLMidzHLOPtWqaFajkIbo1KhqhUokU+1ZnOzhIQdL4pHYk5ApNT3KE3f
6zmv9FmxvN/OSXC82iX0yBvq60bR8GdU8McAY7uN46RhGY6WwNHaqFzJKydTMg+2
jy6AIlXtIpMNnM0mbpr+J3HpjrZNFRt29k5yMaNwjCrVDlrvMU9PDDCLqN3GUZ6S
Ol6/1C8747/HtxIyKcESajDxArRrH3XIxZkdMhSK465ZpGU4lt9c/w5O4sURg9AA
8t1hEN5QOJd5jWXshuEtFouImHvf8HoBEjGXGpC6DBO5LPijUt/PDHW3HQysvTZl
rwIDAQAB
-----END PUBLIC KEY-----"""
INBOX = []
@app.route("/.well-known/webfinger")
def webfinger():
return json.dumps(
{
"subject": "acct:h4kor@mock_masto",
"aliases": [
"http://mock_masto:8000/@h4kor",
"http://mock_masto:8000/users/h4kor",
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": "http://mock_masto:8000/@h4kor",
},
{
"rel": "self",
"type": "application/activity+json",
"href": "http://mock_masto:8000/users/h4kor",
},
{
"rel": "http://ostatus.org/schema/1.0/subscribe",
"template": "http://mock_masto:8000/authorize_interaction?uri={uri}",
},
{
"rel": "http://webfinger.net/rel/avatar",
"type": "image/png",
"href": "http://assets.mock_masto/accounts/avatars/000/082/056/original/a4be9944e3b03229.png",
},
],
}
)
@app.route("/users/h4kor")
def actor():
print("request to actor")
return json.dumps(
{
"@context": [
"http://www.w3.org/ns/activitystreams",
"http://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"toot": "http://joinmastodon.org/ns#",
"featured": {"@id": "toot:featured", "@type": "@id"},
"featuredTags": {"@id": "toot:featuredTags", "@type": "@id"},
"alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
"movedTo": {"@id": "as:movedTo", "@type": "@id"},
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
"discoverable": "toot:discoverable",
"Device": "toot:Device",
"Ed25519Signature": "toot:Ed25519Signature",
"Ed25519Key": "toot:Ed25519Key",
"Curve25519Key": "toot:Curve25519Key",
"EncryptedMessage": "toot:EncryptedMessage",
"publicKeyBase64": "toot:publicKeyBase64",
"deviceId": "toot:deviceId",
"claim": {"@type": "@id", "@id": "toot:claim"},
"fingerprintKey": {"@type": "@id", "@id": "toot:fingerprintKey"},
"identityKey": {"@type": "@id", "@id": "toot:identityKey"},
"devices": {"@type": "@id", "@id": "toot:devices"},
"messageFranking": "toot:messageFranking",
"messageType": "toot:messageType",
"cipherText": "toot:cipherText",
"suspended": "toot:suspended",
"memorial": "toot:memorial",
"indexable": "toot:indexable",
"Hashtag": "as:Hashtag",
"focalPoint": {"@container": "@list", "@id": "toot:focalPoint"},
},
],
"id": "http://mock_masto:8000/users/h4kor",
"type": "Person",
"following": "http://mock_masto:8000/users/h4kor/following",
"followers": "http://mock_masto:8000/users/h4kor/followers",
"inbox": "http://mock_masto:8000/users/h4kor/inbox",
"outbox": "http://mock_masto:8000/users/h4kor/outbox",
"featured": "http://mock_masto:8000/users/h4kor/collections/featured",
"featuredTags": "http://mock_masto:8000/users/h4kor/collections/tags",
"preferredUsername": "h4kor",
"name": "Niko",
"summary": '<p>Teaching computers to do things with arguable efficiency.</p><p>he/him</p><p><a href="http://mock_masto:8000/tags/vegan" class="mention hashtag" rel="tag">#<span>vegan</span></a> <a href="http://mock_masto:8000/tags/cooking" class="mention hashtag" rel="tag">#<span>cooking</span></a> <a href="http://mock_masto:8000/tags/programming" class="mention hashtag" rel="tag">#<span>programming</span></a> <a href="http://mock_masto:8000/tags/politics" class="mention hashtag" rel="tag">#<span>politics</span></a> <a href="http://mock_masto:8000/tags/climate" class="mention hashtag" rel="tag">#<span>climate</span></a></p>',
"url": "http://mock_masto:8000/@h4kor",
"manuallyApprovesFollowers": False,
"discoverable": True,
"indexable": False,
"published": "2018-08-16T00:00:00Z",
"memorial": False,
"devices": "http://mock_masto:8000/users/h4kor/collections/devices",
"publicKey": {
"id": "http://mock_masto:8000/users/h4kor#main-key",
"owner": "http://mock_masto:8000/users/h4kor",
"publicKeyPem": PUB_KEY_PEM,
},
"tag": [
{
"type": "Hashtag",
"href": "http://mock_masto:8000/tags/politics",
"name": "#politics",
},
{
"type": "Hashtag",
"href": "http://mock_masto:8000/tags/climate",
"name": "#climate",
},
{
"type": "Hashtag",
"href": "http://mock_masto:8000/tags/vegan",
"name": "#vegan",
},
{
"type": "Hashtag",
"href": "http://mock_masto:8000/tags/programming",
"name": "#programming",
},
{
"type": "Hashtag",
"href": "http://mock_masto:8000/tags/cooking",
"name": "#cooking",
},
],
"attachment": [
{
"type": "PropertyValue",
"name": "Me",
"value": '<a href="http://rerere.org" target="_blank" rel="nofollow noopener noreferrer me" translate="no"><span class="invisible">http://</span><span class="">rerere.org</span><span class="invisible"></span></a>',
},
{
"type": "PropertyValue",
"name": "Blog",
"value": '<a href="http://blog.libove.org" target="_blank" rel="nofollow noopener noreferrer me" translate="no"><span class="invisible">http://</span><span class="">blog.libove.org</span><span class="invisible"></span></a>',
},
{"type": "PropertyValue", "name": "Location", "value": "Münster"},
{
"type": "PropertyValue",
"name": "Current Project",
"value": '<a href="http://git.libove.org/h4kor/owl-blogs" target="_blank" rel="nofollow noopener noreferrer me" translate="no"><span class="invisible">http://</span><span class="">git.libove.org/h4kor/owl-blogs</span><span class="invisible"></span></a>',
},
],
"endpoints": {"sharedInbox": "http://mock_masto:8000/inbox"},
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "http://assets.mock_masto/accounts/avatars/000/082/056/original/a4be9944e3b03229.png",
},
}
)
@app.route("/users/h4kor/inbox", methods=["POST"])
def inbox():
if request.method == "POST":
INBOX.append(json.loads(request.get_data()))
return ""
@app.route("/msgs")
def msgs():
return json.dumps(INBOX)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port="8000")

View File

@ -0,0 +1 @@
Flask==3.0.3

View File

@ -0,0 +1,17 @@
certifi==2024.2.2
cffi==1.16.0
charset-normalizer==3.3.2
cryptography==42.0.7
exceptiongroup==1.2.1
http-message-signatures==0.5.0
http_sfv==0.9.9
idna==3.7
iniconfig==2.0.0
packaging==24.0
pluggy==1.5.0
pycparser==2.22
pytest==8.2.0
requests==2.31.0
tomli==2.0.1
typing_extensions==4.11.0
urllib3==2.2.1

View File

102
e2e_tests/tests/fixtures.py Normal file
View File

@ -0,0 +1,102 @@
from contextlib import contextmanager
from datetime import datetime, timezone
import json
from time import sleep
from urllib.parse import urlparse
import requests, base64, hashlib
from urllib.parse import urlparse
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
ACCT_NAME = "acct:blog@localhost:3000"
PRIV_KEY_PEM = """-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCni8P4bvkC/3Sx
NTrDw1qw0vWtJKZMsyJ3Mcs4+1apoVqOQhujUqGqFSiRT7Vmc7OEhB0vikdiTkCk
1PcoTd/rOa/0WbG8385JcLzaJfTIG+rrRtHwZ1TwxwBju43jpGEZjpbA0dqoXMkr
J1MyD7aPLoAiVe0ikw2czSZumv4ncemOtk0VG3b2TnIxo3CMKtUOWu8xT08MMIuo
3cZRnpI6Xr/ULzvjv8e3EjIpwRJqMPECtGsfdcjFmR0yFIrjrlmkZTiW31z/Dk7i
xRGD0ADy3WEQ3lA4l3mNZeyG4S0Wi4iYe9/wegESMZcakLoME7ks+KNS388Mdbcd
DKy9NmWvAgMBAAECggEABLQAA0hHhdWv6+Lc9xkpFuTvxTV4fuyvCf4u1eGlnstg
ZF/nW1/6w8XQ8WCgbJ4mKuZz1J14FYKxfoRaj8S9MA2Ff+wd+M77gRpAuDWajRzO
LQk8OW2yd7POXKkAzvln9F9eofkCFKR4zSpPGTenCJaQkuYrQEOKfUf7oofdRzQi
w9kmp3wAxM/EseHZpknYDCgDQV7MDQAaMD7kbynL2WfXPxebktwpRlKUwgtGrevj
gagQL8J/GX6wO3ymw9sln4BhlI2+3LuiMXQdQc1tamkXFCguCuOZCu/2VRdCHmiS
nnpu+FMspBHbvxO+RXo3Cu/S6jjJgoQxD2WZTE0gqQKBgQDM6AQdqBYjISdkI9Gl
6ZLLjwZRJSYpopujtX7pun61l9kUwQevaR2Z39rMWxX62DD6arazi/ygIUBw6Kgp
s/qBEb29ec+0cESdC8aJYb3dGvDzh/8C05p7ozxj8JZQcxq5W5jql/BELlSsUONO
jfqQv8RGZNSkD9uy6TxOr4eWIwKBgQDRUuO/XRDLt8Mp10mTshxTznSQ3gAJYKeG
0WfEC3kPEukHBQb8huqFcQDiQ71oBWuEdOQWgT3aBS6L+nIMyZMT5u+BejQm7/E5
pMM+z0VRpfFSsIrCvU8yKam0aemQGlKQAfhTct1gCg+wKnYsSQMlNHKWEfDbw9I/
cns/IN+dBQKBgQC6/Of0oFVDTZgC3GUPAO3C8QwUtM/0or1hUdk1Nck3shCZzeVT
f5tRtmSWpHCUbwGTJBsCEjdBcda6srXzCJkLe8Moy6ZtxR34KqzM5fM7eMB1nJ9s
Vunc9gPAN+cUF1ZF3H7ZZjoOHjGK5m3oW8xSl41np9Acv5P/2rP8Ilaa/QKBgQDJ
YwISfitGk8mEW8hB/L4cMykapztJyl/i6Vz31EHoKr1fL4sFMZg4QfwjtCBqD6zd
hshajoU/WHTr30wS2WxTXX9YBoZeX8KpPsdJioiagRioAYm+yfuDu2m2VZ+MMIb2
Xa7YOk6Zs5RcXL3M5YHNLaSAlUoxZTjGKhJBLhN1MQKBgQCbo3ngBl7Qjjx4WJ93
2WEEKvSDCv69eecNQDuKWKEiFqBN23LheNrN8DXMWFTtE4miY106dzQ0dUMh418x
K98rXSX3VvY4w48AznvPMKVLqesFjcvwnBdvk/NqXod20CMSpOEVj6W/nGoTBQt2
0PuW3IUym9KvO0WX9E+1Qw8mbw==
-----END PRIVATE KEY-----"""
def ensure_follow(client, inbox_url, actor_url):
req = sign(
"POST",
inbox_url,
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
"type": "Follow",
"actor": "http://mock_masto:8000/users/h4kor",
"object": actor_url,
},
)
resp = requests.Session().send(req)
assert resp.status_code == 200
def sign(method, url, data):
priv_key = load_pem_private_key(PRIV_KEY_PEM.encode(), None)
body = json.dumps(data).encode()
body_hash = hashlib.sha256(body).digest()
digest = "SHA-256=" + base64.b64encode(body_hash).decode()
date = datetime.now(tz=timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT")
host = "localhost:3000"
target = urlparse(url).path
to_sign = f"""(request-target): {method.lower()} {target}
host: {host}
date: {date}""".encode()
sig = priv_key.sign(
to_sign,
padding.PKCS1v15(),
hashes.SHA256(),
)
sig_str = base64.b64encode(sig).decode()
request = requests.Request(method, url, data=body)
request = request.prepare()
request.headers["Content-Digest"] = digest
request.headers["Host"] = host
request.headers["Date"] = date
request.headers["Signature"] = (
f'keyId="http://mock_masto:8000/users/h4kor#main-key",headers="(request-target) host date",signature="{sig_str}"'
)
return request
@contextmanager
def msg_inc(n):
resp = requests.get("http://localhost:8000/msgs")
data = resp.json()
msgs = len(data)
yield
sleep(0.2)
resp = requests.get("http://localhost:8000/msgs")
data = resp.json()
assert msgs + n == len(
data
), f"prev: {msgs}, now: {len(data)}, expected: {msgs + n}"

View File

@ -0,0 +1,88 @@
from pprint import pprint
from time import sleep
import requests
from .fixtures import ensure_follow, msg_inc, sign
import pytest
def test_actor(client, actor_url):
resp = client.get(actor_url, headers={"Content-Type": "application/activity+json"})
assert resp.status_code == 200
data = resp.json()
assert "id" in data
assert "type" in data
assert "inbox" in data
assert "outbox" in data
assert "followers" in data
assert "preferredUsername" in data
assert "publicKey" in data
assert len(data["publicKey"])
pubKey = data["publicKey"]
assert "id" in pubKey
assert "owner" in pubKey
assert "publicKeyPem" in pubKey
assert pubKey["owner"] == data["id"]
assert pubKey["id"] != data["id"]
assert "-----BEGIN RSA PUBLIC KEY-----" in pubKey["publicKeyPem"]
def test_following(client, inbox_url, followers_url, actor_url):
with msg_inc(1):
req = sign(
"POST",
inbox_url,
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
"type": "Follow",
"actor": "http://mock_masto:8000/users/h4kor",
"object": actor_url,
},
)
resp = requests.Session().send(req)
assert resp.status_code == 200
resp = client.get(
followers_url, headers={"Content-Type": "application/activity+json"}
)
assert resp.status_code == 200
data = resp.json()
pprint(data)
assert "items" in data
assert len(data["items"]) == 1
def test_unfollow(client, inbox_url, followers_url, actor_url):
ensure_follow(client, inbox_url, actor_url)
sleep(0.5)
with msg_inc(1):
req = sign(
"POST",
inbox_url,
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://mock_masto:8000/users/h4kor#follows/3632040/undo",
"type": "Undo",
"actor": "http://mock_masto:8000/users/h4kor",
"object": {
"id": "http://mock_masto:8000/d0b5768b-a15b-4ed6-bc84-84c7e2b57588",
"type": "Follow",
"actor": "http://mock_masto:8000/users/h4kor",
"object": actor_url,
},
},
)
resp = requests.Session().send(req)
assert resp.status_code == 200
resp = client.get(
followers_url, headers={"Content-Type": "application/activity+json"}
)
assert resp.status_code == 200
data = resp.json()
pprint(data)
assert "totalItems" in data
assert data["totalItems"] == 0

View File

@ -0,0 +1,27 @@
import pytest
from .fixtures import ACCT_NAME
@pytest.mark.parametrize(
["query", "status"],
[
["", 404],
["?foo=bar", 404],
["?resource=lol@bar.com", 404],
[f"?resource={ACCT_NAME}", 200],
],
)
def test_webfinger_status(client, query, status):
resp = client.get("/.well-known/webfinger" + query)
assert resp.status_code == status
def test_webfinger(client):
resp = client.get(f"/.well-known/webfinger?resource={ACCT_NAME}")
assert resp.status_code == 200
data = resp.json()
assert data["subject"] == ACCT_NAME
assert len(data["links"]) > 0
self_link = [x for x in data["links"] if x["rel"] == "self"][0]
assert self_link["type"] == "application/activity+json"
assert "href" in self_link

View File

@ -23,11 +23,10 @@ func (meta *ArticleMetaData) Form(binSvc model.BinaryStorageInterface) string {
} }
// ParseFormData implements model.EntryMetaData. // ParseFormData implements model.EntryMetaData.
func (*ArticleMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) (model.EntryMetaData, error) { func (meta *ArticleMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
return &ArticleMetaData{ meta.Title = data.FormValue("title")
Title: data.FormValue("title"), meta.Content = data.FormValue("content")
Content: data.FormValue("content"), return nil
}, nil
} }
func (e *Article) Title() string { func (e *Article) Title() string {

View File

@ -24,12 +24,11 @@ func (meta *BookmarkMetaData) Form(binSvc model.BinaryStorageInterface) string {
} }
// ParseFormData implements model.EntryMetaData. // ParseFormData implements model.EntryMetaData.
func (*BookmarkMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) (model.EntryMetaData, error) { func (meta *BookmarkMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
return &BookmarkMetaData{ meta.Title = data.FormValue("title")
Title: data.FormValue("title"), meta.Url = data.FormValue("url")
Url: data.FormValue("url"), meta.Content = data.FormValue("content")
Content: data.FormValue("content"), return nil
}, nil
} }
func (e *Bookmark) Title() string { func (e *Bookmark) Title() string {

View File

@ -24,41 +24,44 @@ func (meta *ImageMetaData) Form(binSvc model.BinaryStorageInterface) string {
} }
// ParseFormData implements model.EntryMetaData. // ParseFormData implements model.EntryMetaData.
func (meta *ImageMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) (model.EntryMetaData, error) { func (meta *ImageMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
file, err := data.FormFile("image") file, err := data.FormFile("image")
var imgId = meta.ImageId var imgId = meta.ImageId
if err != nil && imgId == "" { if err != nil && imgId == "" {
return nil, err return err
} else if err == nil { } else if err == nil {
fileData, err := file.Open() fileData, err := file.Open()
if err != nil { if err != nil {
return nil, err return err
} }
defer fileData.Close() defer fileData.Close()
fileBytes := make([]byte, file.Size) fileBytes := make([]byte, file.Size)
_, err = fileData.Read(fileBytes) _, err = fileData.Read(fileBytes)
if err != nil { if err != nil {
return nil, err return err
} }
bin, err := binSvc.Create(file.Filename, fileBytes) bin, err := binSvc.Create(file.Filename, fileBytes)
if err != nil { if err != nil {
return nil, err return err
} }
imgId = bin.Id imgId = bin.Id
} }
return &ImageMetaData{ meta.ImageId = imgId
ImageId: imgId, meta.Title = data.FormValue("title")
Title: data.FormValue("title"), meta.Content = data.FormValue("content")
Content: data.FormValue("content"), return nil
}, nil
} }
func (e *Image) Title() string { func (e *Image) Title() string {
return e.meta.Title return e.meta.Title
} }
func (e *Image) ImageUrl() string {
return "/media/" + e.meta.ImageId
}
func (e *Image) Content() model.EntryContent { func (e *Image) Content() model.EntryContent {
str, err := render.RenderTemplateToString("entry/Image", e) str, err := render.RenderTemplateToString("entry/Image", e)
if err != nil { if err != nil {

View File

@ -22,10 +22,9 @@ func (meta *NoteMetaData) Form(binSvc model.BinaryStorageInterface) string {
} }
// ParseFormData implements model.EntryMetaData. // ParseFormData implements model.EntryMetaData.
func (*NoteMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) (model.EntryMetaData, error) { func (meta *NoteMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
return &NoteMetaData{ meta.Content = data.FormValue("content")
Content: data.FormValue("content"), return nil
}, nil
} }
func (e *Note) Title() string { func (e *Note) Title() string {

View File

@ -23,11 +23,10 @@ func (meta *PageMetaData) Form(binSvc model.BinaryStorageInterface) string {
} }
// ParseFormData implements model.EntryMetaData. // ParseFormData implements model.EntryMetaData.
func (*PageMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) (model.EntryMetaData, error) { func (meta *PageMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
return &PageMetaData{ meta.Title = data.FormValue("title")
Title: data.FormValue("title"), meta.Content = data.FormValue("content")
Content: data.FormValue("content"), return nil
}, nil
} }
func (e *Page) Title() string { func (e *Page) Title() string {

View File

@ -27,7 +27,7 @@ func (meta *RecipeMetaData) Form(binSvc model.BinaryStorageInterface) string {
} }
// ParseFormData implements model.EntryMetaData. // ParseFormData implements model.EntryMetaData.
func (*RecipeMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) (model.EntryMetaData, error) { func (meta *RecipeMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
ings := strings.Split(data.FormValue("ingredients"), "\n") ings := strings.Split(data.FormValue("ingredients"), "\n")
clean := make([]string, 0) clean := make([]string, 0)
for _, ing := range ings { for _, ing := range ings {
@ -35,13 +35,12 @@ func (*RecipeMetaData) ParseFormData(data model.HttpFormData, binSvc model.Binar
clean = append(clean, strings.TrimSpace(ing)) clean = append(clean, strings.TrimSpace(ing))
} }
} }
return &RecipeMetaData{ meta.Title = data.FormValue("title")
Title: data.FormValue("title"), meta.Yield = data.FormValue("yield")
Yield: data.FormValue("yield"), meta.Duration = data.FormValue("duration")
Duration: data.FormValue("duration"), meta.Ingredients = clean
Ingredients: clean, meta.Content = data.FormValue("content")
Content: data.FormValue("content"), return nil
}, nil
} }
func (e *Recipe) Title() string { func (e *Recipe) Title() string {

View File

@ -24,12 +24,11 @@ func (meta *ReplyMetaData) Form(binSvc model.BinaryStorageInterface) string {
} }
// ParseFormData implements model.EntryMetaData. // ParseFormData implements model.EntryMetaData.
func (*ReplyMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) (model.EntryMetaData, error) { func (meta *ReplyMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
return &ReplyMetaData{ meta.Title = data.FormValue("title")
Title: data.FormValue("title"), meta.Url = data.FormValue("url")
Url: data.FormValue("url"), meta.Content = data.FormValue("content")
Content: data.FormValue("content"), return nil
}, nil
} }
func (e *Reply) Title() string { func (e *Reply) Title() string {

51
go.mod
View File

@ -1,53 +1,50 @@
module owl-blogs module owl-blogs
go 1.20 go 1.22
require ( require (
github.com/go-ap/activitypub v0.0.0-20230719093539-2b6a6f3a25ee github.com/Davincible/goinsta/v3 v3.2.6
github.com/gofiber/fiber/v2 v2.47.0 github.com/go-ap/activitypub v0.0.0-20240408091739-ba76b44c2594
github.com/google/uuid v1.3.0 github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73
github.com/jmoiron/sqlx v1.3.5 github.com/gofiber/fiber/v2 v2.52.4
github.com/mattn/go-sqlite3 v1.14.19 github.com/google/uuid v1.6.0
github.com/spf13/cobra v1.7.0 github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
github.com/yuin/goldmark v1.5.4 github.com/yuin/goldmark v1.7.1
golang.org/x/crypto v0.12.0 golang.org/x/crypto v0.23.0
golang.org/x/net v0.14.0 golang.org/x/net v0.25.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
require ( require (
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect
github.com/Davincible/goinsta/v3 v3.2.6 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/andybalholm/brotli v1.0.5 // indirect github.com/chromedp/cdproto v0.0.0-20240501202034-ef67d660e9fd // indirect
github.com/chromedp/cdproto v0.0.0-20230808232040-5d0fb3432de3 // indirect github.com/chromedp/chromedp v0.9.5 // indirect
github.com/chromedp/chromedp v0.9.2 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-ap/errors v0.0.0-20221205040414-01c1adfc98ea // indirect github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0 // indirect
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 // indirect github.com/go-fed/httpsig v1.1.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.3.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.16.3 // indirect github.com/klauspost/compress v1.17.8 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/philhofer/fwd v1.1.2 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/tinylib/msgp v1.1.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.47.0 // indirect github.com/valyala/fasthttp v1.52.0 // indirect
github.com/valyala/fastjson v1.6.4 // indirect github.com/valyala/fastjson v1.6.4 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.11.0 // indirect golang.org/x/sys v0.20.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

151
go.sum
View File

@ -1,149 +1,106 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4HHsCo6xi2oWZYKWW4bly/Ory9FuTpFPRxj/mAg= git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4HHsCo6xi2oWZYKWW4bly/Ory9FuTpFPRxj/mAg=
git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs= git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs=
github.com/Davincible/goinsta/v3 v3.2.6 h1:+lNIWU6NABWd2VSGe83UQypnef+kzWwjmfgGihPbwD8= github.com/Davincible/goinsta/v3 v3.2.6 h1:+lNIWU6NABWd2VSGe83UQypnef+kzWwjmfgGihPbwD8=
github.com/Davincible/goinsta/v3 v3.2.6/go.mod h1:jIDhrWZmttL/gtXj/mkCaZyeNdAAqW3UYjasOUW0YEw= github.com/Davincible/goinsta/v3 v3.2.6/go.mod h1:jIDhrWZmttL/gtXj/mkCaZyeNdAAqW3UYjasOUW0YEw=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20230808232040-5d0fb3432de3 h1:Qhw280TMvghvzNauTPMwEvaHUYWRJSoSlNVMev4lO7M= github.com/chromedp/cdproto v0.0.0-20240501202034-ef67d660e9fd h1:5/HXKq8EaAWVmnl6Hnyl4SVq7FF5990DBW6AuTrWtVw=
github.com/chromedp/cdproto v0.0.0-20230808232040-5d0fb3432de3/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/cdproto v0.0.0-20240501202034-ef67d660e9fd/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw= github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg=
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ap/activitypub v0.0.0-20230719093539-2b6a6f3a25ee h1:1OMBlmSzLXftIj5z/D1s1Xr3FanVKtLFZPtdIFslh1A= github.com/go-ap/activitypub v0.0.0-20240408091739-ba76b44c2594 h1:er3GvGCm7bJwHostjZlsRy7uiUuCquUVF9Fe0TrwiPI=
github.com/go-ap/activitypub v0.0.0-20230719093539-2b6a6f3a25ee/go.mod h1:qw0WNf+PTG69Xu6mVqUluDuKl1VwVYdgntOZQFBZQ48= github.com/go-ap/activitypub v0.0.0-20240408091739-ba76b44c2594/go.mod h1:yRUfFCoZY6C1CWalauqEQ5xYgSckzEBEO/2MBC6BOME=
github.com/go-ap/errors v0.0.0-20221205040414-01c1adfc98ea h1:ywGtLGVjJjMrq4mu35Qmu+NtlhlTk/gTayE6Bb4tQZk= github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0 h1:H9MGShwybHLSln6K8RxHPMHiLcD86Lru+5TVW2TcXHY=
github.com/go-ap/errors v0.0.0-20221205040414-01c1adfc98ea/go.mod h1:SaTNjEEkp0q+w3pUS1ccyEL/lUrHteORlDq/e21mCc8= github.com/go-ap/errors v0.0.0-20240304112515-6077fa9c17b0/go.mod h1:5x8a6P/dhmMGFxWLcyYlyOuJ2lRNaHGhRv+yu8BaTSI=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw= github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw=
github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA= github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/gobwas/ws v1.3.2/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofiber/fiber/v2 v2.47.0 h1:EN5lHVCc+Pyqh5OEsk8fzRiifgwpbrP0rulQ4iNf3fs= github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM=
github.com/gofiber/fiber/v2 v2.47.0/go.mod h1:mbFMVN1lQuzziTkkakgtKKdjfsXSw9BKR5lmcNksUoU= github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94 h1:rmMl4fXJhKMNWl+K+r/fq4FbbKI+Ia2m9hYBLm2h4G4= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/savsgio/dictpool v0.0.0-20221023140959-7bf2e61cea94/go.mod h1:90zrgN3D/WJsDd1iXHT96alCoN2KJo6/4x1DZC3wZs8= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/savsgio/gotils v0.0.0-20220530130905-52f3993e8d6d/go.mod h1:Gy+0tqhJvgGlqnTF8CVGP0AaGRjwBtXs/a5PA0Y3+A4=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tinylib/msgp v1.1.6/go.mod h1:75BAfg2hauQhs3qedfdDZmWAPcFMAvJE5b9rGOMufyw=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c= github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0=
github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ=
github.com/valyala/fastjson v1.6.3/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

View File

@ -101,9 +101,15 @@ func (repo *DefaultBinaryFileRepo) FindByNameForEntry(name string, entry model.E
} }
// ListIds implements repository.BinaryRepository // ListIds implements repository.BinaryRepository
func (repo *DefaultBinaryFileRepo) ListIds() ([]string, error) { func (repo *DefaultBinaryFileRepo) ListIds(filter string) ([]string, error) {
filter = strings.TrimSpace(strings.ToLower(filter))
if filter == "" {
filter = "%"
} else {
filter = "%" + filter + "%"
}
var ids []string var ids []string
err := repo.db.Select(&ids, "SELECT id FROM binary_files") err := repo.db.Select(&ids, "SELECT id FROM binary_files WHERE LOWER(id) LIKE ?", filter)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -0,0 +1,57 @@
package infra
import (
"owl-blogs/app/repository"
"github.com/jmoiron/sqlx"
)
type sqlFollower struct {
Follwer string `db:"follower"`
}
type DefaultFollowerRepo struct {
db *sqlx.DB
}
func NewFollowerRepository(db Database) repository.FollowerRepository {
sqlxdb := db.Get()
// Create tables if not exists
sqlxdb.MustExec(`
CREATE TABLE IF NOT EXISTS followers (
follower TEXT PRIMARY KEY
);
`)
return &DefaultFollowerRepo{
db: sqlxdb,
}
}
// Add implements repository.FollowerRepository.
func (d *DefaultFollowerRepo) Add(follower string) error {
_, err := d.db.Exec("INSERT INTO followers (follower) VALUES (?) ON CONFLICT DO NOTHING", follower)
return err
}
// Remove implements repository.FollowerRepository.
func (d *DefaultFollowerRepo) Remove(follower string) error {
_, err := d.db.Exec("DELETE FROM followers WHERE follower = ?", follower)
return err
}
// All implements repository.FollowerRepository.
func (d *DefaultFollowerRepo) All() ([]string, error) {
var followers []sqlFollower
err := d.db.Select(&followers, "SELECT * FROM followers")
if err != nil {
return nil, err
}
result := []string{}
for _, follower := range followers {
result = append(result, follower.Follwer)
}
return result, nil
}

View File

@ -0,0 +1,91 @@
package infra_test
import (
"owl-blogs/app/repository"
"owl-blogs/infra"
"owl-blogs/test"
"testing"
"github.com/stretchr/testify/require"
)
func setupFollowerRepo() repository.FollowerRepository {
db := test.NewMockDb()
repo := infra.NewFollowerRepository(db)
return repo
}
func TestAddFollower(t *testing.T) {
repo := setupFollowerRepo()
err := repo.Add("foo@example.com")
require.NoError(t, err)
followers, err := repo.All()
require.NoError(t, err)
require.Len(t, followers, 1)
require.Equal(t, followers[0], "foo@example.com")
}
func TestDoubleAddFollower(t *testing.T) {
repo := setupFollowerRepo()
err := repo.Add("foo@example.com")
require.NoError(t, err)
err = repo.Add("foo@example.com")
require.NoError(t, err)
followers, err := repo.All()
require.NoError(t, err)
require.Len(t, followers, 1)
require.Equal(t, followers[0], "foo@example.com")
}
func TestMultipleAddFollower(t *testing.T) {
repo := setupFollowerRepo()
err := repo.Add("foo@example.com")
require.NoError(t, err)
err = repo.Add("bar@example.com")
require.NoError(t, err)
err = repo.Add("baz@example.com")
require.NoError(t, err)
followers, err := repo.All()
require.NoError(t, err)
require.Len(t, followers, 3)
}
func TestRemoveFollower(t *testing.T) {
repo := setupFollowerRepo()
err := repo.Add("foo@example.com")
require.NoError(t, err)
followers, err := repo.All()
require.NoError(t, err)
require.Len(t, followers, 1)
err = repo.Remove("foo@example.com")
require.NoError(t, err)
followers, err = repo.All()
require.NoError(t, err)
require.Len(t, followers, 0)
}
func TestRemoveNonExistingFollower(t *testing.T) {
repo := setupFollowerRepo()
err := repo.Remove("foo@example.com")
require.NoError(t, err)
followers, err := repo.All()
require.NoError(t, err)
require.Len(t, followers, 0)
}

View File

@ -89,7 +89,7 @@ func (repo *DefaultInteractionRepo) Delete(interaction model.Interaction) error
// FindAll implements repository.InteractionRepository. // FindAll implements repository.InteractionRepository.
func (repo *DefaultInteractionRepo) FindAll(entryId string) ([]model.Interaction, error) { func (repo *DefaultInteractionRepo) FindAll(entryId string) ([]model.Interaction, error) {
data := []sqlInteraction{} data := []sqlInteraction{}
err := repo.db.Select(&data, "SELECT * FROM interactions WHERE entry_id = ?", entryId) err := repo.db.Select(&data, "SELECT * FROM interactions WHERE entry_id = ? ORDER BY created_at DESC", entryId)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -156,3 +156,23 @@ func (repo *DefaultInteractionRepo) sqlInteractionToInteraction(interaction sqlI
return i, nil return i, nil
} }
// ListAllInteractions implements repository.InteractionRepository.
func (repo *DefaultInteractionRepo) ListAllInteractions() ([]model.Interaction, error) {
data := []sqlInteraction{}
err := repo.db.Select(&data, "SELECT * FROM interactions ORDER BY created_at DESC")
if err != nil {
return nil, err
}
interactions := []model.Interaction{}
for _, d := range data {
i, err := repo.sqlInteractionToInteraction(d)
if err != nil {
return nil, err
}
interactions = append(interactions, i)
}
return interactions, nil
}

33
interactions/like.go Normal file
View File

@ -0,0 +1,33 @@
package interactions
import (
"fmt"
"owl-blogs/domain/model"
"owl-blogs/render"
)
type Like struct {
model.InteractionBase
meta LikeMetaData
}
type LikeMetaData struct {
SenderUrl string
SenderName string
}
func (i *Like) Content() model.InteractionContent {
str, err := render.RenderTemplateToString("interaction/Like", i)
if err != nil {
fmt.Println(err)
}
return model.InteractionContent(str)
}
func (i *Like) MetaData() interface{} {
return &i.meta
}
func (i *Like) SetMetaData(metaData interface{}) {
i.meta = *metaData.(*LikeMetaData)
}

33
interactions/repost.go Normal file
View File

@ -0,0 +1,33 @@
package interactions
import (
"fmt"
"owl-blogs/domain/model"
"owl-blogs/render"
)
type Repost struct {
model.InteractionBase
meta RepostMetaData
}
type RepostMetaData struct {
SenderUrl string
SenderName string
}
func (i *Repost) Content() model.InteractionContent {
str, err := render.RenderTemplateToString("interaction/Repost", i)
if err != nil {
fmt.Println(err)
}
return model.InteractionContent(str)
}
func (i *Repost) MetaData() interface{} {
return &i.meta
}
func (i *Repost) SetMetaData(metaData interface{}) {
i.meta = *metaData.(*RepostMetaData)
}

View File

@ -28,11 +28,10 @@ func (cfg *InstagramConfig) Form(binSvc model.BinaryStorageInterface) string {
} }
// ParseFormData implements app.AppConfig. // ParseFormData implements app.AppConfig.
func (*InstagramConfig) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) (app.AppConfig, error) { func (cfg *InstagramConfig) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
return &InstagramConfig{ cfg.User = data.FormValue("User")
User: data.FormValue("User"), cfg.Password = data.FormValue("Password")
Password: data.FormValue("Password"), return nil
}, nil
} }
func RegisterInstagram( func RegisterInstagram(

View File

@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"embed" "embed"
"io" "io"
"net/url"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"text/template" "text/template"
@ -20,6 +21,7 @@ type TemplateData struct {
//go:embed templates //go:embed templates
var templates embed.FS var templates embed.FS
var SiteConfigService model.SiteConfigInterface
var funcMap = template.FuncMap{ var funcMap = template.FuncMap{
"markdown": func(text string) string { "markdown": func(text string) string {
@ -29,6 +31,10 @@ var funcMap = template.FuncMap{
} }
return html return html
}, },
"urljoin": func(elems ...string) string {
r, _ := url.JoinPath(elems[0], elems[1:]...)
return r
},
} }
func CreateTemplateWithBase(templateName string) (*template.Template, error) { func CreateTemplateWithBase(templateName string) (*template.Template, error) {
@ -40,7 +46,7 @@ func CreateTemplateWithBase(templateName string) (*template.Template, error) {
) )
} }
func RenderTemplateWithBase(w io.Writer, siteConfig model.SiteConfig, templateName string, data interface{}) error { func RenderTemplateWithBase(w io.Writer, templateName string, data interface{}) error {
t, err := CreateTemplateWithBase(templateName) t, err := CreateTemplateWithBase(templateName)
@ -48,6 +54,11 @@ func RenderTemplateWithBase(w io.Writer, siteConfig model.SiteConfig, templateNa
return err return err
} }
siteConfig, err := SiteConfigService.GetSiteConfig()
if err != nil {
return err
}
err = t.ExecuteTemplate(w, "base", TemplateData{ err = t.ExecuteTemplate(w, "base", TemplateData{
Data: data, Data: data,
SiteConfig: siteConfig, SiteConfig: siteConfig,

View File

@ -6,113 +6,31 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{template "title" .Data}} - {{ .SiteConfig.Title }}</title> <title>{{template "title" .Data}} - {{ .SiteConfig.Title }}</title>
<meta property="og:title" content="{{template "title" .Data}}" /> <meta property="og:title" content="{{template "title" .Data}}" />
{{ template "head" .}}
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<link rel="webmention" href="/webmention/" /> <link rel="webmention" href="/webmention/" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/index.xml"> <link rel="alternate" type="application/rss+xml" title="RSS" href="/index.xml">
<link rel='stylesheet' href='/static/pico.min.css'> <link rel='stylesheet' href='/static/owl.css'>
<link rel='stylesheet' href='/static/style.css'> <link rel='stylesheet' href='/static/style.css'>
<style> <style>
:root { :root {
--primary: {{.SiteConfig.PrimaryColor}}; --primary: {{.SiteConfig.PrimaryColor}};
--primary-hover: color-mix(in srgb,var(--primary),#000 20%);
--primary-focus: color-mix(in srgb,var(--primary),#fff 40%);
--primary-inverse: #FFF;
--background: {{.SiteConfig.HeaderColor}};
--background-dark: color-mix(in srgb,var(--background),#000 50%);
--background-light: color-mix(in srgb,var(--background),#fff 50%);
} }
[data-theme="light"],
:root:not([data-theme="dark"]) {
--primary: {{.SiteConfig.PrimaryColor}};
--primary-hover: color-mix(in srgb,var(--primary),#000 20%);
--primary-focus: color-mix(in srgb,var(--primary),#fff 40%);
--primary-inverse: #FFF;
}
header {
background-color: var(--background);
padding-bottom: 1rem !important;
}
footer {
border-top: dashed 2px;
border-color: #ccc;
}
.avatar {
float: left;
margin-right: 1rem;
border-radius: 50%;
}
.header {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: flex-start;
}
.header-title {
order: 0;
}
.header-profile {
order: 1;
}
hgroup h2 a { color: inherit; }
.photo-grid {
display: flex;
flex-wrap: wrap;
padding: 0 4px;
}
.photo-grid-item {
flex: 1 0 25%;
padding: 4px;
}
.photo-grid-item img {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1 ;
object-fit: cover;
}
main img {
max-height: 50vh;
margin: auto;
text-align: center;
display: block;
}
</style> </style>
{{ .SiteConfig.HtmlHeadExtra }} {{ .SiteConfig.HtmlHeadExtra }}
</head> </head>
<body> <body>
<header> <header class="h-card">
<div class="container header h-card">
<hgroup class="header-title">
<h2><a class="p-name u-url" href="/">{{ .SiteConfig.Title }}</a></h2>
<h3 class="p-note">{{ .SiteConfig.SubTitle }}</h3>
</hgroup>
<div class="header-profile">
{{ if .SiteConfig.AvatarUrl }} {{ if .SiteConfig.AvatarUrl }}
<img class="u-photo u-logo avatar" src="{{ .SiteConfig.AvatarUrl }}" alt="{{ .SiteConfig.Title }}" width="100" height="100" /> <img class="u-photo u-logo avatar" src="{{ .SiteConfig.AvatarUrl }}" alt="{{ .SiteConfig.Title }}" />
{{ end }} {{ end }}
<div style="float: right; list-style: none;">
{{ range $me := .SiteConfig.Me }} <hgroup>
<li><a href="{{$me.Url}}" rel="me">{{$me.Name}}</a> <h1><a class="p-name u-url" href="/">{{ .SiteConfig.Title }}</a></h1>
</li> <p class="p-note">{{ .SiteConfig.SubTitle }}</p>
{{ end }} </hgroup>
</div>
</div>
</div>
<div class="container">
<nav> <nav>
<ul> <ul>
{{ range $link := .SiteConfig.HeaderMenu }} {{ range $link := .SiteConfig.HeaderMenu }}
@ -126,12 +44,11 @@
{{ end }} {{ end }}
</ul> </ul>
</nav> </nav>
</div>
</header> </header>
<main class="container"> <main>
{{template "main" .Data}} {{template "main" .Data}}
</main> </main>
<footer class="container"> <footer>
<nav> <nav>
<ul> <ul>
<li><a target="_blank" href="/index.xml">RSS Feed</a></li> <li><a target="_blank" href="/index.xml">RSS Feed</a></li>
@ -144,16 +61,21 @@
<li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li> <li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li>
{{ end }} {{ end }}
{{ end }} {{ end }}
</ul> {{ range $me := .SiteConfig.Me }}
</nav> <li><a href="{{$me.Url}}" rel="me">{{$me.Name}}</a>
{{ .SiteConfig.FooterExtra}} </li>
<small> {{ end }}
<nav>
<ul>
<li><a href="/admin/">Editor</a></li> <li><a href="/admin/">Editor</a></li>
</ul> </ul>
</nav> </nav>
</small> <div>
{{ .SiteConfig.FooterExtra}}
</div>
<div style="margin-top:var(--s2);">
powered by <i><a href="https://github.com/H4kor/owl-blogs" target="_blank">owl-blogs</a></i>
</a>
</footer> </footer>
</body> </body>
</html> </html>

View File

@ -2,4 +2,13 @@
<input type="text" name="title" value="{{.Title}}" /> <input type="text" name="title" value="{{.Title}}" />
<label for="content">Content</label> <label for="content">Content</label>
<textarea name="content" rows="16">{{.Content}}</textarea> <textarea
id="contentField"
name="content"
rows="16"
>{{.Content}}</textarea>
<script src="/static/editor.js"></script>
<script>
addFileDrop("contentField")
</script>

View File

@ -1,2 +1,7 @@
<label for="content">Content</label> <label for="content">Content</label>
<textarea name="content" rows="8">{{.Content}}</textarea> <textarea id="contentField" name="content" rows="8">{{.Content}}</textarea>
<script src="/static/editor.js"></script>
<script>
addFileDrop("contentField")
</script>

View File

@ -2,4 +2,9 @@
<input type="text" name="title" value="{{.Title}}" /> <input type="text" name="title" value="{{.Title}}" />
<label for="content">Content</label> <label for="content">Content</label>
<textarea name="content" rows="16">{{.Content}}</textarea> <textarea id="contentField" name="content" rows="16">{{.Content}}</textarea>
<script src="/static/editor.js"></script>
<script>
addFileDrop("contentField")
</script>

View File

@ -12,4 +12,9 @@
{{$i}}{{ end }}</textarea> {{$i}}{{ end }}</textarea>
<label for="content">Content</label> <label for="content">Content</label>
<textarea name="content" rows="16">{{.Content}}</textarea> <textarea id="contentField" name="content" rows="16">{{.Content}}</textarea>
<script src="/static/editor.js"></script>
<script>
addFileDrop("contentField")
</script>

View File

@ -5,4 +5,9 @@
<input type="text" name="url" value="{{.Url}}" placeholder="https://..." /> <input type="text" name="url" value="{{.Url}}" placeholder="https://..." />
<label for="content">Content</label> <label for="content">Content</label>
<textarea name="content" rows="16">{{.Content}}</textarea></textarea> <textarea id="contentField" name="content" rows="16">{{.Content}}</textarea></textarea>
<script src="/static/editor.js"></script>
<script>
addFileDrop("contentField")
</script>

View File

@ -0,0 +1,3 @@
Liked by <a href="{{.MetaData.SenderUrl}}">
{{.MetaData.SenderName}}
</a>

View File

@ -0,0 +1,3 @@
Reposted by <a href="{{.MetaData.SenderUrl}}">
{{.MetaData.SenderName}}
</a>

View File

@ -1,4 +1,5 @@
{{define "title"}}Admin{{end}} {{define "title"}}Admin{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}
<h2 style="margin-bottom: 1rem;">Write</h2> <h2 style="margin-bottom: 1rem;">Write</h2>
@ -13,8 +14,9 @@
<h2 style="margin-bottom: 1rem;">Content</h2> <h2 style="margin-bottom: 1rem;">Content</h2>
<div class="action-tile-list"> <div class="action-tile-list">
<a class="action-tile" href="/admin/binaries/">Files</a>
<a class="action-tile" href="/admin/drafts/">Drafts</a> <a class="action-tile" href="/admin/drafts/">Drafts</a>
<a class="action-tile" href="/admin/binaries/">Files</a>
<a class="action-tile" href="/admin/interactions/">Interactions</a>
</div> </div>
<br> <br>
<br> <br>

View File

@ -1,5 +1,7 @@
{{define "title"}}Configuration{{end}} {{define "title"}}Configuration{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}
<a href="/admin">&larr; Back</a> <a href="/admin">&larr; Back</a>

View File

@ -1,4 +1,5 @@
{{define "title"}}Files{{end}} {{define "title"}}Files{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}
@ -8,14 +9,23 @@
<h2>Files</h2> <h2>Files</h2>
<h3>Upload File</h3>
<form action="/admin/binaries/new" method="post" enctype="multipart/form-data"> <form action="/admin/binaries/new" method="post" enctype="multipart/form-data">
<div> <fieldset role="group">
<label for="file">File</label>
<input type="file" name="file" id="file"> <input type="file" name="file" id="file">
</div>
<div>
<input type="submit" value="Upload"> <input type="submit" value="Upload">
</div> </fieldset>
</form>
<hr>
<h3>Files</h3>
<form action="" method="get">
<fieldset role="search">
<input type="filter" name="filter" id="filter" value="{{.Filter}}">
<input type="submit" value="Search">
</fieldset>
</form> </form>
<table role="grid"> <table role="grid">

View File

@ -1,4 +1,5 @@
{{define "title"}}Index{{end}} {{define "title"}}Index{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}

View File

@ -1,4 +1,5 @@
{{define "title"}}Editor{{end}} {{define "title"}}Editor{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}

View File

@ -1,5 +1,16 @@
{{define "title"}}{{.Entry.Title}}{{end}} {{define "title"}}{{.Entry.Title}}{{end}}
{{define "head"}}
<meta property="og:url" content="{{ urljoin .SiteConfig.FullUrl "/posts/" .Data.Entry.ID }}/" />
<meta property="og:type" content="article" />
{{ if .Data.Entry.ImageUrl}}
<meta property="og:image" content="{{ urljoin .SiteConfig.FullUrl .Data.Entry.ImageUrl}}" />
{{ end }}
{{ if .Data.Entry.PublishedAt }}
<meta property="og:article:published_time" content="{{.Data.Entry.PublishedAt.Format "2006-01-02T15:04:05" }}" />
{{ end }}
{{end}}
{{define "main"}} {{define "main"}}
{{ if not .Entry.PublishedAt }} {{ if not .Entry.PublishedAt }}
@ -11,12 +22,13 @@
{{ end }} {{ end }}
<div class="h-entry"> <div class="h-entry">
<hgroup>
{{if .Entry.Title}} {{if .Entry.Title}}
<h1 class="p-name">{{.Entry.Title}}</h1> <h1 class="p-name entry-title">{{.Entry.Title}}</h1>
{{else}}
<div style="padding-top:4rem;"></div>
{{end}} {{end}}
<small> <div class="entry-meta">
<a class="u-url" href="">#</a> <a class="u-url" href="/posts/{{ .Entry.ID }}/">#</a>
Published: Published:
{{ if .Entry.PublishedAt }} {{ if .Entry.PublishedAt }}
<time class="dt-published" datetime="{{.Entry.PublishedAt.Format "2006-01-02T15:04:05" }}"> <time class="dt-published" datetime="{{.Entry.PublishedAt.Format "2006-01-02T15:04:05" }}">
@ -32,7 +44,7 @@
{{.Author.Name}} {{.Author.Name}}
</a> </a>
{{ end }} {{ end }}
</small> </div>
</hgroup> </hgroup>
<div class="e-content"> <div class="e-content">
@ -77,7 +89,7 @@
<div class="grid"> <div class="grid">
<div style="margin-bottom:1em;"> <div>
<a style="width:100%;" href="/editor/edit/{{.Entry.ID}}/" role="button" class="">Edit</a> <a style="width:100%;" href="/editor/edit/{{.Entry.ID}}/" role="button" class="">Edit</a>
</div> </div>
<div> <div>

View File

@ -1,28 +1,28 @@
{{define "title"}}Index{{end}} {{define "title"}}Index{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}
<div class="h-feed"> <div class="h-feed">
{{ range .Entries }} {{ range .Entries }}
<div class="h-entry"> <div class="h-entry">
<hgroup> <h1 class="entry-title">
<h3> <a class="u-url" href="/posts/{{ .ID }}/">
<a class="u-url" href="/posts/{{ .ID }}">
{{if .Title}} {{if .Title}}
{{ .Title }} {{ .Title }}
{{else}} {{else}}
# #
{{end}} {{end}}
</a> </a>
</h3> </h1>
<small style="font-size: 0.75em;"> <div class="entry-meta">
Published:
{{ if .PublishedAt }} {{ if .PublishedAt }}
<time class="dt-published" datetime="{{.PublishedAt.Format "2006-01-02T15:04:05" }}"> <time class="dt-published" datetime="{{.PublishedAt.Format "2006-01-02T15:04:05" }}">
{{.PublishedAt.Format "2006-01-02" }} {{.PublishedAt.Format "2006-01-02" }}
</time> </time>
{{ end }} {{ end }}
</small> </div>
</hgroup>
{{ .Content }} {{ .Content }}
</div> </div>
<hr> <hr>

View File

@ -0,0 +1,48 @@
{{define "title"}}Interactions{{end}}
{{define "head"}}{{end}}
{{define "main"}}
<a href="/admin">&larr; Back</a>
<br>
<br>
<h2>Recent Interactions</h2>
<table role="grid">
<thead>
<tr>
<th scope="col">Entry</th>
<th scope="col">Created At</th>
</tr>
</thead>
{{ range .Interactions }}
<tr>
<td scope="row">
<a href="/posts/{{ .EntryID }}">{{ .EntryID }}</a>
</td>
<td>
{{ .CreatedAt.Format "2006-01-02 15:04" }}
</td>
</tr>
{{ end }}
</table>
<hr>
<nav class="row">
{{ if not .FirstPage }}
<div>
<a href="?page={{ .PrevPage }}">Prev</a>
</div>
{{ end }}
<div>Page {{.Page}}</div>
{{ if not .LastPage }}
<div>
<a href="?page={{ .NextPage }}">Next</a>
</div>
{{ end }}
</nav>
{{end}}

View File

@ -1,12 +1,12 @@
{{define "title"}}Index{{end}} {{define "title"}}Index{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}
<div class="h-feed"> <div class="h-feed">
{{ range .Entries }} {{ range .Entries }}
<div class="h-entry"> <div class="h-entry">
<hgroup> <h1 class="entry-title">
<h3>
<a class="u-url" href="/posts/{{ .ID }}/"> <a class="u-url" href="/posts/{{ .ID }}/">
{{if .Title}} {{if .Title}}
{{ .Title }} {{ .Title }}
@ -14,15 +14,15 @@
# #
{{end}} {{end}}
</a> </a>
</h3> </h1>
<small style="font-size: 0.75em;"> <div class="entry-meta">
Published:
{{ if .PublishedAt }} {{ if .PublishedAt }}
<time class="dt-published" datetime="{{.PublishedAt.Format "2006-01-02T15:04:05" }}"> <time class="dt-published" datetime="{{.PublishedAt.Format "2006-01-02T15:04:05" }}">
{{.PublishedAt.Format "2006-01-02" }} {{.PublishedAt.Format "2006-01-02" }}
</time> </time>
{{ end }} {{ end }}
</small> </div>
</hgroup>
{{ .Content }} {{ .Content }}
</div> </div>
<hr> <hr>

View File

@ -1,4 +1,5 @@
{{define "title"}}Editor List{{end}} {{define "title"}}Editor List{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}

View File

@ -1,4 +1,5 @@
{{define "title"}}Editor{{end}} {{define "title"}}Editor{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}
@ -13,9 +14,6 @@
<label for="SubTitle">SubTitle</label> <label for="SubTitle">SubTitle</label>
<input type="text" name="SubTitle" id="SubTitle" value="{{.SubTitle}}"/> <input type="text" name="SubTitle" id="SubTitle" value="{{.SubTitle}}"/>
<label for="HeaderColor">HeaderColor</label>
<input type="color" name="HeaderColor" id="HeaderColor" value="{{.HeaderColor}}"/>
<label for="PrimaryColor">PrimaryColor</label> <label for="PrimaryColor">PrimaryColor</label>
<input type="color" name="PrimaryColor" id="PrimaryColor" value="{{.PrimaryColor}}"/> <input type="color" name="PrimaryColor" id="PrimaryColor" value="{{.PrimaryColor}}"/>

View File

@ -1,4 +1,5 @@
{{define "title"}}Editor{{end}} {{define "title"}}Editor{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}

View File

@ -1,4 +1,5 @@
{{define "title"}}Editor{{end}} {{define "title"}}Editor{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}

View File

@ -1,4 +1,5 @@
{{define "title"}}Editor{{end}} {{define "title"}}Editor{{end}}
{{define "head"}}{{end}}
{{define "main"}} {{define "main"}}

View File

@ -18,7 +18,7 @@ func (*MockEntryMetaData) Form(binSvc model.BinaryStorageInterface) string {
} }
// ParseFormData implements model.EntryMetaData. // ParseFormData implements model.EntryMetaData.
func (*MockEntryMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) (model.EntryMetaData, error) { func (*MockEntryMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
panic("unimplemented") panic("unimplemented")
} }

View File

@ -1,48 +1,29 @@
package web package web
import ( import (
"errors"
"log/slog"
"net/http"
"net/url" "net/url"
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository" "strings"
"owl-blogs/config"
"owl-blogs/domain/model"
"owl-blogs/render"
vocab "github.com/go-ap/activitypub" vocab "github.com/go-ap/activitypub"
"github.com/go-ap/jsonld"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/adaptor"
) )
const ACT_PUB_CONF_NAME = "activity_pub"
type ActivityPubServer struct { type ActivityPubServer struct {
configRepo repository.ConfigRepository siteConfigService *app.SiteConfigService
apService *app.ActivityPubService
entryService *app.EntryService entryService *app.EntryService
} }
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 (*ActivityPubConfig) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) (app.AppConfig, error) {
return &ActivityPubConfig{
PreferredUsername: data.FormValue("PreferredUsername"),
PublicKeyPem: data.FormValue("PublicKeyPem"),
PrivateKeyPem: data.FormValue("PrivateKeyPem"),
}, nil
}
type WebfingerResponse struct { type WebfingerResponse struct {
Subject string `json:"subject"` Subject string `json:"subject"`
Aliases []string `json:"aliases"`
Links []WebfingerLink `json:"links"` Links []WebfingerLink `json:"links"`
} }
@ -52,27 +33,38 @@ type WebfingerLink struct {
Href string `json:"href"` Href string `json:"href"`
} }
func NewActivityPubServer(configRepo repository.ConfigRepository, entryService *app.EntryService) *ActivityPubServer { func NewActivityPubServer(siteConfigService *app.SiteConfigService, entryService *app.EntryService, apService *app.ActivityPubService) *ActivityPubServer {
return &ActivityPubServer{ return &ActivityPubServer{
configRepo: configRepo, siteConfigService: siteConfigService,
entryService: entryService, entryService: entryService,
apService: apService,
} }
} }
func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error { func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
siteConfig := model.SiteConfig{} siteConfig, _ := s.siteConfigService.GetSiteConfig()
apConfig := ActivityPubConfig{} apConfig, _ := s.apService.GetApConfig()
s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig) domain, err := url.Parse(siteConfig.FullUrl)
if err != nil {
return err
}
subject := ctx.Query("resource", "")
blogSubject := "acct:" + apConfig.PreferredUsername + "@" + domain.Host
slog.Info("webfinger request", "for", subject, "required", blogSubject)
if subject != blogSubject {
return ctx.Status(404).JSON(nil)
}
webfinger := WebfingerResponse{ webfinger := WebfingerResponse{
Subject: ctx.Query("resource"), Subject: subject,
Links: []WebfingerLink{ Links: []WebfingerLink{
{ {
Rel: "self", Rel: "self",
Type: "application/activity+json", Type: "application/activity+json",
Href: siteConfig.FullUrl + "/activitypub/actor", Href: s.apService.ActorUrl(),
}, },
}, },
} }
@ -82,28 +74,40 @@ func (s *ActivityPubServer) HandleWebfinger(ctx *fiber.Ctx) error {
} }
func (s *ActivityPubServer) Router(router fiber.Router) { func (s *ActivityPubServer) Router(router fiber.Router) {
router.Get("/actor", s.HandleActor)
router.Get("/outbox", s.HandleOutbox) router.Get("/outbox", s.HandleOutbox)
router.Post("/inbox", s.HandleInbox)
router.Get("/followers", s.HandleFollowers)
} }
func (s *ActivityPubServer) HandleActor(ctx *fiber.Ctx) error { func (s *ActivityPubServer) HandleActor(ctx *fiber.Ctx) error {
siteConfig := model.SiteConfig{} accepts := (strings.Contains(string(ctx.Request().Header.Peek("Accept")), "application/activity+json") ||
apConfig := ActivityPubConfig{} strings.Contains(string(ctx.Request().Header.Peek("Accept")), "application/ld+json"))
s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig) req_content := (strings.Contains(string(ctx.Request().Header.Peek("Content-Type")), "application/activity+json") ||
s.configRepo.Get(config.SITE_CONFIG, &siteConfig) strings.Contains(string(ctx.Request().Header.Peek("Content-Type")), "application/ld+json"))
if !accepts && !req_content {
return ctx.Next()
}
apConfig, _ := s.apService.GetApConfig()
actor := vocab.PersonNew(vocab.IRI(siteConfig.FullUrl + "/activitypub/actor")) actor := vocab.PersonNew(vocab.IRI(s.apService.ActorUrl()))
actor.PreferredUsername = vocab.NaturalLanguageValues{{Value: vocab.Content(apConfig.PreferredUsername)}} actor.PreferredUsername = vocab.NaturalLanguageValues{{Value: vocab.Content(apConfig.PreferredUsername)}}
actor.Inbox = vocab.IRI(siteConfig.FullUrl + "/activitypub/inbox") actor.Inbox = vocab.IRI(s.apService.InboxUrl())
actor.Outbox = vocab.IRI(siteConfig.FullUrl + "/activitypub/outbox") actor.Outbox = vocab.IRI(s.apService.OutboxUrl())
actor.Followers = vocab.IRI(siteConfig.FullUrl + "/activitypub/followers") actor.Followers = vocab.IRI(s.apService.FollowersUrl())
actor.PublicKey = vocab.PublicKey{ actor.PublicKey = vocab.PublicKey{
ID: vocab.ID(siteConfig.FullUrl + "/activitypub/actor#main-key"), ID: vocab.IRI(s.apService.MainKeyUri()),
Owner: vocab.IRI(siteConfig.FullUrl + "/activitypub/actor"), Owner: vocab.IRI(s.apService.ActorUrl()),
PublicKeyPem: apConfig.PublicKeyPem, PublicKeyPem: apConfig.PublicKeyPem,
} }
data, err := actor.MarshalJSON() actor.Name = vocab.NaturalLanguageValues{{Value: vocab.Content(s.apService.ActorName())}}
actor.Icon = s.apService.ActorIcon()
actor.Summary = vocab.NaturalLanguageValues{{Value: vocab.Content(s.apService.ActorSummary())}}
data, err := jsonld.WithContext(
jsonld.IRI(vocab.ActivityBaseURI),
jsonld.IRI(vocab.SecurityContextURI),
).Marshal(actor)
if err != nil { if err != nil {
return err return err
} }
@ -112,10 +116,8 @@ func (s *ActivityPubServer) HandleActor(ctx *fiber.Ctx) error {
} }
func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error { func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error {
siteConfig := model.SiteConfig{} siteConfig, _ := s.siteConfigService.GetSiteConfig()
apConfig := ActivityPubConfig{} // apConfig, _ := s.apService.GetApConfig()
s.configRepo.Get(ACT_PUB_CONF_NAME, &apConfig)
s.configRepo.Get(config.SITE_CONFIG, &siteConfig)
entries, err := s.entryService.FindAllByType(nil, true, false) entries, err := s.entryService.FindAllByType(nil, true, false)
if err != nil { if err != nil {
@ -134,7 +136,7 @@ func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error {
}) })
} }
outbox := vocab.OrderedCollectionNew(vocab.IRI(siteConfig.FullUrl + "/activitypub/outbox")) outbox := vocab.OrderedCollectionNew(vocab.IRI(s.apService.OutboxUrl()))
outbox.TotalItems = uint(len(items)) outbox.TotalItems = uint(len(items))
outbox.OrderedItems = items outbox.OrderedItems = items
@ -144,5 +146,158 @@ func (s *ActivityPubServer) HandleOutbox(ctx *fiber.Ctx) error {
} }
ctx.Set("Content-Type", "application/activity+json") ctx.Set("Content-Type", "application/activity+json")
return ctx.Send(data) return ctx.Send(data)
}
func (s *ActivityPubServer) processFollow(r *http.Request, act *vocab.Activity) error {
follower := act.Actor.GetID().String()
err := s.apService.VerifySignature(r, follower)
if err != nil {
slog.Error("wrong signature", "err", err)
return err
}
err = s.apService.AddFollower(follower)
if err != nil {
return err
}
go s.apService.Accept(act)
return nil
}
func (s *ActivityPubServer) processUndo(r *http.Request, act *vocab.Activity) error {
sender := act.Actor.GetID().String()
err := s.apService.VerifySignature(r, sender)
return vocab.OnObject(act.Object, func(o *vocab.Object) error {
if o.Type == vocab.FollowType {
if err != nil {
slog.Error("wrong signature", "err", err)
return err
}
err = s.apService.RemoveFollower(sender)
if err != nil {
return err
}
go s.apService.Accept(act)
return nil
}
if o.Type == vocab.LikeType {
return s.apService.RemoveLike(o.ID.String())
}
if o.Type == vocab.AnnounceType {
return s.apService.RemoveRepost(o.ID.String())
}
slog.Warn("unsupporeted object type for undo", "object", o)
return errors.New("unsupporeted object type")
})
} }
func (s *ActivityPubServer) processLike(r *http.Request, act *vocab.Activity) error {
sender := act.Actor.GetID().String()
liked := act.Object.GetID().String()
err := s.apService.VerifySignature(r, sender)
if err != nil {
slog.Error("wrong signature", "err", err)
return err
}
err = s.apService.AddLike(sender, liked, act.ID.String())
if err != nil {
slog.Error("error saving like", "err", err)
return err
}
go s.apService.Accept(act)
return nil
}
func (s *ActivityPubServer) processAnnounce(r *http.Request, act *vocab.Activity) error {
sender := act.Actor.GetID().String()
liked := act.Object.GetID().String()
err := s.apService.VerifySignature(r, sender)
if err != nil {
slog.Error("wrong signature", "err", err)
return err
}
err = s.apService.AddRepost(sender, liked, act.ID.String())
if err != nil {
slog.Error("error saving like", "err", err)
return err
}
go s.apService.Accept(act)
return nil
}
func (s *ActivityPubServer) processDelete(r *http.Request, act *vocab.Activity) error {
return vocab.OnObject(act.Object, func(o *vocab.Object) error {
slog.Warn("Not processing delete", "action", act, "object", o)
return nil
})
}
func (s *ActivityPubServer) HandleInbox(ctx *fiber.Ctx) error {
body := ctx.Request().Body()
data, err := vocab.UnmarshalJSON(body)
if err != nil {
slog.Error("failed to parse request body", "body", body, "err", err)
return err
}
err = vocab.OnActivity(data, func(act *vocab.Activity) error {
slog.Info("activity retrieved", "activity", act, "type", act.Type)
r, err := adaptor.ConvertRequest(ctx, true)
if err != nil {
return err
}
if act.Type == vocab.FollowType {
return s.processFollow(r, act)
}
if act.Type == vocab.UndoType {
return s.processUndo(r, act)
}
if act.Type == vocab.DeleteType {
return s.processDelete(r, act)
}
if act.Type == vocab.LikeType {
return s.processLike(r, act)
}
if act.Type == vocab.AnnounceType {
return s.processAnnounce(r, act)
}
slog.Warn("Unsupported action", "body", body)
return errors.New("only follow and undo actions supported")
})
return err
}
func (s *ActivityPubServer) HandleFollowers(ctx *fiber.Ctx) error {
fs, err := s.apService.AllFollowers()
if err != nil {
return err
}
followers := vocab.Collection{}
for _, f := range fs {
followers.Append(vocab.IRI(f))
}
followers.TotalItems = uint(len(fs))
followers.ID = vocab.IRI(s.apService.FollowersUrl())
data, err := jsonld.WithContext(
jsonld.IRI(vocab.ActivityBaseURI),
).Marshal(followers)
if err != nil {
return err
}
ctx.Set("Content-Type", "application/activity+json")
return ctx.Send(data)
}

View File

@ -36,7 +36,6 @@ func NewAdminHandler(
func (h *adminHandler) Handle(c *fiber.Ctx) error { func (h *adminHandler) Handle(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := getSiteConfig(h.configRepo)
configs := h.configRegister.Configs() configs := h.configRegister.Configs()
types := h.typeRegistry.Types() types := h.typeRegistry.Types()
@ -53,8 +52,7 @@ func (h *adminHandler) Handle(c *fiber.Ctx) error {
}) })
return render.RenderTemplateWithBase( return render.RenderTemplateWithBase(
c, siteConfig, c, "views/admin", &adminContet{
"views/admin", &adminContet{
Configs: configs, Configs: configs,
Types: typeNames, Types: typeNames,
}, },
@ -73,14 +71,13 @@ func (h *adminHandler) HandleConfigGet(c *fiber.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
siteConfig := getSiteConfig(h.configRepo)
htmlForm := config.Form(h.binSvc) htmlForm := config.Form(h.binSvc)
if err != nil { if err != nil {
return err return err
} }
return render.RenderTemplateWithBase(c, siteConfig, "views/admin_config", htmlForm) return render.RenderTemplateWithBase(c, "views/admin_config", htmlForm)
} }
func (h *adminHandler) HandleConfigPost(c *fiber.Ctx) error { func (h *adminHandler) HandleConfigPost(c *fiber.Ctx) error {
@ -92,12 +89,12 @@ func (h *adminHandler) HandleConfigPost(c *fiber.Ctx) error {
return c.SendStatus(404) return c.SendStatus(404)
} }
newConfig, err := config.ParseFormData(c, h.binSvc) err := config.ParseFormData(c, h.binSvc)
if err != nil { if err != nil {
return err return err
} }
h.configRepo.Update(configName, newConfig) h.configRepo.Update(configName, config)
return c.Redirect("") return c.Redirect("")

View File

@ -2,6 +2,7 @@ package web
import ( import (
"owl-blogs/app/repository" "owl-blogs/app/repository"
"owl-blogs/render"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@ -18,6 +19,28 @@ func NewAdminInteractionHandler(configRepo repository.ConfigRepository, interact
} }
} }
func (h *AdminInteractionHandler) HandleGet(c *fiber.Ctx) error {
filter := c.Query("filter", "")
interactions, err := h.interactionRepo.ListAllInteractions()
if err != nil {
return err
}
pageData := paginate(c, interactions, 50)
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
return render.RenderTemplateWithBase(c, "views/interaction_manager", fiber.Map{
"Interactions": pageData.items,
"Page": pageData.page,
"NextPage": pageData.page + 1,
"PrevPage": pageData.page - 1,
"FirstPage": pageData.page == 1,
"LastPage": pageData.lastPage,
"Filter": filter,
})
}
func (h *AdminInteractionHandler) HandleDelete(c *fiber.Ctx) error { func (h *AdminInteractionHandler) HandleDelete(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)

View File

@ -8,7 +8,6 @@ import (
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository" "owl-blogs/app/repository"
"owl-blogs/config" "owl-blogs/config"
"owl-blogs/domain/model"
"owl-blogs/web/middleware" "owl-blogs/web/middleware"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@ -34,31 +33,36 @@ func NewWebApp(
authorService *app.AuthorService, authorService *app.AuthorService,
configRepo repository.ConfigRepository, configRepo repository.ConfigRepository,
configRegister *app.ConfigRegister, configRegister *app.ConfigRegister,
siteConfigService *app.SiteConfigService,
webmentionService *app.WebmentionService, webmentionService *app.WebmentionService,
interactionRepo repository.InteractionRepository, interactionRepo repository.InteractionRepository,
apService *app.ActivityPubService,
) *WebApp { ) *WebApp {
app := fiber.New() fiberApp := fiber.New(fiber.Config{
app.Use(middleware.NewUserMiddleware(authorService).Handle) BodyLimit: 50 * 1024 * 1024, // 50MB in bytes
DisableStartupMessage: true,
})
fiberApp.Use(middleware.NewUserMiddleware(authorService).Handle)
indexHandler := NewIndexHandler(entryService, configRepo) indexHandler := NewIndexHandler(entryService, siteConfigService)
listHandler := NewListHandler(entryService, configRepo) listHandler := NewListHandler(entryService, siteConfigService)
entryHandler := NewEntryHandler(entryService, typeRegistry, authorService, configRepo, interactionRepo) entryHandler := NewEntryHandler(entryService, typeRegistry, authorService, configRepo, interactionRepo)
mediaHandler := NewMediaHandler(binService) mediaHandler := NewMediaHandler(binService)
rssHandler := NewRSSHandler(entryService, configRepo) rssHandler := NewRSSHandler(entryService, siteConfigService)
loginHandler := NewLoginHandler(authorService, configRepo) loginHandler := NewLoginHandler(authorService, configRepo)
editorHandler := NewEditorHandler(entryService, typeRegistry, binService, configRepo) editorHandler := NewEditorHandler(entryService, typeRegistry, binService, configRepo)
webmentionHandler := NewWebmentionHandler(webmentionService, configRepo) webmentionHandler := NewWebmentionHandler(webmentionService, configRepo)
// Login // Login
app.Get("/auth/login", loginHandler.HandleGet) fiberApp.Get("/auth/login", loginHandler.HandleGet)
app.Post("/auth/login", loginHandler.HandlePost) fiberApp.Post("/auth/login", loginHandler.HandlePost)
// admin // admin
adminHandler := NewAdminHandler(configRepo, configRegister, typeRegistry) adminHandler := NewAdminHandler(configRepo, configRegister, typeRegistry)
draftHandler := NewDraftHandler(entryService, configRepo) draftHandler := NewDraftHandler(entryService, siteConfigService)
binaryManageHandler := NewBinaryManageHandler(configRepo, binService) binaryManageHandler := NewBinaryManageHandler(configRepo, binService)
adminInteractionHandler := NewAdminInteractionHandler(configRepo, interactionRepo) adminInteractionHandler := NewAdminInteractionHandler(configRepo, interactionRepo)
admin := app.Group("/admin") admin := fiberApp.Group("/admin")
admin.Use(middleware.NewAuthMiddleware(authorService).Handle) admin.Use(middleware.NewAuthMiddleware(authorService).Handle)
admin.Get("/", adminHandler.Handle) admin.Get("/", adminHandler.Handle)
admin.Get("/drafts/", draftHandler.Handle) admin.Get("/drafts/", draftHandler.Handle)
@ -68,9 +72,13 @@ func NewWebApp(
admin.Post("/binaries/new/", binaryManageHandler.HandleUpload) admin.Post("/binaries/new/", binaryManageHandler.HandleUpload)
admin.Post("/binaries/delete", binaryManageHandler.HandleDelete) admin.Post("/binaries/delete", binaryManageHandler.HandleDelete)
admin.Post("/interactions/:id/delete/", adminInteractionHandler.HandleDelete) admin.Post("/interactions/:id/delete/", adminInteractionHandler.HandleDelete)
admin.Get("/interactions/", adminInteractionHandler.HandleGet)
adminApi := admin.Group("/api")
adminApi.Post("/binaries", binaryManageHandler.HandleUploadApi)
// Editor // Editor
editor := app.Group("/editor") editor := fiberApp.Group("/editor")
editor.Use(middleware.NewAuthMiddleware(authorService).Handle) editor.Use(middleware.NewAuthMiddleware(authorService).Handle)
editor.Get("/new/:editor/", editorHandler.HandleGetNew) editor.Get("/new/:editor/", editorHandler.HandleGetNew)
editor.Post("/new/:editor/", editorHandler.HandlePostNew) editor.Post("/new/:editor/", editorHandler.HandlePostNew)
@ -80,74 +88,62 @@ func NewWebApp(
editor.Post("/unpublish/:id/", editorHandler.HandlePostUnpublish) editor.Post("/unpublish/:id/", editorHandler.HandlePostUnpublish)
// SiteConfig // SiteConfig
siteConfig := app.Group("/site-config") siteConfig := fiberApp.Group("/site-config")
siteConfig.Use(middleware.NewAuthMiddleware(authorService).Handle) siteConfig.Use(middleware.NewAuthMiddleware(authorService).Handle)
siteConfigHandler := NewSiteConfigHandler(configRepo) siteConfigHandler := NewSiteConfigHandler(siteConfigService)
siteConfig.Get("/", siteConfigHandler.HandleGet) siteConfig.Get("/", siteConfigHandler.HandleGet)
siteConfig.Post("/", siteConfigHandler.HandlePost) siteConfig.Post("/", siteConfigHandler.HandlePost)
siteConfigMeHandler := NewSiteConfigMeHandler(configRepo) siteConfigMeHandler := NewSiteConfigMeHandler(siteConfigService)
siteConfig.Get("/me", siteConfigMeHandler.HandleGet) siteConfig.Get("/me", siteConfigMeHandler.HandleGet)
siteConfig.Post("/me/create/", siteConfigMeHandler.HandleCreate) siteConfig.Post("/me/create/", siteConfigMeHandler.HandleCreate)
siteConfig.Post("/me/delete/", siteConfigMeHandler.HandleDelete) siteConfig.Post("/me/delete/", siteConfigMeHandler.HandleDelete)
siteConfigListHandler := NewSiteConfigListHandler(configRepo, typeRegistry) siteConfigListHandler := NewSiteConfigListHandler(siteConfigService, typeRegistry)
siteConfig.Get("/lists", siteConfigListHandler.HandleGet) siteConfig.Get("/lists", siteConfigListHandler.HandleGet)
siteConfig.Post("/lists/create/", siteConfigListHandler.HandleCreate) siteConfig.Post("/lists/create/", siteConfigListHandler.HandleCreate)
siteConfig.Post("/lists/delete/", siteConfigListHandler.HandleDelete) siteConfig.Post("/lists/delete/", siteConfigListHandler.HandleDelete)
siteConfigMenusHandler := NewSiteConfigMenusHandler(configRepo) siteConfigMenusHandler := NewSiteConfigMenusHandler(siteConfigService)
siteConfig.Get("/menus", siteConfigMenusHandler.HandleGet) siteConfig.Get("/menus", siteConfigMenusHandler.HandleGet)
siteConfig.Post("/menus/create/", siteConfigMenusHandler.HandleCreate) siteConfig.Post("/menus/create/", siteConfigMenusHandler.HandleCreate)
siteConfig.Post("/menus/delete/", siteConfigMenusHandler.HandleDelete) siteConfig.Post("/menus/delete/", siteConfigMenusHandler.HandleDelete)
// app.Static("/static/*filepath", http.Dir(repo.StaticDir())) activityPubServer := NewActivityPubServer(siteConfigService, entryService, apService)
app.Use("/static", filesystem.New(filesystem.Config{ configRegister.Register(config.ACT_PUB_CONF_NAME, &app.ActivityPubConfig{})
fiberApp.Use("/static", filesystem.New(filesystem.Config{
Root: http.FS(embedDirStatic), Root: http.FS(embedDirStatic),
PathPrefix: "static", PathPrefix: "static",
Browse: false, Browse: false,
})) }))
app.Get("/", indexHandler.Handle) fiberApp.Get("/", activityPubServer.HandleActor, indexHandler.Handle)
app.Get("/lists/:list/", listHandler.Handle) fiberApp.Get("/lists/:list/", listHandler.Handle)
// Media // Media
app.Get("/media/+", mediaHandler.Handle) fiberApp.Get("/media/+", mediaHandler.Handle)
// RSS // RSS
app.Get("/index.xml", rssHandler.Handle) fiberApp.Get("/index.xml", rssHandler.Handle)
// Posts // Posts
app.Get("/posts/:post/", entryHandler.Handle) fiberApp.Get("/posts/:post/", entryHandler.Handle)
// Webmention // Webmention
app.Post("/webmention/", webmentionHandler.Handle) fiberApp.Post("/webmention/", webmentionHandler.Handle)
// robots.txt // robots.txt
app.Get("/robots.txt", func(c *fiber.Ctx) error { fiberApp.Get("/robots.txt", func(c *fiber.Ctx) error {
siteConfig := model.SiteConfig{} siteConfig, _ := siteConfigService.GetSiteConfig()
configRepo.Get(config.SITE_CONFIG, &siteConfig)
sitemapUrl, _ := url.JoinPath(siteConfig.FullUrl, "/sitemap.xml") sitemapUrl, _ := url.JoinPath(siteConfig.FullUrl, "/sitemap.xml")
c.Set("Content-Type", "text/plain") c.Set("Content-Type", "text/plain")
return c.SendString(fmt.Sprintf("User-agent: GPTBot\nDisallow: /\n\nUser-agent: *\nAllow: /\n\nSitemap: %s\n", sitemapUrl)) return c.SendString(fmt.Sprintf("User-agent: GPTBot\nDisallow: /\n\nUser-agent: *\nAllow: /\n\nSitemap: %s\n", sitemapUrl))
}) })
// sitemap.xml // sitemap.xml
app.Get("/sitemap.xml", NewSiteMapHandler(entryService, configRepo).Handle) fiberApp.Get("/sitemap.xml", NewSiteMapHandler(entryService, siteConfigService).Handle)
// ActivityPub // ActivityPub
activityPubServer := NewActivityPubServer(configRepo, entryService) fiberApp.Get("/.well-known/webfinger", activityPubServer.HandleWebfinger)
configRegister.Register(ACT_PUB_CONF_NAME, &ActivityPubConfig{}) fiberApp.Route("/activitypub", activityPubServer.Router)
app.Get("/.well-known/webfinger", activityPubServer.HandleWebfinger)
app.Route("/activitypub", activityPubServer.Router)
// Webmention
// app.Post("/webmention/", userWebmentionHandler(repo))
// Micropub
// app.Post("/micropub/", userMicropubHandler(repo))
// IndieAuth
// app.Get("/auth/", userAuthHandler(repo))
// app.Post("/auth/", userAuthProfileHandler(repo))
// app.Post("/auth/verify/", userAuthVerifyHandler(repo))
// app.Post("/auth/token/", userAuthTokenHandler(repo))
// app.Get("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo))
// app.NotFound = http.HandlerFunc(notFoundHandler(repo))
return &WebApp{ return &WebApp{
FiberApp: app, FiberApp: fiberApp,
EntryService: entryService, EntryService: entryService,
Registry: typeRegistry, Registry: typeRegistry,
BinaryService: binService, BinaryService: binService,

View File

@ -3,6 +3,7 @@ package web
import ( import (
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository" "owl-blogs/app/repository"
"owl-blogs/domain/model"
"owl-blogs/render" "owl-blogs/render"
"sort" "sort"
"strings" "strings"
@ -23,9 +24,9 @@ func NewBinaryManageHandler(configRepo repository.ConfigRepository, service *app
} }
func (h *BinaryManageHandler) Handle(c *fiber.Ctx) error { func (h *BinaryManageHandler) Handle(c *fiber.Ctx) error {
siteConfig := getSiteConfig(h.configRepo) filter := c.Query("filter", "")
allIds, err := h.service.ListIds() allIds, err := h.service.ListIds(filter)
sort.Slice(allIds, func(i, j int) bool { sort.Slice(allIds, func(i, j int) bool {
return strings.ToLower(allIds[i]) < strings.ToLower(allIds[j]) return strings.ToLower(allIds[i]) < strings.ToLower(allIds[j])
}) })
@ -35,42 +36,60 @@ func (h *BinaryManageHandler) Handle(c *fiber.Ctx) error {
pageData := paginate(c, allIds, 50) pageData := paginate(c, allIds, 50)
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
return render.RenderTemplateWithBase(c, siteConfig, "views/binary_manager", fiber.Map{ return render.RenderTemplateWithBase(c, "views/binary_manager", fiber.Map{
"Binaries": pageData.items, "Binaries": pageData.items,
"Page": pageData.page, "Page": pageData.page,
"NextPage": pageData.page + 1, "NextPage": pageData.page + 1,
"PrevPage": pageData.page - 1, "PrevPage": pageData.page - 1,
"FirstPage": pageData.page == 1, "FirstPage": pageData.page == 1,
"LastPage": pageData.lastPage, "LastPage": pageData.lastPage,
"Filter": filter,
}) })
} }
func (h *BinaryManageHandler) HandleUpload(c *fiber.Ctx) error { func (h *BinaryManageHandler) saveFileUpload(c *fiber.Ctx) (*model.BinaryFile, error) {
file, err := c.FormFile("file") file, err := c.FormFile("file")
if err != nil { if err != nil {
return err return nil, err
} }
reader, err := file.Open() reader, err := file.Open()
if err != nil { if err != nil {
return err return nil, err
} }
defer reader.Close() defer reader.Close()
content := make([]byte, file.Size) content := make([]byte, file.Size)
_, err = reader.Read(content) _, err = reader.Read(content)
if err != nil { if err != nil {
return err return nil, err
} }
binary, err := h.service.Create(file.Filename, content) binary, err := h.service.Create(file.Filename, content)
if err != nil {
return nil, err
}
return binary, nil
}
func (h *BinaryManageHandler) HandleUpload(c *fiber.Ctx) error {
binary, err := h.saveFileUpload(c)
if err != nil { if err != nil {
return err return err
} }
return c.Redirect("/media/" + binary.Id) return c.Redirect("/media/" + binary.Id)
} }
func (h *BinaryManageHandler) HandleUploadApi(c *fiber.Ctx) error {
binary, err := h.saveFileUpload(c)
if err != nil {
return err
}
return c.JSON(map[string]string{
"location": "/media/" + binary.Id,
})
}
func (h *BinaryManageHandler) HandleDelete(c *fiber.Ctx) error { func (h *BinaryManageHandler) HandleDelete(c *fiber.Ctx) error {
id := c.FormValue("file") id := c.FormValue("file")
binary, err := h.service.FindById(id) binary, err := h.service.FindById(id)

View File

@ -2,7 +2,6 @@ package web
import ( import (
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"owl-blogs/render" "owl-blogs/render"
"sort" "sort"
@ -12,17 +11,17 @@ import (
) )
type DraftHandler struct { type DraftHandler struct {
configRepo repository.ConfigRepository siteConfigService *app.SiteConfigService
entrySvc *app.EntryService entrySvc *app.EntryService
} }
func NewDraftHandler( func NewDraftHandler(
entryService *app.EntryService, entryService *app.EntryService,
configRepo repository.ConfigRepository, siteConfigService *app.SiteConfigService,
) *DraftHandler { ) *DraftHandler {
return &DraftHandler{ return &DraftHandler{
entrySvc: entryService, entrySvc: entryService,
configRepo: configRepo, siteConfigService: siteConfigService,
} }
} }
@ -38,7 +37,10 @@ type DraftRenderData struct {
func (h *DraftHandler) Handle(c *fiber.Ctx) error { func (h *DraftHandler) Handle(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := getSiteConfig(h.configRepo) siteConfig, err := h.siteConfigService.GetSiteConfig()
if err != nil {
return err
}
entries, err := h.entrySvc.FindAllByType(&siteConfig.PrimaryListInclude, false, true) entries, err := h.entrySvc.FindAllByType(&siteConfig.PrimaryListInclude, false, true)
if err != nil { if err != nil {
@ -76,7 +78,7 @@ func (h *DraftHandler) Handle(c *fiber.Ctx) error {
return err return err
} }
return render.RenderTemplateWithBase(c, siteConfig, "views/draft_list", DraftRenderData{ return render.RenderTemplateWithBase(c, "views/draft_list", DraftRenderData{
Entries: entries, Entries: entries,
Page: pageNum, Page: pageNum,
NextPage: pageNum + 1, NextPage: pageNum + 1,

View File

@ -48,7 +48,7 @@ func (h *EditorHandler) HandleGetNew(c *fiber.Ctx) error {
return err return err
} }
htmlForm := entryType.MetaData().Form(h.binSvc) htmlForm := entryType.MetaData().Form(h.binSvc)
return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/editor", htmlForm) return render.RenderTemplateWithBase(c, "views/editor", htmlForm)
} }
func (h *EditorHandler) HandlePostNew(c *fiber.Ctx) error { func (h *EditorHandler) HandlePostNew(c *fiber.Ctx) error {
@ -59,7 +59,8 @@ func (h *EditorHandler) HandlePostNew(c *fiber.Ctx) error {
return err return err
} }
entryMeta, err := entry.MetaData().ParseFormData(c, h.binSvc) entryMeta := entry.MetaData()
err = entryMeta.ParseFormData(c, h.binSvc)
if err != nil { if err != nil {
return err return err
} }
@ -93,7 +94,7 @@ func (h *EditorHandler) HandleGetEdit(c *fiber.Ctx) error {
} }
htmlForm := entry.MetaData().Form(h.binSvc) htmlForm := entry.MetaData().Form(h.binSvc)
return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/editor", htmlForm) return render.RenderTemplateWithBase(c, "views/editor", htmlForm)
} }
func (h *EditorHandler) HandlePostEdit(c *fiber.Ctx) error { func (h *EditorHandler) HandlePostEdit(c *fiber.Ctx) error {
@ -106,7 +107,8 @@ func (h *EditorHandler) HandlePostEdit(c *fiber.Ctx) error {
} }
// get form data // get form data
meta, err := entry.MetaData().ParseFormData(c, h.binSvc) meta := entry.MetaData()
err = meta.ParseFormData(c, h.binSvc)
if err != nil { if err != nil {
return err return err
} }

View File

@ -72,7 +72,6 @@ func (h *EntryHandler) Handle(c *fiber.Ctx) error {
return render.RenderTemplateWithBase( return render.RenderTemplateWithBase(
c, c,
getSiteConfig(h.configRepo),
"views/entry", "views/entry",
entryData{ entryData{
Entry: entry, Entry: entry,

View File

@ -2,7 +2,6 @@ package web
import ( import (
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"owl-blogs/render" "owl-blogs/render"
"sort" "sort"
@ -12,17 +11,17 @@ import (
) )
type IndexHandler struct { type IndexHandler struct {
configRepo repository.ConfigRepository siteConfigService *app.SiteConfigService
entrySvc *app.EntryService entrySvc *app.EntryService
} }
func NewIndexHandler( func NewIndexHandler(
entryService *app.EntryService, entryService *app.EntryService,
configRepo repository.ConfigRepository, siteConfigService *app.SiteConfigService,
) *IndexHandler { ) *IndexHandler {
return &IndexHandler{ return &IndexHandler{
entrySvc: entryService, entrySvc: entryService,
configRepo: configRepo, siteConfigService: siteConfigService,
} }
} }
@ -38,7 +37,10 @@ type indexRenderData struct {
func (h *IndexHandler) Handle(c *fiber.Ctx) error { func (h *IndexHandler) Handle(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := getSiteConfig(h.configRepo) siteConfig, err := h.siteConfigService.GetSiteConfig()
if err != nil {
return err
}
entries, err := h.entrySvc.FindAllByType(&siteConfig.PrimaryListInclude, true, false) entries, err := h.entrySvc.FindAllByType(&siteConfig.PrimaryListInclude, true, false)
if err != nil { if err != nil {
@ -76,7 +78,7 @@ func (h *IndexHandler) Handle(c *fiber.Ctx) error {
return err return err
} }
return render.RenderTemplateWithBase(c, siteConfig, "views/index", indexRenderData{ return render.RenderTemplateWithBase(c, "views/index", indexRenderData{
Entries: entries, Entries: entries,
Page: pageNum, Page: pageNum,
NextPage: pageNum + 1, NextPage: pageNum + 1,

View File

@ -2,7 +2,6 @@ package web
import ( import (
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"owl-blogs/render" "owl-blogs/render"
"sort" "sort"
@ -12,17 +11,17 @@ import (
) )
type ListHandler struct { type ListHandler struct {
configRepo repository.ConfigRepository siteConfigService *app.SiteConfigService
entrySvc *app.EntryService entrySvc *app.EntryService
} }
func NewListHandler( func NewListHandler(
entryService *app.EntryService, entryService *app.EntryService,
configRepo repository.ConfigRepository, siteConfigService *app.SiteConfigService,
) *ListHandler { ) *ListHandler {
return &ListHandler{ return &ListHandler{
entrySvc: entryService, entrySvc: entryService,
configRepo: configRepo, siteConfigService: siteConfigService,
} }
} }
@ -39,7 +38,10 @@ type listRenderData struct {
func (h *ListHandler) Handle(c *fiber.Ctx) error { func (h *ListHandler) Handle(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := getSiteConfig(h.configRepo) siteConfig, err := h.siteConfigService.GetSiteConfig()
if err != nil {
return err
}
listId := c.Params("list") listId := c.Params("list")
list := model.EntryList{} list := model.EntryList{}
for _, l := range siteConfig.Lists { for _, l := range siteConfig.Lists {
@ -87,7 +89,7 @@ func (h *ListHandler) Handle(c *fiber.Ctx) error {
return err return err
} }
return render.RenderTemplateWithBase(c, siteConfig, "views/list", listRenderData{ return render.RenderTemplateWithBase(c, "views/list", listRenderData{
List: list, List: list,
Entries: entries, Entries: entries,
Page: pageNum, Page: pageNum,

View File

@ -26,7 +26,7 @@ func NewLoginHandler(
func (h *LoginHandler) HandleGet(c *fiber.Ctx) error { func (h *LoginHandler) HandleGet(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
return render.RenderTemplateWithBase(c, getSiteConfig(h.configRepo), "views/login", nil) return render.RenderTemplateWithBase(c, "views/login", nil)
} }
func (h *LoginHandler) HandlePost(c *fiber.Ctx) error { func (h *LoginHandler) HandlePost(c *fiber.Ctx) error {

View File

@ -5,7 +5,6 @@ import (
"encoding/xml" "encoding/xml"
"net/url" "net/url"
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"sort" "sort"
"time" "time"
@ -104,18 +103,21 @@ func RenderRSSFeed(config model.SiteConfig, entries []model.Entry) (string, erro
} }
type RSSHandler struct { type RSSHandler struct {
configRepo repository.ConfigRepository siteConfigService *app.SiteConfigService
entrySvc *app.EntryService entrySvc *app.EntryService
} }
func NewRSSHandler(entryService *app.EntryService, configRepo repository.ConfigRepository) *RSSHandler { func NewRSSHandler(entryService *app.EntryService, siteConfigService *app.SiteConfigService) *RSSHandler {
return &RSSHandler{entrySvc: entryService, configRepo: configRepo} return &RSSHandler{entrySvc: entryService, siteConfigService: siteConfigService}
} }
func (h *RSSHandler) Handle(c *fiber.Ctx) error { func (h *RSSHandler) Handle(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMEApplicationXML) c.Set(fiber.HeaderContentType, fiber.MIMEApplicationXML)
siteConfig := getSiteConfig(h.configRepo) siteConfig, err := h.siteConfigService.GetSiteConfig()
if err != nil {
return err
}
entries, err := h.entrySvc.FindAllByType(&siteConfig.PrimaryListInclude, true, false) entries, err := h.entrySvc.FindAllByType(&siteConfig.PrimaryListInclude, true, false)
if err != nil { if err != nil {

View File

@ -1,49 +1,43 @@
package web package web
import ( import (
"owl-blogs/app/repository" "owl-blogs/app"
"owl-blogs/config"
"owl-blogs/domain/model"
"owl-blogs/render" "owl-blogs/render"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
type SiteConfigHandler struct { type SiteConfigHandler struct {
siteConfigRepo repository.ConfigRepository svc *app.SiteConfigService
} }
func NewSiteConfigHandler(siteConfigRepo repository.ConfigRepository) *SiteConfigHandler { func NewSiteConfigHandler(svc *app.SiteConfigService) *SiteConfigHandler {
return &SiteConfigHandler{ return &SiteConfigHandler{
siteConfigRepo: siteConfigRepo, svc: svc,
} }
} }
func (h *SiteConfigHandler) HandleGet(c *fiber.Ctx) error { func (h *SiteConfigHandler) HandleGet(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := model.SiteConfig{} siteConfig, err := h.svc.GetSiteConfig()
err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
return err return err
} }
return render.RenderTemplateWithBase(c, getSiteConfig(h.siteConfigRepo), "views/site_config", siteConfig) return render.RenderTemplateWithBase(c, "views/site_config", siteConfig)
} }
func (h *SiteConfigHandler) HandlePost(c *fiber.Ctx) error { func (h *SiteConfigHandler) HandlePost(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := model.SiteConfig{} siteConfig, err := h.svc.GetSiteConfig()
err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
return err return err
} }
siteConfig.Title = c.FormValue("Title") siteConfig.Title = c.FormValue("Title")
siteConfig.SubTitle = c.FormValue("SubTitle") siteConfig.SubTitle = c.FormValue("SubTitle")
siteConfig.HeaderColor = c.FormValue("HeaderColor")
siteConfig.PrimaryColor = c.FormValue("PrimaryColor") siteConfig.PrimaryColor = c.FormValue("PrimaryColor")
siteConfig.AuthorName = c.FormValue("AuthorName") siteConfig.AuthorName = c.FormValue("AuthorName")
siteConfig.AvatarUrl = c.FormValue("AvatarUrl") siteConfig.AvatarUrl = c.FormValue("AvatarUrl")
@ -51,7 +45,7 @@ func (h *SiteConfigHandler) HandlePost(c *fiber.Ctx) error {
siteConfig.HtmlHeadExtra = c.FormValue("HtmlHeadExtra") siteConfig.HtmlHeadExtra = c.FormValue("HtmlHeadExtra")
siteConfig.FooterExtra = c.FormValue("FooterExtra") siteConfig.FooterExtra = c.FormValue("FooterExtra")
err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) err = h.svc.UpdateSiteConfig(siteConfig)
if err != nil { if err != nil {
return err return err
} }

View File

@ -2,8 +2,6 @@ package web
import ( import (
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository"
"owl-blogs/config"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"owl-blogs/render" "owl-blogs/render"
"strconv" "strconv"
@ -12,7 +10,7 @@ import (
) )
type SiteConfigListHandler struct { type SiteConfigListHandler struct {
siteConfigRepo repository.ConfigRepository siteConfigService *app.SiteConfigService
typeRegistry *app.EntryTypeRegistry typeRegistry *app.EntryTypeRegistry
} }
@ -22,11 +20,11 @@ type siteConfigListTemplateData struct {
} }
func NewSiteConfigListHandler( func NewSiteConfigListHandler(
siteConfigRepo repository.ConfigRepository, siteConfigService *app.SiteConfigService,
typeRegistry *app.EntryTypeRegistry, typeRegistry *app.EntryTypeRegistry,
) *SiteConfigListHandler { ) *SiteConfigListHandler {
return &SiteConfigListHandler{ return &SiteConfigListHandler{
siteConfigRepo: siteConfigRepo, siteConfigService: siteConfigService,
typeRegistry: typeRegistry, typeRegistry: typeRegistry,
} }
} }
@ -34,9 +32,7 @@ func NewSiteConfigListHandler(
func (h *SiteConfigListHandler) HandleGet(c *fiber.Ctx) error { func (h *SiteConfigListHandler) HandleGet(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := model.SiteConfig{} siteConfig, err := h.siteConfigService.GetSiteConfig()
err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
return err return err
} }
@ -51,7 +47,7 @@ func (h *SiteConfigListHandler) HandleGet(c *fiber.Ctx) error {
} }
return render.RenderTemplateWithBase( return render.RenderTemplateWithBase(
c, getSiteConfig(h.siteConfigRepo), "views/site_config_list", siteConfigListTemplateData{ c, "views/site_config_list", siteConfigListTemplateData{
Lists: siteConfig.Lists, Lists: siteConfig.Lists,
Types: types, Types: types,
}) })
@ -60,8 +56,7 @@ func (h *SiteConfigListHandler) HandleGet(c *fiber.Ctx) error {
func (h *SiteConfigListHandler) HandleCreate(c *fiber.Ctx) error { func (h *SiteConfigListHandler) HandleCreate(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := model.SiteConfig{} siteConfig, err := h.siteConfigService.GetSiteConfig()
err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
return err return err
@ -79,7 +74,7 @@ func (h *SiteConfigListHandler) HandleCreate(c *fiber.Ctx) error {
ListType: c.FormValue("ListType"), ListType: c.FormValue("ListType"),
}) })
err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) err = h.siteConfigService.UpdateSiteConfig(siteConfig)
if err != nil { if err != nil {
return err return err
} }
@ -90,8 +85,7 @@ func (h *SiteConfigListHandler) HandleCreate(c *fiber.Ctx) error {
func (h *SiteConfigListHandler) HandleDelete(c *fiber.Ctx) error { func (h *SiteConfigListHandler) HandleDelete(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := model.SiteConfig{} siteConfig, err := h.siteConfigService.GetSiteConfig()
err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
return err return err
@ -104,7 +98,7 @@ func (h *SiteConfigListHandler) HandleDelete(c *fiber.Ctx) error {
siteConfig.Lists = append(siteConfig.Lists[:id], siteConfig.Lists[id+1:]...) siteConfig.Lists = append(siteConfig.Lists[:id], siteConfig.Lists[id+1:]...)
err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) err = h.siteConfigService.UpdateSiteConfig(siteConfig)
if err != nil { if err != nil {
return err return err
} }

View File

@ -1,8 +1,7 @@
package web package web
import ( import (
"owl-blogs/app/repository" "owl-blogs/app"
"owl-blogs/config"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"owl-blogs/render" "owl-blogs/render"
"strconv" "strconv"
@ -11,34 +10,31 @@ import (
) )
type SiteConfigMeHandler struct { type SiteConfigMeHandler struct {
siteConfigRepo repository.ConfigRepository siteConfigService *app.SiteConfigService
} }
func NewSiteConfigMeHandler(siteConfigRepo repository.ConfigRepository) *SiteConfigMeHandler { func NewSiteConfigMeHandler(siteConfigService *app.SiteConfigService) *SiteConfigMeHandler {
return &SiteConfigMeHandler{ return &SiteConfigMeHandler{
siteConfigRepo: siteConfigRepo, siteConfigService: siteConfigService,
} }
} }
func (h *SiteConfigMeHandler) HandleGet(c *fiber.Ctx) error { func (h *SiteConfigMeHandler) HandleGet(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := model.SiteConfig{} siteConfig, err := h.siteConfigService.GetSiteConfig()
err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
return err return err
} }
return render.RenderTemplateWithBase( return render.RenderTemplateWithBase(
c, getSiteConfig(h.siteConfigRepo), "views/site_config_me", siteConfig.Me) c, "views/site_config_me", siteConfig.Me)
} }
func (h *SiteConfigMeHandler) HandleCreate(c *fiber.Ctx) error { func (h *SiteConfigMeHandler) HandleCreate(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := model.SiteConfig{} siteConfig, err := h.siteConfigService.GetSiteConfig()
err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
return err return err
@ -49,7 +45,7 @@ func (h *SiteConfigMeHandler) HandleCreate(c *fiber.Ctx) error {
Url: c.FormValue("Url"), Url: c.FormValue("Url"),
}) })
err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) err = h.siteConfigService.UpdateSiteConfig(siteConfig)
if err != nil { if err != nil {
return err return err
} }
@ -60,8 +56,7 @@ func (h *SiteConfigMeHandler) HandleCreate(c *fiber.Ctx) error {
func (h *SiteConfigMeHandler) HandleDelete(c *fiber.Ctx) error { func (h *SiteConfigMeHandler) HandleDelete(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := model.SiteConfig{} siteConfig, err := h.siteConfigService.GetSiteConfig()
err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
return err return err
@ -73,7 +68,7 @@ func (h *SiteConfigMeHandler) HandleDelete(c *fiber.Ctx) error {
} }
siteConfig.Me = append(siteConfig.Me[:idx], siteConfig.Me[idx+1:]...) siteConfig.Me = append(siteConfig.Me[:idx], siteConfig.Me[idx+1:]...)
err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) err = h.siteConfigService.UpdateSiteConfig(siteConfig)
if err != nil { if err != nil {
return err return err
} }

View File

@ -1,8 +1,7 @@
package web package web
import ( import (
"owl-blogs/app/repository" "owl-blogs/app"
"owl-blogs/config"
"owl-blogs/domain/model" "owl-blogs/domain/model"
"owl-blogs/render" "owl-blogs/render"
"strconv" "strconv"
@ -11,7 +10,7 @@ import (
) )
type SiteConfigMenusHandler struct { type SiteConfigMenusHandler struct {
siteConfigRepo repository.ConfigRepository siteConfigService *app.SiteConfigService
} }
type siteConfigMenusTemplateData struct { type siteConfigMenusTemplateData struct {
@ -19,24 +18,23 @@ type siteConfigMenusTemplateData struct {
FooterMenu []model.MenuItem FooterMenu []model.MenuItem
} }
func NewSiteConfigMenusHandler(siteConfigRepo repository.ConfigRepository) *SiteConfigMenusHandler { func NewSiteConfigMenusHandler(siteConfigService *app.SiteConfigService) *SiteConfigMenusHandler {
return &SiteConfigMenusHandler{ return &SiteConfigMenusHandler{
siteConfigRepo: siteConfigRepo, siteConfigService: siteConfigService,
} }
} }
func (h *SiteConfigMenusHandler) HandleGet(c *fiber.Ctx) error { func (h *SiteConfigMenusHandler) HandleGet(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := model.SiteConfig{} siteConfig, err := h.siteConfigService.GetSiteConfig()
err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
return err return err
} }
return render.RenderTemplateWithBase( return render.RenderTemplateWithBase(
c, getSiteConfig(h.siteConfigRepo), "views/site_config_menus", siteConfigMenusTemplateData{ c, "views/site_config_menus", siteConfigMenusTemplateData{
HeaderMenu: siteConfig.HeaderMenu, HeaderMenu: siteConfig.HeaderMenu,
FooterMenu: siteConfig.FooterMenu, FooterMenu: siteConfig.FooterMenu,
}) })
@ -45,8 +43,7 @@ func (h *SiteConfigMenusHandler) HandleGet(c *fiber.Ctx) error {
func (h *SiteConfigMenusHandler) HandleCreate(c *fiber.Ctx) error { func (h *SiteConfigMenusHandler) HandleCreate(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := model.SiteConfig{} siteConfig, err := h.siteConfigService.GetSiteConfig()
err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
return err return err
@ -65,7 +62,7 @@ func (h *SiteConfigMenusHandler) HandleCreate(c *fiber.Ctx) error {
siteConfig.FooterMenu = append(siteConfig.FooterMenu, menuItem) siteConfig.FooterMenu = append(siteConfig.FooterMenu, menuItem)
} }
err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) err = h.siteConfigService.UpdateSiteConfig(siteConfig)
if err != nil { if err != nil {
return err return err
} }
@ -76,8 +73,7 @@ func (h *SiteConfigMenusHandler) HandleCreate(c *fiber.Ctx) error {
func (h *SiteConfigMenusHandler) HandleDelete(c *fiber.Ctx) error { func (h *SiteConfigMenusHandler) HandleDelete(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMETextHTML) c.Set(fiber.HeaderContentType, fiber.MIMETextHTML)
siteConfig := model.SiteConfig{} siteConfig, err := h.siteConfigService.GetSiteConfig()
err := h.siteConfigRepo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil { if err != nil {
return err return err
@ -95,7 +91,7 @@ func (h *SiteConfigMenusHandler) HandleDelete(c *fiber.Ctx) error {
siteConfig.FooterMenu = append(siteConfig.FooterMenu[:idx], siteConfig.FooterMenu[idx+1:]...) siteConfig.FooterMenu = append(siteConfig.FooterMenu[:idx], siteConfig.FooterMenu[idx+1:]...)
} }
err = h.siteConfigRepo.Update(config.SITE_CONFIG, siteConfig) err = h.siteConfigService.UpdateSiteConfig(siteConfig)
if err != nil { if err != nil {
return err return err
} }

View File

@ -5,14 +5,13 @@ import (
"encoding/xml" "encoding/xml"
"net/url" "net/url"
"owl-blogs/app" "owl-blogs/app"
"owl-blogs/app/repository"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
type SiteMapHandler struct { type SiteMapHandler struct {
entryService *app.EntryService entryService *app.EntryService
configRepo repository.ConfigRepository siteConfigService *app.SiteConfigService
} }
type Sitemap struct { type Sitemap struct {
@ -25,15 +24,18 @@ type SitemapUrl struct {
Loc string `xml:"loc"` Loc string `xml:"loc"`
} }
func NewSiteMapHandler(entryService *app.EntryService, configRepo repository.ConfigRepository) *SiteMapHandler { func NewSiteMapHandler(entryService *app.EntryService, siteConfigService *app.SiteConfigService) *SiteMapHandler {
return &SiteMapHandler{entryService: entryService, configRepo: configRepo} return &SiteMapHandler{entryService: entryService, siteConfigService: siteConfigService}
} }
// Handle handles GET /sitemap.xml // Handle handles GET /sitemap.xml
func (h *SiteMapHandler) Handle(c *fiber.Ctx) error { func (h *SiteMapHandler) Handle(c *fiber.Ctx) error {
c.Set(fiber.HeaderContentType, fiber.MIMEApplicationXML) c.Set(fiber.HeaderContentType, fiber.MIMEApplicationXML)
siteConfig := getSiteConfig(h.configRepo) siteConfig, err := h.siteConfigService.GetSiteConfig()
if err != nil {
return err
}
entries, err := h.entryService.FindAllByType(nil, true, false) entries, err := h.entryService.FindAllByType(nil, true, false)
if err != nil { if err != nil {
return err return err

96
web/static/editor.js Normal file
View File

@ -0,0 +1,96 @@
/**
* Add "drop to upload" to a text area
* @param {string} id id of a textarea
*/
function addFileDrop(id) {
// deactivate file drop on body
// this prevents accidentally opening the file instead uploading
document.body.ondrop = (ev) => {
ev.preventDefault();
return false
}
document.body.ondragover = (ev) => {
ev.preventDefault();
return false
}
// get field
const textArea = document.getElementById(id)
/**
* Uploads a single file and add markdown to textarea
* @param {File} file file object to upload
*/
function processFile(file) {
console.log(`name = ${file.name}`);
console.log(`size = ${file.size}`);
console.log(`type = ${file.type}`);
console.log(file)
const formData = new FormData()
formData.append("file", file)
textArea.classList.add("drop-file-process")
fetch(
"/admin/api/binaries",
{
method: "POST",
body: formData
}
).then((resp) => {
return resp.json()
}).then(data => {
if (file.type.split("/")[0] == "image") {
textArea.value += `\n![](${data.location})`
} else {
textArea.value += `\n[${file.name}](${data.location})`
}
textArea.classList.remove("drop-file-process")
}).catch(err => {
console.error(err);
textArea.classList.add("drop-file-error")
setTimeout(() => {
textArea.classList.remove("drop-file-error")
}, 2000)
}).finally(() => {
textArea.classList.remove("drop-file-process")
})
}
function dropHandler(ev) {
textArea.classList.remove("drop-file")
ev.preventDefault();
if (ev.dataTransfer.items) {
// Use DataTransferItemList interface to access the file(s)
[...ev.dataTransfer.items].forEach((item, i) => {
// If dropped items aren't files, reject them
if (item.kind === "file") {
const file = item.getAsFile();
processFile(file)
}
});
} else {
// Use DataTransfer interface to access the file(s)
[...ev.dataTransfer.files].forEach((file, i) => {
processFile(file)
});
}
}
function dragOverHandler(ev) {
textArea.classList.add("drop-file")
ev.preventDefault();
}
function dragLeaveHandler(ev) {
textArea.classList.remove("drop-file")
ev.preventDefault();
}
textArea.ondrop = dropHandler;
textArea.ondragover = dragOverHandler;
textArea.ondragleave = dragLeaveHandler;
}

486
web/static/owl.css Normal file
View File

@ -0,0 +1,486 @@
:root {
/* font sizes (fs) */
/*
0 base
lX large
sX small
*/
--fs-scale: 1.125;
--fs0: 1rem;
--fsl1: calc(var(--fs0) * var(--fs-scale));
--fsl2: calc(var(--fsl1) * var(--fs-scale));
--fsl3: calc(var(--fsl2) * var(--fs-scale));
--fsl4: calc(var(--fsl3) * var(--fs-scale));
--fss1: calc(var(--fs0) / var(--fs-scale));
--fss2: calc(var(--fss1) / var(--fs-scale));
--fss3: calc(var(--fss2) / var(--fs-scale));
--fss4: calc(var(--fss3) / var(--fs-scale));
/* font weight */
--fw: 400;
--fwb: 700;
--fwl: 100;
/* font color */
/* fonts */
--font: Arial, Helvetica, sans-serif;
--font-h: 'Courier New', Courier, monospace;
--font-code: 'Courier New', Courier, monospace;
/* spacings */
--spacing-scale: 1.5;
--max-spacing: 4rem;
--s5: var(--max-spacing);
--s4: calc(var(--s5) / var(--spacing-scale));
--s3: calc(var(--s4) / var(--spacing-scale));
--s2: calc(var(--s3) / var(--spacing-scale));
--s1: calc(var(--s2) / var(--spacing-scale));
--s0: calc(var(--s1) / var(--spacing-scale));
/* content-width */
--cw: 620px;
/* colors */
--text: hsl(0, 0%, 17%);
--primary: hsl(200, 25%, 50%);
--primary-l1: color-mix(in srgb, var(--primary), #fff 20%);
--primary-l2: color-mix(in srgb, var(--primary), #fff 40%);
--primary-l3: color-mix(in srgb, var(--primary), #fff 60%);
--primary-l4: color-mix(in srgb, var(--primary), #fff 80%);
--primary-d1: color-mix(in srgb, var(--primary), #000 20%);
--primary-d2: color-mix(in srgb, var(--primary), #000 40%);
--primary-d3: color-mix(in srgb, var(--primary), #000 60%);
--primary-d4: color-mix(in srgb, var(--primary), #000 80%);
--secondary: color-mix(in hsl longer hue, var(--primary), var(--primary) 50%);
--secondary-l1: color-mix(in srgb, var(--secondary), #fff 20%);
--secondary-l2: color-mix(in srgb, var(--secondary), #fff 40%);
--secondary-l3: color-mix(in srgb, var(--secondary), #fff 60%);
--secondary-l4: color-mix(in srgb, var(--secondary), #fff 80%);
--secondary-d1: color-mix(in srgb, var(--secondary), #000 20%);
--secondary-d2: color-mix(in srgb, var(--secondary), #000 40%);
--secondary-d3: color-mix(in srgb, var(--secondary), #000 60%);
--secondary-d4: color-mix(in srgb, var(--secondary), #000 80%);
--text-primary: color-mix(in srgb, var(--primary), #fff 90%);
--text-secondary: color-mix(in srgb, var(--secondary), #fff 90%);
}
/* Styling of main page elements */
@media only screen and (max-width: 600px) {
main {
max-width: 100%;
padding-left: var(--s1);
padding-right: var(--s1);
}
}
@media screen and (min-width: 600px) {
main {
max-width: var(--cw);
}
}
* {
/* global properties*/
font-family: var(--font);
color: var(--text);
}
body {
display: flex;
flex-direction: column;
margin: 0;
min-height: 100vh;
}
html {
margin: 0;
min-height: 100vh;
}
main {
flex: 1;
margin: 0 auto;
}
:is(h1, h2, h3, h4, h5, h6) {
font-family: var(--font-h);
margin-top: var(--s4);
margin-bottom: var(--s0);
}
h1 {
font-size: var(--fsl4);
}
h2 {
font-size: var(--fsl3);
}
h3 {
font-size: var(--fsl2);
}
h4 {
font-size: var(--fsl1);
}
h5 {
font-size: var(--fs0);
font-weight: var(--fwb);
}
h6 {
font-size: var(--fsl4);
}
hr {
color: var(--primary);
border-style: dashed;
border-bottom: none;
border-width: 3px;
}
pre {
font-family: var(--font-code);
background-color: var(--primary-l4);
padding: var(--s0);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
main img {
max-width: 100%;
}
/* inline elements */
a {
text-decoration: none;
color: var(--primary);
}
a:hover {
text-decoration: underline;
color: var(--primary-d2);
}
abbr[title] {
border-bottom: 1px dashed;
text-decoration: none;
cursor: help;
}
kbd {
background-color: var(--primary-d3);
color: var(--text-primary);
padding: 0 var(--s0);
border-radius: var(--s0);
}
mark {
background-color: var(--secondary-l3);
}
code {
font-family: var(--font-code);
background-color: var(--primary-l4);
padding: 0 var(--s0);
border-radius: 2px;
}
/* lists */
ul,
ol {
padding-left: var(--s3);
}
li {
padding-bottom: var(--s0);
}
ul li {
list-style: square;
}
/* Quote */
blockquote {
margin-left: 0;
padding-left: var(--s4);
border-left: solid var(--primary) 3px;
}
blockquote>footer {
width: initial;
margin: initial;
background-color: initial;
color: initial;
padding-bottom: initial;
}
/* Table */
:where(table) {
border-collapse: collapse;
border-spacing: 0;
text-indent: 0;
}
table {
width: 100%;
}
th {
text-align: inherit;
padding: var(--s0);
border-bottom: solid var(--primary) 1px;
}
tr:nth-child(2n) {
background: var(--primary-l4);
}
td {
margin: 0;
padding: var(--s0);
border-bottom: solid var(--primary-l3) 1px;
}
/* buttons */
a[role='button'] {
display: inline-block;
box-sizing: border-box;
text-align: center;
}
a[role='button']:hover {
text-decoration: none;
}
button,
input[type='button'],
input[type='submit'],
a[role='button'] {
background-color: var(--primary);
color: var(--text-primary);
padding: var(--s1);
border: none;
border-radius: var(--s0);
font-size: var(--fs0);
}
button:hover,
input[type='button']:hover,
input[type='submit']:hover,
a[role='button']:hover {
background-color: var(--primary-d1);
}
button:disabled,
input[type='button']:disabled,
input[type='submit']:disabled,
a[role='button']:disabled {
background-color: var(--primary-l2);
}
input[type=checkbox]:checked,
input[type=radio]:checked,
input[type=range] {
accent-color: var(--primary);
}
/* forms */
label {
display: inline-block;
padding-bottom: var(--s0);
}
input,
textarea {
display: inline-block;
width: 100%;
vertical-align: middle;
margin-bottom: var(--s3);
padding: var(--s1);
box-sizing: border-box;
border: solid var(--primary-l2) 1px;
border-radius: var(--s0);
}
input[type='color'] {
padding: 0;
background-color: #fff;
}
label {
width: 100%;
}
label>input {
display: inherit;
width: initial;
margin-left: var(--s1);
margin-top: 0;
margin-bottom: 0;
}
label>input[type='color'] {
min-width: 170px;
}
/* button classes */
button.secondary,
input[type='button'].secondary,
input[type='submit'].secondary {
background-color: var(--secondary);
color: var(--text-secondary);
}
button.secondary:hover,
input[type='button'].secondary:hover,
input[type='submit'].secondary:hover {
background-color: var(--secondary-d1);
}
button.secondary:disabled,
input[type='button'].secondary:disabled,
input[type='submit'].secondary:disabled {
background-color: var(--secondary-l2);
}
/* Header specific styling */
header {
width: 100vw;
margin: 0;
margin-bottom: var(--s4);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
background-color: var(--primary);
color: var(--text-primary);
}
header img {
max-height: var(--s5);
margin-left: var(--s1);
}
header>hgroup {
flex: 1;
min-width: calc(var(--cw) / 2);
margin-right: var(--s5);
margin-left: var(--s1);
}
header>hgroup>h1 {
margin-top: var(--s0);
margin-bottom: var(--s0);
color: var(--text-primary);
}
header>hgroup>h1>a {
color: var(--text-primary);
}
header>hgroup>h1>a:hover {
color: var(--text-primary);
}
header>hgroup>p {
font-size: var(--fsl1);
margin-top: var(--s0);
margin-bottom: var(--s0);
font-family: var(--font);
color: var(--text-primary);
}
header>nav>ul {
display: flex;
list-style-type: none;
margin: 0;
padding-left: 0;
padding-top: var(--s1);
padding-bottom: var(--s1);
align-items: center;
flex-wrap: wrap;
}
header>nav>ul>li {
float: left;
margin-right: var(--s1);
padding: var(--s1);
color: var(--text-primary);
list-style-type: none;
}
header>nav>ul>li>a {
color: var(--text-primary);
}
header>nav>ul>li>a:hover {
color: var(--text-primary);
}
/* Footer specific styling */
footer {
width: 100vw;
margin: 0;
background-color: var(--primary);
color: var(--text-primary);
padding-bottom: var(--s5);
}
footer>div {
max-width: var(--cw);
margin: 0 auto;
color: var(--text-primary);
text-align: center;
}
footer>nav {
margin: 0 auto;
color: var(--text-primary);
}
footer>nav>ul {
display: flex;
justify-content: center;
list-style-type: none;
margin: 0;
padding-left: 0;
padding-top: var(--s1);
padding-bottom: var(--s1);
align-items: center;
flex-wrap: wrap;
}
footer>nav>ul>li {
float: left;
margin-right: var(--s1);
padding: var(--s1);
color: var(--text-primary);
list-style-type: none;
}
footer a {
color: var(--text-primary);
}
footer a:hover {
color: var(--text-primary);
}

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,33 @@
.row {
display: flex;
justify-content: space-between;
}
.grid {
display: flex;
justify-content: space-between;
align-items: stretch;
}
.grid > * {
flex: 1;
margin: 0.25rem;
}
nav.row {
margin-bottom: 2rem;
}
.entry-title {
margin-bottom: 0;
}
.entry-meta {
font-size: 0.8rem;
padding-bottom: 2rem;
}
.action-tile-list { .action-tile-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -5,20 +35,71 @@
} }
.action-tile { .action-tile {
border: 1px solid var(--background-dark); border: 1px solid var(--primary-l1);
padding: 20px; padding: 20px;
flex: 1 1 0px; flex: 1 1 0px;
margin: 6px; margin: 6px;
min-width: 20%; min-width: 20%;
text-align: center; text-align: center;
background: var(--background); background: var(--primary-l4);
border-radius: 4px; border-radius: 4px;
} }
.action-tile:hover { .action-tile:hover {
background: var(--background-light); background: var(--primary-l3);
} }
.danger { .danger {
background-color: var(--del-color) !important; background-color: rgb(136, 57, 53) !important;
} }
.drop-file {
background: #a2dbff;
border-style: dashed;
border-width: 4px;
border-color: #5391ff;
}
@keyframes border-pulsate {
0% {
border-color: #5391ff;
}
50% {
border-color: rgba(0, 255, 255, 0);
}
100% {
border-color: #5391ff;
}
}
.drop-file-process {
border-style: dashed;
border-width: 4px;
border-color: #5391ff;
animation: border-pulsate 2s infinite;
}
@keyframes border-pulsate-error {
0% {
border-color: #ff3e3e;
}
50% {
border-color: rgba(0, 255, 255, 0);
}
100% {
border-color: #ff3e3e;
}
}
.drop-file-error {
border-style: solid;
border-width: 4px;
border-color: #ff3e3e;
animation: border-pulsate-error 1s 2;
}

View File

@ -1,23 +1,11 @@
package web package web
import ( import (
"owl-blogs/app/repository"
"owl-blogs/config"
"owl-blogs/domain/model"
"strconv" "strconv"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
func getSiteConfig(repo repository.ConfigRepository) model.SiteConfig {
siteConfig := model.SiteConfig{}
err := repo.Get(config.SITE_CONFIG, &siteConfig)
if err != nil {
panic(err)
}
return siteConfig
}
type paginationData[T any] struct { type paginationData[T any] struct {
items []T items []T
page uint page uint