Compare commits

..

No commits in common. "main" and "version_2" have entirely different histories.

210 changed files with 7999 additions and 9524 deletions

View File

@ -1,46 +0,0 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "/tmp"
[build]
args_bin = ["web"]
bin = "/tmp/main"
cmd = "go build -buildvcs=false -o /tmp/main owl-blogs/cmd/owl"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "css"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

View File

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

View File

@ -1,45 +0,0 @@
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

View File

@ -1,38 +0,0 @@
# 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

7
.gitignore vendored
View File

@ -24,10 +24,3 @@ users/
.vscode/
*.swp
*.db
tmp/
venv/
*.pyc

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"editor.formatOnSave": true,
}

View File

@ -1,10 +1,10 @@
##
## Build Container
##
FROM golang:1.22-alpine as build
FROM golang:1.19-alpine as build
RUN apk add --no-cache --update git gcc g++
RUN apk add --no-cache git
WORKDIR /tmp/owl
@ -15,21 +15,19 @@ RUN go mod download
COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -o ./out/owl ./cmd/owl
RUN go build -o ./out/owl ./cmd/owl
##
## Run Container
##
FROM alpine
FROM alpine:3.9
RUN apk add ca-certificates
COPY --from=build /tmp/owl/out/ /bin/
# This container exposes port 8080 to the outside world
EXPOSE 3000
WORKDIR /owl
EXPOSE 8080
# Run the binary program produced by `go install`
ENTRYPOINT ["/bin/owl"]

119
README.md
View File

@ -2,60 +2,121 @@
# Owl Blogs
Owl-blogs is a blogging software focused on simplicity with IndieWeb and Fediverse support.
A simple web server for blogs generated from Markdown files.
# Usage
**_This project is not yet stable. Expect frequent breaking changes! Only use this if you are willing to regularly adjust your project accordingly._**
## Run
## Repository
To run the web server use the command:
A repository holds all data for a web server. It contains multiple users.
## User
A user has a collection of posts.
Each directory in the `/users/` directory of a repository is considered a user.
### User Directory structure
```
owl web
<user-name>/
\- public/
\- <post-name>
\- index.md
-- This will be rendered as the blog post.
-- Must be present for the blog post to be valid.
-- All other folders will be ignored
\- incoming_webmentions.yml
-- Used to track incoming webmentions
\- outgoing_webmentions.yml
-- Used to track outgoing webmentions
\- media/
-- Contains all media files used in the blog post.
-- All files in this folder will be publicly available
\- webmention/
\- <hash>.yml
-- Contains data for a received webmention
\- meta/
\- base.html
-- The template used to render all sites
\- config.yml
-- Holds information about the user
\- VERSION
-- Contains the version string.
-- Used to determine compatibility in the future
\- media/
-- All this files will be publicly available. To be used for general files
\- avatar.{png|jpg|jpeg|gif}
-- Optional: Avatar to be used in various places
\- favicon.{png|jpg|jpeg|gif|ico}
-- Optional: Favicon for the site
```
The blog will run on port 3000 (http://localhost:3000)
### User Config
To create a new account:
Stored in `meta/config.yml`
```
owl new-author -u <name> -p <password>
title: "Title of the Blog"
subtitle: "Subtitle of the Blog"
header_color: "#ff0000"
author_name: "Your Name"
me:
- name: "Connect on Mastodon"
url: "https://chaos.social/@h4kor"
- name: "I'm on Twitter"
url: "https://twitter.com/h4kor"
```
To retrieve a list of all commands run:
### Post
Posts are Markdown files with a mandatory metadata head.
- The `title` will be added to the web page and does not have to be reapeated in the body. It will be used in any lists of posts.
- `description` is optional. At the moment this is only used for the HTML head meta data.
- `aliases` are optional. They are used as permanent redirects to the actual blog page.
- `draft` is false by default. If set to `true` the post will not be accessible.
- `reply` optional. Will add the link to the top of the post with `rel="in-reply-to"`. For more infos see: [https://indieweb.org/reply](https://indieweb.org/reply)
```
owl -h
```
---
title: My new Post
Description: Short text used in meta data (and lists in the future)
date: 13 Aug 2022 17:07 UTC
aliases:
- /my/new/post
- /old_blog_path/
draft: false
reply:
url: https://link.to/referred_post
text: Text used for link
---
# Development
## Build
Actual post
```
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))
### Webmentions
## Tests
This feature is not yet full supported and needs a lot of manual work. Expect this to change quiet frequently and breaking existing usages.
The project has two test suites; "unit tests" written in go and "end-to-end tests" written in python.
To send webmentions use the command `owl webmention`
### Unit Tests
Retrieved webmentions have to be approved manually by changing the `approval_status` in the `incoming_webmentions.yml` file.
#### incoming_webmentions.yml
```
go test ./...
- source: https://example.com/post
title: Example Post
approval_status: ["", "approved", "rejected"]
retrieved_at: 2021-08-13T17:07:00Z
```
### End-to-End tests
#### outgoing_webmentions.yml
- 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
- target: https://example.com/post
supported: true
scanned_at: 2021-08-13T17:07:00Z
last_sent_at: 2021-08-13T17:07:00Z
```
- Run the e2e_tests with `pytest`

View File

@ -1,712 +0,0 @@
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

@ -1,101 +0,0 @@
package app
import (
"crypto/sha256"
"fmt"
"owl-blogs/app/repository"
"owl-blogs/domain/model"
"strings"
"golang.org/x/crypto/bcrypt"
)
type AuthorService struct {
repo repository.AuthorRepository
siteConfigService *SiteConfigService
}
func NewAuthorService(repo repository.AuthorRepository, siteConfigService *SiteConfigService) *AuthorService {
return &AuthorService{repo: repo, siteConfigService: siteConfigService}
}
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10)
if err != nil {
return "", err
}
return string(bytes), nil
}
func (s *AuthorService) Create(name string, password string) (*model.Author, error) {
hash, err := hashPassword(password)
if err != nil {
return nil, err
}
return s.repo.Create(name, hash)
}
func (s *AuthorService) SetPassword(name string, password string) error {
hash, err := hashPassword(password)
if err != nil {
return err
}
author, err := s.repo.FindByName(name)
if err != nil {
return err
}
author.PasswordHash = hash
err = s.repo.Update(author)
return err
}
func (s *AuthorService) FindByName(name string) (*model.Author, error) {
return s.repo.FindByName(name)
}
func (s *AuthorService) Authenticate(name string, password string) bool {
author, err := s.repo.FindByName(name)
if err != nil {
return false
}
err = bcrypt.CompareHashAndPassword([]byte(author.PasswordHash), []byte(password))
return err == nil
}
func (s *AuthorService) getSecretKey() string {
siteConfig, err := s.siteConfigService.GetSiteConfig()
if err != nil {
panic(err)
}
if siteConfig.Secret == "" {
siteConfig.Secret = RandStringRunes(64)
err = s.siteConfigService.UpdateSiteConfig(siteConfig)
if err != nil {
panic(err)
}
}
return siteConfig.Secret
}
func (s *AuthorService) CreateToken(name string) (string, error) {
hash := sha256.New()
_, err := hash.Write([]byte(name + s.getSecretKey()))
if err != nil {
return "", err
}
return fmt.Sprintf("%s.%x", name, hash.Sum(nil)), nil
}
func (s *AuthorService) ValidateToken(token string) (bool, string) {
parts := strings.Split(token, ".")
witness := parts[len(parts)-1]
name := strings.Join(parts[:len(parts)-1], ".")
hash := sha256.New()
_, err := hash.Write([]byte(name + s.getSecretKey()))
if err != nil {
return false, ""
}
return fmt.Sprintf("%x", hash.Sum(nil)) == witness, name
}

View File

