Compare commits

...

11 Commits

Author SHA1 Message Date
Niko Abeler 7a70be9839 test for 'multi process' concurrency 2022-09-11 21:22:59 +02:00
Niko Abeler e9bcaa4c4a list format date 2022-09-11 17:37:18 +02:00
Niko Abeler 968fb30f53 formatted date 2022-09-11 17:34:50 +02:00
Niko Abeler 9ca50eafff allow different time formats in posts 2022-09-11 17:25:26 +02:00
Niko Abeler 3c669d0d5f more docs 2022-09-10 15:32:22 +02:00
Niko Abeler fac75dd273 updated docs 2022-09-10 15:30:52 +02:00
Niko Abeler 216291a022 p-note 2022-09-10 15:28:45 +02:00
Niko Abeler 534dc3ba9b avatar and new header design 2022-09-10 15:22:18 +02:00
Niko Abeler ae29a0221c error for not scanning 2022-09-10 14:23:02 +02:00
Niko Abeler 9559d27bf6 did not scan error 2022-09-10 14:22:06 +02:00
Niko Abeler e8184a5a4c send error 2022-09-10 14:20:07 +02:00
15 changed files with 272 additions and 55 deletions

View File

@ -23,9 +23,8 @@ Each directory in the `/users/` directory of a repository is considered a user.
-- This will be rendered as the blog post.
-- Must be present for the blog post to be valid.
-- All other folders will be ignored
\- status.yml
-- Used to track various process status related to the post,
-- such as if a webmention was sent.
\- webmentions.yml
-- Used to track incoming and outgoing webmentions
\- media/
-- Contains all media files used in the blog post.
-- All files in this folder will be publicly available
@ -38,6 +37,10 @@ Each directory in the `/users/` directory of a repository is considered a 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}
-- The avatar for the user
\- config.yml
-- Contains settings global to the user.
-- For example: page title and style options
@ -64,10 +67,15 @@ Actual post
```
#### status.yml
#### webmentions.yml
```
webmentions:
incoming:
- source: https://example.com/post
title: Example Post
ApprovalStatus: ["", "approved", "rejected"]
retrieved_at: 2021-08-13T17:07:00Z
outgoing:
- target: https://example.com/post
supported: true
scanned_at: 2021-08-13T17:07:00Z

View File

@ -237,6 +237,26 @@ func postMediaHandler(repo *owl.Repository) func(http.ResponseWriter, *http.Requ
}
}
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())
notFoundHandler(repo)(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

View File

@ -14,10 +14,11 @@ func Router(repo *owl.Repository) http.Handler {
router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir()))
router.GET("/", repoIndexHandler(repo))
router.GET("/user/:user/", userIndexHandler(repo))
router.POST("/user/:user/webmention/", userWebmentionHandler(repo))
router.GET("/user/:user/media/*filepath", userMediaHandler(repo))
router.GET("/user/:user/index.xml", userRSSHandler(repo))
router.GET("/user/:user/posts/:post/", postHandler(repo))
router.GET("/user/:user/posts/:post/media/*filepath", postMediaHandler(repo))
router.POST("/user/:user/webmention/", userWebmentionHandler(repo))
router.NotFound = http.HandlerFunc(notFoundHandler(repo))
return router
}
@ -26,10 +27,11 @@ func SingleUserRouter(repo *owl.Repository) http.Handler {
router := httprouter.New()
router.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir()))
router.GET("/", userIndexHandler(repo))
router.POST("/webmention/", userWebmentionHandler(repo))
router.GET("/media/*filepath", userMediaHandler(repo))
router.GET("/index.xml", userRSSHandler(repo))
router.GET("/posts/:post/", postHandler(repo))
router.GET("/posts/:post/media/*filepath", postMediaHandler(repo))
router.POST("/webmention/", userWebmentionHandler(repo))
router.NotFound = http.HandlerFunc(notFoundHandler(repo))
return router
}

View File

