Compare commits

...

6 commits

19 changed files with 252 additions and 36 deletions

View file

@ -25,6 +25,7 @@ FROM alpine:latest
RUN apk --no-cache add ca-certificates RUN apk --no-cache add ca-certificates
RUN mkdir -p /data RUN mkdir -p /data
RUN mkdir -p /scratch
WORKDIR /root/ WORKDIR /root/
@ -34,6 +35,7 @@ COPY --from=builder /app/static ./static
COPY --from=builder /app/views ./views COPY --from=builder /app/views ./views
ENV DATA_DIR=/data ENV DATA_DIR=/data
ENV SCRATCH_DIR=/scratch
EXPOSE 3000 EXPOSE 3000

View file

@ -116,7 +116,7 @@ Starting weiro without any arguments will start the server.
app.Post("/login", lh.DoLogin) app.Post("/login", lh.DoLogin)
app.Post("/logout", lh.Logout) app.Post("/logout", lh.Logout)
siteGroup := app.Group("/sites/:siteID", middleware.RequireUser(svcs.Auth), middleware.RequiresSite(svcs.Sites)) siteGroup := app.Group("/sites/:siteID", middleware.LogErrors(), middleware.RequireUser(svcs.Auth), middleware.RequiresSite(svcs.Sites))
siteGroup.Get("/posts", ph.Index) siteGroup.Get("/posts", ph.Index)
siteGroup.Get("/posts/new", ph.New) siteGroup.Get("/posts/new", ph.New)

View file