@ -1,95 +0,0 @@
package app_test
import (
"owl-blogs/app"
"owl-blogs/domain/model"
"owl-blogs/infra"
"owl-blogs/test"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type testConfigRepo struct {
config model.SiteConfig
}
// Get implements repository.SiteConfigRepository.
func (c *testConfigRepo) Get(name string, result interface{}) error {
*result.(*model.SiteConfig) = c.config
return nil
}
// Update implements repository.SiteConfigRepository.
func (c *testConfigRepo) Update(name string, result interface{}) error {
c.config = result.(model.SiteConfig)
return nil
}
func getAutherService() *app.AuthorService {
db := test.NewMockDb()
authorRepo := infra.NewDefaultAuthorRepo(db)
authorService := app.NewAuthorService(authorRepo, app.NewSiteConfigService(&testConfigRepo{}))
return authorService
}
func TestAuthorCreate(t *testing.T) {
authorService := getAutherService()
author, err := authorService.Create("test", "test")
require.NoError(t, err)
require.Equal(t, "test", author.Name)
require.NotEmpty(t, author.PasswordHash)
require.NotEqual(t, "test", author.PasswordHash)
}
func TestAuthorFindByName(t *testing.T) {
authorService := getAutherService()
_, err := authorService.Create("test", "test")
require.NoError(t, err)
author, err := authorService.FindByName("test")
require.NoError(t, err)
require.Equal(t, "test", author.Name)
require.NotEmpty(t, author.PasswordHash)
require.NotEqual(t, "test", author.PasswordHash)
}
func TestAuthorAuthenticate(t *testing.T) {
authorService := getAutherService()
_, err := authorService.Create("test", "test")
require.NoError(t, err)
require.True(t, authorService.Authenticate("test", "test"))
require.False(t, authorService.Authenticate("test", "test1"))
require.False(t, authorService.Authenticate("test1", "test"))
}
func TestAuthorCreateToken(t *testing.T) {
authorService := getAutherService()
_, err := authorService.Create("test", "test")
require.NoError(t, err)
token, err := authorService.CreateToken("test")
require.NoError(t, err)
require.NotEmpty(t, token)
require.NotEqual(t, "test", token)
}
func TestAuthorValidateToken(t *testing.T) {
authorService := getAutherService()
_, err := authorService.Create("test", "test")
require.NoError(t, err)
token, err := authorService.CreateToken("test")
require.NoError(t, err)
valid, name := authorService.ValidateToken(token)
require.True(t, valid)
require.Equal(t, "test", name)
valid, _ = authorService.ValidateToken(token[:len(token)-2])
require.False(t, valid)
valid, _ = authorService.ValidateToken("test")
require.False(t, valid)
valid, _ = authorService.ValidateToken("test.test")
require.False(t, valid)
valid, _ = authorService.ValidateToken(strings.Replace(token, "test", "test1", 1))
require.False(t, valid)
}

View File

@ -1,37 +0,0 @@
package app
import (
"owl-blogs/app/repository"
"owl-blogs/domain/model"
)
type BinaryService struct {
repo repository.BinaryRepository
}
func NewBinaryFileService(repo repository.BinaryRepository) *BinaryService {
return &BinaryService{repo: repo}
}
func (s *BinaryService) Create(name string, file []byte) (*model.BinaryFile, error) {
return s.repo.Create(name, file, nil)
}
func (s *BinaryService) CreateEntryFile(name string, file []byte, entry model.Entry) (*model.BinaryFile, error) {
return s.repo.Create(name, file, entry)
}
func (s *BinaryService) FindById(id string) (*model.BinaryFile, error) {
return s.repo.FindById(id)
}
// 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
func (s *BinaryService) ListIds(filter string) ([]string, error) {
return s.repo.ListIds(filter)
}
func (s *BinaryService) Delete(binary *model.BinaryFile) error {
return s.repo.Delete(binary)
}

View File

@ -1,39 +0,0 @@
package app
import "owl-blogs/domain/model"
type AppConfig interface {
model.Formable
}
type ConfigRegister struct {
configs map[string]AppConfig
}
type RegisteredConfig struct {
Name string
Config AppConfig
}
func NewConfigRegister() *ConfigRegister {
return &ConfigRegister{configs: map[string]AppConfig{}}
}
func (r *ConfigRegister) Register(name string, config AppConfig) {
r.configs[name] = config
}
func (r *ConfigRegister) Configs() []RegisteredConfig {
var configs []RegisteredConfig
for name, config := range r.configs {
configs = append(configs, RegisteredConfig{
Name: name,
Config: config,
})
}
return configs
}
func (r *ConfigRegister) GetConfig(name string) AppConfig {
return r.configs[name]
}

View File

@ -1,11 +0,0 @@
package app
import (
"owl-blogs/domain/model"
)
type EntryTypeRegistry = TypeRegistry[model.Entry]
func NewEntryTypeRegistry() *EntryTypeRegistry {
return NewTypeRegistry[model.Entry]()
}

View File

@ -1,23 +0,0 @@
package app_test
import (
"owl-blogs/app"
"owl-blogs/test"
"testing"
"github.com/stretchr/testify/require"
)
func TestRegistryTypeNameNotExisting(t *testing.T) {
register := app.NewEntryTypeRegistry()
_, err := register.TypeName(&test.MockEntry{})
require.Error(t, err)
}
func TestRegistryTypeName(t *testing.T) {
register := app.NewEntryTypeRegistry()
register.Register(&test.MockEntry{})
name, err := register.TypeName(&test.MockEntry{})
require.NoError(t, err)
require.Equal(t, "MockEntry", name)
}

View File

@ -1,127 +0,0 @@
package app
import (
"errors"
"fmt"
"owl-blogs/app/repository"
"owl-blogs/domain/model"
"regexp"
"strings"
)
type EntryService struct {
EntryRepository repository.EntryRepository
siteConfigServcie *SiteConfigService
Bus *EventBus
}
func NewEntryService(
entryRepository repository.EntryRepository,
siteConfigServcie *SiteConfigService,
bus *EventBus,
) *EntryService {
return &EntryService{
EntryRepository: entryRepository,
siteConfigServcie: siteConfigServcie,
Bus: bus,
}
}
func (s *EntryService) Create(entry model.Entry) error {
// try to find a good ID
m := regexp.MustCompile(`[^a-z0-9-]`)
prefix := m.ReplaceAllString(strings.ToLower(entry.Title()), "-")
title := prefix
counter := 0
for {
_, err := s.EntryRepository.FindById(title)
if err == nil {
counter += 1
title = prefix + "-" + fmt.Sprintf("%s-%d", prefix, counter)
} else {
break
}
}
entry.SetID(title)
err := s.EntryRepository.Create(entry)
if err != nil {
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)
}
return nil
}
func (s *EntryService) Update(entry model.Entry) error {
err := s.EntryRepository.Update(entry)
if err != nil {
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)
}
return nil
}
func (s *EntryService) Delete(entry model.Entry) error {
err := s.EntryRepository.Delete(entry)
if err != nil {
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)
return nil
}
func (s *EntryService) FindById(id string) (model.Entry, error) {
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 {
filteredEntries := make([]model.Entry, 0)
for _, entry := range entries {
if published && entry.PublishedAt() != nil && !entry.PublishedAt().IsZero() {
filteredEntries = append(filteredEntries, entry)
}
if drafts && (entry.PublishedAt() == nil || entry.PublishedAt().IsZero()) {
filteredEntries = append(filteredEntries, entry)
}
}
return filteredEntries
}
func (s *EntryService) FindAllByType(types *[]string, published bool, drafts bool) ([]model.Entry, error) {
entries, err := s.EntryRepository.FindAll(types)
return s.filterEntries(entries, published, drafts), err
}
func (s *EntryService) FindAll() ([]model.Entry, error) {
entries, err := s.EntryRepository.FindAll(nil)
return s.filterEntries(entries, true, true), err
}

View File

@ -1,47 +0,0 @@
package app_test
import (
"owl-blogs/app"
"owl-blogs/infra"
"owl-blogs/test"
"testing"
"github.com/stretchr/testify/require"
)
func setupService() *app.EntryService {
db := test.NewMockDb()
register := app.NewEntryTypeRegistry()
register.Register(&test.MockEntry{})
repo := infra.NewEntryRepository(db, register)
cfgRepo := infra.NewConfigRepo(db)
cfgService := app.NewSiteConfigService(cfgRepo)
service := app.NewEntryService(repo, cfgService, app.NewEventBus())
return service
}
func TestNiceEntryId(t *testing.T) {
service := setupService()
entry := &test.MockEntry{}
meta := test.MockEntryMetaData{
Title: "Hello World",
}
entry.SetMetaData(&meta)
err := service.Create(entry)
require.NoError(t, err)
require.Equal(t, "hello-world", entry.ID())
}
func TestNoTitleCreation(t *testing.T) {
service := setupService()
entry := &test.MockEntry{}
meta := test.MockEntryMetaData{
Title: "",
}
entry.SetMetaData(&meta)
err := service.Create(entry)
require.NoError(t, err)
require.NotEqual(t, "", entry.ID())
}

View File

@ -1,51 +0,0 @@
package app
import "owl-blogs/domain/model"
type Subscriber interface{}
type EntryCreatedSubscriber interface {
NotifyEntryCreated(entry model.Entry)
}
type EntryUpdatedSubscriber interface {
NotifyEntryUpdated(entry model.Entry)
}
type EntryDeletedSubscriber interface {
NotifyEntryDeleted(entry model.Entry)
}
type EventBus struct {
subscribers []Subscriber
}
func NewEventBus() *EventBus {
return &EventBus{subscribers: make([]Subscriber, 0)}
}
func (b *EventBus) Subscribe(subscriber Subscriber) {
b.subscribers = append(b.subscribers, subscriber)
}
func (b *EventBus) NotifyCreated(entry model.Entry) {
for _, subscriber := range b.subscribers {
if sub, ok := subscriber.(EntryCreatedSubscriber); ok {
go sub.NotifyEntryCreated(entry)
}
}
}
func (b *EventBus) NotifyUpdated(entry model.Entry) {
for _, subscriber := range b.subscribers {
if sub, ok := subscriber.(EntryUpdatedSubscriber); ok {
go sub.NotifyEntryUpdated(entry)
}
}
}
func (b *EventBus) NotifyDeleted(entry model.Entry) {
for _, subscriber := range b.subscribers {
if sub, ok := subscriber.(EntryDeletedSubscriber); ok {
go sub.NotifyEntryDeleted(entry)
}
}
}

View File

@ -1,57 +0,0 @@
package app
import (
"errors"
"reflect"
)
type TypeRegistry[T any] struct {
types map[string]T
}
func NewTypeRegistry[T any]() *TypeRegistry[T] {
return &TypeRegistry[T]{types: map[string]T{}}
}
func (r *TypeRegistry[T]) entryType(entry T) string {
return reflect.TypeOf(entry).Elem().Name()
}
func (r *TypeRegistry[T]) Register(entry T) error {
t := r.entryType(entry)
if _, ok := r.types[t]; ok {
return errors.New("entry type already registered")
}
r.types[t] = entry
return nil
}
func (r *TypeRegistry[T]) Types() []T {
types := []T{}
for _, t := range r.types {
types = append(types, t)
}
return types
}
func (r *TypeRegistry[T]) TypeName(entry T) (string, error) {
t := r.entryType(entry)
if _, ok := r.types[t]; !ok {
return "", errors.New("entry type not registered")
}
return t, nil
}
func (r *TypeRegistry[T]) Type(name string) (T, error) {
if _, ok := r.types[name]; !ok {
return *new(T), errors.New("entry type not registered")
}
val := reflect.ValueOf(r.types[name])
if val.Kind() == reflect.Ptr {
val = reflect.Indirect(val)
}
newEntry := reflect.New(val.Type()).Interface().(T)
return newEntry, nil
}

View File

@ -1,11 +0,0 @@
package app
import (
"owl-blogs/domain/model"
)
type InteractionTypeRegistry = TypeRegistry[model.Interaction]
func NewInteractionTypeRegistry() *InteractionTypeRegistry {
return NewTypeRegistry[model.Interaction]()
}

View File

@ -1,14 +0,0 @@
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

@ -1,59 +0,0 @@
package repository
import (
"owl-blogs/domain/model"
)
type EntryRepository interface {
Create(entry model.Entry) error
Update(entry model.Entry) error
Delete(entry model.Entry) error
FindById(id string) (model.Entry, error)
FindAll(types *[]string) ([]model.Entry, error)
}
type BinaryRepository interface {
// Create creates a new binary file
// The name is the original file name, and is not unique
// BinaryFile.Id is a unique identifier
Create(name string, data []byte, entry model.Entry) (*model.BinaryFile, error)
FindById(id string) (*model.BinaryFile, error)
FindByNameForEntry(name string, entry model.Entry) (*model.BinaryFile, 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
}
type AuthorRepository interface {
// Create creates a new author
// It returns an error if the name is already taken
Create(name string, passwordHash string) (*model.Author, error)
Update(author *model.Author) error
// FindByName finds an author by name
// It returns an error if the author is not found
FindByName(name string) (*model.Author, error)
}
type ConfigRepository interface {
Get(name string, config interface{}) error
Update(name string, siteConfig interface{}) error
}
type InteractionRepository interface {
Create(interaction model.Interaction) error
Update(interaction model.Interaction) error
Delete(interaction model.Interaction) error
FindById(id 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

@ -1,58 +0,0 @@
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

@ -1,25 +0,0 @@
package app
import (
"math/rand"
"strings"
)
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandStringRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}
func UrlToEntryId(url string) string {
parts := strings.Split(url, "/")
if parts[len(parts)-1] == "" {
return parts[len(parts)-2]
} else {
return parts[len(parts)-1]
}
}

View File

@ -1,151 +0,0 @@
package app
import (
"fmt"
"net/url"
"owl-blogs/app/owlhttp"
"owl-blogs/app/repository"
"owl-blogs/domain/model"
"owl-blogs/interactions"
"time"
)
type WebmentionService struct {
siteConfigService *SiteConfigService
InteractionRepository repository.InteractionRepository
EntryRepository repository.EntryRepository
Http owlhttp.HttpClient
}
func NewWebmentionService(
siteConfigService *SiteConfigService,
interactionRepository repository.InteractionRepository,
entryRepository repository.EntryRepository,
http owlhttp.HttpClient,
bus *EventBus,
) *WebmentionService {
svc := &WebmentionService{
siteConfigService: siteConfigService,
InteractionRepository: interactionRepository,
EntryRepository: entryRepository,
Http: http,
}
bus.Subscribe(svc)
return svc
}
func (s *WebmentionService) GetExistingWebmention(entryId string, source string, target string) (*interactions.Webmention, error) {
inters, err := s.InteractionRepository.FindAll(entryId)
if err != nil {
return nil, err
}
for _, interaction := range inters {
if webm, ok := interaction.(*interactions.Webmention); ok {
m := webm.MetaData().(*interactions.WebmentionMetaData)
if m.Source == source && m.Target == target {
return webm, nil
}
}
}
return nil, nil
}
func (s *WebmentionService) ProcessWebmention(source string, target string) error {
resp, err := s.Http.Get(source)
if err != nil {
return err
}
hEntry, err := ParseHEntry(resp)
if err != nil {
return err
}
entryId := UrlToEntryId(target)
_, err = s.EntryRepository.FindById(entryId)
if err != nil {
return err
}
webmention, err := s.GetExistingWebmention(entryId, source, target)
if err != nil {
return err
}
if webmention != nil {
data := interactions.WebmentionMetaData{
Source: source,
Target: target,
Title: hEntry.Title,
}
webmention.SetMetaData(&data)
webmention.SetEntryID(entryId)
webmention.SetCreatedAt(time.Now())
err = s.InteractionRepository.Update(webmention)
return err
} else {
webmention = &interactions.Webmention{}
data := interactions.WebmentionMetaData{
Source: source,
Target: target,
Title: hEntry.Title,
}
webmention.SetMetaData(&data)
webmention.SetEntryID(entryId)
webmention.SetCreatedAt(time.Now())
err = s.InteractionRepository.Create(webmention)
return err
}
}
func (s *WebmentionService) ScanForLinks(entry model.Entry) ([]string, error) {
content := string(entry.Content())
return ParseLinksFromString(content)
}
func (s *WebmentionService) FullEntryUrl(entry model.Entry) string {
siteConfig, _ := s.siteConfigService.GetSiteConfig()
url, _ := url.JoinPath(
siteConfig.FullUrl,
fmt.Sprintf("/posts/%s/", entry.ID()),
)
return url
}
func (s *WebmentionService) SendWebmention(entry model.Entry) error {
links, err := s.ScanForLinks(entry)
if err != nil {
return err
}
for _, target := range links {
resp, err := s.Http.Get(target)
if err != nil {
continue
}
endpoint, err := GetWebmentionEndpoint(resp)
if err != nil {
continue
}
payload := url.Values{}
payload.Set("source", s.FullEntryUrl(entry))
payload.Set("target", target)
_, err = s.Http.PostForm(endpoint, payload)
if err != nil {
continue
}
println("Send webmention for target", target)
}
return nil
}
func (s *WebmentionService) NotifyEntryCreated(entry model.Entry) {
s.SendWebmention(entry)
}
func (s *WebmentionService) NotifyEntryUpdated(entry model.Entry) {
s.SendWebmention(entry)
}
func (s *WebmentionService) NotifyEntryDeleted(entry model.Entry) {
s.SendWebmention(entry)
}

View File

@ -1,217 +0,0 @@
package app_test
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"owl-blogs/app"
"owl-blogs/infra"
"owl-blogs/interactions"
"owl-blogs/test"
"testing"
"github.com/stretchr/testify/require"
)
func constructResponse(html []byte) *http.Response {
url, _ := url.Parse("http://example.com/foo/bar")
return &http.Response{
Request: &http.Request{
URL: url,
},
Body: io.NopCloser(bytes.NewReader([]byte(html))),
}
}
type MockHttpClient struct {
PageContent string
}
// Post implements owlhttp.HttpClient.
func (MockHttpClient) Post(url string, contentType string, body io.Reader) (resp *http.Response, err error) {
panic("unimplemented")
}
// PostForm implements owlhttp.HttpClient.
func (MockHttpClient) PostForm(url string, data url.Values) (resp *http.Response, err error) {
panic("unimplemented")
}
func (c *MockHttpClient) Get(url string) (*http.Response, error) {
return &http.Response{
Body: io.NopCloser(bytes.NewReader([]byte(c.PageContent))),
}, nil
}
func getWebmentionService() *app.WebmentionService {
db := test.NewMockDb()
entryRegister := app.NewEntryTypeRegistry()
entryRegister.Register(&test.MockEntry{})
entryRepo := infra.NewEntryRepository(db, entryRegister)
interactionRegister := app.NewInteractionTypeRegistry()
interactionRegister.Register(&interactions.Webmention{})
interactionRepo := infra.NewInteractionRepo(db, interactionRegister)
configRepo := infra.NewConfigRepo(db)
bus := app.NewEventBus()
http := infra.OwlHttpClient{}
return app.NewWebmentionService(
app.NewSiteConfigService(configRepo), interactionRepo, entryRepo, &http, bus,
)
}
//
// https://www.w3.org/TR/webmention/#h-webmention-verification
//
func TestParseValidHEntry(t *testing.T) {
html := []byte("<div class=\"h-entry\"><div class=\"p-name\">Foo</div></div>")
entry, err := app.ParseHEntry(&http.Response{Body: io.NopCloser(bytes.NewReader(html))})
require.NoError(t, err)
require.Equal(t, entry.Title, "Foo")
}
func TestParseValidHEntryWithoutTitle(t *testing.T) {
html := []byte("<div class=\"h-entry\"></div><div class=\"p-name\">Foo</div>")
entry, err := app.ParseHEntry(&http.Response{Body: io.NopCloser(bytes.NewReader(html))})
require.NoError(t, err)
require.Equal(t, entry.Title, "")
}
func TestCreateNewWebmention(t *testing.T) {
service := getWebmentionService()
service.Http = &MockHttpClient{
PageContent: "<div class=\"h-entry\"><div class=\"p-name\">Foo</div></div>",
}
entry := test.MockEntry{}
service.EntryRepository.Create(&entry)
err := service.ProcessWebmention(
"http://example.com/foo",
fmt.Sprintf("https.//example.com/posts/%s/", entry.ID()),
)
require.NoError(t, err)
inters, err := service.InteractionRepository.FindAll(entry.ID())
require.NoError(t, err)
require.Equal(t, len(inters), 1)
webm := inters[0].(*interactions.Webmention)
meta := webm.MetaData().(*interactions.WebmentionMetaData)
require.Equal(t, meta.Source, "http://example.com/foo")
require.Equal(t, meta.Target, fmt.Sprintf("https.//example.com/posts/%s/", entry.ID()))
require.Equal(t, meta.Title, "Foo")
}
func TestGetWebmentionEndpointLink(t *testing.T) {
html := []byte("<link rel=\"webmention\" href=\"http://example.com/webmention\" />")
endpoint, err := app.GetWebmentionEndpoint(constructResponse(html))
require.NoError(t, err)
require.Equal(t, endpoint, "http://example.com/webmention")
}
func TestGetWebmentionEndpointLinkA(t *testing.T) {
html := []byte("<a rel=\"webmention\" href=\"http://example.com/webmention\" />")
endpoint, err := app.GetWebmentionEndpoint(constructResponse(html))
require.NoError(t, err)
require.Equal(t, endpoint, "http://example.com/webmention")
}
func TestGetWebmentionEndpointLinkAFakeWebmention(t *testing.T) {
html := []byte("<a rel=\"not-webmention\" href=\"http://example.com/foo\" /><a rel=\"webmention\" href=\"http://example.com/webmention\" />")
endpoint, err := app.GetWebmentionEndpoint(constructResponse(html))
require.NoError(t, err)
require.Equal(t, endpoint, "http://example.com/webmention")
}
func TestGetWebmentionEndpointLinkHeader(t *testing.T) {
html := []byte("")
resp := constructResponse(html)
resp.Header = http.Header{"Link": []string{"<http://example.com/webmention>; rel=\"webmention\""}}
endpoint, err := app.GetWebmentionEndpoint(resp)
require.NoError(t, err)
require.Equal(t, endpoint, "http://example.com/webmention")
}
func TestGetWebmentionEndpointLinkHeaderCommas(t *testing.T) {
html := []byte("")
resp := constructResponse(html)
resp.Header = http.Header{
"Link": []string{"<https://webmention.rocks/test/19/webmention/error>; rel=\"other\", <https://webmention.rocks/test/19/webmention>; rel=\"webmention\""},
}
endpoint, err := app.GetWebmentionEndpoint(resp)
require.NoError(t, err)
require.Equal(t, endpoint, "https://webmention.rocks/test/19/webmention")
}
func TestGetWebmentionEndpointRelativeLink(t *testing.T) {
html := []byte("<link rel=\"webmention\" href=\"/webmention\" />")
endpoint, err := app.GetWebmentionEndpoint(constructResponse(html))
require.NoError(t, err)
require.Equal(t, endpoint, "http://example.com/webmention")
}
func TestGetWebmentionEndpointRelativeLinkInHeader(t *testing.T) {
html := []byte("<link rel=\"webmention\" href=\"/webmention\" />")
resp := constructResponse(html)
resp.Header = http.Header{"Link": []string{"</webmention>; rel=\"webmention\""}}
endpoint, err := app.GetWebmentionEndpoint(resp)
require.NoError(t, err)
require.Equal(t, endpoint, "http://example.com/webmention")
}
// func TestRealWorldWebmention(t *testing.T) {
// service := getWebmentionService()
// links := []string{
// "https://webmention.rocks/test/1",
// "https://webmention.rocks/test/2",
// "https://webmention.rocks/test/3",
// "https://webmention.rocks/test/4",
// "https://webmention.rocks/test/5",
// "https://webmention.rocks/test/6",
// "https://webmention.rocks/test/7",
// "https://webmention.rocks/test/8",
// "https://webmention.rocks/test/9",
// // "https://webmention.rocks/test/10", // not supported
// "https://webmention.rocks/test/11",
// "https://webmention.rocks/test/12",
// "https://webmention.rocks/test/13",
// "https://webmention.rocks/test/14",
// "https://webmention.rocks/test/15",
// "https://webmention.rocks/test/16",
// "https://webmention.rocks/test/17",
// "https://webmention.rocks/test/18",
// "https://webmention.rocks/test/19",
// "https://webmention.rocks/test/20",
// "https://webmention.rocks/test/21",
// "https://webmention.rocks/test/22",
// "https://webmention.rocks/test/23/page",
// }
// for _, link := range links {
//
// client := &owl.OwlHttpClient{}
// html, _ := client.Get(link)
// _, err := app.GetWebmentionEndpoint(html)
// if err != nil {
// t.Errorf("Unable to find webmention: %v for link %v", err, link)
// }
// }
// }

48
auth_test.go Normal file
View File

@ -0,0 +1,48 @@
package owl_test
import (
"h4kor/owl-blogs"
"h4kor/owl-blogs/test/assertions"
"net/http"
"testing"
)
func TestGetRedirctUrisLink(t *testing.T) {
html := []byte("<link rel=\"redirect_uri\" href=\"http://example.com/redirect\" />")
parser := &owl.OwlHtmlParser{}
uris, err := parser.GetRedirctUris(constructResponse(html))
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect")
}
func TestGetRedirctUrisLinkMultiple(t *testing.T) {
html := []byte(`
<link rel="redirect_uri" href="http://example.com/redirect1" />
<link rel="redirect_uri" href="http://example.com/redirect2" />
<link rel="redirect_uri" href="http://example.com/redirect3" />
<link rel="foo" href="http://example.com/redirect4" />
<link href="http://example.com/redirect5" />
`)
parser := &owl.OwlHtmlParser{}
uris, err := parser.GetRedirctUris(constructResponse(html))
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect1")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect2")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect3")
assertions.AssertLen(t, uris, 3)
}
func TestGetRedirectUrisLinkHeader(t *testing.T) {
html := []byte("")
parser := &owl.OwlHtmlParser{}
resp := constructResponse(html)
resp.Header = http.Header{"Link": []string{"<http://example.com/redirect>; rel=\"redirect_uri\""}}
uris, err := parser.GetRedirctUris(resp)
assertions.AssertNoError(t, err, "Unable to parse feed")
assertions.AssertArrayContains(t, uris, "http://example.com/redirect")
}

View File

@ -1,29 +0,0 @@
package main
import (
"owl-blogs/infra"
"github.com/spf13/cobra"
)
var user string
var password string
func init() {
rootCmd.AddCommand(newAuthorCmd)
newAuthorCmd.Flags().StringVarP(&user, "user", "u", "", "The user name")
newAuthorCmd.MarkFlagRequired("user")
newAuthorCmd.Flags().StringVarP(&password, "password", "p", "", "The password")
newAuthorCmd.MarkFlagRequired("password")
}
var newAuthorCmd = &cobra.Command{
Use: "new-author",
Short: "Creates a new author",
Long: `Creates a new author`,
Run: func(cmd *cobra.Command, args []string) {
db := infra.NewSqliteDB(DbPath)
App(db).AuthorService.Create(user, password)
},
}

View File

@ -1,136 +0,0 @@
package main
import (
"bytes"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"owl-blogs/app"
entrytypes "owl-blogs/entry_types"
"owl-blogs/infra"
"owl-blogs/test"
"path"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func getUserToken(service *app.AuthorService) string {
_, err := service.Create("test", "test")
if err != nil {
panic(err)
}
token, err := service.CreateToken("test")
if err != nil {
panic(err)
}
return token
}
func TestEditorFormGet(t *testing.T) {
db := test.NewMockDb()
owlApp := App(db)
app := owlApp.FiberApp
token := getUserToken(owlApp.AuthorService)
req := httptest.NewRequest("GET", "/editor/new/Image", nil)
req.AddCookie(&http.Cookie{Name: "token", Value: token})
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
}
func TestEditorFormGetNoAuth(t *testing.T) {
db := test.NewMockDb()
owlApp := App(db)
app := owlApp.FiberApp
req := httptest.NewRequest("GET", "/editor/new/Image", nil)
req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"})
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, 302, resp.StatusCode)
}
func TestEditorFormPost(t *testing.T) {
db := test.NewMockDb()
owlApp := App(db)
app := owlApp.FiberApp
token := getUserToken(owlApp.AuthorService)
repo := infra.NewEntryRepository(db, owlApp.Registry)
binRepo := infra.NewBinaryFileRepo(db)
fileDir, _ := os.Getwd()
fileName := "../../test/fixtures/test.png"
filePath := path.Join(fileDir, fileName)
file, err := os.Open(filePath)
require.NoError(t, err)
fileBytes, err := ioutil.ReadFile(filePath)
require.NoError(t, err)
defer file.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("image", filepath.Base(file.Name()))
io.Copy(part, file)
part, _ = writer.CreateFormField("content")
io.WriteString(part, "test content")
writer.Close()
req := httptest.NewRequest("POST", "/editor/new/Image", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(&http.Cookie{Name: "token", Value: token})
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, 302, resp.StatusCode)
require.Contains(t, resp.Header.Get("Location"), "/posts/")
id := strings.Split(resp.Header.Get("Location"), "/")[2]
entry, err := repo.FindById(id)
require.NoError(t, err)
require.Equal(t, "test content", entry.MetaData().(*entrytypes.ImageMetaData).Content)
imageId := entry.MetaData().(*entrytypes.ImageMetaData).ImageId
require.NotZero(t, imageId)
bin, err := binRepo.FindById(imageId)
require.NoError(t, err)
require.Equal(t, bin.Name, "test.png")
require.Equal(t, fileBytes, bin.Data)
}
func TestEditorFormPostNoAuth(t *testing.T) {
db := test.NewMockDb()
owlApp := App(db)
app := owlApp.FiberApp
fileDir, _ := os.Getwd()
fileName := "../../test/fixtures/test.png"
filePath := path.Join(fileDir, fileName)
file, err := os.Open(filePath)
require.NoError(t, err)
defer file.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("ImageId", filepath.Base(file.Name()))
io.Copy(part, file)
part, _ = writer.CreateFormField("Content")
io.WriteString(part, "test content")
writer.Close()
req := httptest.NewRequest("POST", "/editor/new/Image", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"})
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, 302, resp.StatusCode)
require.Contains(t, resp.Header.Get("Location"), "/auth/login")
}

View File

@ -1,204 +0,0 @@
package main
import (
"fmt"
"os"
"owl-blogs/config"
"owl-blogs/domain/model"
entrytypes "owl-blogs/entry_types"
"owl-blogs/importer"
"owl-blogs/infra"
"path"
"github.com/spf13/cobra"
"gopkg.in/yaml.v2"
)
var userPath string
var author string
func init() {
rootCmd.AddCommand(importCmd)
importCmd.Flags().StringVarP(&userPath, "path", "p", "", "Path to the user folder")
importCmd.MarkFlagRequired("path")
importCmd.Flags().StringVarP(&author, "author", "a", "", "The author name")
importCmd.MarkFlagRequired("author")
}
var importCmd = &cobra.Command{
Use: "import",
Short: "Import data from v1",
Long: `Import data from v1`,
Run: func(cmd *cobra.Command, args []string) {
db := infra.NewSqliteDB(DbPath)
app := App(db)
posts, err := importer.AllUserPosts(userPath)
if err != nil {
panic(err)
}
// import config
bytes, err := os.ReadFile(path.Join(userPath, "meta/config.yml"))
if err != nil {
panic(err)
}
v1Config := importer.V1UserConfig{}
yaml.Unmarshal(bytes, &v1Config)
mes := []model.MeLinks{}
for _, me := range v1Config.Me {
mes = append(mes, model.MeLinks{
Name: me.Name,
Url: me.Url,
})
}
lists := []model.EntryList{}
for _, list := range v1Config.Lists {
lists = append(lists, model.EntryList{
Id: list.Id,
Title: list.Title,
Include: importer.ConvertTypeList(list.Include, app.Registry),
ListType: list.ListType,
})
}
headerMenu := []model.MenuItem{}
for _, item := range v1Config.HeaderMenu {
headerMenu = append(headerMenu, model.MenuItem{
Title: item.Title,
List: item.List,
Url: item.Url,
Post: item.Post,
})
}
footerMenu := []model.MenuItem{}
for _, item := range v1Config.FooterMenu {
footerMenu = append(footerMenu, model.MenuItem{
Title: item.Title,
List: item.List,
Url: item.Url,
Post: item.Post,
})
}
v2Config := &model.SiteConfig{}
err = app.SiteConfigRepo.Get(config.SITE_CONFIG, v2Config)
if err != nil {
panic(err)
}
v2Config.Title = v1Config.Title
v2Config.SubTitle = v1Config.SubTitle
v2Config.AuthorName = v1Config.AuthorName
v2Config.Me = mes
v2Config.Lists = lists
v2Config.PrimaryListInclude = importer.ConvertTypeList(v1Config.PrimaryListInclude, app.Registry)
v2Config.HeaderMenu = headerMenu
v2Config.FooterMenu = footerMenu
err = app.SiteConfigRepo.Update(config.SITE_CONFIG, v2Config)
if err != nil {
panic(err)
}
for _, post := range posts {
existing, _ := app.EntryService.FindById(post.Id)
if existing != nil {
continue
}
fmt.Println(post.Meta.Type)
// import assets
mediaDir := path.Join(userPath, post.MediaDir())
println(mediaDir)
files := importer.ListDir(mediaDir)
for _, file := range files {
// mock entry to pass to binary service
entry := &entrytypes.Article{}
entry.SetID(post.Id)
fileData, err := os.ReadFile(path.Join(mediaDir, file))
if err != nil {
panic(err)
}
app.BinaryService.CreateEntryFile(file, fileData, entry)
}
var entry model.Entry
switch post.Meta.Type {
case "article":
entry = &entrytypes.Article{}
entry.SetID(post.Id)
entry.SetPublishedAt(&post.Meta.Date)
entry.SetMetaData(&entrytypes.ArticleMetaData{
Title: post.Meta.Title,
Content: post.Content,
})
case "bookmark":
entry = &entrytypes.Bookmark{}
entry.SetID(post.Id)
entry.SetPublishedAt(&post.Meta.Date)
entry.SetMetaData(&entrytypes.BookmarkMetaData{
Url: post.Meta.Bookmark.Url,
Title: post.Meta.Bookmark.Text,
Content: post.Content,
})
case "reply":
entry = &entrytypes.Reply{}
entry.SetID(post.Id)
entry.SetPublishedAt(&post.Meta.Date)
entry.SetMetaData(&entrytypes.ReplyMetaData{
Url: post.Meta.Reply.Url,
Title: post.Meta.Reply.Text,
Content: post.Content,
})
case "photo":
entry = &entrytypes.Image{}
entry.SetID(post.Id)
entry.SetPublishedAt(&post.Meta.Date)
entry.SetMetaData(&entrytypes.ImageMetaData{
Title: post.Meta.Title,
Content: post.Content,
ImageId: post.Meta.PhotoPath,
})
case "note":
entry = &entrytypes.Note{}
entry.SetID(post.Id)
entry.SetPublishedAt(&post.Meta.Date)
entry.SetMetaData(&entrytypes.NoteMetaData{
Content: post.Content,
})
case "recipe":
entry = &entrytypes.Recipe{}
entry.SetID(post.Id)
entry.SetPublishedAt(&post.Meta.Date)
entry.SetMetaData(&entrytypes.RecipeMetaData{
Title: post.Meta.Title,
Yield: post.Meta.Recipe.Yield,
Duration: post.Meta.Recipe.Duration,
Ingredients: post.Meta.Recipe.Ingredients,
Content: post.Content,
})
case "page":
entry = &entrytypes.Page{}
entry.SetID(post.Id)
entry.SetPublishedAt(&post.Meta.Date)
entry.SetMetaData(&entrytypes.PageMetaData{
Title: post.Meta.Title,
Content: post.Content,
})
default:
panic("Unknown type")
}
if entry != nil {
entry.SetAuthorId(author)
app.EntryService.Create(entry)
}
}
},
}

38
cmd/owl/init.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"h4kor/owl-blogs"
"github.com/spf13/cobra"
)
var domain string
var singleUser string
var unsafe bool
func init() {
rootCmd.AddCommand(initCmd)
initCmd.PersistentFlags().StringVar(&domain, "domain", "http://localhost:8080", "Domain to use")
initCmd.PersistentFlags().StringVar(&singleUser, "single-user", "", "Use single user mode with given username")
initCmd.PersistentFlags().BoolVar(&unsafe, "unsafe", false, "Allow raw html")
}
var initCmd = &cobra.Command{
Use: "init",
Short: "Creates a new repository",
Long: `Creates a new repository`,
Run: func(cmd *cobra.Command, args []string) {
_, err := owl.CreateRepository(repoPath, owl.RepoConfig{
Domain: domain,
SingleUser: singleUser,
AllowRawHtml: unsafe,
})
if err != nil {
println("Error creating repository: ", err.Error())
} else {
println("Repository created: ", repoPath)
}
},
}

View File

@ -3,19 +3,11 @@ package main
import (
"fmt"
"os"
"owl-blogs/app"
entrytypes "owl-blogs/entry_types"
"owl-blogs/infra"
"owl-blogs/interactions"
"owl-blogs/plugings"
"owl-blogs/render"
"owl-blogs/web"
"github.com/spf13/cobra"
)
const DbPath = "owlblogs.db"
var repoPath string
var rootCmd = &cobra.Command{
Use: "owl",
Short: "Owl Blogs is a not so static blog generator",
@ -28,68 +20,10 @@ func Execute() {
}
}
func App(db infra.Database) *web.WebApp {
// Register Types
entryRegister := app.NewEntryTypeRegistry()
entryRegister.Register(&entrytypes.Image{})
entryRegister.Register(&entrytypes.Article{})
entryRegister.Register(&entrytypes.Page{})
entryRegister.Register(&entrytypes.Recipe{})
entryRegister.Register(&entrytypes.Note{})
entryRegister.Register(&entrytypes.Bookmark{})
entryRegister.Register(&entrytypes.Reply{})
func init() {
interactionRegister := app.NewInteractionTypeRegistry()
interactionRegister.Register(&interactions.Webmention{})
interactionRegister.Register(&interactions.Like{})
interactionRegister.Register(&interactions.Repost{})
configRegister := app.NewConfigRegister()
// Create Repositories
entryRepo := infra.NewEntryRepository(db, entryRegister)
binRepo := infra.NewBinaryFileRepo(db)
authorRepo := infra.NewDefaultAuthorRepo(db)
configRepo := infra.NewConfigRepo(db)
interactionRepo := infra.NewInteractionRepo(db, interactionRegister)
followersRepo := infra.NewFollowerRepository(db)
// Create External Services
httpClient := &infra.OwlHttpClient{}
// busses
eventBus := app.NewEventBus()
// Create Services
siteConfigService := app.NewSiteConfigService(configRepo)
entryService := app.NewEntryService(entryRepo, siteConfigService, eventBus)
binaryService := app.NewBinaryFileService(binRepo)
authorService := app.NewAuthorService(authorRepo, siteConfigService)
webmentionService := app.NewWebmentionService(
siteConfigService, interactionRepo, entryRepo, httpClient, eventBus,
)
apService := app.NewActivityPubService(
followersRepo, configRepo, interactionRepo,
entryService, siteConfigService, binaryService,
eventBus,
)
// setup render functions
render.SiteConfigService = siteConfigService
// plugins
plugings.NewEcho(eventBus)
plugings.RegisterInstagram(
configRepo, configRegister, binaryService, eventBus,
)
// Create WebApp
return web.NewWebApp(
entryService, entryRegister, binaryService,
authorService, configRepo, configRegister,
siteConfigService, webmentionService, interactionRepo,
apService,
)
rootCmd.PersistentFlags().StringVar(&repoPath, "repo", ".", "Path to the repository to use.")
rootCmd.PersistentFlags().StringVar(&user, "user", "", "Username. Required for some commands.")
}

View File

@ -1,24 +0,0 @@
package main
import (
"net/http/httptest"
"owl-blogs/test"
"testing"
"github.com/stretchr/testify/require"
)
func TestMediaWithSpace(t *testing.T) {
db := test.NewMockDb()
owlApp := App(db)
app := owlApp.FiberApp
_, err := owlApp.BinaryService.Create("name with space.jpg", []byte("111"))
require.NoError(t, err)
req := httptest.NewRequest("GET", "/media/name%20with%20space.jpg", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
}

51
cmd/owl/new_post.go Normal file
View File

@ -0,0 +1,51 @@
package main
import (
"h4kor/owl-blogs"
"github.com/spf13/cobra"
)
var postTitle string
func init() {
rootCmd.AddCommand(newPostCmd)
newPostCmd.PersistentFlags().StringVar(&postTitle, "title", "", "Post title")
}
var newPostCmd = &cobra.Command{
Use: "new-post",
Short: "Creates a new post",
Long: `Creates a new post`,
Run: func(cmd *cobra.Command, args []string) {
if user == "" {
println("Username is required")
return
}
if postTitle == "" {
println("Post title is required")
return
}
repo, err := owl.OpenRepository(repoPath)
if err != nil {
println("Error opening repository: ", err.Error())
return
}
user, err := repo.GetUser(user)
if err != nil {
println("Error getting user: ", err.Error())
return
}
post, err := user.CreateNewPost(owl.PostMeta{Type: "article", Title: postTitle, Draft: true}, "")
if err != nil {
println("Error creating post: ", err.Error())
} else {
println("Post created: ", postTitle)
println("Edit: ", post.ContentFile())
}
},
}

38
cmd/owl/new_user.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"h4kor/owl-blogs"
"github.com/spf13/cobra"
)
var user string
func init() {
rootCmd.AddCommand(newUserCmd)
}
var newUserCmd = &cobra.Command{
Use: "new-user",
Short: "Creates a new user",
Long: `Creates a new user`,
Run: func(cmd *cobra.Command, args []string) {
if user == "" {
println("Username is required")
return
}
repo, err := owl.OpenRepository(repoPath)
if err != nil {
println("Error opening repository: ", err.Error())
return
}
_, err = repo.CreateUser(user)
if err != nil {
println("Error creating user: ", err.Error())
} else {
println("User created: ", user)
}
},
}

View File

@ -1,26 +1,44 @@
package main
import (
"owl-blogs/infra"
"fmt"
"h4kor/owl-blogs"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(resetPasswordCmd)
resetPasswordCmd.Flags().StringVarP(&user, "user", "u", "", "The user name")
resetPasswordCmd.MarkFlagRequired("user")
resetPasswordCmd.Flags().StringVarP(&password, "password", "p", "", "The new password")
resetPasswordCmd.MarkFlagRequired("password")
}
var resetPasswordCmd = &cobra.Command{
Use: "reset-password",
Short: "Resets the password of an author",
Long: `Resets the password of an author`,
Short: "Reset the password for a user",
Long: `Reset the password for a user`,
Run: func(cmd *cobra.Command, args []string) {
db := infra.NewSqliteDB(DbPath)
App(db).AuthorService.Create(user, password)
if user == "" {
println("Username is required")
return
}
repo, err := owl.OpenRepository(repoPath)
if err != nil {
println("Error opening repository: ", err.Error())
return
}
user, err := repo.GetUser(user)
if err != nil {
println("Error getting user: ", err.Error())
return
}
// generate a random password and print it
password := owl.GenerateRandomString(16)
user.ResetPassword(password)
fmt.Println("User: ", user.Name())
fmt.Println("New Password: ", password)
},
}

View File

@ -1,13 +1,17 @@
package main
import (
"owl-blogs/infra"
web "h4kor/owl-blogs/cmd/owl/web"
"github.com/spf13/cobra"
)
var port int
func init() {
rootCmd.AddCommand(webCmd)
webCmd.PersistentFlags().IntVar(&port, "port", 8080, "Port to use")
}
var webCmd = &cobra.Command{
@ -15,7 +19,6 @@ var webCmd = &cobra.Command{
Short: "Start the web server",
Long: `Start the web server`,
Run: func(cmd *cobra.Command, args []string) {
db := infra.NewSqliteDB(DbPath)
App(db).Run()
web.StartServer(repoPath, port)
},
}

191
cmd/owl/web/aliases_test.go Normal file
View File

@ -0,0 +1,191 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"net/http"
"net/http/httptest"
"os"
"testing"
)
func TestRedirectOnAliases(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - /foo/bar\n"
content += " - /foo/baz\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", "/foo/bar", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusMovedPermanently)
// Check that Location header is set correctly
assertions.AssertEqual(t, rr.Header().Get("Location"), post.UrlPath())
}
func TestNoRedirectOnNonExistingAliases(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - /foo/bar\n"
content += " - /foo/baz\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", "/foo/bar2", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusNotFound)
}
func TestNoRedirectIfValidPostUrl(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
post2, _ := user.CreateNewPost(owl.PostMeta{Title: "post-2"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - " + post2.UrlPath() + "\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", post2.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestRedirectIfInvalidPostUrl(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - " + user.UrlPath() + "posts/not-a-real-post/" + "\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", user.UrlPath()+"posts/not-a-real-post/", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusMovedPermanently)
}
func TestRedirectIfInvalidUserUrl(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - /user/not-real/ \n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", "/user/not-real/", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusMovedPermanently)
}
func TestRedirectIfInvalidMediaUrl(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - " + post.UrlMediaPath("not-real") + "\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlMediaPath("not-real"), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusMovedPermanently)
}
func TestDeepAliasInSingleUserMode(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{SingleUser: "test-1"})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: Create tileable textures with GIMP\n"
content += "author: h4kor\n"
content += "type: post\n"
content += "date: Tue, 13 Sep 2016 16:19:09 +0000\n"
content += "aliases:\n"
content += " - /2016/09/13/create-tileable-textures-with-gimp/\n"
content += "categories:\n"
content += " - GameDev\n"
content += "tags:\n"
content += " - gamedev\n"
content += " - textures\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", "/2016/09/13/create-tileable-textures-with-gimp/", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusMovedPermanently)
}