@ -62,9 +62,9 @@ var webmentionCmd = &cobra.Command{
for _, webmention := range webmentions {
go func(webmention owl.WebmentionOut) {
defer wg.Done()
err = post.SendWebmention(webmention)
if err != nil {
println("Error sending webmentions: ", err.Error())
sendErr := post.SendWebmention(webmention)
if sendErr != nil {
println("Error sending webmentions: ", sendErr.Error())
} else {
println("Webmention sent to ", webmention.Target)
}

View File

@ -18,6 +18,26 @@
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; }
</style>
@ -25,34 +45,34 @@
<body>
<header>
<nav class="container">
<ul>
<li>
<hgroup>
<h2><a href="{{ .User.UrlPath }}">{{ .UserConfig.Title }}</a></h2>
<h3>{{ .UserConfig.SubTitle }}</h3>
</hgroup>
</li>
</ul>
<div class="container header h-card">
<hgroup class="header-title">
<h2><a class="p-name u-url" href="{{ .User.UrlPath }}">{{ .UserConfig.Title }}</a></h2>
<h3 class="p-note">{{ .UserConfig.SubTitle }}</h3>
</hgroup>
</nav>
<div class="header-profile">
{{ if .User.AvatarUrl }}
<img class="u-logo avatar" src="{{ .User.AvatarUrl }}" alt="{{ .UserConfig.Title }}" width="100" height="100" />
{{ end }}
<div style="float: right; list-style: none;">
{{ if .UserConfig.TwitterHandle}}
<li><a href="https://twitter.com/{{.UserConfig.TwitterHandle}}" rel="me">@{{.UserConfig.TwitterHandle}} on Twitter</a>
</li>
{{ end }}
{{ if .UserConfig.GitHubHandle}}
<li><a href="https://github.com/{{.UserConfig.GitHubHandle}}" rel="me">@{{.UserConfig.GitHubHandle}} on GitHub</a>
</li>
{{ end }}
</div>
</div>
</div>
</header>
<main class="container">
{{ .Content }}
</main>
<footer class="container">
<nav>
<ul>
{{ if .UserConfig.TwitterHandle}}
<li><a href="https://twitter.com/{{.UserConfig.TwitterHandle}}" rel="me">@{{.UserConfig.TwitterHandle}} on Twitter</a>
</li>
{{ end }}
{{ if .UserConfig.GitHubHandle}}
<li><a href="https://github.com/{{.UserConfig.GitHubHandle}}" rel="me">@{{.UserConfig.GitHubHandle}} on GitHub</a>
</li>
{{ end }}
</ul>
</nav>
</footer>
</body>

View File

@ -6,7 +6,7 @@
<small>
Published:
<time class="dt-published" datetime="{{.Meta.Date}}">
{{.Meta.Date}}
{{.Meta.FormattedDate}}
</time>
</small>
</hgroup>

View File

@ -4,7 +4,7 @@
<small>
Published:
<time class="dt-published" datetime="{{.Post.Meta.Date}}">
{{.Post.Meta.Date}}
{{.Post.Meta.FormattedDate}}
</time>
<a class="u-url" href="{{.Post.FullUrl}}">#</a>
</small>

67
post.go
View File

@ -2,6 +2,7 @@ package owl
import (
"bytes"
"errors"
"io/ioutil"
"net/url"
"os"
@ -27,10 +28,66 @@ type Post struct {
}
type PostMeta struct {
Title string `yaml:"title"`
Aliases []string `yaml:"aliases"`
Date string `yaml:"date"`
Draft bool `yaml:"draft"`
Title string `yaml:"title"`
Aliases []string `yaml:"aliases"`
Date time.Time `yaml:"date"`
Draft bool `yaml:"draft"`
}
func (pm PostMeta) FormattedDate() string {
return pm.Date.Format("02-01-2006 15:04:05")
}
func (pm *PostMeta) UnmarshalYAML(unmarshal func(interface{}) error) error {
type T struct {
Title string `yaml:"title"`
Aliases []string `yaml:"aliases"`
Draft bool `yaml:"draft"`
}
type S struct {
Date string `yaml:"date"`
}
var t T
var s S
if err := unmarshal(&t); err != nil {
return err
}
if err := unmarshal(&s); err != nil {
return err
}
pm.Title = t.Title
pm.Aliases = t.Aliases
pm.Draft = t.Draft
possibleFormats := []string{
"2006-01-02",
time.Layout,
time.ANSIC,
time.UnixDate,
time.RubyDate,
time.RFC822,
time.RFC822Z,
time.RFC850,
time.RFC1123,
time.RFC1123Z,
time.RFC3339,
time.RFC3339Nano,
time.Stamp,
time.StampMilli,
time.StampMicro,
time.StampNano,
}
for _, format := range possibleFormats {
if t, err := time.Parse(format, s.Date); err == nil {
pm.Date = t
break
}
}
return nil
}
type PostWebmetions struct {
@ -308,7 +365,7 @@ func (post *Post) SendWebmention(webmention WebmentionOut) error {
// if last scan is less than 7 days ago, don't send webmention
if webmention.ScannedAt.After(time.Now().Add(-7*24*time.Hour)) && !webmention.Supported {
return nil
return errors.New("did not scan. Last scan was less than 7 days ago")
}
webmention.ScannedAt = time.Now()

View File

@ -156,7 +156,7 @@ func TestLoadMeta(t *testing.T) {
t.Errorf("Expected title: %v, got %v", []string{"foo/bar/"}, post.Meta().Aliases)
}
if post.Meta().Date != "Wed, 17 Aug 2022 10:50:02 +0000" {
if post.Meta().Date.Format(time.RFC1123Z) != "Wed, 17 Aug 2022 10:50:02 +0000" {
t.Errorf("Expected title: %s, got %s", "Wed, 17 Aug 2022 10:50:02 +0000", post.Meta().Title)
}
@ -392,8 +392,8 @@ func TestSendWebmentionOnlyScansOncePerWeek(t *testing.T) {
webmention = webmentions[0]
err := post.SendWebmention(webmention)
if err != nil {
t.Errorf("Error sending webmention: %v", err)
if err == nil {
t.Errorf("Expected error, got nil")
}
webmentions = post.OutgoingWebmentions()
@ -550,3 +550,62 @@ func TestComplexParallelWebmentions(t *testing.T) {
t.Errorf("Expected 20 webmentions, got %d", len(outs))
}
}
func TestComplexParallelSimulatedProcessesWebmentions(t *testing.T) {
repoName := testRepoName()
repo, _ := owl.CreateRepository(repoName, owl.RepoConfig{})
repo.HttpClient = &MockHttpClient{}
repo.Parser = &MockParseLinksHtmlParser{
Links: []string{
"http://example.com/1",
"http://example.com/2",
"http://example.com/3",
},
}
user, _ := repo.CreateUser("testuser")
post, _ := user.CreateNewPost("testpost")
wg := sync.WaitGroup{}
wg.Add(40)
for i := 0; i < 20; i++ {
go func(k int) {
defer wg.Done()
fRepo, _ := owl.OpenRepository(repoName)
fUser, _ := fRepo.GetUser("testuser")
fPost, err := fUser.GetPost(post.Id())
if err != nil {
t.Error(err)
return
}
fPost.AddIncomingWebmention("http://example.com/" + strconv.Itoa(k))
}(i)
go func(k int) {
defer wg.Done()
fRepo, _ := owl.OpenRepository(repoName)
fUser, _ := fRepo.GetUser("testuser")
fPost, err := fUser.GetPost(post.Id())
if err != nil {
t.Error(err)
return
}
webmention := owl.WebmentionOut{
Target: "http://example.com/" + strconv.Itoa(k),
}
fPost.SendWebmention(webmention)
}(i)
}
wg.Wait()
ins := post.IncomingWebmentions()
if len(ins) != 20 {
t.Errorf("Expected 20 webmentions, got %d", len(ins))
}
outs := post.OutgoingWebmentions()
if len(outs) != 20 {
t.Errorf("Expected 20 webmentions, got %d", len(outs))
}
}

View File

@ -251,3 +251,13 @@ func TestRenderIncludesFullUrl(t *testing.T) {
t.Error("Expected: " + post.FullUrl())
}
}
func TestAddAvatarIfExist(t *testing.T) {
user := getTestUser()
os.WriteFile(path.Join(user.MediaDir(), "avatar.png"), []byte("test"), 0644)
result, _ := owl.RenderIndexPage(user)
if !strings.Contains(result, "avatar.png") {
t.Error("Avatar not rendered. Got: " + result)
}
}

View File

@ -136,9 +136,10 @@ func (repo *Repository) CreateUser(name string) (User, error) {
// creates repo/name folder if it doesn't exist
user_dir := new_user.Dir()
os.Mkdir(user_dir, 0755)
// create folders
os.Mkdir(path.Join(user_dir, "meta"), 0755)
// create public folder
os.Mkdir(path.Join(user_dir, "public"), 0755)
os.Mkdir(path.Join(user_dir, "media"), 0755)
// create Meta files
os.WriteFile(path.Join(user_dir, "meta", "VERSION"), []byte(VERSION), 0644)

3
rss.go
View File

@ -3,6 +3,7 @@ package owl
import (
"bytes"
"encoding/xml"
"time"
)
type RSS struct {
@ -46,7 +47,7 @@ func RenderRSSFeed(user User) (string, error) {
Guid: post.FullUrl(),
Title: post.Title(),
Link: post.FullUrl(),
PubDate: meta.Date,
PubDate: meta.Date.Format(time.RFC1123Z),
})
}

View File

@ -71,7 +71,7 @@ func TestRenderRSSFeedPostData(t *testing.T) {
if !strings.Contains(res, post.FullUrl()) {
t.Error("SubTitle not rendered. Got: " + res)
}
if !strings.Contains(res, "2015-01-01") {
if !strings.Contains(res, "Thu, 01 Jan 2015 00:00:00 +0000") {
t.Error("Date not rendered. Got: " + res)
}
}

40
user.go
View File

@ -43,6 +43,11 @@ func (user User) WebmentionUrl() string {
return url
}
func (user User) MediaUrl() string {
url, _ := url.JoinPath(user.UrlPath(), "media")
return url
}
func (user User) PostDir() string {
return path.Join(user.Dir(), "public")
}
@ -51,6 +56,10 @@ func (user User) MetaDir() string {
return path.Join(user.Dir(), "meta")
}
func (user User) MediaDir() string {
return path.Join(user.Dir(), "media")
}
func (user User) ConfigFile() string {
return path.Join(user.MetaDir(), "config.yml")
}
@ -59,6 +68,16 @@ func (user User) Name() string {
return user.name
}
func (user User) AvatarUrl() string {
for _, ext := range []string{".jpg", ".jpeg", ".png", ".gif"} {
if fileExists(path.Join(user.MediaDir(), "avatar"+ext)) {
url, _ := url.JoinPath(user.MediaUrl(), "avatar"+ext)
return url
}
}
return ""
}
func (user User) Posts() ([]*Post, error) {
postFiles := listDir(path.Join(user.Dir(), "public"))
posts := make([]*Post, 0)
@ -91,12 +110,7 @@ func (user User) Posts() ([]*Post, error) {
postDates := make([]PostWithDate, len(posts))
for i, post := range posts {
meta := post.Meta()
date, err := time.Parse(time.RFC1123Z, meta.Date)
if err != nil {
// invalid date -> use 1970-01-01
date = time.Time{}
}
postDates[i] = PostWithDate{post: post, date: date}
postDates[i] = PostWithDate{post: post, date: meta.Date}
}
// sort posts by date
@ -143,11 +157,21 @@ func (user User) CreateNewPost(title string) (Post, error) {
}
}
post := Post{user: &user, id: folder_name, title: title}
meta := PostMeta{
Title: title,
Date: time.Now(),
Aliases: []string{},
Draft: false,
}
initial_content := ""
initial_content += "---\n"
initial_content += "title: " + title + "\n"
initial_content += "date: " + time.Now().UTC().Format(time.RFC1123Z) + "\n"
// write meta
meta_bytes, err := yaml.Marshal(meta)
if err != nil {
return Post{}, err
}
initial_content += string(meta_bytes)
initial_content += "---\n"
initial_content += "\n"
initial_content += "Write your post here.\n"

View File

@ -42,8 +42,8 @@ func TestCreateNewPostAddsDateToMetaBlock(t *testing.T) {
posts, _ := user.Posts()
post, _ := user.GetPost(posts[0].Id())
meta := post.Meta()
if meta.Date == "" {
t.Error("Found no date. Got: " + meta.Date)
if meta.Date.IsZero() {
t.Errorf("Found no date. Got: %v", meta.Date)
}
}
@ -302,3 +302,18 @@ func TestPostsSortedByPublishingDateBrokenAtBottom(t *testing.T) {
t.Error("Wrong Id, Got: " + posts[1].Id())
}
}
func TestAvatarEmptyIfNotExist(t *testing.T) {
user := getTestUser()
if user.AvatarUrl() != "" {
t.Error("Avatar should be empty")
}
}
func TestAvatarSetIfFileExist(t *testing.T) {
user := getTestUser()
os.WriteFile(path.Join(user.MediaDir(), "avatar.png"), []byte("test"), 0644)
if user.AvatarUrl() == "" {
t.Error("Avatar should not be empty")
}
}