@ -9,7 +9,7 @@ import (
type Config struct { type Config struct {
DataDir string `env:"DATA_DIR"` DataDir string `env:"DATA_DIR"`
ScratchDir string `env:"SCRATCH_DIR"` ScratchDir string `env:"SCRATCH_DIR,default=/tmp"`
SiteDomain string `env:"SITE_DOMAIN"` SiteDomain string `env:"SITE_DOMAIN"`
LoginLocked bool `env:"LOGIN_LOCKED,default=false"` LoginLocked bool `env:"LOGIN_LOCKED,default=false"`
Env string `env:"ENV,default=prod"` Env string `env:"ENV,default=prod"`

1
go.mod
View file

@ -52,6 +52,7 @@ require (
github.com/gofiber/template/v2 v2.1.0 // indirect github.com/gofiber/template/v2 v2.1.0 // indirect
github.com/gofiber/utils/v2 v2.0.2 // indirect github.com/gofiber/utils/v2 v2.0.2 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gopherlibs/feedhub v1.2.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect

2
go.sum
View file

@ -279,6 +279,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherlibs/feedhub v1.2.0 h1:1nfM8gRoiA+VNjKc1FzrwiXkrBKsnAghA3PVvgAiSI0=
github.com/gopherlibs/feedhub v1.2.0/go.mod h1:vvQEZzTKr2KhO0mCdEUGfKLvUJFfO8U+WUpaMyoZttc=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=

View file

@ -0,0 +1,17 @@
package middleware
import (
"log"
"github.com/gofiber/fiber/v3"
)
func LogErrors() func(c fiber.Ctx) error {
return func(c fiber.Ctx) error {
if err := c.Next(); err != nil {
log.Printf("error: %v\n", err)
return err
}
return nil
}
}

View file

@ -4,6 +4,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Site.Title }}</title> <title>{{ .Site.Title }}</title>
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="{{ url_abs "/feed.xml" }}"/>
<link rel="alternate" type="application/json" title="JSON feed" href="{{ url_abs "/feed.json" }}"/>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css"> <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css">
</head> </head>
<body> <body>

10
models/iters.go Normal file
View file

@ -0,0 +1,10 @@
package models
type Maybe[T any] struct {
Value T
Err error
}
func (m Maybe[T]) Get() (T, error) {
return m.Value, m.Err
}

View file

@ -1,7 +1,9 @@
package pubmodel package pubmodel
import ( import (
"context"
"io" "io"
"iter"
"lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/models"
) )
@ -9,8 +11,11 @@ import (
type Site struct { type Site struct {
models.Site models.Site
BaseURL string BaseURL string
Posts []*models.Post //Posts []*models.Post
Uploads []models.Upload Uploads []models.Upload
OpenUpload func(u models.Upload) (io.ReadCloser, error) OpenUpload func(u models.Upload) (io.ReadCloser, error)
// PostItr returns a new post iterator
PostIter func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]]
} }

View file

@ -123,21 +123,28 @@ func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, erro
const selectPostsOfSite = `-- name: SelectPostsOfSite :many const selectPostsOfSite = `-- name: SelectPostsOfSite :many
SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at
FROM posts FROM posts
WHERE site_id = ? AND ( WHERE site_id = ?1 AND (
CASE CAST (?2 AS TEXT) CASE CAST (?2 AS TEXT)
WHEN 'deleted' THEN deleted_at > 0 WHEN 'deleted' THEN deleted_at > 0
ELSE deleted_at = 0 ELSE deleted_at = 0
END END
) ORDER BY created_at DESC LIMIT 10 ) ORDER BY created_at DESC LIMIT ?4 OFFSET ?3
` `
type SelectPostsOfSiteParams struct { type SelectPostsOfSiteParams struct {
SiteID int64 SiteID int64
PostFilter string PostFilter string
Offset int64
Limit int64
} }
func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSiteParams) ([]Post, error) { func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSiteParams) ([]Post, error) {
rows, err := q.db.QueryContext(ctx, selectPostsOfSite, arg.SiteID, arg.PostFilter) rows, err := q.db.QueryContext(ctx, selectPostsOfSite,
arg.SiteID,
arg.PostFilter,
arg.Offset,
arg.Limit,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -8,7 +8,12 @@ import (
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen" "lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
) )
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) ([]*models.Post, error) { type PagingParams struct {
Limit int64
Offset int64
}
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool, pp PagingParams) ([]*models.Post, error) {
var filter = "" var filter = ""
if showDeleted { if showDeleted {
filter = "deleted" filter = "deleted"
@ -17,6 +22,8 @@ func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDel
rows, err := db.queries.SelectPostsOfSite(ctx, sqlgen.SelectPostsOfSiteParams{ rows, err := db.queries.SelectPostsOfSite(ctx, sqlgen.SelectPostsOfSiteParams{
SiteID: siteID, SiteID: siteID,
PostFilter: filter, PostFilter: filter,
Limit: pp.Limit,
Offset: pp.Offset,
}) })
if err != nil { if err != nil {
return nil, err return nil, err

View file

@ -6,11 +6,13 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"io" "io"
"iter"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"time"
"github.com/gopherlibs/feedhub/feedhub"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/models/pubmodel" "lmika.dev/lmika/weiro/models/pubmodel"
@ -47,10 +49,14 @@ func (b *Builder) BuildSite(outDir string) error {
return err return err
} }
eg := errgroup.Group{} eg, ctx := errgroup.WithContext(context.Background())
eg.Go(func() error { eg.Go(func() error {
for _, post := range b.site.Posts { for mp := range b.site.PostIter(ctx) {
post, err := mp.Get()
if err != nil {
return err
}
if err := b.writePost(buildCtx, post); err != nil { if err := b.writePost(buildCtx, post); err != nil {
return err return err
} }
@ -59,7 +65,14 @@ func (b *Builder) BuildSite(outDir string) error {
}) })
eg.Go(func() error { eg.Go(func() error {
if err := b.renderPostList(buildCtx, b.site.Posts); err != nil { if err := b.renderPostList(buildCtx, b.site.PostIter(ctx)); err != nil {
return err
}
return nil
})
eg.Go(func() error {
if err := b.renderFeeds(buildCtx, b.site.PostIter(ctx)); err != nil {
return err return err
} }
return nil return nil
@ -79,14 +92,16 @@ func (b *Builder) BuildSite(outDir string) error {
return nil return nil
} }
func (b *Builder) renderPostList(ctx buildContext, postList []*models.Post) error { func (b *Builder) renderPostList(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error {
// TODO: paging // TODO: paging
postCopy := make([]*models.Post, len(postList)) postCopy := make([]*models.Post, 0)
copy(postCopy, postList) for mp := range postIter {
post, err := mp.Get()
sort.Slice(postCopy, func(i, j int) bool { if err != nil {
return postCopy[i].PublishedAt.After(postCopy[j].PublishedAt) return err
}) }
postCopy = append(postCopy, post)
}
pl := postListData{ pl := postListData{
commonData: commonData{Site: b.site}, commonData: commonData{Site: b.site},
@ -104,6 +119,68 @@ func (b *Builder) renderPostList(ctx buildContext, postList []*models.Post) erro
}) })
} }
func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error {
now := time.Now()
feed := &feedhub.Feed{
Title: b.site.Title,
Link: &feedhub.Link{Href: b.site.BaseURL},
Description: b.site.Tagline,
// TO FIX: Author
Created: now,
}
items := 0
for mp := range postIter {
post, err := mp.Get()
if err != nil {
return fmt.Errorf("failed to get post: %w", err)
}
renderedPost, err := b.renderPost(post)
if err != nil {
return err
}
feed.Items = append(feed.Items, &feedhub.Item{
Id: filepath.Join(b.site.BaseURL, post.GUID),
Title: post.Title,
Link: &feedhub.Link{Href: renderedPost.PostURL},
Content: string(renderedPost.HTML),
// TO FIX: Created should be first published
Created: post.PublishedAt,
Updated: post.UpdatedAt,
})
items++
if items >= b.opts.FeedItems {
break
}
}
if err := b.createAtPath(ctx, "/feed.xml", func(f io.Writer) error {
rss, err := feed.ToRss()
if err != nil {
return fmt.Errorf("failed to convert feed to RSS: %w", err)
}
_, err = io.WriteString(f, rss)
return err
}); err != nil {
return err
}
if err := b.createAtPath(ctx, "/feed.json", func(f io.Writer) error {
rss, err := feed.ToJSON()
if err != nil {
return fmt.Errorf("failed to convert feed to JSON feed: %w", err)
}
_, err = io.WriteString(f, rss)
return err
}); err != nil {
return err
}
return nil
}
func (b *Builder) renderPost(post *models.Post) (postSingleData, error) { func (b *Builder) renderPost(post *models.Post) (postSingleData, error) {
postPath := post.Slug postPath := post.Slug
if b.opts.BasePosts != "" { if b.opts.BasePosts != "" {
@ -115,10 +192,13 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) {
return postSingleData{}, fmt.Errorf("failed to write post %s: %w", post.Slug, err) return postSingleData{}, fmt.Errorf("failed to write post %s: %w", post.Slug, err)
} }
postURL := strings.TrimSuffix(b.site.BaseURL, "/") + "/" + strings.TrimPrefix(postPath, "/")
return postSingleData{ return postSingleData{
commonData: commonData{Site: b.site}, commonData: commonData{Site: b.site},
Path: postPath, Path: postPath,
Post: post, Post: post,
PostURL: postURL,
HTML: template.HTML(md.String()), HTML: template.HTML(md.String()),
}, nil }, nil
} }

View file

@ -29,6 +29,9 @@ type Options struct {
// TemplatesFS provides the raw templates for rendering the site. // TemplatesFS provides the raw templates for rendering the site.
TemplatesFS fs.FS TemplatesFS fs.FS
// FeedItems holds the number of posts to show in the feed.
FeedItems int
RenderTZ *time.Location RenderTZ *time.Location
} }
@ -38,9 +41,10 @@ type commonData struct {
type postSingleData struct { type postSingleData struct {
commonData commonData
Post *models.Post Post *models.Post
HTML template.HTML HTML template.HTML
Path string Path string
PostURL string
} }
type postListData struct { type postListData struct {

View file

@ -26,12 +26,46 @@ func (p *Provider) AdoptFile(site models.Site, up models.Upload, filename string
return err return err
} }
if err := os.Rename(filename, fullPath); err != nil { if err := os.Rename(filename, fullPath); err == nil {
return nil
}
// Can't rename, possibly because of a cross-link device issue. So copy instead
if err := moveFile(filename, fullPath); err != nil {
return err return err
} }
return nil return nil
} }
func moveFile(src, dst string) error {
if err := copyFile(src, dst); err != nil {
_ = os.Remove(dst)
return err
}
_ = os.Remove(src)
return nil
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
if _, err = io.Copy(out, in); err != nil {
return err
}
return err
}
func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser, error) { func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser, error) {
fullPath := p.uploadFileName(site, up) fullPath := p.uploadFileName(site, up)
return os.Open(fullPath) return os.Open(fullPath)

View file

@ -17,6 +17,8 @@ type CreatePostParams struct {
} }
func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) { func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
now := time.Now()
site, ok := models.GetSite(ctx) site, ok := models.GetSite(ctx)
if !ok { if !ok {
return nil, models.SiteRequiredError return nil, models.SiteRequiredError
@ -29,14 +31,14 @@ func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*mod
post.Title = params.Title post.Title = params.Title
post.Body = params.Body post.Body = params.Body
post.UpdatedAt = time.Now() post.UpdatedAt = now
post.Slug = post.BestSlug() post.Slug = post.BestSlug()
oldState := post.State oldState := post.State
switch strings.ToLower(params.Action) { switch strings.ToLower(params.Action) {
case "publish": case "publish":
post.State = models.StatePublished post.State = models.StatePublished
post.PublishedAt = time.Now() post.PublishedAt = now
case "save draft": case "save draft":
post.State = models.StateDraft post.State = models.StateDraft
post.PublishedAt = time.Time{} post.PublishedAt = time.Time{}

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
) )
func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) { func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) {
@ -12,7 +13,10 @@ func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Po
return nil, models.SiteRequiredError return nil, models.SiteRequiredError
} }
posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted) posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, db.PagingParams{
Offset: 0,
Limit: 25,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -0,0 +1,37 @@
package publisher
import (
"context"
"iter"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
)
// PostIter returns a post iterator which returns posts in reverse chronological order.
func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] {
return func(yield func(models.Maybe[*models.Post]) bool) {
paging := db.PagingParams{Offset: 0, Limit: 50}
page, err := s.db.SelectPostsOfSite(ctx, site, false, paging)
if err != nil {
yield(models.Maybe[*models.Post]{Err: err})
return
}
for {
for _, post := range page {
if !yield(models.Maybe[*models.Post]{Value: post}) {
return
}
}
paging.Offset += paging.Limit
page, err = s.db.SelectPostsOfSite(ctx, site, false, paging)
if err != nil {
yield(models.Maybe[*models.Post]{Err: err})
return
} else if len(page) == 0 {
return
}
}
}
}

View file

@ -3,6 +3,7 @@ package publisher
import ( import (
"context" "context"
"io" "io"
"iter"
"log" "log"
"os" "os"
@ -38,12 +39,6 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
return err return err
} }
// Fetch all content of site
posts, err := p.db.SelectPostsOfSite(ctx, site.ID, false)
if err != nil {
return err
}
// Fetch all uploads of site // Fetch all uploads of site
uploads, err := p.db.SelectUploadsOfSite(ctx, site.ID) uploads, err := p.db.SelectUploadsOfSite(ctx, site.ID)
if err != nil { if err != nil {
@ -56,8 +51,10 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
} }
pubSite := pubmodel.Site{ pubSite := pubmodel.Site{
Site: site, Site: site,
Posts: posts, PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
return p.postIter(ctx, site.ID)
},
BaseURL: target.BaseURL, BaseURL: target.BaseURL,
Uploads: uploads, Uploads: uploads,
OpenUpload: func(u models.Upload) (io.ReadCloser, error) { OpenUpload: func(u models.Upload) (io.ReadCloser, error) {
@ -77,6 +74,7 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ
sb, err := sitebuilder.New(pubSite, sitebuilder.Options{ sb, err := sitebuilder.New(pubSite, sitebuilder.Options{
BasePosts: "/posts", BasePosts: "/posts",
TemplatesFS: simplecss.FS, TemplatesFS: simplecss.FS,
FeedItems: 30,
}) })
if err != nil { if err != nil {
return err return err
@ -88,7 +86,11 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ
if err := exporter.WriteSiteYAML(); err != nil { if err := exporter.WriteSiteYAML(); err != nil {
return err return err
} }
for _, p := range pubSite.Posts { for mp := range pubSite.PostIter(ctx) {
p, err := mp.Get()
if err != nil {
return err
}
if err := exporter.WritePost(p); err != nil { if err := exporter.WritePost(p); err != nil {
return err return err
} }

View file

@ -1,12 +1,12 @@
-- name: SelectPostsOfSite :many -- name: SelectPostsOfSite :many
SELECT * SELECT *
FROM posts FROM posts
WHERE site_id = ? AND ( WHERE site_id = sqlc.arg(site_id) AND (
CASE CAST (sqlc.arg(post_filter) AS TEXT) CASE CAST (sqlc.arg(post_filter) AS TEXT)
WHEN 'deleted' THEN deleted_at > 0 WHEN 'deleted' THEN deleted_at > 0
ELSE deleted_at = 0 ELSE deleted_at = 0
END END
) ORDER BY created_at DESC LIMIT 10; ) ORDER BY created_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset);
-- name: SelectPost :one -- name: SelectPost :one
SELECT * FROM posts WHERE id = ? LIMIT 1; SELECT * FROM posts WHERE id = ? LIMIT 1;