396
cmd/owl/web/auth_handler.go Normal file
View File

@ -0,0 +1,396 @@
package web
import (
"encoding/json"
"fmt"
"h4kor/owl-blogs"
"net/http"
"net/url"
"strings"
"github.com/julienschmidt/httprouter"
)
type IndieauthMetaDataResponse struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
}
type MeProfileResponse struct {
Name string `json:"name"`
Url string `json:"url"`
Photo string `json:"photo"`
}
type MeResponse struct {
Me string `json:"me"`
Profile MeProfileResponse `json:"profile"`
}
type AccessTokenResponse struct {
Me string `json:"me"`
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
Scope string `json:"scope"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
}
func jsonResponse(w http.ResponseWriter, response interface{}) {
jsonData, err := json.Marshal(response)
if err != nil {
println("Error marshalling json: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
}
w.Header().Add("Content-Type", "application/json")
w.Write(jsonData)
}
func userAuthMetadataHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
w.WriteHeader(http.StatusOK)
jsonResponse(w, IndieauthMetaDataResponse{
Issuer: user.FullUrl(),
AuthorizationEndpoint: user.AuthUrl(),
TokenEndpoint: user.TokenUrl(),
CodeChallengeMethodsSupported: []string{"S256", "plain"},
ScopesSupported: []string{"profile"},
ResponseTypesSupported: []string{"code"},
GrantTypesSupported: []string{"authorization_code"},
})
}
}
func userAuthHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
// get me, cleint_id, redirect_uri, state and response_type from query
me := r.URL.Query().Get("me")
clientId := r.URL.Query().Get("client_id")
redirectUri := r.URL.Query().Get("redirect_uri")
state := r.URL.Query().Get("state")
responseType := r.URL.Query().Get("response_type")
codeChallenge := r.URL.Query().Get("code_challenge")
codeChallengeMethod := r.URL.Query().Get("code_challenge_method")
scope := r.URL.Query().Get("scope")
// check if request is valid
missing_params := []string{}
if clientId == "" {
missing_params = append(missing_params, "client_id")
}
if redirectUri == "" {
missing_params = append(missing_params, "redirect_uri")
}
if responseType == "" {
missing_params = append(missing_params, "response_type")
}
if len(missing_params) > 0 {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing parameters",
Message: "Missing parameters: " + strings.Join(missing_params, ", "),
})
w.Write([]byte(html))
return
}
if responseType == "id" {
responseType = "code"
}
if responseType != "code" {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Invalid response_type",
Message: "Must be 'code' ('id' converted to 'code' for legacy support).",
})
w.Write([]byte(html))
return
}
if codeChallengeMethod != "" && (codeChallengeMethod != "S256" && codeChallengeMethod != "plain") {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Invalid code_challenge_method",
Message: "Must be 'S256' or 'plain'.",
})
w.Write([]byte(html))
return
}
client_id_url, err := url.Parse(clientId)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Invalid client_id",
Message: "Invalid client_id: " + clientId,
})
w.Write([]byte(html))
return
}
redirect_uri_url, err := url.Parse(redirectUri)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Invalid redirect_uri",
Message: "Invalid redirect_uri: " + redirectUri,
})
w.Write([]byte(html))
return
}
if client_id_url.Host != redirect_uri_url.Host || client_id_url.Scheme != redirect_uri_url.Scheme {
// check if redirect_uri is registered
resp, _ := repo.HttpClient.Get(clientId)
registered_redirects, _ := repo.Parser.GetRedirctUris(resp)
is_registered := false
for _, registered_redirect := range registered_redirects {
if registered_redirect == redirectUri {
// redirect_uri is registered
is_registered = true
break
}
}
if !is_registered {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Invalid redirect_uri",
Message: redirectUri + " is not registered for " + clientId,
})
w.Write([]byte(html))
return
}
}
// Double Submit Cookie Pattern
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
csrfToken := owl.GenerateRandomString(32)
cookie := http.Cookie{
Name: "csrf_token",
Value: csrfToken,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
http.SetCookie(w, &cookie)
reqData := owl.AuthRequestData{
Me: me,
ClientId: clientId,
RedirectUri: redirectUri,
State: state,
Scope: scope,
ResponseType: responseType,
CodeChallenge: codeChallenge,
CodeChallengeMethod: codeChallengeMethod,
User: user,
CsrfToken: csrfToken,
}
html, err := owl.RenderUserAuthPage(reqData)
if err != nil {
println("Error rendering auth page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal Server Error",
Message: "Internal Server Error",
})
w.Write([]byte(html))
return
}
w.Write([]byte(html))
}
}
func verifyAuthCodeRequest(user owl.User, w http.ResponseWriter, r *http.Request) (bool, owl.AuthCode) {
// get form data from post request
err := r.ParseForm()
if err != nil {
println("Error parsing form: ", err.Error())
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Error parsing form"))
return false, owl.AuthCode{}
}
code := r.Form.Get("code")
client_id := r.Form.Get("client_id")
redirect_uri := r.Form.Get("redirect_uri")
code_verifier := r.Form.Get("code_verifier")
// check if request is valid
valid, authCode := user.VerifyAuthCode(code, client_id, redirect_uri, code_verifier)
if !valid {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Invalid code"))
}
return valid, authCode
}
func userAuthProfileHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
valid, _ := verifyAuthCodeRequest(user, w, r)
if valid {
w.WriteHeader(http.StatusOK)
jsonResponse(w, MeResponse{
Me: user.FullUrl(),
Profile: MeProfileResponse{
Name: user.Name(),
Url: user.FullUrl(),
Photo: user.AvatarUrl(),
},
})
return
}
}
}
func userAuthTokenHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
valid, authCode := verifyAuthCodeRequest(user, w, r)
if valid {
if authCode.Scope == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Empty scope, no token issued"))
return
}
accessToken, duration, err := user.GenerateAccessToken(authCode)
if err != nil {
println("Error generating access token: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
return
}
jsonResponse(w, AccessTokenResponse{
Me: user.FullUrl(),
TokenType: "Bearer",
AccessToken: accessToken,
Scope: authCode.Scope,
ExpiresIn: duration,
})
return
}
}
}
func userAuthVerifyHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
// get form data from post request
err = r.ParseForm()
if err != nil {
println("Error parsing form: ", err.Error())
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Error parsing form",
Message: "Error parsing form",
})
w.Write([]byte(html))
return
}
password := r.FormValue("password")
client_id := r.FormValue("client_id")
redirect_uri := r.FormValue("redirect_uri")
response_type := r.FormValue("response_type")
state := r.FormValue("state")
code_challenge := r.FormValue("code_challenge")
code_challenge_method := r.FormValue("code_challenge_method")
scope := r.FormValue("scope")
// CSRF check
formCsrfToken := r.FormValue("csrf_token")
cookieCsrfToken, err := r.Cookie("csrf_token")
if err != nil {
println("Error getting csrf token from cookie: ", err.Error())
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "CSRF Error",
Message: "Error getting csrf token from cookie",
})
w.Write([]byte(html))
return
}
if formCsrfToken != cookieCsrfToken.Value {
println("Invalid csrf token")
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "CSRF Error",
Message: "Invalid csrf token",
})
w.Write([]byte(html))
return
}
password_valid := user.VerifyPassword(password)
if !password_valid {
redirect := fmt.Sprintf(
"%s?error=invalid_password&client_id=%s&redirect_uri=%s&response_type=%s&state=%s",
user.AuthUrl(), client_id, redirect_uri, response_type, state,
)
if code_challenge != "" {
redirect += fmt.Sprintf("&code_challenge=%s&code_challenge_method=%s", code_challenge, code_challenge_method)
}
http.Redirect(w, r,
redirect,
http.StatusFound,
)
return
} else {
// password is valid, generate code
code, err := user.GenerateAuthCode(
client_id, redirect_uri, code_challenge, code_challenge_method, scope)
if err != nil {
println("Error generating code: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal Server Error",
Message: "Error generating code",
})
w.Write([]byte(html))
return
}
http.Redirect(w, r,
fmt.Sprintf(
"%s?code=%s&state=%s&iss=%s",
redirect_uri, code, state,
user.FullUrl(),
),
http.StatusFound,
)
return
}
}
}

428
cmd/owl/web/auth_test.go Normal file
View File

@ -0,0 +1,428 @@
package web_test
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"h4kor/owl-blogs/test/mocks"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
func TestAuthPostWrongPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.AuthUrl()+"verify/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertContains(t, rr.Header().Get("Location"), "error=invalid_password")
}
func TestAuthPostCorrectPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "testpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.AuthUrl()+"verify/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertContains(t, rr.Header().Get("Location"), "code=")
assertions.AssertContains(t, rr.Header().Get("Location"), "state=test_state")
assertions.AssertContains(t, rr.Header().Get("Location"), "iss="+user.FullUrl())
assertions.AssertContains(t, rr.Header().Get("Location"), "http://example.com/response")
}
func TestAuthPostWithIncorrectCode(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile")
// Create Request and Response
form := url.Values{}
form.Add("code", "wrongcode")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}
func TestAuthPostWithCorrectCode(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile")
// Create Request and Response
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// parse response as json
type responseType struct {
Me string `json:"me"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Me, user.FullUrl())
}
func TestAuthPostWithCorrectCodeAndPKCE(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
// create code challenge
h := sha256.New()
h.Write([]byte(code_verifier))
code_challenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "S256", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// parse response as json
type responseType struct {
Me string `json:"me"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Me, user.FullUrl())
}
func TestAuthPostWithCorrectCodeAndWrongPKCE(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
// create code challenge
h := sha256.New()
h.Write([]byte(code_verifier + "wrong"))
code_challenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "S256", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}
func TestAuthPostWithCorrectCodePKCEPlain(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
code_challenge := code_verifier
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "plain", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestAuthPostWithCorrectCodePKCEPlainWrong(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
// Create Request and Response
code_verifier := "test_code_verifier"
code_challenge := code_verifier + "wrong"
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", code_challenge, "plain", "profile")
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
form.Add("code_verifier", code_verifier)
req, err := http.NewRequest("POST", user.AuthUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Accept", "application/json")
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}
func TestAuthRedirectUriNotSet(t *testing.T) {
repo, user := getSingleUserTestRepo()
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockParseLinksHtmlParser{
Links: []string{"http://example2.com/response"},
}
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example2.com/response_not_set")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusBadRequest)
}
func TestAuthRedirectUriSet(t *testing.T) {
repo, user := getSingleUserTestRepo()
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockParseLinksHtmlParser{
Links: []string{"http://example.com/response"},
}
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestAuthRedirectUriSameHost(t *testing.T) {
repo, user := getSingleUserTestRepo()
repo.HttpClient = &mocks.MockHttpClient{}
repo.Parser = &mocks.MockParseLinksHtmlParser{
Links: []string{},
}
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("response_type", "code")
form.Add("state", "test_state")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("GET", user.AuthUrl()+"?"+form.Encode(), nil)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestAccessTokenCorrectPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile create")
// Create Request and Response
form := url.Values{}
form.Add("code", code)
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl()+"token/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// parse response as json
type responseType struct {
Me string `json:"me"`
TokenType string `json:"token_type"`
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Me, user.FullUrl())
assertions.AssertEqual(t, response.TokenType, "Bearer")
assertions.AssertEqual(t, response.Scope, "profile create")
assertions.Assert(t, response.ExpiresIn > 0, "ExpiresIn should be greater than 0")
assertions.Assert(t, len(response.AccessToken) > 0, "AccessToken should be greater than 0")
}
func TestAccessTokenWithIncorrectCode(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
user.GenerateAuthCode("http://example.com", "http://example.com/response", "", "", "profile")
// Create Request and Response
form := url.Values{}
form.Add("code", "wrongcode")
form.Add("client_id", "http://example.com")
form.Add("redirect_uri", "http://example.com/response")
form.Add("grant_type", "authorization_code")
req, err := http.NewRequest("POST", user.AuthUrl()+"token/", strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}
func TestIndieauthMetadata(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
req, _ := http.NewRequest("GET", user.IndieauthMetadataUrl(), nil)
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// parse response as json
type responseType struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
ScopesSupported []string `json:"scopes_supported"`
ResponseTypesSupported []string `json:"response_types_supported"`
GrantTypesSupported []string `json:"grant_types_supported"`
}
var response responseType
json.Unmarshal(rr.Body.Bytes(), &response)
assertions.AssertEqual(t, response.Issuer, user.FullUrl())
assertions.AssertEqual(t, response.AuthorizationEndpoint, user.AuthUrl())
assertions.AssertEqual(t, response.TokenEndpoint, user.TokenUrl())
}

View File

@ -0,0 +1,364 @@
package web
import (
"fmt"
"h4kor/owl-blogs"
"io"
"mime/multipart"
"net/http"
"os"
"path"
"strings"
"sync"
"time"
"github.com/julienschmidt/httprouter"
)
func isUserLoggedIn(user *owl.User, r *http.Request) bool {
sessionCookie, err := r.Cookie("session")
if err != nil {
return false
}
return user.ValidateSession(sessionCookie.Value)
}
func setCSRFCookie(w http.ResponseWriter) string {
csrfToken := owl.GenerateRandomString(32)
cookie := http.Cookie{
Name: "csrf_token",
Value: csrfToken,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
}
http.SetCookie(w, &cookie)
return csrfToken
}
func checkCSRF(r *http.Request) bool {
// CSRF check
formCsrfToken := r.FormValue("csrf_token")
cookieCsrfToken, err := r.Cookie("csrf_token")
if err != nil {
println("Error getting csrf token from cookie: ", err.Error())
return false
}
if formCsrfToken != cookieCsrfToken.Value {
println("Invalid csrf token")
return false
}
return true
}
func userLoginGetHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
if isUserLoggedIn(&user, r) {
http.Redirect(w, r, user.EditorUrl(), http.StatusFound)
return
}
csrfToken := setCSRFCookie(w)
// get error from query
error_type := r.URL.Query().Get("error")
html, err := owl.RenderLoginPage(user, error_type, csrfToken)
if err != nil {
println("Error rendering login page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
w.Write([]byte(html))
}
}
func userLoginPostHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
err = r.ParseForm()
if err != nil {
println("Error parsing form: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
// CSRF check
if !checkCSRF(r) {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "CSRF Error",
Message: "Invalid csrf token",
})
w.Write([]byte(html))
return
}
password := r.Form.Get("password")
if password == "" || !user.VerifyPassword(password) {
http.Redirect(w, r, user.EditorLoginUrl()+"?error=wrong_password", http.StatusFound)
return
}
// set session cookie
cookie := http.Cookie{
Name: "session",
Value: user.CreateNewSession(),
Path: "/",
Expires: time.Now().Add(30 * 24 * time.Hour),
HttpOnly: true,
}
http.SetCookie(w, &cookie)
http.Redirect(w, r, user.EditorUrl(), http.StatusFound)
}
}
func userEditorGetHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
if !isUserLoggedIn(&user, r) {
http.Redirect(w, r, user.EditorLoginUrl(), http.StatusFound)
return
}
csrfToken := setCSRFCookie(w)
html, err := owl.RenderEditorPage(user, csrfToken)
if err != nil {
println("Error rendering editor page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
w.Write([]byte(html))
}
}
func userEditorPostHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
if !isUserLoggedIn(&user, r) {
http.Redirect(w, r, user.EditorLoginUrl(), http.StatusFound)
return
}
if strings.Split(r.Header.Get("Content-Type"), ";")[0] == "multipart/form-data" {
err = r.ParseMultipartForm(32 << 20)
} else {
err = r.ParseForm()
}
if err != nil {
println("Error parsing form: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
// CSRF check
if !checkCSRF(r) {
w.WriteHeader(http.StatusBadRequest)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "CSRF Error",
Message: "Invalid csrf token",
})
w.Write([]byte(html))
return
}
// get form values
post_type := r.Form.Get("type")
title := r.Form.Get("title")
description := r.Form.Get("description")
content := strings.ReplaceAll(r.Form.Get("content"), "\r", "")
draft := r.Form.Get("draft")
// recipe values
recipe_yield := r.Form.Get("yield")
recipe_ingredients := strings.ReplaceAll(r.Form.Get("ingredients"), "\r", "")
recipe_duration := r.Form.Get("duration")
// conditional values
reply_url := r.Form.Get("reply_url")
bookmark_url := r.Form.Get("bookmark_url")
// photo values
var photo_file multipart.File
var photo_header *multipart.FileHeader
if strings.Split(r.Header.Get("Content-Type"), ";")[0] == "multipart/form-data" {
photo_file, photo_header, err = r.FormFile("photo")
if err != nil && err != http.ErrMissingFile {
println("Error getting photo file: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
}
// validate form values
if post_type == "" {
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing post type",
Message: "Post type is required",
})
w.Write([]byte(html))
return
}
if (post_type == "article" || post_type == "page" || post_type == "recipe") && title == "" {
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing Title",
Message: "Articles and Pages must have a title",
})
w.Write([]byte(html))
return
}
if post_type == "reply" && reply_url == "" {
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing URL",
Message: "You must provide a URL to reply to",
})
w.Write([]byte(html))
return
}
if post_type == "bookmark" && bookmark_url == "" {
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing URL",
Message: "You must provide a URL to bookmark",
})
w.Write([]byte(html))
return
}
if post_type == "photo" && photo_file == nil {
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Missing Photo",
Message: "You must provide a photo to upload",
})
w.Write([]byte(html))
return
}
// TODO: scrape reply_url for title and description
// TODO: scrape bookmark_url for title and description
// create post
meta := owl.PostMeta{
Type: post_type,
Title: title,
Description: description,
Draft: draft == "on",
Date: time.Now(),
Reply: owl.ReplyData{
Url: reply_url,
},
Bookmark: owl.BookmarkData{
Url: bookmark_url,
},
Recipe: owl.RecipeData{
Yield: recipe_yield,
Ingredients: strings.Split(recipe_ingredients, "\n"),
Duration: recipe_duration,
},
}
if photo_file != nil {
meta.PhotoPath = photo_header.Filename
}
post, err := user.CreateNewPost(meta, content)
// save photo
if photo_file != nil {
println("Saving photo: ", photo_header.Filename)
photo_path := path.Join(post.MediaDir(), photo_header.Filename)
media_file, err := os.Create(photo_path)
if err != nil {
println("Error creating photo file: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
defer media_file.Close()
io.Copy(media_file, photo_file)
}
if err != nil {
println("Error creating post: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
// redirect to post
if !post.Meta().Draft {
// scan for webmentions
post.ScanForLinks()
webmentions := post.OutgoingWebmentions()
println("Found ", len(webmentions), " links")
wg := sync.WaitGroup{}
wg.Add(len(webmentions))
for _, mention := range post.OutgoingWebmentions() {
go func(mention owl.WebmentionOut) {
fmt.Printf("Sending webmention to %s", mention.Target)
defer wg.Done()
post.SendWebmention(mention)
}(mention)
}
wg.Wait()
http.Redirect(w, r, post.FullUrl(), http.StatusFound)
} else {
http.Redirect(w, r, user.EditorUrl(), http.StatusFound)
}
}
}

346
cmd/owl/web/editor_test.go Normal file
View File

@ -0,0 +1,346 @@
package web_test
import (
"bytes"
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"h4kor/owl-blogs/test/mocks"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"path"
"strconv"
"strings"
"testing"
)
type CountMockHttpClient struct {
InvokedGet int
InvokedPost int
InvokedPostForm int
}
func (c *CountMockHttpClient) Get(url string) (resp *http.Response, err error) {
c.InvokedGet++
return &http.Response{}, nil
}
func (c *CountMockHttpClient) Post(url, contentType string, body io.Reader) (resp *http.Response, err error) {
c.InvokedPost++
return &http.Response{}, nil
}
func (c *CountMockHttpClient) PostForm(url string, data url.Values) (resp *http.Response, err error) {
c.InvokedPostForm++
return &http.Response{}, nil
}
func TestLoginWrongPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "wrongpassword")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorLoginUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
// check redirect to login page
assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorLoginUrl()+"?error=wrong_password")
}
func TestLoginCorrectPassword(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("password", "testpassword")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorLoginUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
// check redirect to login page
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorUrl())
}
func TestEditorWithoutSession(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
user.CreateNewSession()
req, err := http.NewRequest("GET", user.EditorUrl(), nil)
// req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorLoginUrl())
}
func TestEditorWithSession(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
sessionId := user.CreateNewSession()
req, err := http.NewRequest("GET", user.EditorUrl(), nil)
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestEditorPostWithoutSession(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
user.CreateNewSession()
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("type", "article")
form.Add("title", "testtitle")
form.Add("content", "testcontent")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), user.EditorLoginUrl())
}
func TestEditorPostWithSession(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
sessionId := user.CreateNewSession()
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("type", "article")
form.Add("title", "testtitle")
form.Add("content", "testcontent")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
posts, _ := user.AllPosts()
assertions.AssertEqual(t, len(posts), 1)
post := posts[0]
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl())
}
func TestEditorPostWithSessionNote(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
sessionId := user.CreateNewSession()
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("type", "note")
form.Add("content", "testcontent")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
posts, _ := user.AllPosts()
assertions.AssertEqual(t, len(posts), 1)
post := posts[0]
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl())
}
func TestEditorSendsWebmentions(t *testing.T) {
repo, user := getSingleUserTestRepo()
repo.HttpClient = &CountMockHttpClient{}
repo.Parser = &mocks.MockHtmlParser{}
user.ResetPassword("testpassword")
mentioned_post, _ := user.CreateNewPost(owl.PostMeta{Title: "test"}, "")
sessionId := user.CreateNewSession()
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("type", "note")
form.Add("content", "[test]("+mentioned_post.FullUrl()+")")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
posts, _ := user.AllPosts()
assertions.AssertEqual(t, len(posts), 2)
post := posts[0]
assertions.AssertLen(t, post.OutgoingWebmentions(), 1)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, repo.HttpClient.(*CountMockHttpClient).InvokedPostForm, 1)
}
func TestEditorPostWithSessionRecipe(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
sessionId := user.CreateNewSession()
csrfToken := "test_csrf_token"
// Create Request and Response
form := url.Values{}
form.Add("type", "recipe")
form.Add("title", "testtitle")
form.Add("yield", "2")
form.Add("duration", "1 hour")
form.Add("ingredients", "water\nwheat")
form.Add("content", "testcontent")
form.Add("csrf_token", csrfToken)
req, err := http.NewRequest("POST", user.EditorUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
posts, _ := user.AllPosts()
assertions.AssertEqual(t, len(posts), 1)
post := posts[0]
assertions.AssertLen(t, post.Meta().Recipe.Ingredients, 2)
assertions.AssertStatus(t, rr, http.StatusFound)
assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl())
}
func TestEditorPostWithSessionPhoto(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
sessionId := user.CreateNewSession()
csrfToken := "test_csrf_token"
// read photo from file
photo_data, err := ioutil.ReadFile("../../../fixtures/image.png")
assertions.AssertNoError(t, err, "Error reading photo")
// Create Request and Response
bodyBuf := &bytes.Buffer{}
bodyWriter := multipart.NewWriter(bodyBuf)
// write photo
fileWriter, err := bodyWriter.CreateFormFile("photo", "../../../fixtures/image.png")
assertions.AssertNoError(t, err, "Error creating form file")
_, err = fileWriter.Write(photo_data)
assertions.AssertNoError(t, err, "Error writing photo")
// write other fields
bodyWriter.WriteField("type", "photo")
bodyWriter.WriteField("title", "testtitle")
bodyWriter.WriteField("content", "testcontent")
bodyWriter.WriteField("csrf_token", csrfToken)
// close body writer
err = bodyWriter.Close()
assertions.AssertNoError(t, err, "Error closing body writer")
req, err := http.NewRequest("POST", user.EditorUrl(), bodyBuf)
req.Header.Add("Content-Type", "multipart/form-data; boundary="+bodyWriter.Boundary())
req.Header.Add("Content-Length", strconv.Itoa(len(bodyBuf.Bytes())))
req.AddCookie(&http.Cookie{Name: "csrf_token", Value: csrfToken})
req.AddCookie(&http.Cookie{Name: "session", Value: sessionId})
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusFound)
posts, _ := user.AllPosts()
assertions.AssertEqual(t, len(posts), 1)
post := posts[0]
assertions.AssertEqual(t, rr.Header().Get("Location"), post.FullUrl())
assertions.AssertNotEqual(t, post.Meta().PhotoPath, "")
ret_photo_data, err := ioutil.ReadFile(path.Join(post.MediaDir(), post.Meta().PhotoPath))
assertions.AssertNoError(t, err, "Error reading photo")
assertions.AssertEqual(t, len(photo_data), len(ret_photo_data))
if len(photo_data) == len(ret_photo_data) {
for i := range photo_data {
assertions.AssertEqual(t, photo_data[i], ret_photo_data[i])
}
}
}

407
cmd/owl/web/handler.go Normal file
View File

@ -0,0 +1,407 @@
package web
import (
"fmt"
"h4kor/owl-blogs"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"github.com/julienschmidt/httprouter"
)
func getUserFromRepo(repo *owl.Repository, ps httprouter.Params) (owl.User, error) {
if config, _ := repo.Config(); config.SingleUser != "" {
return repo.GetUser(config.SingleUser)
}
userName := ps.ByName("user")
user, err := repo.GetUser(userName)
if err != nil {
return owl.User{}, err
}
return user, nil
}
func repoIndexHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
html, err := owl.RenderUserList(*repo)
if err != nil {
println("Error rendering index: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
return
}
println("Rendering index")
w.Write([]byte(html))
}
}
func userIndexHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
html, err := owl.RenderIndexPage(user)
if err != nil {
println("Error rendering index page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
println("Rendering index page for user", user.Name())
w.Write([]byte(html))
}
}
func postListHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
listId := ps.ByName("list")
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
list, err := user.GetPostList(listId)
if err != nil {
println("Error getting post list: ", err.Error())
notFoundUserHandler(repo, user)(w, r)
return
}
html, err := owl.RenderPostList(user, list)
if err != nil {
println("Error rendering index page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
println("Rendering index page for user", user.Name())
w.Write([]byte(html))
}
}
func userWebmentionHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("User not found"))
return
}
err = r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Unable to parse form data"))
return
}
params := r.PostForm
target := params["target"]
source := params["source"]
if len(target) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("No target provided"))
return
}
if len(source) == 0 {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("No source provided"))
return
}
if len(target[0]) < 7 || (target[0][:7] != "http://" && target[0][:8] != "https://") {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Not a valid target"))
return
}
if len(source[0]) < 7 || (source[0][:7] != "http://" && source[0][:8] != "https://") {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Not a valid source"))
return
}
if source[0] == target[0] {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("target and source are equal"))
return
}
tryAlias := func(target string) owl.Post {
parsedTarget, _ := url.Parse(target)
aliases, _ := repo.PostAliases()
fmt.Printf("aliases %v", aliases)
fmt.Printf("parsedTarget %v", parsedTarget)
if _, ok := aliases[parsedTarget.Path]; ok {
return aliases[parsedTarget.Path]
}
return nil
}
var aliasPost owl.Post
parts := strings.Split(target[0], "/")
if len(parts) < 2 {
aliasPost = tryAlias(target[0])
if aliasPost == nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Not found"))
return
}
}
postId := parts[len(parts)-2]
foundPost, err := user.GetPost(postId)
if err != nil && aliasPost == nil {
aliasPost = tryAlias(target[0])
if aliasPost == nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Post not found"))
return
}
}
if aliasPost != nil {
foundPost = aliasPost
}
err = foundPost.AddIncomingWebmention(source[0])
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Unable to process webmention"))
return
}
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(""))
}
}
func userRSSHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
xml, err := owl.RenderRSSFeed(user)
if err != nil {
println("Error rendering index page: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
println("Rendering index page for user", user.Name())
w.Header().Set("Content-Type", "application/rss+xml")
w.Write([]byte(xml))
}
}
func postHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
postId := ps.ByName("post")
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
post, err := user.GetPost(postId)
if err != nil {
println("Error getting post: ", err.Error())
notFoundUserHandler(repo, user)(w, r)
return
}
meta := post.Meta()
if meta.Draft {
println("Post is a draft")
notFoundUserHandler(repo, user)(w, r)
return
}
html, err := owl.RenderPost(post)
if err != nil {
println("Error rendering post: ", err.Error())
w.WriteHeader(http.StatusInternalServerError)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Internal server error",
Message: "Internal server error",
})
w.Write([]byte(html))
return
}
println("Rendering post", postId)
w.Write([]byte(html))
}
}
func postMediaHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
postId := ps.ByName("post")
filepath := ps.ByName("filepath")
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
post, err := user.GetPost(postId)
if err != nil {
println("Error getting post: ", err.Error())
notFoundUserHandler(repo, user)(w, r)
return
}
filepath = path.Join(post.MediaDir(), filepath)
if _, err := os.Stat(filepath); err != nil {
println("Error getting file: ", err.Error())
notFoundUserHandler(repo, user)(w, r)
return
}
http.ServeFile(w, r, filepath)
}
}
func userMicropubHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
// parse request form
err = r.ParseForm()
if err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Bad request"))
return
}
// verify access token
token := r.Header.Get("Authorization")
if token == "" {
token = r.Form.Get("access_token")
} else {
token = strings.TrimPrefix(token, "Bearer ")
}
valid, _ := user.ValidateAccessToken(token)
if !valid {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("Unauthorized"))
return
}
h := r.Form.Get("h")
content := r.Form.Get("content")
name := r.Form.Get("name")
inReplyTo := r.Form.Get("in-reply-to")
if h != "entry" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Bad request. h must be entry. Got " + h))
return
}
if content == "" {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Bad request. content is required"))
return
}
// create post
post, err := user.CreateNewPost(
owl.PostMeta{
Title: name,
Reply: owl.ReplyData{
Url: inReplyTo,
},
Date: time.Now(),
},
content,
)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("Internal server error"))
return
}
w.Header().Add("Location", post.FullUrl())
w.WriteHeader(http.StatusCreated)
}
}
func userMediaHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request, httprouter.Params) {
return func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
filepath := ps.ByName("filepath")
user, err := getUserFromRepo(repo, ps)
if err != nil {
println("Error getting user: ", err.Error())
notFoundHandler(repo)(w, r)
return
}
filepath = path.Join(user.MediaDir(), filepath)
if _, err := os.Stat(filepath); err != nil {
println("Error getting file: ", err.Error())
notFoundUserHandler(repo, user)(w, r)
return
}
http.ServeFile(w, r, filepath)
}
}
func notFoundHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
aliases, _ := repo.PostAliases()
if _, ok := aliases[path]; ok {
http.Redirect(w, r, aliases[path].UrlPath(), http.StatusMovedPermanently)
return
}
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Not found"))
}
}
func notFoundUserHandler(repo *owl.Repository, user owl.User) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
aliases, _ := repo.PostAliases()
if _, ok := aliases[path]; ok {
http.Redirect(w, r, aliases[path].UrlPath(), http.StatusMovedPermanently)
return
}
w.WriteHeader(http.StatusNotFound)
html, _ := owl.RenderUserError(user, owl.ErrorMessage{
Error: "Not found",
Message: "The page you requested could not be found",
})
w.Write([]byte(html))
}
}

View File

@ -0,0 +1,183 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
func TestMicropubMinimalArticle(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode(
"test", "test", "test", "test", "test",
)
token, _, _ := user.GenerateAccessToken(owl.AuthCode{
Code: code,
ClientId: "test",
RedirectUri: "test",
CodeChallenge: "test",
CodeChallengeMethod: "test",
Scope: "test",
})
// Create Request and Response
form := url.Values{}
form.Add("h", "entry")
form.Add("name", "Test Article")
form.Add("content", "Test Content")
req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Authorization", "Bearer "+token)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusCreated)
}
func TestMicropubWithoutName(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode(
"test", "test", "test", "test", "test",
)
token, _, _ := user.GenerateAccessToken(owl.AuthCode{
Code: code,
ClientId: "test",
RedirectUri: "test",
CodeChallenge: "test",
CodeChallengeMethod: "test",
Scope: "test",
})
// Create Request and Response
form := url.Values{}
form.Add("h", "entry")
form.Add("content", "Test Content")
req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
req.Header.Add("Authorization", "Bearer "+token)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusCreated)
loc_header := rr.Header().Get("Location")
assertions.Assert(t, loc_header != "", "Location header should be set")
}
func TestMicropubAccessTokenInBody(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode(
"test", "test", "test", "test", "test",
)
token, _, _ := user.GenerateAccessToken(owl.AuthCode{
Code: code,
ClientId: "test",
RedirectUri: "test",
CodeChallenge: "test",
CodeChallengeMethod: "test",
Scope: "test",
})
// Create Request and Response
form := url.Values{}
form.Add("h", "entry")
form.Add("content", "Test Content")
form.Add("access_token", token)
req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusCreated)
loc_header := rr.Header().Get("Location")
assertions.Assert(t, loc_header != "", "Location header should be set")
}
// func TestMicropubAccessTokenInBoth(t *testing.T) {
// repo, user := getSingleUserTestRepo()
// user.ResetPassword("testpassword")
// code, _ := user.GenerateAuthCode(
// "test", "test", "test", "test", "test",
// )
// token, _, _ := user.GenerateAccessToken(owl.AuthCode{
// Code: code,
// ClientId: "test",
// RedirectUri: "test",
// CodeChallenge: "test",
// CodeChallengeMethod: "test",
// Scope: "test",
// })
// // Create Request and Response
// form := url.Values{}
// form.Add("h", "entry")
// form.Add("content", "Test Content")
// form.Add("access_token", token)
// req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode()))
// req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
// req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
// req.Header.Add("Authorization", "Bearer "+token)
// assertions.AssertNoError(t, err, "Error creating request")
// rr := httptest.NewRecorder()
// router := main.SingleUserRouter(&repo)
// router.ServeHTTP(rr, req)
// assertions.AssertStatus(t, rr, http.StatusBadRequest)
// }
func TestMicropubNoAccessToken(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.ResetPassword("testpassword")
code, _ := user.GenerateAuthCode(
"test", "test", "test", "test", "test",
)
user.GenerateAccessToken(owl.AuthCode{
Code: code,
ClientId: "test",
RedirectUri: "test",
CodeChallenge: "test",
CodeChallengeMethod: "test",
Scope: "test",
})
// Create Request and Response
form := url.Values{}
form.Add("h", "entry")
form.Add("content", "Test Content")
req, err := http.NewRequest("POST", user.MicropubUrl(), strings.NewReader(form.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(form.Encode())))
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusUnauthorized)
}

View File

@ -0,0 +1,108 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"math/rand"
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
"time"
)
func randomName() string {
rand.Seed(time.Now().UnixNano())
var letters = []rune("abcdefghijklmnopqrstuvwxyz")
b := make([]rune, 8)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}
func testRepoName() string {
return "/tmp/" + randomName()
}
func getTestRepo(config owl.RepoConfig) owl.Repository {
repo, _ := owl.CreateRepository(testRepoName(), config)
return repo
}
func TestMultiUserRepoIndexHandler(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
repo.CreateUser("user_1")
repo.CreateUser("user_2")
// Create Request and Response
req, err := http.NewRequest("GET", "/", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains names of users
assertions.AssertContains(t, rr.Body.String(), "user_1")
assertions.AssertContains(t, rr.Body.String(), "user_2")
}
func TestMultiUserUserIndexHandler(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create Request and Response
req, err := http.NewRequest("GET", user.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains names of users
assertions.AssertContains(t, rr.Body.String(), "post-1")
}
func TestMultiUserPostHandler(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestMultiUserPostMediaHandler(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create test media file
path := path.Join(post.MediaDir(), "data.txt")
err := os.WriteFile(path, []byte("test"), 0644)
assertions.AssertNoError(t, err, "Error creating request")
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlMediaPath("data.txt"), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains data of media file
assertions.Assert(t, rr.Body.String() == "test", "Response body is not equal to test")
}

34
cmd/owl/web/post_test.go Normal file
View File

@ -0,0 +1,34 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"net/http"
"net/http/httptest"
"os"
"testing"
)
func TestPostHandlerReturns404OnDrafts(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Title: "post-1"}, "")
content := "---\n"
content += "title: test\n"
content += "draft: true\n"
content += "---\n"
content += "\n"
content += "Write your post here.\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusNotFound)
}

31
cmd/owl/web/rss_test.go Normal file
View File

@ -0,0 +1,31 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"net/http"
"net/http/httptest"
"testing"
)
func TestMultiUserUserRssIndexHandler(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create Request and Response
req, err := http.NewRequest("GET", user.UrlPath()+"index.xml", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response Content-Type is what we expect.
assertions.AssertContains(t, rr.Header().Get("Content-Type"), "application/rss+xml")
// Check the response body contains names of users
assertions.AssertContains(t, rr.Body.String(), "post-1")
}

95
cmd/owl/web/server.go Normal file
View File

@ -0,0 +1,95 @@
package web
import (
"h4kor/owl-blogs"
"net/http"
"os"
"strconv"
"github.com/julienschmidt/httprouter"
)
func Router(repo *owl.Repository) http.Handler {
router := httprouter.New()
router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir()))
router.GET("/", repoIndexHandler(repo))
router.GET("/user/:user/", userIndexHandler(repo))
router.GET("/user/:user/lists/:list/", postListHandler(repo))
// Editor
router.GET("/user/:user/editor/auth/", userLoginGetHandler(repo))
router.POST("/user/:user/editor/auth/", userLoginPostHandler(repo))
router.GET("/user/:user/editor/", userEditorGetHandler(repo))
router.POST("/user/:user/editor/", userEditorPostHandler(repo))
// Media
router.GET("/user/:user/media/*filepath", userMediaHandler(repo))
// RSS
router.GET("/user/:user/index.xml", userRSSHandler(repo))
// Posts
router.GET("/user/:user/posts/:post/", postHandler(repo))
router.GET("/user/:user/posts/:post/media/*filepath", postMediaHandler(repo))
// Webmention
router.POST("/user/:user/webmention/", userWebmentionHandler(repo))
// Micropub
router.POST("/user/:user/micropub/", userMicropubHandler(repo))
// IndieAuth
router.GET("/user/:user/auth/", userAuthHandler(repo))
router.POST("/user/:user/auth/", userAuthProfileHandler(repo))
router.POST("/user/:user/auth/verify/", userAuthVerifyHandler(repo))
router.POST("/user/:user/auth/token/", userAuthTokenHandler(repo))
router.GET("/user/:user/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo))
router.NotFound = http.HandlerFunc(notFoundHandler(repo))
return router
}
func SingleUserRouter(repo *owl.Repository) http.Handler {
router := httprouter.New()
router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir()))
router.GET("/", userIndexHandler(repo))
router.GET("/lists/:list/", postListHandler(repo))
// Editor
router.GET("/editor/auth/", userLoginGetHandler(repo))
router.POST("/editor/auth/", userLoginPostHandler(repo))
router.GET("/editor/", userEditorGetHandler(repo))
router.POST("/editor/", userEditorPostHandler(repo))
// Media
router.GET("/media/*filepath", userMediaHandler(repo))
// RSS
router.GET("/index.xml", userRSSHandler(repo))
// Posts
router.GET("/posts/:post/", postHandler(repo))
router.GET("/posts/:post/media/*filepath", postMediaHandler(repo))
// Webmention
router.POST("/webmention/", userWebmentionHandler(repo))
// Micropub
router.POST("/micropub/", userMicropubHandler(repo))
// IndieAuth
router.GET("/auth/", userAuthHandler(repo))
router.POST("/auth/", userAuthProfileHandler(repo))
router.POST("/auth/verify/", userAuthVerifyHandler(repo))
router.POST("/auth/token/", userAuthTokenHandler(repo))
router.GET("/.well-known/oauth-authorization-server", userAuthMetadataHandler(repo))
router.NotFound = http.HandlerFunc(notFoundHandler(repo))
return router
}
func StartServer(repoPath string, port int) {
var repo owl.Repository
var err error
repo, err = owl.OpenRepository(repoPath)
if err != nil {
println("Error opening repository: ", err.Error())
os.Exit(1)
}
var router http.Handler
if config, _ := repo.Config(); config.SingleUser != "" {
router = SingleUserRouter(&repo)
} else {
router = Router(&repo)
}
println("Listening on port", port)
http.ListenAndServe(":"+strconv.Itoa(port), router)
}

View File

@ -0,0 +1,132 @@
package web_test
import (
owl "h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
)
func getSingleUserTestRepo() (owl.Repository, owl.User) {
repo, _ := owl.CreateRepository(testRepoName(), owl.RepoConfig{SingleUser: "test-1"})
user, _ := repo.CreateUser("test-1")
return repo, user
}
func TestSingleUserUserIndexHandler(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create Request and Response
req, err := http.NewRequest("GET", user.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains names of users
assertions.AssertContains(t, rr.Body.String(), "post-1")
}
func TestSingleUserPostHandler(t *testing.T) {
repo, user := getSingleUserTestRepo()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlPath(), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
}
func TestSingleUserPostMediaHandler(t *testing.T) {
repo, user := getSingleUserTestRepo()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
// Create test media file
path := path.Join(post.MediaDir(), "data.txt")
err := os.WriteFile(path, []byte("test"), 0644)
assertions.AssertNoError(t, err, "Error creating request")
// Create Request and Response
req, err := http.NewRequest("GET", post.UrlMediaPath("data.txt"), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains data of media file
assertions.Assert(t, rr.Body.String() == "test", "Media file data not returned")
}
func TestHasNoDraftsInList(t *testing.T) {
repo, user := getSingleUserTestRepo()
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
content := ""
content += "---\n"
content += "title: Articles September 2019\n"
content += "author: h4kor\n"
content += "type: post\n"
content += "date: -001-11-30T00:00:00+00:00\n"
content += "draft: true\n"
content += "url: /?p=426\n"
content += "categories:\n"
content += " - Uncategorised\n"
content += "\n"
content += "---\n"
content += "<https://nesslabs.com/time-anxiety>\n"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
// Create Request and Response
req, err := http.NewRequest("GET", "/", nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
// Check if title is in the response body
assertions.AssertNotContains(t, rr.Body.String(), "Articles September 2019")
}
func TestSingleUserUserPostListHandler(t *testing.T) {
repo, user := getSingleUserTestRepo()
user.CreateNewPost(owl.PostMeta{
Title: "post-1",
Type: "article",
}, "hi")
user.CreateNewPost(owl.PostMeta{
Title: "post-2",
Type: "note",
}, "hi")
list := owl.PostList{
Title: "list-1",
Id: "list-1",
Include: []string{"article"},
}
user.AddPostList(list)
// Create Request and Response
req, err := http.NewRequest("GET", user.ListUrl(list), nil)
assertions.AssertNoError(t, err, "Error creating request")
rr := httptest.NewRecorder()
router := main.SingleUserRouter(&repo)
router.ServeHTTP(rr, req)
assertions.AssertStatus(t, rr, http.StatusOK)
// Check the response body contains names of users
assertions.AssertContains(t, rr.Body.String(), "post-1")
assertions.AssertNotContains(t, rr.Body.String(), "post-2")
}

View File

@ -0,0 +1,162 @@
package web_test
import (
"h4kor/owl-blogs"
main "h4kor/owl-blogs/cmd/owl/web"
"h4kor/owl-blogs/test/assertions"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
)
func setupWebmentionTest(repo owl.Repository, user owl.User, target string, source string) (*httptest.ResponseRecorder, error) {
data := url.Values{}
data.Set("target", target)
data.Set("source", source)
// Create Request and Response
req, err := http.NewRequest("POST", user.UrlPath()+"webmention/", strings.NewReader(data.Encode()))
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Content-Length", strconv.Itoa(len(data.Encode())))
if err != nil {
return nil, err
}
rr := httptest.NewRecorder()
router := main.Router(&repo)
router.ServeHTTP(rr, req)
return rr, nil
}
func TestWebmentionHandleAccepts(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := post.FullUrl()
source := "https://example.com"
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusAccepted)
}
func TestWebmentionWrittenToPost(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := post.FullUrl()
source := "https://example.com"
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusAccepted)
assertions.AssertLen(t, post.IncomingWebmentions(), 1)
}
//
// https://www.w3.org/TR/webmention/#h-request-verification
//
// The receiver MUST check that source and target are valid URLs [URL]
// and are of schemes that are supported by the receiver.
// (Most commonly this means checking that the source and target schemes are http or https).
func TestWebmentionSourceValidation(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := post.FullUrl()
source := "ftp://example.com"
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusBadRequest)
}
func TestWebmentionTargetValidation(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := "ftp://example.com"
source := post.FullUrl()
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusBadRequest)
}
// The receiver MUST reject the request if the source URL is the same as the target URL.
func TestWebmentionSameTargetAndSource(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := post.FullUrl()
source := post.FullUrl()
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusBadRequest)
}
// The receiver SHOULD check that target is a valid resource for which it can accept Webmentions.
// This check SHOULD happen synchronously to reject invalid Webmentions before more in-depth verification begins.
// What a "valid resource" means is up to the receiver.
func TestValidationOfTarget(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
target := post.FullUrl()
target = target[:len(target)-1] + "invalid"
source := post.FullUrl()
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusBadRequest)
}
func TestAcceptWebmentionForAlias(t *testing.T) {
repo := getTestRepo(owl.RepoConfig{})
user, _ := repo.CreateUser("test-1")
post, _ := user.CreateNewPost(owl.PostMeta{Type: "article", Title: "post-1"}, "")
content := "---\n"
content += "title: Test\n"
content += "aliases: \n"
content += " - /foo/bar\n"
content += " - /foo/baz\n"
content += "---\n"
content += "This is a test"
os.WriteFile(post.ContentFile(), []byte(content), 0644)
target := "https://example.com/foo/bar"
source := "https://example.com"
rr, err := setupWebmentionTest(repo, user, target, source)
assertions.AssertNoError(t, err, "Error setting up webmention test")
assertions.AssertStatus(t, rr, http.StatusAccepted)
}

99
cmd/owl/webmention.go Normal file
View File

@ -0,0 +1,99 @@
package main
import (
"h4kor/owl-blogs"
"sync"
"github.com/spf13/cobra"
)
var postId string
func init() {
rootCmd.AddCommand(webmentionCmd)
webmentionCmd.Flags().StringVar(
&postId, "post", "",
"specify the post to send webmentions for. Otherwise, all posts will be checked.",
)
}
var webmentionCmd = &cobra.Command{
Use: "webmention",
Short: "Send webmentions for posts, optionally for a specific user",
Long: `Send webmentions for posts, optionally for a specific user`,
Run: func(cmd *cobra.Command, args []string) {
repo, err := owl.OpenRepository(repoPath)
if err != nil {
println("Error opening repository: ", err.Error())
return
}
var users []owl.User
if user == "" {
// send webmentions for all users
users, err = repo.Users()
if err != nil {
println("Error getting users: ", err.Error())
return
}
} else {
// send webmentions for a specific user
user, err := repo.GetUser(user)
users = append(users, user)
if err != nil {
println("Error getting user: ", err.Error())
return
}
}
processPost := func(user owl.User, post owl.Post) error {
println("Webmentions for post: ", post.Title())
err := post.ScanForLinks()
if err != nil {
println("Error scanning post for links: ", err.Error())
return err
}
webmentions := post.OutgoingWebmentions()
println("Found ", len(webmentions), " links")
wg := sync.WaitGroup{}
wg.Add(len(webmentions))
for _, webmention := range webmentions {
go func(webmention owl.WebmentionOut) {
defer wg.Done()
sendErr := post.SendWebmention(webmention)
if sendErr != nil {
println("Error sending webmentions: ", sendErr.Error())
} else {
println("Webmention sent to ", webmention.Target)
}
}(webmention)
}
wg.Wait()
return nil
}
for _, user := range users {
if postId != "" {
// send webmentions for a specific post
post, err := user.GetPost(postId)
if err != nil {
println("Error getting post: ", err.Error())
return
}
processPost(user, post)
return
}
posts, err := user.PublishedPosts()
if err != nil {
println("Error getting posts: ", err.Error())
}
for _, post := range posts {
processPost(user, post)
}
}
},
}

View File

@ -1,26 +0,0 @@
package config
import "os"
const (
SITE_CONFIG = "site_config"
ACT_PUB_CONF_NAME = "activity_pub"
)
type Config interface {
}
type EnvConfig struct {
}
func getEnvOrPanic(key string) string {
value, set := os.LookupEnv(key)
if !set {
panic("Environment variable " + key + " is not set")
}
return value
}
func NewConfig() Config {
return &EnvConfig{}
}

45
directories.go Normal file
View File

@ -0,0 +1,45 @@
package owl
import (
"os"
"strings"
)
func dirExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// lists all files/dirs in a directory, not recursive
func listDir(path string) []string {
dir, _ := os.Open(path)
defer dir.Close()
files, _ := dir.Readdirnames(-1)
return files
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func toDirectoryName(name string) string {
name = strings.ToLower(strings.ReplaceAll(name, " ", "-"))
// remove all non-alphanumeric characters
name = strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' {
return r
}
if r >= 'A' && r <= 'Z' {
return r
}
if r >= '0' && r <= '9' {
return r
}
if r == '-' {
return r
}
return -1
}, name)
return name
}

View File

@ -1,8 +0,0 @@
package model
type Author struct {
Name string
PasswordHash string
FullUrl string
AvatarUrl string
}

View File

@ -1,24 +0,0 @@
package model
import (
"mime"
"strings"
)
type BinaryFile struct {
Id string
Name string
Data []byte
}
func (b *BinaryFile) Mime() string {
parts := strings.Split(b.Name, ".")
if len(parts) < 2 {
return "application/octet-stream"
}
t := mime.TypeByExtension("." + parts[len(parts)-1])
if t == "" {
return "application/octet-stream"
}
return t
}

View File

@ -1,13 +0,0 @@
package model_test
import (
"owl-blogs/domain/model"
"testing"
"github.com/stretchr/testify/require"
)
func TestMimeType(t *testing.T) {
bin := model.BinaryFile{Name: "test.jpg"}
require.Equal(t, "image/jpeg", bin.Mime())
}

View File

@ -1,5 +0,0 @@
package model
type BinaryStorageInterface interface {
Create(name string, file []byte) (*BinaryFile, error)
}

View File

@ -1,70 +0,0 @@
package model
import (
"net/url"
"time"
)
type EntryContent string
type Entry interface {
ID() string
Content() EntryContent
PublishedAt() *time.Time
AuthorId() string
MetaData() EntryMetaData
// Optional: can return empty string
Title() string
ImageUrl() string
SetID(id string)
SetPublishedAt(publishedAt *time.Time)
SetMetaData(metaData EntryMetaData)
SetAuthorId(authorId string)
FullUrl(cfg SiteConfig) string
}
type EntryMetaData interface {
Formable
}
type EntryBase struct {
id string
publishedAt *time.Time
authorId string
}
func (e *EntryBase) ID() string {
return e.id
}
func (e *EntryBase) PublishedAt() *time.Time {
return e.publishedAt
}
func (e *EntryBase) ImageUrl() string {
return ""
}
func (e *EntryBase) SetID(id string) {
e.id = id
}
func (e *EntryBase) SetPublishedAt(publishedAt *time.Time) {
e.publishedAt = publishedAt
}
func (e *EntryBase) AuthorId() string {
return e.authorId
}
func (e *EntryBase) SetAuthorId(authorId string) {
e.authorId = authorId
}
func (e *EntryBase) FullUrl(cfg SiteConfig) string {
u, _ := url.JoinPath(cfg.FullUrl, "/posts/", e.ID(), "/")
return u
}

View File

@ -1,34 +0,0 @@
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

@ -1,20 +0,0 @@
package model
import "mime/multipart"
type Formable interface {
Form(binSvc BinaryStorageInterface) string
ParseFormData(data HttpFormData, binSvc BinaryStorageInterface) error
}
type HttpFormData interface {
// FormFile returns the first file by key from a MultipartForm.
FormFile(key string) (*multipart.FileHeader, error)
// FormValue returns the first value by key from a MultipartForm.
// Search is performed in QueryArgs, PostArgs, MultipartForm and FormFile in this particular order.
// Defaults to the empty string "" if the form value doesn't exist.
// If a default value is given, it will return that value if the form value does not exist.
// Returned value is only valid within the handler. Do not store any references.
// Make copies or use the Immutable setting instead.
FormValue(key string, defaultValue ...string) string
}

View File

@ -1,53 +0,0 @@
package model
import "time"
type InteractionContent string
// Interaction is a generic interface for all interactions with entries
// These interactions can be:
// - Webmention, Pingback, Trackback
// - Likes, Comments on third party sites
// - Comments on the site itself
type Interaction interface {
ID() string
EntryID() string
Content() InteractionContent
CreatedAt() time.Time
MetaData() interface{}
SetID(id string)
SetEntryID(entryID string)
SetCreatedAt(createdAt time.Time)
SetMetaData(metaData interface{})
}
type InteractionBase struct {
id string
entryID string
createdAt time.Time
}
func (i *InteractionBase) ID() string {
return i.id
}
func (i *InteractionBase) EntryID() string {
return i.entryID
}
func (i *InteractionBase) CreatedAt() time.Time {
return i.createdAt
}
func (i *InteractionBase) SetID(id string) {
i.id = id
}
func (i *InteractionBase) SetEntryID(entryID string) {
i.entryID = entryID
}
func (i *InteractionBase) SetCreatedAt(createdAt time.Time) {
i.createdAt = createdAt
}

View File

@ -1,37 +0,0 @@
package model
type MeLinks struct {
Name string
Url string
}
type EntryList struct {
Id string
Title string
Include []string
ListType string
}
type MenuItem struct {
Title string
List string
Url string
Post string
}
type SiteConfig struct {
Title string
SubTitle string
PrimaryColor string
AuthorName string
Me []MeLinks
Lists []EntryList
PrimaryListInclude []string
HeaderMenu []MenuItem
FooterMenu []MenuItem
Secret string
AvatarUrl string
FullUrl string
HtmlHeadExtra string
FooterExtra string
}

View File

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

View File

@ -1,49 +0,0 @@
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

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

View File

@ -1,24 +0,0 @@
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

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

View File

@ -1,214 +0,0 @@
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

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

View File

@ -1,17 +0,0 @@
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

@ -1,102 +0,0 @@
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

@ -1,88 +0,0 @@
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

@ -1,27 +0,0 @@
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

6
embed.go Normal file
View File

@ -0,0 +1,6 @@
package owl
import "embed"
//go:embed embed/*
var embed_files embed.FS

60
embed/article/detail.html Normal file
View File

@ -0,0 +1,60 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.Bookmark.Url }}
<p style="font-style: italic;filter: opacity(80%);">
Bookmark: <a class="u-bookmark-of h-cite" href="{{.Post.Meta.Bookmark.Url}}">
{{ if .Post.Meta.Bookmark.Text }}
{{.Post.Meta.Bookmark.Text}}
{{ else }}
{{.Post.Meta.Bookmark.Url}}
{{ end }}
</a>
</p>
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

24
embed/auth.html Normal file
View File

@ -0,0 +1,24 @@
<h3>Authorization for {{.ClientId}}</h3>
<h5>Requesting scope:</h5>
<ul>
{{range $index, $element := .Scopes}}
<li>{{$element}}</li>
{{end}}
</ul>
<br><br>
<form action="verify/" method="post">
<label for="password">Password</label>
<input type="password" name="password" placeholder="Password">
<input type="hidden" name="client_id" value="{{.ClientId}}">
<input type="hidden" name="redirect_uri" value="{{.RedirectUri}}">
<input type="hidden" name="response_type" value="{{.ResponseType}}">
<input type="hidden" name="state" value="{{.State}}">
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="code_challenge" value="{{.CodeChallenge}}">
<input type="hidden" name="code_challenge_method" value="{{.CodeChallengeMethod}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="submit" value="Login">
</form>

View File

@ -0,0 +1,72 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.Reply.Url }}
<p style="font-style: italic;filter: opacity(80%);">
In reply to: <a class="u-in-reply-to h-cite" rel="in-reply-to" href="{{.Post.Meta.Reply.Url}}">
{{ if .Post.Meta.Reply.Text }}
{{.Post.Meta.Reply.Text}}
{{ else }}
{{.Post.Meta.Reply.Url}}
{{ end }}
</a>
</p>
{{ end }}
{{ if .Post.Meta.Bookmark.Url }}
<p style="font-style: italic;filter: opacity(80%);">
Bookmark: <a class="u-bookmark-of h-cite" href="{{.Post.Meta.Bookmark.Url}}">
{{ if .Post.Meta.Bookmark.Text }}
{{.Post.Meta.Bookmark.Text}}
{{ else }}
{{.Post.Meta.Bookmark.Url}}
{{ end }}
</a>
</p>
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

127
embed/editor/editor.html Normal file
View File

@ -0,0 +1,127 @@
<details>
<summary>Write Article/Page</summary>
<form action="" method="post">
<h2>Create New Article</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<select name="type">
<option value="article">Article</option>
<option value="page">Page</option>
</select>
<label for="title">Title</label>
<input type="text" name="title" placeholder="Title" />
<label for="description">Description</label>
<input type="text" name="description" placeholder="Description" />
<label for="content">Content</label>
<textarea name="content" placeholder="Content" rows="24"></textarea>
<input type="checkbox" name="draft" />
<label for="draft">Draft</label>
<br><br>
<input type="submit" value="Create Article" />
</form>
</details>
<details>
<summary>Upload Photo</summary>
<form action="" method="post" enctype="multipart/form-data">
<h2>Upload Photo</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="type" value="photo">
<label for="title">Title</label>
<input type="text" name="title" placeholder="Title" />
<label for="description">Description</label>
<input type="text" name="description" placeholder="Description" />
<label for="content">Content</label>
<textarea name="content" placeholder="Content" rows="4"></textarea>
<label for="photo">Photo</label>
<input type="file" name="photo" placeholder="Photo" />
<br><br>
<input type="submit" value="Create Article" />
</form>
</details>
<details>
<summary>Write Recipe</summary>
<form action="" method="post">
<h2>Create new Recipe</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="type" value="recipe">
<label for="title">Title</label>
<input type="text" name="title" placeholder="Title" />
<label for="yield">Yield</label>
<input type="text" name="yield" placeholder="Yield" />
<label for="duration">Duration</label>
<input type="text" name="duration" placeholder="Duration" />
<label for="description">Description</label>
<input type="text" name="description" placeholder="Description" />
<label for="ingredients">Ingredients (1 per line)</label>
<textarea name="ingredients" placeholder="Ingredients" rows="8"></textarea>
<label for="content">Instructions</label>
<textarea name="content" placeholder="Ingredients" rows="24"></textarea>
<br><br>
<input type="submit" value="Create Reply" />
</form>
</details>
<details>
<summary>Write Note</summary>
<form action="" method="post">
<h2>Create New Note</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="type" value="note">
<label for="content">Content</label>
<textarea name="content" placeholder="Content" rows="8"></textarea>
<br><br>
<input type="submit" value="Create Note" />
</form>
</details>
<details>
<summary>Write Reply</summary>
<form action="" method="post">
<h2>Create New Reply</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="type" value="reply">
<label for="reply_url">Reply To</label>
<input type="text" name="reply_url" placeholder="URL" />
<label for="title">Title</label>
<input type="text" name="title" placeholder="Title" />
<label for="content">Content</label>
<textarea name="content" placeholder="Content" rows="8"></textarea>
<br><br>
<input type="submit" value="Create Reply" />
</form>
</details>
<details>
<summary>Bookmark</summary>
<form action="" method="post">
<h2>Create Bookmark</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="hidden" name="type" value="bookmark">
<label for="bookmark_url">Bookmark</label>
<input type="text" name="bookmark_url" placeholder="URL" />
<label for="title">Title</label>
<input type="text" name="title" placeholder="Title" />
<label for="content">Content</label>
<textarea name="content" placeholder="Content" rows="8"></textarea>
<br><br>
<input type="submit" value="Create Bookmark" />
</form>
</details>

13
embed/editor/login.html Normal file
View File

@ -0,0 +1,13 @@
{{ if eq .Error "wrong_password" }}
<article style="background-color: #dd867f;color: #481212;padding: 1em;">
Wrong Password
</article>
{{ end }}
<form action="" method="post">
<h2>Login to Editor</h2>
<input type="hidden" name="csrf_token" value="{{.CsrfToken}}">
<input type="password" name="password" />
<input type="submit" value="Login" />
</form>

4
embed/error.html Normal file
View File

@ -0,0 +1,4 @@
<article style="background-color: #dd867f;color: #481212;">
<h3>{{ .Error }}</h3>
{{ .Message }}
</article>

146
embed/initial/base.html Normal file
View File

@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }} - {{ .User.Config.Title }}</title>
{{ if .User.FaviconUrl }}
<link rel="icon" href="{{ .User.FaviconUrl }}">
{{ else }}
<link rel="icon" href="data:,">
{{ end }}
<meta property="og:title" content="{{ .Title }}" />
{{ if .Description }}
<meta name="description" content="{{ .Description }}">
<meta property="og:description" content="{{ .Description }}" />
{{ end }}
{{ if .Type }}
<meta property="og:type" content="{{ .Type }}" />
{{ end }}
{{ if .SelfUrl }}
<meta property="og:url" content="{{ .SelfUrl }}" />
{{ end }}
<link rel="stylesheet" href="/static/pico.min.css">
<link rel="webmention" href="{{ .User.WebmentionUrl }}">
{{ if .User.AuthUrl }}
<link rel="indieauth-metadata" href="{{ .User.IndieauthMetadataUrl }}">
<link rel="authorization_endpoint" href="{{ .User.AuthUrl}}">
<link rel="token_endpoint" href="{{ .User.TokenUrl}}">
<link rel="micropub" href="{{ .User.MicropubUrl}}">
{{ end }}
<style>
header {
background-color: {{.User.Config.HeaderColor}};
padding-bottom: 1rem !important;
}
footer {
border-top: dashed 2px;
border-color: #ccc;
}
.avatar {
float: left;
margin-right: 1rem;
}
.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;
}
</style>
</head>
<body>
<header>
<div class="container header h-card">
<hgroup class="header-title">
<h2><a class="p-name u-url" href="{{ .User.UrlPath }}">{{ .User.Config.Title }}</a></h2>
<h3 class="p-note">{{ .User.Config.SubTitle }}</h3>
</hgroup>
<div class="header-profile">
{{ if .User.AvatarUrl }}
<img class="u-photo u-logo avatar" src="{{ .User.AvatarUrl }}" alt="{{ .User.Config.Title }}" width="100" height="100" />
{{ end }}
<div style="float: right; list-style: none;">
{{ range $me := .User.Config.Me }}
<li><a href="{{$me.Url}}" rel="me">{{$me.Name}}</a>
</li>
{{ end }}
</div>
</div>
</div>
<div class="container">
<nav>
<ul>
{{ range $link := .User.Config.HeaderMenu }}
{{ if $link.List }}
<li><a href="{{ listUrl $.User $link.List }}">{{ $link.Title }}</a></li>
{{ else if $link.Post }}
<li><a href="{{ postUrl $.User $link.Post }}">{{ $link.Title }}</a></li>
{{ else }}
<li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li>
{{ end }}
{{ end }}
</ul>
</nav>
</div>
</header>
{{ .Content }}
</main>
<footer class="container">
<nav>
<ul>
{{ range $link := .User.Config.FooterMenu }}
{{ if $link.List }}
<li><a href="{{ listUrl $.User $link.List }}">{{ $link.Title }}</a></li>
{{ else if $link.Post }}
<li><a href="{{ postUrl $.User $link.Post }}">{{ $link.Title }}</a></li>
{{ else }}
<li><a href="{{ $link.Url }}">{{ $link.Title }}</a></li>
{{ end }}
{{ end }}
</ul>
</nav>
</footer>
</body>
</html>

View File

@ -0,0 +1,5 @@
<ul>
{{ range .UserLinks }}
<li><a href="{{.Href}}">{{.Text}}</a></li>
{{ end }}
</ul>

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/pico.min.css">
</head>
<body>
{{ .Content }}
</body>
</html>

5
embed/initial/static/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

47
embed/note/detail.html Normal file
View File

@ -0,0 +1,47 @@
<div class="h-entry">
<hgroup>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

34
embed/page/detail.html Normal file
View File

@ -0,0 +1,34 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
</small>
</hgroup>
<hr>
<br>
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

52
embed/photo/detail.html Normal file
View File

@ -0,0 +1,52 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.PhotoPath }}
<img class="u-photo" src="media/{{.Post.Meta.PhotoPath}}" alt="{{.Post.Meta.Description}}" />
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

View File

@ -0,0 +1,9 @@
<div class="h-feed photo-grid">
{{range .}}
<div class="h-entry photo-grid-item">
<a class="u-url" href="{{.UrlPath}}">
<img class="u-photo" src="{{.UrlPath}}media/{{.Meta.PhotoPath}}" alt="{{.Meta.Description}}" />
</a>
</div>
{{end}}
</div>

25
embed/post-list.html Normal file
View File

@ -0,0 +1,25 @@
<div class="h-feed">
{{range .}}
<div class="h-entry">
<hgroup>
{{ if eq .Meta.Type "note"}}
<h6><a class="u-url" href="{{.UrlPath}}">
{{ if .Title }}{{.Title}}{{ else }}#{{.Id}}{{ end }}
</a></h6>
<p>{{.RenderedContent | noescape}}</p>
{{ else }}
<h3><a class="u-url" href="{{.UrlPath}}">
{{ if .Title }}{{.Title}}{{ else }}#{{.Id}}{{ end }}
</a></h3>
{{ end }}
<small style="font-size: 0.75em;">
Published:
<time class="dt-published" datetime="{{.Meta.Date}}">
{{.Meta.FormattedDate}}
</time>
</small>
</hgroup>
</div>
<hr>
{{end}}
</div>

71
embed/post.html Normal file
View File

@ -0,0 +1,71 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}" alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.Reply.Url }}
<p style="font-style: italic;filter: opacity(80%);">
In reply to: <a class="u-in-reply-to h-cite" rel="in-reply-to" href="{{.Post.Meta.Reply.Url}}">
{{ if .Post.Meta.Reply.Text }}
{{.Post.Meta.Reply.Text}}
{{ else }}
{{.Post.Meta.Reply.Url}}
{{ end }}
</a>
</p>
{{ end }}
{{ if .Post.Meta.Bookmark.Url }}
<p style="font-style: italic;filter: opacity(80%);">
Bookmark: <a class="u-bookmark-of h-cite" href="{{.Post.Meta.Bookmark.Url}}">
{{ if .Post.Meta.Bookmark.Text }}
{{.Post.Meta.Bookmark.Text}}
{{ else }}
{{.Post.Meta.Bookmark.Url}}
{{ end }}
</a>
</p>
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

78
embed/recipe/detail.html Normal file
View File

@ -0,0 +1,78 @@
<div class="h-entry h-recipe">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
<div class="e-content">
<small>
{{ if .Post.Meta.Recipe.Yield }}
Servings: <span class="p-yield">{{ .Post.Meta.Recipe.Yield }}</span>
{{ if .Post.Meta.Recipe.Duration }}, {{end}}
{{ end }}
{{ if .Post.Meta.Recipe.Duration }}
Prep Time: <time class="dt-duration" value="{{ .Post.Meta.Recipe.Duration }}">
{{ .Post.Meta.Recipe.Duration }}
</time>
{{ end }}
</small>
<h2>Ingredients</h2>
<ul>
{{ range $ingredient := .Post.Meta.Recipe.Ingredients }}
<li class="p-ingredient">
{{ $ingredient }}
</li>
{{ end }}
</ul>
<h2>Instructions</h2>
<div class="e-instructions">
{{.Content}}
</div>
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

60
embed/reply/detail.html Normal file
View File

@ -0,0 +1,60 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.Reply.Url }}
<p style="font-style: italic;filter: opacity(80%);">
In reply to: <a class="u-in-reply-to h-cite" rel="in-reply-to" href="{{.Post.Meta.Reply.Url}}">
{{ if .Post.Meta.Reply.Text }}
{{.Post.Meta.Reply.Text}}
{{ else }}
{{.Post.Meta.Reply.Url}}
{{ end }}
</a>
</p>
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

72
embed/untyped/detail.html Normal file
View File

@ -0,0 +1,72 @@
<div class="h-entry">
<hgroup>
<h1 class="p-name">{{.Title}}</h1>
<small>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.FormattedDate}}
</time>
{{ if .Post.User.Config.AuthorName }}
by
<a class="p-author h-card" href="{{.Post.User.FullUrl}}">
{{ if .Post.User.AvatarUrl }}
<img class="u-photo u-logo" style="height: 1em;" src="{{ .Post.User.AvatarUrl }}"
alt="{{ .Post.User.Config.Title }}" />
{{ end }}
{{.Post.User.Config.AuthorName}}
</a>
{{ end }}
</small>
</hgroup>
<hr>
<br>
{{ if .Post.Meta.Reply.Url }}
<p style="font-style: italic;filter: opacity(80%);">
In reply to: <a class="u-in-reply-to h-cite" rel="in-reply-to" href="{{.Post.Meta.Reply.Url}}">
{{ if .Post.Meta.Reply.Text }}
{{.Post.Meta.Reply.Text}}
{{ else }}
{{.Post.Meta.Reply.Url}}
{{ end }}
</a>
</p>
{{ end }}
{{ if .Post.Meta.Bookmark.Url }}
<p style="font-style: italic;filter: opacity(80%);">
Bookmark: <a class="u-bookmark-of h-cite" href="{{.Post.Meta.Bookmark.Url}}">
{{ if .Post.Meta.Bookmark.Text }}
{{.Post.Meta.Bookmark.Text}}
{{ else }}
{{.Post.Meta.Bookmark.Url}}
{{ end }}
</a>
</p>
{{ end }}
<div class="e-content">
{{.Content}}
</div>
<hr>
{{if .Post.ApprovedIncomingWebmentions}}
<h3>
Webmentions
</h3>
<ul>
{{range .Post.ApprovedIncomingWebmentions}}
<li>
<a href="{{.Source}}">
{{if .Title}}
{{.Title}}
{{else}}
{{.Source}}
{{end}}
</a>
</li>
{{end}}
</ul>
{{end}}
</div>

9
embed/user-list.html Normal file
View File

@ -0,0 +1,9 @@
{{range .}}
<ul>
<li>
<a href="{{ .UrlPath }}">
{{ .Name }}
</a>
</li>
</ul>
{{end}}

View File

@ -1,50 +0,0 @@
package entrytypes
import (
"fmt"
"owl-blogs/domain/model"
"owl-blogs/render"
)
type Article struct {
model.EntryBase
meta ArticleMetaData
}
type ArticleMetaData struct {
Title string
Content string
}
// Form implements model.EntryMetaData.
func (meta *ArticleMetaData) Form(binSvc model.BinaryStorageInterface) string {
f, _ := render.RenderTemplateToString("forms/Article", meta)
return f
}
// ParseFormData implements model.EntryMetaData.
func (meta *ArticleMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
meta.Title = data.FormValue("title")
meta.Content = data.FormValue("content")
return nil
}
func (e *Article) Title() string {
return e.meta.Title
}
func (e *Article) Content() model.EntryContent {
str, err := render.RenderTemplateToString("entry/Article", e)
if err != nil {
fmt.Println(err)
}
return model.EntryContent(str)
}
func (e *Article) MetaData() model.EntryMetaData {
return &e.meta
}
func (e *Article) SetMetaData(metaData model.EntryMetaData) {
e.meta = *metaData.(*ArticleMetaData)
}

View File

@ -1,52 +0,0 @@
package entrytypes
import (
"fmt"
"owl-blogs/domain/model"
"owl-blogs/render"
)
type Bookmark struct {
model.EntryBase
meta BookmarkMetaData
}
type BookmarkMetaData struct {
Title string
Url string
Content string
}
// Form implements model.EntryMetaData.
func (meta *BookmarkMetaData) Form(binSvc model.BinaryStorageInterface) string {
f, _ := render.RenderTemplateToString("forms/Bookmark", meta)
return f
}
// ParseFormData implements model.EntryMetaData.
func (meta *BookmarkMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
meta.Title = data.FormValue("title")
meta.Url = data.FormValue("url")
meta.Content = data.FormValue("content")
return nil
}
func (e *Bookmark) Title() string {
return e.meta.Title
}
func (e *Bookmark) Content() model.EntryContent {
str, err := render.RenderTemplateToString("entry/Bookmark", e)
if err != nil {
fmt.Println(err)
}
return model.EntryContent(str)
}
func (e *Bookmark) MetaData() model.EntryMetaData {
return &e.meta
}
func (e *Bookmark) SetMetaData(metaData model.EntryMetaData) {
e.meta = *metaData.(*BookmarkMetaData)
}

View File

@ -1,79 +0,0 @@
package entrytypes
import (
"fmt"
"owl-blogs/domain/model"
"owl-blogs/render"
)
type Image struct {
model.EntryBase
meta ImageMetaData
}
type ImageMetaData struct {
ImageId string
Title string
Content string
}
// Form implements model.EntryMetaData.
func (meta *ImageMetaData) Form(binSvc model.BinaryStorageInterface) string {
f, _ := render.RenderTemplateToString("forms/Image", meta)
return f
}
// ParseFormData implements model.EntryMetaData.
func (meta *ImageMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
file, err := data.FormFile("image")
var imgId = meta.ImageId
if err != nil && imgId == "" {
return err
} else if err == nil {
fileData, err := file.Open()
if err != nil {
return err
}
defer fileData.Close()
fileBytes := make([]byte, file.Size)
_, err = fileData.Read(fileBytes)
if err != nil {
return err
}
bin, err := binSvc.Create(file.Filename, fileBytes)
if err != nil {
return err
}
imgId = bin.Id
}
meta.ImageId = imgId
meta.Title = data.FormValue("title")
meta.Content = data.FormValue("content")
return nil
}
func (e *Image) Title() string {
return e.meta.Title
}
func (e *Image) ImageUrl() string {
return "/media/" + e.meta.ImageId
}
func (e *Image) Content() model.EntryContent {
str, err := render.RenderTemplateToString("entry/Image", e)
if err != nil {
fmt.Println(err)
}
return model.EntryContent(str)
}
func (e *Image) MetaData() model.EntryMetaData {
return &e.meta
}
func (e *Image) SetMetaData(metaData model.EntryMetaData) {
e.meta = *metaData.(*ImageMetaData)
}

View File

@ -1,48 +0,0 @@
package entrytypes
import (
"fmt"
"owl-blogs/domain/model"
"owl-blogs/render"
)
type Note struct {
model.EntryBase
meta NoteMetaData
}
type NoteMetaData struct {
Content string
}
// Form implements model.EntryMetaData.
func (meta *NoteMetaData) Form(binSvc model.BinaryStorageInterface) string {
f, _ := render.RenderTemplateToString("forms/Note", meta)
return f
}
// ParseFormData implements model.EntryMetaData.
func (meta *NoteMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
meta.Content = data.FormValue("content")
return nil
}
func (e *Note) Title() string {
return ""
}
func (e *Note) Content() model.EntryContent {
str, err := render.RenderTemplateToString("entry/Note", e)
if err != nil {
fmt.Println(err)
}
return model.EntryContent(str)
}
func (e *Note) MetaData() model.EntryMetaData {
return &e.meta
}
func (e *Note) SetMetaData(metaData model.EntryMetaData) {
e.meta = *metaData.(*NoteMetaData)
}

View File

@ -1,50 +0,0 @@
package entrytypes
import (
"fmt"
"owl-blogs/domain/model"
"owl-blogs/render"
)
type Page struct {
model.EntryBase
meta PageMetaData
}
type PageMetaData struct {
Title string
Content string
}
// Form implements model.EntryMetaData.
func (meta *PageMetaData) Form(binSvc model.BinaryStorageInterface) string {
f, _ := render.RenderTemplateToString("forms/Page", meta)
return f
}
// ParseFormData implements model.EntryMetaData.
func (meta *PageMetaData) ParseFormData(data model.HttpFormData, binSvc model.BinaryStorageInterface) error {
meta.Title = data.FormValue("title")
meta.Content = data.FormValue("content")
return nil
}
func (e *Page) Title() string {
return e.meta.Title
}
func (e *Page) Content() model.EntryContent {
str, err := render.RenderTemplateToString("entry/Page", e)
if err != nil {
fmt.Println(err)
}
return model.EntryContent(str)
}
func (e *Page) MetaData() model.EntryMetaData {
return &e.meta
}
func (e *Page) SetMetaData(metaData model.EntryMetaData) {
e.meta = *metaData.(*PageMetaData)
}

Some files were not shown because too many files have changed in this diff Show More