Compare commits
No commits in common. "main" and "version_2" have entirely different histories.
46
.air.toml
46
.air.toml
|
@ -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
|
|
@ -1,3 +0,0 @@
|
|||
e2e_tests/
|
||||
tmp/
|
||||
*.db
|
|
@ -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
|
|
@ -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
|
|
@ -24,10 +24,3 @@ users/
|
|||
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
|
||||
*.db
|
||||
tmp/
|
||||
|
||||
venv/
|
||||
*.pyc
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
}
|
12
Dockerfile
12
Dockerfile
|
@ -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
119
README.md
|
@ -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`
|
||||
|
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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]
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"owl-blogs/domain/model"
|
||||
)
|
||||
|
||||
type EntryTypeRegistry = TypeRegistry[model.Entry]
|
||||
|
||||
func NewEntryTypeRegistry() *EntryTypeRegistry {
|
||||
return NewTypeRegistry[model.Entry]()
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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())
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"owl-blogs/domain/model"
|
||||
)
|
||||
|
||||
type InteractionTypeRegistry = TypeRegistry[model.Interaction]
|
||||
|
||||
func NewInteractionTypeRegistry() *InteractionTypeRegistry {
|
||||
return NewTypeRegistry[model.Interaction]()
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
25
app/utils.go
25
app/utils.go
|
@ -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]
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
// }
|
||||
// }
|
||||
|
||||
// }
|
|
@ -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")
|
||||
}
|
|
@ -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)
|
||||
},
|
||||
}
|
|
@ -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")
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
},
|
||||
}
|
|
@ -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.")
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
},
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
}
|
|
@ -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)
|
||||
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
|
@ -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{}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
package model
|
||||
|
||||
type Author struct {
|
||||
Name string
|
||||
PasswordHash string
|
||||
FullUrl string
|
||||
AvatarUrl string
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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())
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
package model
|
||||
|
||||
type BinaryStorageInterface interface {
|
||||
Create(name string, file []byte) (*BinaryFile, error)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
package model
|
||||
|
||||
type SiteConfigInterface interface {
|
||||
GetSiteConfig() (SiteConfig, error)
|
||||
UpdateSiteConfig(cfg SiteConfig) error
|
||||
}
|
|
@ -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"]
|
|
@ -1,12 +0,0 @@
|
|||
services:
|
||||
web:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: Dockerfile
|
||||
command: web
|
||||
ports:
|
||||
- "3000:3000"
|
||||
mock_masto:
|
||||
build: mock_masto
|
||||
ports:
|
||||
- 8000:8000
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
FROM python:3.11
|
||||
|
||||
COPY . .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
CMD [ "python", "main.py" ]
|
|
@ -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")
|
|
@ -1 +0,0 @@
|
|||
Flask==3.0.3
|
|
@ -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
|
|
@ -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}"
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
package owl
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed embed/*
|
||||
var embed_files embed.FS
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,4 @@
|
|||
<article style="background-color: #dd867f;color: #481212;">
|
||||
<h3>{{ .Error }}</h3>
|
||||
{{ .Message }}
|
||||
</article>
|
|
@ -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>
|
|
@ -0,0 +1,5 @@
|
|||
<ul>
|
||||
{{ range .UserLinks }}
|
||||
<li><a href="{{.Href}}">{{.Text}}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
|
@ -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>
|
File diff suppressed because one or more lines are too long
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
|||
{{range .}}
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{ .UrlPath }}">
|
||||
{{ .Name }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
{{end}}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue