Compare commits

...

10 commits

74 changed files with 2223 additions and 294 deletions

View file

@ -1,6 +1,7 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "build/tmp"
env_files = [".env"]
[build]
args_bin = []

1
.gitignore vendored
View file

@ -4,3 +4,4 @@ node_modules/
static/assets/
# Local Netlify folder
.netlify
.env

40
Dockerfile Normal file
View file

@ -0,0 +1,40 @@
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /app
# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download
# Install the build dependencies
RUN apk update && \
apk add nodejs npm make gcc libc-dev
# Copy source code
COPY . .
# Build the frontend
RUN make frontend
# Build the application
RUN CGO_ENABLED=1 GOOS=linux go build -o weiro .
# Runtime stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
RUN mkdir -p /data
WORKDIR /root/
# Copy the binary from builder
COPY --from=builder /app/weiro .
COPY --from=builder /app/static ./static
COPY --from=builder /app/views ./views
ENV DATA_DIR=/data
EXPOSE 3000
CMD ["./weiro"]

View file

@ -0,0 +1,8 @@
---
id: X-fIs5JROC49
title: ""
date: 2026-02-23T10:12:47Z
tags: []
slug: /2026/02/23/another-post-to
---
Another post to delete.

View file

@ -0,0 +1,10 @@
---
id: Uk11zptnUi3A
title: ""
date: 2026-02-23T10:28:37Z
tags: []
slug: /2026/02/23/be-a-comma
---
Be a comma than a full stop.
Also, this will be deleted soon.

View file

@ -0,0 +1,8 @@
---
id: hTF0-vhojyR7
title: ""
date: 2026-02-23T10:16:19Z
tags: []
slug: /2026/02/23/i-should-soft
---
I should soft delete.

View file

@ -0,0 +1,8 @@
---
id: EWhQUasFRLfJ
title: ""
date: 2026-02-23T10:12:00Z
tags: []
slug: /2026/02/23/this-is-to
---
This is to be deleted.

View file

@ -0,0 +1,10 @@
---
id: Ed6vIR86gspx
title: ""
date: 2026-02-24T10:55:39Z
tags: []
slug: /2026/02/24/it-may-have
---
It may have been an issue with the call to air? Hmm. Will need to make sure to check that is working correctly.
I am now updating this. I am also updating this too.

View file

@ -0,0 +1,8 @@
---
id: bzAVD55SB2LE
title: ""
date: 2026-02-24T11:20:23Z
tags: []
slug: /2026/02/24/it-will-even
---
It will even do it for new posts.

View file

@ -0,0 +1,12 @@
---
id: MFHzBhJwJCQ3
title: ""
date: 2026-02-24T11:20:11Z
tags: []
slug: /2026/02/24/this-is-a
---
This is a new post, and will be saved as a draft.
It's still a draft. But the minute I publish it, it will be updated as an updated post. You see? I'm still writing in this.
But the minute I press enter, it will always publish.

View file

@ -0,0 +1,10 @@
---
id: -sU1lmmL7i56
title: ""
date: 2026-02-24T11:20:44Z
tags: []
slug: /2026/02/24/this-was-a
---
This was a draft.
But now it's a published post.

View file

@ -0,0 +1,66 @@
import { Controller } from "@hotwired/stimulus"
import { showToast } from "../services/toast";
export default class FirstRunController extends Controller {
static targets = ['pages'];
connect() {
this.pagesTargets.forEach((x) => x.classList.add('d-none'));
this.pagesTargets[0].classList.remove('d-none');
this.element.querySelector('input[name="username"]').focus();
}
nextPage(ev) {
ev.preventDefault();
const currentIndex = this.pagesTargets.findIndex(x => !x.classList.contains('d-none'));
if (currentIndex === -1) {
return;
}
if (!this._validate(currentIndex)) {
return;
}
let nextPage = currentIndex + 1;
if (nextPage >= this.pagesTargets.length) {
this.element.querySelector('form').submit();
return;
}
this.pagesTargets[currentIndex].classList.add('d-none');
this.pagesTargets[nextPage].classList.remove('d-none');
if (nextPage === 1) {
this.element.querySelector('input[name="siteName"]').focus();
}
}
_validate(pageNumber) {
let newUsername = this.element.querySelector('input[name="username"]');
let newPassword1 = this.element.querySelector('input[name="password1"]');
let newPassword2 = this.element.querySelector('input[name="password2"]');
if (newUsername.value === '') {
alert('Please enter a username');
newUsername.focus();
return false;
}
if (!newUsername.value.match(/^[a-zA-Z0-9_-]+$/)) {
alert('Please enter a username with letters, numbers, underscores, and dashes only');
newUsername.focus();
newUsername.select();
return false;
}
if (newPassword1.value === '') {
alert('Please enter a password');
newPassword1.focus();
return false;
}
if (newPassword2.value !== newPassword1.value) {
alert('Passwords do not match');
newPassword2.focus();
return false;
}
return true;
}
}

View file

@ -0,0 +1,9 @@
import { Controller } from "@hotwired/stimulus"
export default class LogoutController extends Controller {
async logout(ev) {
ev.preventDefault();
await fetch(`/logout`, { method: 'POST' });
window.location.href = '/login';
}
}

View file

@ -0,0 +1,80 @@
import { Controller } from "@hotwired/stimulus"
import { showToast } from "../services/toast";
export default class PosteditController extends Controller {
static targets = ['bodyTextEdit'];
static values = {
saveAction: String,
};
connect() {
this.bodyTextEditTarget.focus();
}
async save(ev) {
ev.preventDefault();
try {
await this._postForm(this.saveActionValue);
showToast({
title: "💾 Post Saved",
body: (this.saveActionValue === "Save Draft") ? "Post saved as draft." : "Post updated.",
});
} catch (e) {
console.error(e);
showToast({
title: "❌ Error",
body: "Unable to save post. Please try again later.",
});
}
}
async publish(ev) {
ev.preventDefault();
try {
await this._postForm("Publish");
window.location.href = this.element.getAttribute("action");
} catch (e) {
console.error(e);
showToast({
title: "❌ Error",
body: "Unable to publish post. Please try again later.",
});
}
}
async _postForm(action) {
if (this._isPosting) {
return;
}
this._isPosting = true;
try {
const formData = new FormData(this.element);
let data = Object.fromEntries(formData.entries());
data = {...data, action: action || 'save'};
const response = await fetch(this.element.getAttribute("action"), {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
} finally {
this._isPosting = false;
}
}
}

View file

@ -0,0 +1,76 @@
import { Controller } from "@hotwired/stimulus"
import { showToast } from "../services/toast";
export default class PostlistController extends Controller {
static values = {
siteId: Number,
postId: Number,
nanoSummary: String,
};
async deletePost(ev) {
ev.preventDefault();
let isHardDelete = ev.params && ev.params.hardDelete;
if (isHardDelete) {
if (!confirm("Are you sure you want to delete this post?")) {
return;
}
}
try {
let deleteQuery = isHardDelete ? '?hard=true' : '';
this.element.remove();
await fetch(`/sites/${this.siteIdValue}/posts/${this.postIdValue}${deleteQuery}`, {
method: 'DELETE',
headers: { 'Accept': 'application/json' },
});
if (isHardDelete) {
showToast({
title: "🔥 Post Delete",
body: this.nanoSummaryValue,
});
} else {
showToast({
title: "🗑️ Sent To Trash",
body: this.nanoSummaryValue,
});
}
} catch (error) {
showToast({
title: "❌ Error",
body: "Failed to delete post. Please try again later.",
});
}
}
async restorePost(ev) {
ev.preventDefault();
try {
this.element.remove();
await fetch(`/sites/${this.siteIdValue}/posts/${this.postIdValue}`, {
method: 'PATCH',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'restore'
})
});
showToast({
title: "🗑️ Restored From Trash",
body: this.nanoSummaryValue,
});
} catch (error) {
showToast({
title: "❌ Error",
body: "Failed to rstore post. Please try again later.",
});
}
}
}

View file

@ -0,0 +1,24 @@
import { Toast } from 'bootstrap/dist/js/bootstrap.js';
import { Controller } from "@hotwired/stimulus"
export default class ToastController extends Controller {
static targets = ['title', 'body'];
initialize() {
this._toast = new Toast(this.element);
}
showToast(ev) {
let toastDetails = ev.detail;
if (!toastDetails) {
return;
}
this.titleTarget.innerText = toastDetails.title || "Title";
this.bodyTarget.innerText = toastDetails.body || "Body";
if (!this._toast.isShown()) {
this._toast.show();
}
}
}

14
assets/js/main.js Normal file
View file

@ -0,0 +1,14 @@
import { Application } from "@hotwired/stimulus";
import ToastController from "./controllers/toast";
import PostlistController from "./controllers/postlist";
import PosteditController from "./controllers/postedit";
import LogoutController from "./controllers/logout";
import FirstRunController from "./controllers/firstrun";
window.Stimulus = Application.start()
Stimulus.register("toast", ToastController);
Stimulus.register("postlist", PostlistController);
Stimulus.register("postedit", PosteditController);
Stimulus.register("logout", LogoutController);
Stimulus.register("first-run", FirstRunController);

View file

@ -0,0 +1,6 @@
export function showToast(details) {
let event = new CustomEvent('weiroToast', {
detail: details
});
window.dispatchEvent(event);
}

118
cmds/pubtargets.go Normal file
View file

@ -0,0 +1,118 @@
package cmds
import (
"context"
"fmt"
"log"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
"lmika.dev/lmika/weiro/config"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/services"
)
func PubTargetsAdd() *cobra.Command {
var (
siteGUID string
targetType string
targetRef string
targetKey string
baseURL string
enabled bool
)
cmd := &cobra.Command{
Use: "add",
Short: "Add a publication target",
Run: func(cmd *cobra.Command, args []string) {
cfg, err := config.LoadConfig()
if err != nil {
log.Fatal(err)
}
svcs, err := services.New(cfg)
if err != nil {
log.Fatal(err)
}
defer svcs.Close()
ctx := context.Background()
site, err := svcs.DB.SelectSiteByGUID(ctx, siteGUID)
if err != nil {
log.Fatal(err)
}
target := &models.SitePublishTarget{
SiteID: site.ID,
GUID: models.NewNanoID(),
Enabled: enabled,
BaseURL: baseURL,
TargetType: targetType,
TargetRef: targetRef,
TargetKey: targetKey,
}
if err := svcs.DB.SavePublishTarget(ctx, target); err != nil {
log.Fatal(err)
}
fmt.Printf("Added publish target %s\n", target.GUID)
},
}
cmd.Flags().StringVarP(&siteGUID, "site", "s", "", "Site GUID")
cmd.Flags().StringVarP(&targetType, "type", "t", "", "Target type (localfs, netlify)")
cmd.Flags().StringVarP(&targetRef, "ref", "r", "", "Target reference")
cmd.Flags().StringVarP(&targetKey, "key", "k", "", "Target key")
cmd.Flags().StringVarP(&baseURL, "url", "u", "", "Base URL")
cmd.Flags().BoolVar(&enabled, "enabled", true, "Enable target")
cmd.MarkFlagRequired("site")
cmd.MarkFlagRequired("type")
cmd.MarkFlagRequired("ref")
cmd.MarkFlagRequired("url")
return cmd
}
func PubTargets() *cobra.Command {
cmd := &cobra.Command{
Use: "pubtargets <site-guid>",
Short: "Manage publication targets",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
cfg, err := config.LoadConfig()
if err != nil {
log.Fatal(err)
}
svcs, err := services.New(cfg)
if err != nil {
log.Fatal(err)
}
defer svcs.Close()
ctx := context.Background()
site, err := svcs.DB.SelectSiteByGUID(ctx, args[0])
if err != nil {
log.Fatal(err)
}
targets, err := svcs.DB.SelectPublishTargetsOfSite(ctx, site.ID)
if err != nil {
log.Fatal(err)
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "GUID\tTARGET_TYPE\tENABLED\tTARGET_REF")
for _, target := range targets {
fmt.Fprintf(w, "%s\t%s\t%v\t%s\n", target.GUID, target.TargetType, target.Enabled, target.TargetRef)
}
w.Flush()
},
}
cmd.AddCommand(PubTargetsAdd())
return cmd
}

139
cmds/server.go Normal file
View file

@ -0,0 +1,139 @@
package cmds
import (
"context"
"html"
"html/template"
"log"
"path/filepath"
"strings"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/extractors"
"github.com/gofiber/fiber/v3/middleware/session"
"github.com/gofiber/fiber/v3/middleware/static"
"github.com/gofiber/storage/sqlite3/v2"
fiber_html "github.com/gofiber/template/html/v3"
"github.com/gofiber/utils/v2"
"github.com/spf13/cobra"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"lmika.dev/lmika/weiro/config"
"lmika.dev/lmika/weiro/handlers"
"lmika.dev/lmika/weiro/handlers/middleware"
"lmika.dev/lmika/weiro/services"
)
func Root() *cobra.Command {
cmd := &cobra.Command{
Use: "weiro",
Short: "Weiro is a simple blogging platform",
Long: `Weiro is a simple blogging platform.
Starting weiro without any arguments will start the server.
`,
Run: func(cmd *cobra.Command, args []string) {
cfg, err := config.LoadConfig()
if err != nil {
log.Fatal(err)
}
svcs, err := services.New(cfg)
if err != nil {
log.Fatal(err)
}
defer svcs.Close()
svcs.PublisherQueue.Start(context.Background())
fiberTemplate := fiber_html.New("./views", ".html")
fiberTemplate.Funcmap["sub"] = func(x, y int) int { return x - y }
fiberTemplate.Funcmap["markdown"] = func() func(s string) template.HTML {
mdParser := goldmark.New(
goldmark.WithExtensions(extension.GFM),
)
return func(s string) template.HTML {
var sb strings.Builder
if err := mdParser.Convert([]byte(s), &sb); err != nil {
return template.HTML("Markdown error: " + html.EscapeString(err.Error()))
}
return template.HTML(sb.String())
}
}()
// Initialize custom config
store := sqlite3.New(sqlite3.Config{
Database: filepath.Join(cfg.DataDir, "./fiber.db"),
Table: "fiber_storage",
Reset: false,
GCInterval: 10 * time.Second,
MaxOpenConns: 100,
MaxIdleConns: 100,
ConnMaxLifetime: 1 * time.Second,
})
app := fiber.New(fiber.Config{
Views: fiberTemplate,
ViewsLayout: "layouts/main",
PassLocalsToViews: true,
})
app.Use(session.New(session.Config{
// Storage
Storage: store,
// Security
CookieSecure: cfg.IsProd(),
CookieSameSite: "Lax",
// Session Management
IdleTimeout: 24 * time.Hour, // Inactivity timeout
AbsoluteTimeout: 7 * 24 * time.Hour, // Maximum session duration
// Cookie Settings
CookiePath: "/",
CookieDomain: cfg.SiteDomain,
CookieSessionOnly: false, // Persist across browser restarts
// Session ID
Extractor: extractors.FromCookie("__wro-session_id"),
KeyGenerator: utils.SecureToken,
// Error Handling
ErrorHandler: func(c fiber.Ctx, err error) {
log.Printf("Session error: %v", err)
},
}))
ih := handlers.IndexHandler{SiteService: svcs.Sites}
lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth}
ph := handlers.PostsHandler{PostService: svcs.Posts}
app.Get("/login", lh.Login)
app.Post("/login", lh.DoLogin)
app.Post("/logout", lh.Logout)
siteGroup := app.Group("/sites/:siteID", middleware.RequireUser(svcs.Auth), middleware.RequiresSite(svcs.Sites))
siteGroup.Get("/posts", ph.Index)
siteGroup.Get("/posts/new", ph.New)
siteGroup.Get("/posts/:postID", ph.Edit)
siteGroup.Post("/posts", ph.Update)
siteGroup.Patch("/posts/:postID", ph.Patch)
siteGroup.Delete("/posts/:postID", ph.Delete)
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
app.Get("/first-run", ih.FirstRun)
app.Post("/first-run", ih.FirstRunSubmit)
app.Get("/static/*", static.New("./static"))
if err := app.Listen(":3000"); err != nil {
log.Println(err)
}
},
}
cmd.AddCommand(Sites())
cmd.AddCommand(PubTargets())
return cmd
}

46
cmds/sites.go Normal file
View file

@ -0,0 +1,46 @@
package cmds
import (
"context"
"fmt"
"log"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
"lmika.dev/lmika/weiro/config"
"lmika.dev/lmika/weiro/services"
)
func Sites() *cobra.Command {
cmd := &cobra.Command{
Use: "sites",
Short: "Manage sites",
Run: func(cmd *cobra.Command, args []string) {
cfg, err := config.LoadConfig()
if err != nil {
log.Fatal(err)
}
svcs, err := services.New(cfg)
if err != nil {
log.Fatal(err)
}
defer svcs.Close()
ctx := context.Background()
sites, err := svcs.Sites.ListAllSitesWithOwners(ctx)
if err != nil {
log.Fatal(err)
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "GUID\tOWNER\tNAME")
for _, site := range sites {
fmt.Fprintf(w, "%s\t%s\t%s\n", site.GUID, site.Username, site.Title)
}
w.Flush()
},
}
return cmd
}

31
config/config.go Normal file
View file

@ -0,0 +1,31 @@
package config
import (
"fmt"
"path/filepath"
"github.com/Netflix/go-env"
)
type Config struct {
DataDir string `env:"DATA_DIR"`
SiteDomain string `env:"SITE_DOMAIN"`
LoginLocked bool `env:"LOGIN_LOCKED,default=false"`
Env string `env:"ENV,default=prod"`
}
func LoadConfig() (Config, error) {
cfg := Config{}
if _, err := env.UnmarshalFromEnviron(&cfg); err != nil {
return Config{}, fmt.Errorf("failed to load config: %w", err)
}
return cfg, nil
}
func (c Config) IsProd() bool {
return c.Env != "dev"
}
func (c Config) DBName() string {
return filepath.Join(c.DataDir, "weiro.db")
}

View file

@ -1,9 +1,16 @@
import * as esbuild from 'esbuild'
import {sassPlugin} from 'esbuild-sass-plugin'
await esbuild.build({
await Promise.all([
esbuild.build({
entryPoints: ['./assets/css/main.scss'],
bundle: true,
plugins: [sassPlugin()],
outfile: './static/assets/main.css',
});
}),
esbuild.build({
entryPoints: ['./assets/js/main.js'],
bundle: true,
outfile: './static/assets/main.js',
})
]);

23
go.mod
View file

@ -18,6 +18,7 @@ require (
github.com/Azure/go-autorest/autorest/date v0.2.0 // indirect
github.com/Azure/go-autorest/logger v0.1.0 // indirect
github.com/Azure/go-autorest/tracing v0.5.0 // indirect
github.com/Netflix/go-env v0.1.2 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
@ -36,22 +37,26 @@ require (
github.com/go-openapi/strfmt v0.19.11 // indirect
github.com/go-openapi/swag v0.19.12 // indirect
github.com/go-openapi/validate v0.20.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/gofiber/fiber/v3 v3.0.0 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/fiber/v3 v3.1.0 // indirect
github.com/gofiber/schema v1.7.0 // indirect
github.com/gofiber/storage/sqlite3/v2 v2.2.3 // indirect
github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/template/html/v3 v3.0.2 // indirect
github.com/gofiber/template/v2 v2.1.0 // indirect
github.com/gofiber/utils/v2 v2.0.0 // indirect
github.com/gofiber/utils/v2 v2.0.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect
github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.33 // indirect
github.com/mitchellh/mapstructure v1.4.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/netlify/open-api/v2 v2.49.1 // indirect
@ -61,17 +66,19 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rsc/goversion v1.2.0 // indirect
github.com/sirupsen/logrus v1.6.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect
go.mongodb.org/mongo-driver v1.4.4 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect

35
go.sum
View file

@ -31,6 +31,8 @@ github.com/Azure/go-autorest/tracing v0.5.0 h1:TRn4WjSnkcSy5AEG3pnbtFSwNtwzjr4VY
github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Netflix/go-env v0.1.2 h1:0DRoLR9lECQ9Zqvkswuebm3jJ/2enaDX6Ei8/Z+EnK0=
github.com/Netflix/go-env v0.1.2/go.mod h1:WlIhYi++8FlKNJtrop1mjXYAJMzv1f43K4MqCoh0yGE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
@ -71,6 +73,7 @@ github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHo
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -176,6 +179,8 @@ github.com/go-openapi/validate v0.19.12/go.mod h1:Rzou8hA/CBw8donlS6WNEUQupNvUZ0
github.com/go-openapi/validate v0.19.15/go.mod h1:tbn/fdOwYHgrhPBzidZfJC2MIVvs9GA7monOmWBbeCI=
github.com/go-openapi/validate v0.20.0 h1:pzutNCCBZGZlE+u8HD3JZyWdc/TVbtVwlWUp8/vgUKk=
github.com/go-openapi/validate v0.20.0/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@ -207,8 +212,14 @@ github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/V
github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY=
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/storage/sqlite3/v2 v2.2.3 h1:m3n80wUewnB5ruAV3Qq0mzIS+bwBrYYETo4N+fvBoow=
github.com/gofiber/storage/sqlite3/v2 v2.2.3/go.mod h1:F1w9BpQtU7BD5cCjlQnFIEjWHUaAcm9Hh5fuCpfG/OE=
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
github.com/gofiber/template/html/v3 v3.0.2 h1:/Fh8UcEsB4uhf1QWNbYaAOwXxSORebJ2zXkb5tgG/TI=
@ -217,6 +228,8 @@ github.com/gofiber/template/v2 v2.1.0 h1:vrLY6uEW2HdioJm6J5FGUpYZuapVQhHciNz21XQ
github.com/gofiber/template/v2 v2.1.0/go.mod h1:ohgpR/Ng90nJbK+IyNzrgR/XpnBNt862/oTF5G7SAmE=
github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@ -283,6 +296,8 @@ github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0m
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
@ -302,6 +317,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
@ -335,6 +352,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
@ -392,6 +411,7 @@ github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rsc/goversion v1.2.0 h1:zVF4y5ciA/rw779S62bEAq4Yif1cBc/UwRkXJ2xZyT4=
github.com/rsc/goversion v1.2.0/go.mod h1:Tf/O0TQyfRvp7NelXAyfXYRKUO+LX3KNgXc8ALRUv4k=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
@ -410,10 +430,15 @@ github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTd
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -464,6 +489,7 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -481,6 +507,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -535,6 +563,8 @@ golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -582,6 +612,8 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -595,6 +627,8 @@ golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -629,6 +663,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

35
handlers/accepts.go Normal file
View file

@ -0,0 +1,35 @@
package handlers
import (
"github.com/gofiber/fiber/v3"
)
type acceptor struct {
canAccept func(ctx fiber.Ctx) bool
acceptFn func(ctx fiber.Ctx) error
}
func accepts(ctx fiber.Ctx, acceptors ...acceptor) error {
for _, a := range acceptors {
if a.canAccept(ctx) {
return a.acceptFn(ctx)
}
}
return fiber.ErrNotFound
}
func json(fn func() any) acceptor {
return acceptor{
canAccept: func(ctx fiber.Ctx) bool { return ctx.AcceptsJSON() && !ctx.AcceptsHTML() },
acceptFn: func(ctx fiber.Ctx) error {
return ctx.Status(fiber.StatusOK).JSON(fn())
},
}
}
func html(fn func(ctx fiber.Ctx) error) acceptor {
return acceptor{
canAccept: func(ctx fiber.Ctx) bool { return ctx.AcceptsHTML() },
acceptFn: fn,
}
}

77
handlers/index.go Normal file
View file

@ -0,0 +1,77 @@
package handlers
import (
"fmt"
"net/url"
"regexp"
"emperror.dev/errors"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/session"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/services/sites"
)
var sitePath = regexp.MustCompile(`^/sites/([0-9]+)`)
type IndexHandler struct {
SiteService *sites.Service
}
func (h IndexHandler) Index(c fiber.Ctx) error {
hasUserAndSites, err := h.SiteService.HasUsersAsSites(c.Context())
if err != nil {
return err
} else if !hasUserAndSites {
return c.Redirect().To("/first-run")
}
user, hasUser := models.GetUser(c.Context())
if !hasUser {
return c.Redirect().To("/login")
}
if refUrl, rerr := url.Parse(c.Get("Referer")); rerr == nil {
if parts := sitePath.FindStringSubmatch(refUrl.Path); len(parts) == 2 {
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", parts[1]))
}
}
site, err := h.SiteService.BestSite(c.Context(), user)
if err != nil {
return err
}
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", site.ID))
}
func (h IndexHandler) FirstRun(c fiber.Ctx) error {
hasUserAndSites, err := h.SiteService.HasUsersAsSites(c.Context())
if err != nil {
return err
} else if hasUserAndSites {
return errors.New("you already have a site")
}
return c.Render("index/first-run", fiber.Map{}, "layouts/bare_with_scripts")
}
func (h IndexHandler) FirstRunSubmit(c fiber.Ctx) error {
var req sites.FirstRunRequest
if err := c.Bind().Body(&req); err != nil {
return errors.Wrap(err, "failed to parse first run request")
}
sess := session.FromContext(c)
if err := sess.Regenerate(); err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
}
user, site, err := h.SiteService.FirstRun(c.Context(), req)
if err != nil {
return err
}
sess.Set("user_id", user.ID)
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", site.ID))
}

72
handlers/login.go Normal file
View file

@ -0,0 +1,72 @@
package handlers
import (
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/session"
"lmika.dev/lmika/weiro/config"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/services/auth"
)
type LoginHandler struct {
Config config.Config
AuthService *auth.Service
}
func (lh *LoginHandler) Login(c fiber.Ctx) error {
if lh.Config.LoginLocked {
return c.Status(fiber.StatusForbidden).SendString("Login is locked")
}
loginChallenge := models.NewNanoID()
sess := session.FromContext(c)
sess.Set("_login_challenge", loginChallenge)
c.Render("login/login", fiber.Map{
"challenge": loginChallenge,
}, "layouts/bare")
return nil
}
func (lh *LoginHandler) Logout(c fiber.Ctx) error {
sess := session.FromContext(c)
sess.Destroy()
return c.Redirect().To("/login")
}
func (lh *LoginHandler) DoLogin(c fiber.Ctx) error {
var req struct {
Username string `form:"username"`
Password string `form:"password"`
LoginChallenge string `form:"_login_challenge"`
}
if err := c.Bind().Body(&req); err != nil {
return c.Status(fiber.StatusBadRequest).SendString("Failed to parse request body")
}
if req.Username == "" || req.Password == "" {
return c.Status(fiber.StatusBadRequest).SendString("Username and password are required")
}
sess := session.FromContext(c)
challenge, _ := sess.Get("_login_challenge").(string)
if challenge != req.LoginChallenge {
return c.Redirect().To("/login")
}
user, err := lh.AuthService.Login(c.Context(), req.Username, req.Password)
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
}
if err := sess.Regenerate(); err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
}
sess.Set("user_id", user.ID)
sess.Delete("_login_challenge")
return c.Redirect().To("/")
}

View file

@ -3,12 +3,14 @@ package middleware
import (
"strconv"
"emperror.dev/errors"
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/sites"
)
func RequiresSite(db *db.Provider) func(c fiber.Ctx) error {
func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error {
return func(c fiber.Ctx) error {
siteIDStr := c.Params("siteID")
if siteIDStr == "" {
@ -20,18 +22,15 @@ func RequiresSite(db *db.Provider) func(c fiber.Ctx) error {
return fiber.ErrBadRequest
}
user, ok := models.GetUser(c.Context())
if !ok {
return fiber.ErrUnauthorized
}
site, err := db.SelectSiteByID(c.Context(), siteID)
site, err := sites.GetSiteByID(c.Context(), siteID)
if err != nil {
return fiber.ErrNotFound
}
if site.OwnerID != user.ID {
if errors.Is(err, models.UserRequiredError) {
return fiber.ErrForbidden
} else if errors.Is(err, models.PermissionError) || db.ErrorIsNoRows(err) {
return fiber.ErrNotFound
} else if errors.Is(err, models.NotFoundError) || db.ErrorIsNoRows(err) {
return err
}
}
c.Locals("site", site)

View file

@ -1,23 +1,47 @@
package middleware
import (
"log"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/session"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/services/auth"
)
func AuthUser() func(c fiber.Ctx) error {
func OptionalUser(auth *auth.Service) func(c fiber.Ctx) error {
return func(c fiber.Ctx) error {
// TEMP - Actually do the auth here
user := models.User{
ID: 1,
Username: "testuser",
sess := session.FromContext(c)
userID, _ := sess.Get("user_id").(int64)
if userID == 0 {
return c.Next()
}
user, err := auth.GetUser(c.Context(), userID)
if err != nil {
return c.Next()
}
c.Locals("user", user)
c.SetContext(models.WithUser(c.Context(), user))
return c.Next()
}
}
func RequireUser(auth *auth.Service) func(c fiber.Ctx) error {
return func(c fiber.Ctx) error {
sess := session.FromContext(c)
userID, _ := sess.Get("user_id").(int64)
if userID == 0 {
return c.Redirect().To("/login")
}
user, err := auth.GetUser(c.Context(), userID)
if err != nil {
return c.Redirect().To("/login")
}
c.Locals("user", user)
c.SetContext(models.WithUser(c.Context(), user))
log.Printf("User %s authenticated", user.Username)
return c.Next()
}

View file

@ -2,6 +2,7 @@ package handlers
import (
"fmt"
"log"
"strconv"
"github.com/gofiber/fiber/v3"
@ -14,19 +15,32 @@ type PostsHandler struct {
}
func (ph PostsHandler) Index(c fiber.Ctx) error {
posts, err := ph.PostService.ListPosts(c.Context())
var req struct {
Filter string `query:"filter"`
}
if err := c.Bind().Query(&req); err != nil {
return fiber.ErrBadRequest
}
posts, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted")
if err != nil {
return err
}
return accepts(c, json(func() any {
return posts
}), html(func(c fiber.Ctx) error {
return c.Render("posts/index", fiber.Map{
"req": req,
"posts": posts,
})
}))
}
func (ph PostsHandler) New(c fiber.Ctx) error {
p := models.Post{
GUID: models.NewNanoID(),
State: models.StateDraft,
}
return c.Render("posts/edit", fiber.Map{
@ -49,9 +63,13 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error {
return err
}
return accepts(c, json(func() any {
return post
}), html(func(c fiber.Ctx) error {
return c.Render("posts/edit", fiber.Map{
"post": post,
})
}))
}
func (ph PostsHandler) Update(c fiber.Ctx) error {
@ -60,10 +78,82 @@ func (ph PostsHandler) Update(c fiber.Ctx) error {
return err
}
post, err := ph.PostService.PublishPost(c.Context(), req)
post, err := ph.PostService.UpdatePost(c.Context(), req)
if err != nil {
return err
}
return accepts(c, json(func() any {
// TODO: should be created if brand new
return post
}), html(func(c fiber.Ctx) error {
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", post.SiteID))
}))
}
func (ph PostsHandler) Patch(c fiber.Ctx) error {
log.Println("PATCH")
postIDStr := c.Params("postID")
if postIDStr == "" {
return fiber.ErrBadRequest
}
postID, err := strconv.ParseInt(postIDStr, 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
var req struct {
Action string `json:"action"`
}
if err := c.Bind().Body(&req); err != nil {
return err
}
log.Println("Request")
switch req.Action {
case "restore":
if err := ph.PostService.RestorePost(c.Context(), postID); err != nil {
return err
}
default:
return fiber.ErrBadRequest
}
return accepts(c, json(func() any {
return struct{}{}
}), html(func(c fiber.Ctx) error {
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts"))
}))
}
func (ph PostsHandler) Delete(c fiber.Ctx) error {
postIDStr := c.Params("postID")
if postIDStr == "" {
return fiber.ErrBadRequest
}
postID, err := strconv.ParseInt(postIDStr, 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
var req struct {
Hard bool `query:"hard"`
}
if err := c.Bind().Query(&req); err != nil {
return err
}
if err := ph.PostService.DeletePost(c.Context(), postID, req.Hard); err != nil {
return err
}
return accepts(c, json(func() any {
return fiber.Map{}
}), html(func(c fiber.Ctx) error {
return c.Redirect().To("/sites")
}))
}

100
main.go
View file

@ -1,106 +1,14 @@
package main
import (
"html"
"html/template"
"log"
"strings"
"os"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/static"
fiber_html "github.com/gofiber/template/html/v3"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"lmika.dev/lmika/weiro/handlers"
"lmika.dev/lmika/weiro/handlers/middleware"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/posts"
"lmika.dev/lmika/weiro/services/publisher"
"lmika.dev/lmika/weiro/cmds"
_ "modernc.org/sqlite"
)
func main() {
dbp, err := db.New("build/weiro.db")
if err != nil {
log.Fatal(err)
if err := cmds.Root().Execute(); err != nil {
os.Exit(1)
}
defer dbp.Close()
publisherSvc := publisher.New(dbp)
postService := posts.New(dbp, publisherSvc)
//user, err := dbp.SelectUserByUsername(context.Background(), "testuser")
//if err != nil {
// user = models.User{
// Username: "testuser",
// PasswordHashed: []byte("changeme"),
// }
// if err := dbp.SaveUser(context.Background(), &user); err != nil {
// log.Fatal(err)
// }
//}
fiberTemplate := fiber_html.New("./views", ".html")
fiberTemplate.Funcmap["markdown"] = func() func(s string) template.HTML {
mdParser := goldmark.New(
goldmark.WithExtensions(extension.GFM),
)
return func(s string) template.HTML {
var sb strings.Builder
if err := mdParser.Convert([]byte(s), &sb); err != nil {
return template.HTML("Markdown error: " + html.EscapeString(err.Error()))
}
return template.HTML(sb.String())
}
}()
app := fiber.New(fiber.Config{
Views: fiberTemplate,
ViewsLayout: "layouts/main",
PassLocalsToViews: true,
})
siteGroup := app.Group("/sites/:siteID", middleware.AuthUser(), middleware.RequiresSite(dbp))
ph := handlers.PostsHandler{PostService: postService}
siteGroup.Get("/posts", ph.Index)
siteGroup.Get("/posts/new", ph.New)
siteGroup.Get("/posts/:postID/edit", ph.Edit)
siteGroup.Post("/posts", ph.Update)
app.Get("/", func(c fiber.Ctx) error {
return c.Redirect().To("/sites/1/posts")
})
app.Get("/static/*", static.New("./static"))
// TEMP
//
/*
dbp.SaveUser(context.Background(), &models.User{Username: "testuser"})
ctx := models.WithUser(context.Background(), models.User{ID: 1})
site, err := importer.New(dbp).Import(ctx, "_test-site")
if err != nil {
log.Fatal(err)
}
target := models.SitePublishTarget{
SiteID: site.ID,
BaseURL: "https://jolly-boba-9e2486.netlify.app",
TargetType: "netlify",
TargetRef: "55c878a7-189e-42cf-aa02-5c60908143f3",
TargetKey: os.Getenv("NETLIFY_AUTH_TOKEN"),
}
if err := dbp.SavePublishTarget(ctx, &target); err != nil {
log.Fatal(err)
}
*/
//if err := publisherSvc.Publish(ctx, site.ID); err != nil {
// log.Fatal(err)
//}
log.Fatal(app.Listen(":3000"))
}

View file

@ -6,3 +6,4 @@ var UserRequiredError = errors.New("user required")
var PermissionError = errors.New("permission denied")
var NotFoundError = errors.New("not found")
var SiteRequiredError = errors.New("site required")
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")

View file

@ -3,6 +3,6 @@ package models
import "github.com/matoous/go-nanoid/v2"
func NewNanoID() string {
id, _ := gonanoid.New(12)
id, _ := gonanoid.New(16)
return id
}

View file

@ -8,15 +8,36 @@ import (
"unicode"
)
const (
StatePublished = iota
StateDraft
)
type Post struct {
ID int64
SiteID int64
GUID string
Title string
Body string
Slug string
CreatedAt time.Time
PublishedAt time.Time
ID int64 `json:"id"`
SiteID int64 `json:"site_id"`
State int `json:"state"`
GUID string `json:"guid"`
Title string `json:"title"`
Body string `json:"body"`
Slug string `json:"slug"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
DeletedAt time.Time `json:"deleted_at,omitempty"`
PublishedAt time.Time `json:"published_at,omitempty"`
}
func (p *Post) NanoSummary() string {
if p.Title != "" {
return p.Title
}
firstWords := firstNWords(p.Body, 7, wordForSummary)
if firstWords == "" {
firstWords = "(no content)"
} else if len(firstWords) < len(p.Body) {
return firstWords + "..."
}
return firstWords
}
func (p *Post) BestSlug() string {
@ -46,6 +67,10 @@ func (p *Post) BestSlug() string {
return fmt.Sprintf("/%s/%s", datePart, slugPath)
}
func wordForSummary(word string) string {
return word
}
func wordForSlug(word string) string {
var sb strings.Builder
for _, c := range word {

View file

@ -1,5 +1,7 @@
package models
import "time"
type PublishTargetType int
const (
@ -8,18 +10,31 @@ const (
PublishTargetTypeNetlify PublishTargetType = 2
)
func ParsePublishTargetType(s string) (PublishTargetType, error) {
switch s {
case "localfs":
return PublishTargetTypeLocalFS, nil
case "netlify":
return PublishTargetTypeNetlify, nil
default:
return PublishTargetTypeNone, nil
}
}
type Site struct {
ID int64
OwnerID int64
GUID string
Created time.Time
Title string
Tagline string
//Meta SiteMeta
//Posts []*Post
}
type SitePublishTarget struct {
ID int64
SiteID int64
GUID string
Enabled bool
BaseURL string
@ -27,24 +42,3 @@ type SitePublishTarget struct {
TargetRef string
TargetKey string
}
/*
type SiteMeta struct {
Title string `yaml:"title"`
Tagline string `yaml:"tagline"`
BaseURL string `yaml:"base_url"`
}
type PostMeta struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Date time.Time `yaml:"date"`
Tags []string `yaml:"tags"`
Slug string `yaml:"slug"`
}
type Post struct {
Meta PostMeta
Content string
}
*/

View file

@ -1,7 +1,49 @@
package models
import (
"regexp"
"time"
"golang.org/x/crypto/bcrypt"
)
var ValidUserName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
type User struct {
ID int64
Username string
PasswordHashed []byte
TimeZone string
Created time.Time
}
func (u *User) SetPassword(pwd string) {
bcrypted, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
u.PasswordHashed = bcrypted
}
func (u User) CheckPassword(pwd string) bool {
err := bcrypt.CompareHashAndPassword(u.PasswordHashed, []byte(pwd))
return err == nil
}
func (u User) FormatTime(t time.Time) string {
if loc := getLocation(u.TimeZone); loc != nil {
return t.In(loc).Format("2006-01-02 15:04:05")
}
return t.Format("2006-01-02 15:04:05")
}
var loadedLocation = map[string]*time.Location{}
func getLocation(tz string) *time.Location {
if loc, ok := loadedLocation[tz]; ok {
return loc
}
loc, err := time.LoadLocation(tz)
if err != nil {
loc = time.Local
}
loadedLocation[tz] = loc
return loc
}

7
package-lock.json generated
View file

@ -5,6 +5,7 @@
"packages": {
"": {
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"bootstrap": "^5.3.8",
"esbuild-sass-plugin": "^3.6.0"
},
@ -435,6 +436,12 @@
"node": ">=18"
}
},
"node_modules/@hotwired/stimulus": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==",
"license": "MIT"
},
"node_modules/@parcel/watcher": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",

View file

@ -3,6 +3,7 @@
"esbuild": "0.27.3"
},
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"bootstrap": "^5.3.8",
"esbuild-sass-plugin": "^3.6.0"
}

View file

@ -7,17 +7,21 @@ package sqlgen
type Post struct {
ID int64
SiteID int64
State int64
Guid string
Title string
Body string
Slug string
CreatedAt int64
UpdatedAt int64
PublishedAt int64
DeletedAt int64
}
type PublishTarget struct {
ID int64
SiteID int64
Guid string
TargetType string
Enabled int64
BaseUrl string
@ -28,12 +32,15 @@ type PublishTarget struct {
type Site struct {
ID int64
OwnerID int64
Guid string
Title string
Tagline string
CreatedAt int64
}
type User struct {
ID int64
Username string
Password string
CreatedAt int64
}

View file

@ -9,46 +9,73 @@ import (
"context"
)
const hardDeletePost = `-- name: HardDeletePost :exec
DELETE FROM posts WHERE id = ?
`
func (q *Queries) HardDeletePost(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, hardDeletePost, id)
return err
}
const insertPost = `-- name: InsertPost :one
INSERT INTO posts (
site_id,
state,
guid,
title,
body,
slug,
created_at,
published_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
updated_at,
published_at,
deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id
`
type InsertPostParams struct {
SiteID int64
State int64
Guid string
Title string
Body string
Slug string
CreatedAt int64
UpdatedAt int64
PublishedAt int64
DeletedAt int64
}
func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertPost,
arg.SiteID,
arg.State,
arg.Guid,
arg.Title,
arg.Body,
arg.Slug,
arg.CreatedAt,
arg.UpdatedAt,
arg.PublishedAt,
arg.DeletedAt,
)
var id int64
err := row.Scan(&id)
return id, err
}
const restorePost = `-- name: RestorePost :exec
UPDATE posts SET deleted_at = 0 WHERE id = ?
`
func (q *Queries) RestorePost(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, restorePost, id)
return err
}
const selectPost = `-- name: SelectPost :one
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE id = ? LIMIT 1
SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at FROM posts WHERE id = ? LIMIT 1
`
func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) {
@ -57,18 +84,21 @@ func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) {
err := row.Scan(
&i.ID,
&i.SiteID,
&i.State,
&i.Guid,
&i.Title,
&i.Body,
&i.Slug,
&i.CreatedAt,
&i.UpdatedAt,
&i.PublishedAt,
&i.DeletedAt,
)
return i, err
}
const selectPostByGUID = `-- name: SelectPostByGUID :one
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE guid = ? LIMIT 1
SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at FROM posts WHERE guid = ? LIMIT 1
`
func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, error) {
@ -77,22 +107,37 @@ func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, erro
err := row.Scan(
&i.ID,
&i.SiteID,
&i.State,
&i.Guid,
&i.Title,
&i.Body,
&i.Slug,
&i.CreatedAt,
&i.UpdatedAt,
&i.PublishedAt,
&i.DeletedAt,
)
return i, err
}
const selectPostsOfSite = `-- name: SelectPostsOfSite :many
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10
SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at
FROM posts
WHERE site_id = ? AND (
CASE CAST (?2 AS TEXT)
WHEN 'deleted' THEN deleted_at > 0
ELSE deleted_at = 0
END
) ORDER BY created_at DESC LIMIT 10
`
func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post, error) {
rows, err := q.db.QueryContext(ctx, selectPostsOfSite, siteID)
type SelectPostsOfSiteParams struct {
SiteID int64
PostFilter string
}
func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSiteParams) ([]Post, error) {
rows, err := q.db.QueryContext(ctx, selectPostsOfSite, arg.SiteID, arg.PostFilter)
if err != nil {
return nil, err
}
@ -103,12 +148,15 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post,
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.State,
&i.Guid,
&i.Title,
&i.Body,
&i.Slug,
&i.CreatedAt,
&i.UpdatedAt,
&i.PublishedAt,
&i.DeletedAt,
); err != nil {
return nil, err
}
@ -123,29 +171,52 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post,
return items, nil
}
const softDeletePost = `-- name: SoftDeletePost :exec
UPDATE posts SET deleted_at = ? WHERE id = ?
`
type SoftDeletePostParams struct {
DeletedAt int64
ID int64
}
func (q *Queries) SoftDeletePost(ctx context.Context, arg SoftDeletePostParams) error {
_, err := q.db.ExecContext(ctx, softDeletePost, arg.DeletedAt, arg.ID)
return err
}
const updatePost = `-- name: UpdatePost :exec
UPDATE posts SET
title = ?,
state = ?,
body = ?,
slug = ?,
published_at = ?
updated_at = ?,
published_at = ?,
deleted_at = ?
WHERE id = ?
`
type UpdatePostParams struct {
Title string
State int64
Body string
Slug string
UpdatedAt int64
PublishedAt int64
DeletedAt int64
ID int64
}
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error {
_, err := q.db.ExecContext(ctx, updatePost,
arg.Title,
arg.State,
arg.Body,
arg.Slug,
arg.UpdatedAt,
arg.PublishedAt,
arg.DeletedAt,
arg.ID,
)
return err

View file

@ -12,17 +12,19 @@ import (
const insertPublishTarget = `-- name: InsertPublishTarget :one
INSERT INTO publish_targets (
site_id,
guid,
target_type,
enabled,
base_url,
target_ref,
target_key
) VALUES (?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id
`
type InsertPublishTargetParams struct {
SiteID int64
Guid string
TargetType string
Enabled int64
BaseUrl string
@ -33,6 +35,7 @@ type InsertPublishTargetParams struct {
func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTargetParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertPublishTarget,
arg.SiteID,
arg.Guid,
arg.TargetType,
arg.Enabled,
arg.BaseUrl,
@ -45,7 +48,7 @@ func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTarg
}
const selectPublishTargetsOfSite = `-- name: SelectPublishTargetsOfSite :many
SELECT id, site_id, target_type, enabled, base_url, target_ref, target_key FROM publish_targets WHERE site_id = ?
SELECT id, site_id, guid, target_type, enabled, base_url, target_ref, target_key FROM publish_targets WHERE site_id = ?
`
func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64) ([]PublishTarget, error) {
@ -60,6 +63,7 @@ func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64)
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.Guid,
&i.TargetType,
&i.Enabled,
&i.BaseUrl,

View file

@ -7,32 +7,116 @@ package sqlgen
import (
"context"
"database/sql"
)
const hasUsersAndSites = `-- name: HasUsersAndSites :one
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites
`
func (q *Queries) HasUsersAndSites(ctx context.Context) (sql.NullBool, error) {
row := q.db.QueryRowContext(ctx, hasUsersAndSites)
var has_users_and_sites sql.NullBool
err := row.Scan(&has_users_and_sites)
return has_users_and_sites, err
}
const insertSite = `-- name: InsertSite :one
INSERT INTO sites (
owner_id,
guid,
title,
tagline
) VALUES (?, ?, ?)
tagline,
created_at
) VALUES (?, ?, ?, ?, ?)
RETURNING id
`
type InsertSiteParams struct {
OwnerID int64
Guid string
Title string
Tagline string
CreatedAt int64
}
func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertSite, arg.OwnerID, arg.Title, arg.Tagline)
row := q.db.QueryRowContext(ctx, insertSite,
arg.OwnerID,
arg.Guid,
arg.Title,
arg.Tagline,
arg.CreatedAt,
)
var id int64
err := row.Scan(&id)
return id, err
}
const selectAllSitesWithOwners = `-- name: SelectAllSitesWithOwners :many
SELECT s.id, s.guid, s.title, s.owner_id, u.username
FROM sites s
JOIN users u ON s.owner_id = u.id
ORDER BY s.title ASC
`
type SelectAllSitesWithOwnersRow struct {
ID int64
Guid string
Title string
OwnerID int64
Username string
}
func (q *Queries) SelectAllSitesWithOwners(ctx context.Context) ([]SelectAllSitesWithOwnersRow, error) {
rows, err := q.db.QueryContext(ctx, selectAllSitesWithOwners)
if err != nil {
return nil, err
}
defer rows.Close()
var items []SelectAllSitesWithOwnersRow
for rows.Next() {
var i SelectAllSitesWithOwnersRow
if err := rows.Scan(
&i.ID,
&i.Guid,
&i.Title,
&i.OwnerID,
&i.Username,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const selectSiteByGUID = `-- name: SelectSiteByGUID :one
SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE guid = ?
`
func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, error) {
row := q.db.QueryRowContext(ctx, selectSiteByGUID, guid)
var i Site
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Guid,
&i.Title,
&i.Tagline,
&i.CreatedAt,
)
return i, err
}
const selectSiteByID = `-- name: SelectSiteByID :one
SELECT id, owner_id, title, tagline FROM sites WHERE id = ?
SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE id = ?
`
func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
@ -41,14 +125,16 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
err := row.Scan(
&i.ID,
&i.OwnerID,
&i.Guid,
&i.Title,
&i.Tagline,
&i.CreatedAt,
)
return i, err
}
const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many
SELECT id, owner_id, title, tagline FROM sites WHERE owner_id = ?
SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE owner_id = ? ORDER BY title ASC
`
func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) {
@ -63,8 +149,10 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.Guid,
&i.Title,
&i.Tagline,
&i.CreatedAt,
); err != nil {
return nil, err
}

View file

@ -10,29 +10,51 @@ import (
)
const insertUser = `-- name: InsertUser :one
INSERT INTO users (username, password) VALUES (?, ?) RETURNING id
INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id
`
type InsertUserParams struct {
Username string
Password string
CreatedAt int64
}
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password)
row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password, arg.CreatedAt)
var id int64
err := row.Scan(&id)
return id, err
}
const selectUserByID = `-- name: SelectUserByID :one
SELECT id, username, password, created_at FROM users WHERE id = ? LIMIT 1
`
func (q *Queries) SelectUserByID(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRowContext(ctx, selectUserByID, id)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.Password,
&i.CreatedAt,
)
return i, err
}
const selectUserByUsername = `-- name: SelectUserByUsername :one
SELECT id, username, password FROM users WHERE username = ?
SELECT id, username, password, created_at FROM users WHERE username = ? LIMIT 1
`
func (q *Queries) SelectUserByUsername(ctx context.Context, username string) (User, error) {
row := q.db.QueryRowContext(ctx, selectUserByUsername, username)
var i User
err := row.Scan(&i.ID, &i.Username, &i.Password)
err := row.Scan(
&i.ID,
&i.Username,
&i.Password,
&i.CreatedAt,
)
return i, err
}

View file

@ -8,8 +8,16 @@ import (
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
)
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64) ([]*models.Post, error) {
rows, err := db.queries.SelectPostsOfSite(ctx, siteID)
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) ([]*models.Post, error) {
var filter = ""
if showDeleted {
filter = "deleted"
}
rows, err := db.queries.SelectPostsOfSite(ctx, sqlgen.SelectPostsOfSiteParams{
SiteID: siteID,
PostFilter: filter,
})
if err != nil {
return nil, err
}
@ -43,12 +51,15 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
if post.ID == 0 {
newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{
SiteID: post.SiteID,
State: int64(post.State),
Guid: post.GUID,
Title: post.Title,
Body: post.Body,
Slug: post.Slug,
CreatedAt: post.CreatedAt.Unix(),
PublishedAt: post.PublishedAt.Unix(),
CreatedAt: timeToInt(post.CreatedAt),
UpdatedAt: timeToInt(post.UpdatedAt),
PublishedAt: timeToInt(post.PublishedAt),
DeletedAt: timeToInt(post.DeletedAt),
})
if err != nil {
return err
@ -59,10 +70,13 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{
ID: post.ID,
State: int64(post.State),
Title: post.Title,
Body: post.Body,
Slug: post.Slug,
PublishedAt: post.PublishedAt.Unix(),
UpdatedAt: timeToInt(post.UpdatedAt),
PublishedAt: timeToInt(post.PublishedAt),
DeletedAt: timeToInt(post.DeletedAt),
})
}
@ -70,11 +84,21 @@ func dbPostToPost(row sqlgen.Post) *models.Post {
return &models.Post{
ID: row.ID,
SiteID: row.SiteID,
State: int(row.State),
GUID: row.Guid,
Title: row.Title,
Body: row.Body,
Slug: row.Slug,
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(),
PublishedAt: time.Unix(row.PublishedAt, 0).UTC(),
DeletedAt: time.Unix(row.DeletedAt, 0).UTC(),
}
}
func timeToInt(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}

View file

@ -3,6 +3,7 @@ package db
import (
"context"
"database/sql"
"time"
"github.com/Southclaws/fault"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
@ -38,3 +39,18 @@ func New(dbFile string) (*Provider, error) {
func (db *Provider) Close() error {
return db.drvr.Close()
}
func (db *Provider) SoftDeletePost(ctx context.Context, postID int64) error {
return db.queries.SoftDeletePost(ctx, sqlgen.SoftDeletePostParams{
DeletedAt: time.Now().Unix(),
ID: postID,
})
}
func (db *Provider) HardDeletePost(ctx context.Context, postID int64) error {
return db.queries.HardDeletePost(ctx, postID)
}
func (db *Provider) RestorePost(ctx context.Context, postID int64) error {
return db.queries.RestorePost(ctx, postID)
}

View file

@ -158,7 +158,7 @@ func TestProvider_Posts(t *testing.T) {
require.NoError(t, err)
assert.NotZero(t, post.ID)
posts, err := p.SelectPostsOfSite(ctx, site.ID)
posts, err := p.SelectPostsOfSite(ctx, site.ID, false)
require.NoError(t, err)
require.Len(t, posts, 1)
assert.Equal(t, post.ID, posts[0].ID)
@ -205,7 +205,7 @@ func TestProvider_Posts(t *testing.T) {
require.NoError(t, p.SavePost(ctx, post1))
require.NoError(t, p.SavePost(ctx, post2))
posts, err := p.SelectPostsOfSite(ctx, site2.ID)
posts, err := p.SelectPostsOfSite(ctx, site2.ID, false)
require.NoError(t, err)
require.Len(t, posts, 2)
assert.Equal(t, "New Post", posts[0].Title)
@ -220,7 +220,7 @@ func TestProvider_Posts(t *testing.T) {
}
require.NoError(t, p.SaveSite(ctx, emptySite))
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID)
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false)
require.NoError(t, err)
assert.Empty(t, posts)
})
@ -248,6 +248,7 @@ func TestProvider_PublishTargets(t *testing.T) {
target := &models.SitePublishTarget{
SiteID: site.ID,
TargetType: "netlify",
GUID: "target-001",
BaseURL: "https://example.netlify.app",
TargetRef: "netlify-site-123",
TargetKey: "secret-key",

View file

@ -18,6 +18,7 @@ func (db *Provider) SelectPublishTargetsOfSite(ctx context.Context, siteID int64
targets[i] = models.SitePublishTarget{
ID: row.ID,
SiteID: row.SiteID,
GUID: row.Guid,
Enabled: row.Enabled != 0,
TargetType: row.TargetType,
BaseURL: row.BaseUrl,
@ -38,6 +39,7 @@ func (db *Provider) SavePublishTarget(ctx context.Context, target *models.SitePu
newID, err := db.queries.InsertPublishTarget(ctx, sqlgen.InsertPublishTargetParams{
SiteID: target.SiteID,
TargetType: target.TargetType,
Guid: target.GUID,
Enabled: enabled,
BaseUrl: target.BaseURL,
TargetRef: target.TargetRef,

View file

@ -2,6 +2,7 @@ package db
import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
@ -13,12 +14,16 @@ func (db *Provider) SelectSiteByID(ctx context.Context, id int64) (models.Site,
return models.Site{}, err
}
return models.Site{
ID: row.ID,
OwnerID: row.OwnerID,
Title: row.Title,
Tagline: row.Tagline,
}, nil
return dbSiteToSite(row), nil
}
func (db *Provider) SelectSiteByGUID(ctx context.Context, guid string) (models.Site, error) {
row, err := db.queries.SelectSiteByGUID(ctx, guid)
if err != nil {
return models.Site{}, err
}
return dbSiteToSite(row), nil
}
func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]models.Site, error) {
@ -29,12 +34,7 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) (
sites := make([]models.Site, len(rows))
for i, row := range rows {
sites[i] = models.Site{
ID: row.ID,
OwnerID: row.OwnerID,
Title: row.Title,
Tagline: row.Tagline,
}
sites[i] = dbSiteToSite(row)
}
return sites, nil
}
@ -43,8 +43,10 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
if site.ID == 0 {
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
OwnerID: site.OwnerID,
Guid: site.GUID,
Title: site.Title,
Tagline: site.Tagline,
CreatedAt: timeToInt(site.Created),
})
if err != nil {
return err
@ -56,3 +58,49 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
// No update query defined in sqlgen yet
return nil
}
func (db *Provider) HasUsersAndSites(ctx context.Context) (bool, error) {
nullBool, err := db.queries.HasUsersAndSites(ctx)
if err != nil {
return false, err
}
return nullBool.Valid && nullBool.Bool, nil
}
type SiteWithOwner struct {
ID int64
GUID string
Title string
OwnerID int64
Username string
}
func (db *Provider) SelectAllSitesWithOwners(ctx context.Context) ([]SiteWithOwner, error) {
rows, err := db.queries.SelectAllSitesWithOwners(ctx)
if err != nil {
return nil, err
}
sites := make([]SiteWithOwner, len(rows))
for i, row := range rows {
sites[i] = SiteWithOwner{
ID: row.ID,
GUID: row.Guid,
Title: row.Title,
OwnerID: row.OwnerID,
Username: row.Username,
}
}
return sites, nil
}
func dbSiteToSite(row sqlgen.Site) models.Site {
return models.Site{
ID: row.ID,
OwnerID: row.OwnerID,
GUID: row.Guid,
Title: row.Title,
Tagline: row.Tagline,
Created: time.Unix(row.CreatedAt, 0).UTC(),
}
}

View file

@ -3,6 +3,7 @@ package db
import (
"context"
"encoding/base64"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
@ -14,16 +15,16 @@ func (db *Provider) SelectUserByUsername(ctx context.Context, username string) (
return models.User{}, err
}
pwdBytes, err := base64.StdEncoding.DecodeString(res.Password)
return dbUserToUser(res)
}
func (db *Provider) SelectUserByID(ctx context.Context, userID int64) (models.User, error) {
res, err := db.queries.SelectUserByID(ctx, userID)
if err != nil {
return models.User{}, err
}
return models.User{
ID: res.ID,
Username: res.Username,
PasswordHashed: pwdBytes,
}, nil
return dbUserToUser(res)
}
func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
@ -33,6 +34,7 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
newID, err := db.queries.InsertUser(ctx, sqlgen.InsertUserParams{
Username: user.Username,
Password: hashedPassword,
CreatedAt: timeToInt(user.Created),
})
if err != nil {
return err
@ -47,3 +49,17 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
Password: hashedPassword,
})
}
func dbUserToUser(res sqlgen.User) (models.User, error) {
pwdBytes, err := base64.StdEncoding.DecodeString(res.Password)
if err != nil {
return models.User{}, err
}
return models.User{
ID: res.ID,
Username: res.Username,
PasswordHashed: pwdBytes,
Created: time.Unix(res.CreatedAt, 0).UTC(),
}, nil
}

46
services/auth/service.go Normal file
View file

@ -0,0 +1,46 @@
package auth
import (
"context"
"emperror.dev/errors"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
)
type Service struct {
db *db.Provider
}
func New(db *db.Provider) *Service {
return &Service{
db: db,
}
}
func (s *Service) Login(ctx context.Context, username, password string) (models.User, error) {
user, err := s.db.SelectUserByUsername(ctx, username)
if err != nil {
return models.User{}, err
}
if !user.CheckPassword(password) {
return models.User{}, errors.New("invalid password")
}
return user, nil
}
func (s *Service) GetUser(ctx context.Context, userID int64) (models.User, error) {
return s.db.SelectUserByID(ctx, userID)
}
func (s *Service) SetPassword(ctx context.Context, username, password string) (models.User, error) {
user, err := s.db.SelectUserByUsername(ctx, username)
if err != nil {
return models.User{}, err
}
user.SetPassword(password)
return user, s.db.SaveUser(ctx, &user)
}

View file

@ -2,6 +2,7 @@ package posts
import (
"context"
"strings"
"time"
"lmika.dev/lmika/weiro/models"
@ -12,9 +13,10 @@ type CreatePostParams struct {
GUID string `form:"guid" json:"guid"`
Title string `form:"title" json:"title"`
Body string `form:"body" json:"body"`
Action string `form:"action" json:"action"`
}
func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
@ -27,16 +29,27 @@ func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*mo
post.Title = params.Title
post.Body = params.Body
post.PublishedAt = time.Now()
post.UpdatedAt = time.Now()
post.Slug = post.BestSlug()
oldState := post.State
switch strings.ToLower(params.Action) {
case "publish":
post.State = models.StatePublished
post.PublishedAt = time.Now()
case "save draft":
post.State = models.StateDraft
post.PublishedAt = time.Time{}
default:
// Leave unchanged
}
if err := s.db.SavePost(ctx, post); err != nil {
return nil, err
}
// TODO: do on separate thread
if err := s.publisher.Publish(ctx, site); err != nil {
return nil, err
if oldState != post.State || post.State == models.StatePublished {
s.publisher.Queue(site)
}
return post, nil
@ -59,6 +72,7 @@ func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, param
GUID: params.GUID,
Title: params.Title,
Body: params.Body,
State: models.StateDraft,
CreatedAt: time.Now(),
}
return post, nil

67
services/posts/delete.go Normal file
View file

@ -0,0 +1,67 @@
package posts
import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
)
const (
deleteDebounce = 2 * time.Second
)
func (s *Service) DeletePost(ctx context.Context, pid int64, hardDelete bool) error {
post, site, err := s.fetchPostAndSite(ctx, pid)
if err != nil {
return err
}
if hardDelete && post.DeletedAt.Unix() > 0 {
delta := time.Now().Sub(post.DeletedAt)
if delta < deleteDebounce {
return models.DeleteDebounceError
}
if err := s.db.HardDeletePost(ctx, post.ID); err != nil {
return err
}
} else {
if err := s.db.SoftDeletePost(ctx, post.ID); err != nil {
return err
}
}
s.publisher.Queue(site)
return nil
}
func (s *Service) RestorePost(ctx context.Context, pid int64) error {
post, site, err := s.fetchPostAndSite(ctx, pid)
if err != nil {
return err
}
if err := s.db.RestorePost(ctx, post.ID); err != nil {
return err
}
s.publisher.Queue(site)
return nil
}
func (s *Service) fetchPostAndSite(ctx context.Context, pid int64) (*models.Post, models.Site, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.Site{}, models.SiteRequiredError
}
post, err := s.db.SelectPost(ctx, pid)
if err != nil {
return nil, models.Site{}, err
} else if post.SiteID != site.ID {
return nil, models.Site{}, models.NotFoundError
}
return post, site, nil
}

View file

@ -6,13 +6,13 @@ import (
"lmika.dev/lmika/weiro/models"
)
func (s *Service) ListPosts(ctx context.Context) ([]*models.Post, error) {
func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
posts, err := s.db.SelectPostsOfSite(ctx, site.ID)
posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted)
if err != nil {
return nil, err
}
@ -21,16 +21,9 @@ func (s *Service) ListPosts(ctx context.Context) ([]*models.Post, error) {
}
func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
post, err := s.db.SelectPost(ctx, pid)
post, _, err := s.fetchPostAndSite(ctx, pid)
if err != nil {
return nil, err
} else if post.SiteID != site.ID {
return nil, models.NotFoundError
}
return post, nil

View file

@ -7,10 +7,10 @@ import (
type Service struct {
db *db.Provider
publisher *publisher.Publisher
publisher *publisher.Queue
}
func New(db *db.Provider, publisher *publisher.Publisher) *Service {
func New(db *db.Provider, publisher *publisher.Queue) *Service {
return &Service{
db: db,
publisher: publisher,

View file

@ -0,0 +1,44 @@
package publisher
import (
"context"
"log"
"lmika.dev/lmika/weiro/models"
)
type Queue struct {
publisher *Publisher
pending chan models.Site
}
func NewQueue(publisher *Publisher) *Queue {
return &Queue{
publisher: publisher,
pending: make(chan models.Site, 1),
}
}
func (q *Queue) Queue(site models.Site) bool {
select {
case q.pending <- site:
return true
default:
return false
}
}
func (q *Queue) Start(ctx context.Context) {
go func() {
for {
select {
case site := <-q.pending:
if err := q.publisher.Publish(ctx, site); err != nil {
log.Printf("error publishing site: %v", err)
}
case <-ctx.Done():
return
}
}
}()
}

View file

@ -35,7 +35,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
}
// Fetch all content of site
posts, err := p.db.SelectPostsOfSite(ctx, site.ID)
posts, err := p.db.SelectPostsOfSite(ctx, site.ID, false)
if err != nil {
return err
}

47
services/services.go Normal file
View file

@ -0,0 +1,47 @@
package services
import (
"path/filepath"
"lmika.dev/lmika/weiro/config"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/auth"
"lmika.dev/lmika/weiro/services/posts"
"lmika.dev/lmika/weiro/services/publisher"
"lmika.dev/lmika/weiro/services/sites"
)
type Services struct {
DB *db.Provider
Auth *auth.Service
Publisher *publisher.Publisher
PublisherQueue *publisher.Queue
Posts *posts.Service
Sites *sites.Service
}
func New(cfg config.Config) (*Services, error) {
dbp, err := db.New(filepath.Join(cfg.DataDir, "weiro.db"))
if err != nil {
return nil, err
}
authSvc := auth.New(dbp)
publisherSvc := publisher.New(dbp)
publisherQueue := publisher.NewQueue(publisherSvc)
postService := posts.New(dbp, publisherQueue)
siteService := sites.New(dbp)
return &Services{
DB: dbp,
Auth: authSvc,
Publisher: publisherSvc,
PublisherQueue: publisherQueue,
Posts: postService,
Sites: siteService,
}, nil
}
func (s *Services) Close() error {
return s.DB.Close()
}

128
services/sites/services.go Normal file
View file

@ -0,0 +1,128 @@
package sites
import (
"context"
"time"
"emperror.dev/errors"
"github.com/go-ozzo/ozzo-validation/v4"
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
)
type Service struct {
db *db.Provider
}
func New(dbp *db.Provider) *Service {
return &Service{
db: dbp,
}
}
func (s *Service) HasUsersAsSites(ctx context.Context) (bool, error) {
return s.db.HasUsersAndSites(ctx)
}
func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, error) {
sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID)
if err != nil {
return models.Site{}, err
} else if len(sites) == 0 {
return models.Site{}, errors.New("no sites found")
}
return sites[0], nil
}
type FirstRunRequest struct {
Username string `form:"username"`
Password1 string `form:"password1"`
Password2 string `form:"password2"`
SiteName string `form:"siteName"`
SiteURL string `form:"siteUrl"`
NetlifySiteID string `form:"netlifySiteId"`
NetlifyAPIKey string `form:"netlifyAPIToken"`
}
func (frr FirstRunRequest) Validate() error {
return validation.ValidateStruct(&frr,
validation.Field(&frr.Username, validation.Required, validation.Match(models.ValidUserName)),
validation.Field(&frr.Password1, validation.Required),
validation.Field(&frr.Password2, validation.Required, validation.By(stringEquals(frr.Password1))),
)
}
func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser models.User, newSite models.Site, _ error) {
if err := req.Validate(); err != nil {
return newUser, newSite, err
}
hasSite, err := s.db.HasUsersAndSites(ctx)
if err != nil {
return newUser, newSite, err
} else if hasSite {
return newUser, newSite, errors.New("user and site already exists")
}
newUser = models.User{
Username: req.Username,
TimeZone: "UTC",
Created: time.Now(),
}
newUser.SetPassword(req.Password1)
if err := s.db.SaveUser(ctx, &newUser); err != nil {
return newUser, newSite, err
}
newSite = models.Site{
Title: defaultIfEmpty(req.SiteName, "New Site"),
GUID: models.NewNanoID(),
OwnerID: newUser.ID,
Created: time.Now(),
}
if err := s.db.SaveSite(ctx, &newSite); err != nil {
return newUser, newSite, err
}
hasNetlifyConfig := req.SiteURL != "" && req.NetlifySiteID != "" && req.NetlifyAPIKey != ""
if hasNetlifyConfig {
target := models.SitePublishTarget{
SiteID: newSite.ID,
Enabled: true,
GUID: models.NewNanoID(),
BaseURL: req.SiteURL,
TargetType: "netlify",
TargetRef: req.NetlifySiteID,
TargetKey: req.NetlifyAPIKey,
}
if err := s.db.SavePublishTarget(ctx, &target); err != nil {
return newUser, newSite, err
}
}
return newUser, newSite, nil
}
func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, error) {
user, ok := models.GetUser(ctx)
if !ok {
return models.Site{}, models.UserRequiredError
}
site, err := s.db.SelectSiteByID(ctx, siteID)
if err != nil {
return models.Site{}, err
}
if site.OwnerID != user.ID {
return models.Site{}, fiber.ErrForbidden
}
return site, nil
}
func (s *Service) ListAllSitesWithOwners(ctx context.Context) ([]db.SiteWithOwner, error) {
return s.db.SelectAllSitesWithOwners(ctx)
}

23
services/sites/utils.go Normal file
View file

@ -0,0 +1,23 @@
package sites
import (
"emperror.dev/errors"
validation "github.com/go-ozzo/ozzo-validation/v4"
)
func stringEquals(str string) validation.RuleFunc {
return func(value interface{}) error {
s, _ := value.(string)
if s != str {
return errors.New("unexpected string")
}
return nil
}
}
func defaultIfEmpty(value string, defaultValue string) string {
if value == "" {
return defaultValue
}
return value
}

View file

@ -1,5 +1,12 @@
-- name: SelectPostsOfSite :many
SELECT * FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10;
SELECT *
FROM posts
WHERE site_id = ? AND (
CASE CAST (sqlc.arg(post_filter) AS TEXT)
WHEN 'deleted' THEN deleted_at > 0
ELSE deleted_at = 0
END
) ORDER BY created_at DESC LIMIT 10;
-- name: SelectPost :one
SELECT * FROM posts WHERE id = ? LIMIT 1;
@ -10,19 +17,34 @@ SELECT * FROM posts WHERE guid = ? LIMIT 1;
-- name: InsertPost :one
INSERT INTO posts (
site_id,
state,
guid,
title,
body,
slug,
created_at,
published_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
updated_at,
published_at,
deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id;
-- name: UpdatePost :exec
UPDATE posts SET
title = ?,
state = ?,
body = ?,
slug = ?,
published_at = ?
updated_at = ?,
published_at = ?,
deleted_at = ?
WHERE id = ?;
-- name: SoftDeletePost :exec
UPDATE posts SET deleted_at = ? WHERE id = ?;
-- name: RestorePost :exec
UPDATE posts SET deleted_at = 0 WHERE id = ?;
-- name: HardDeletePost :exec
DELETE FROM posts WHERE id = ?;

View file

@ -4,10 +4,11 @@ SELECT * FROM publish_targets WHERE site_id = ?;
-- name: InsertPublishTarget :one
INSERT INTO publish_targets (
site_id,
guid,
target_type,
enabled,
base_url,
target_ref,
target_key
) VALUES (?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id;

View file

@ -1,13 +1,27 @@
-- name: SelectSitesOwnedByUser :many
SELECT * FROM sites WHERE owner_id = ?;
SELECT * FROM sites WHERE owner_id = ? ORDER BY title ASC;
-- name: SelectSiteByID :one
SELECT * FROM sites WHERE id = ?;
-- name: SelectSiteByGUID :one
SELECT * FROM sites WHERE guid = ?;
-- name: InsertSite :one
INSERT INTO sites (
owner_id,
guid,
title,
tagline
) VALUES (?, ?, ?)
tagline,
created_at
) VALUES (?, ?, ?, ?, ?)
RETURNING id;
-- name: HasUsersAndSites :one
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites;
-- name: SelectAllSitesWithOwners :many
SELECT s.id, s.guid, s.title, s.owner_id, u.username
FROM sites s
JOIN users u ON s.owner_id = u.id
ORDER BY s.title ASC;

View file

@ -1,8 +1,11 @@
-- name: SelectUserByUsername :one
SELECT * FROM users WHERE username = ?;
SELECT * FROM users WHERE username = ? LIMIT 1;
-- name: SelectUserByID :one
SELECT * FROM users WHERE id = ? LIMIT 1;
-- name: InsertUser :one
INSERT INTO users (username, password) VALUES (?, ?) RETURNING id;
INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id;
-- name: UpdateUser :exec
UPDATE users SET username = ?, password = ? WHERE id = ?;

View file

@ -1,23 +1,28 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
password TEXT NOT NULL
password TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX idx_users_username ON users (username);
CREATE TABLE sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
guid TEXT NOT NULL,
title TEXT NOT NULL,
tagline TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE INDEX idx_site_owner ON sites (owner_id);
CREATE UNIQUE INDEX idx_site_guid ON sites (guid);
CREATE TABLE publish_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL,
guid TEXT NOT NULL,
target_type TEXT NOT NULL,
enabled INT NOT NULL,
base_url TEXT NOT NULL,
@ -25,16 +30,20 @@ CREATE TABLE publish_targets (
target_key TEXT NOT NULL
);
CREATE INDEX idx_publish_targets_site ON publish_targets (site_id);
CREATE UNIQUE INDEX idx_publish_targets_guid ON publish_targets (guid);
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL,
state INTEGER NOT NULL,
guid TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
slug TEXT NOT NULL,
created_at INTEGER NOT NULL,
published_at INTEGER NOT NULL
updated_at INTEGER NOT NULL,
published_at INTEGER NOT NULL,
deleted_at INTEGER NOT NULL
);
CREATE INDEX idx_post_site ON posts (site_id);
CREATE UNIQUE INDEX idx_post_guid ON posts (guid);

View file

@ -1,6 +1,6 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="#">Weiro</a>
<a class="navbar-brand" href="/">Weiro</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
@ -8,7 +8,7 @@
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Posts</a>
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/posts">Posts</a>
</li>
</ul>
<form class="d-flex align-items-center" role="search">
@ -18,8 +18,17 @@
</div>
-->
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search"/>
<button class="btn btn-outline-success" type="submit">Search</button>
<div class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ .user.Username }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" data-controller="logout" data-action="logout#logout">Logout</a></li>
</ul>
</div>
</form>
</div>
</div>

8
views/_common/toast.html Normal file
View file

@ -0,0 +1,8 @@
<div class="toast position-fixed bottom-0 end-0" role="alert" aria-live="assertive" aria-atomic="true"
data-controller="toast" data-action="weiroToast@window->toast#showToast">
<div class="toast-header">
<strong class="me-auto" data-toast-target="title">Title</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body" data-toast-target="body">Body</div>
</div>

View file

@ -0,0 +1,52 @@
<div class="mx-auto p-2" style="width: 400px; margin-block-start: 50px;" data-controller="first-run">
<div class="text-center mb-4">
<h4>Welcome to</h4>
<h1>Weiro</h1>
</div>
<form action="/first-run" method="post">
<div data-first-run-target="pages needs-validation">
<div class="text-center mb-4">
<p>Please enter the username and password you'd like to use to login.</p>
</div>
<div class="mb-2">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" name="username" id="username">
</div>
<div class="mb-3">
<label for="password1" class="form-label">Password</label>
<input type="password" class="form-control" name="password1" id="password1">
</div>
<div class="mb-3">
<label for="password2" class="form-label">Re-enter password</label>
<input type="password" class="form-control" name="password2" id="password2">
</div>
<div class="mb-3 text-end">
<button class="btn btn-primary" value="Next" data-action="click->first-run#nextPage">Next »</button>
</div>
</div>
<div data-first-run-target="pages">
<div class="text-center mb-4">
<p>Enter the details of your blog, if you know them.<br>All fields are optional, and can be changed later.</p>
</div>
<div class="mb-2">
<label for="siteName" class="form-label">Site Name</label>
<input type="text" class="form-control" name="siteName" id="siteName">
</div>
<div class="mb-3">
<label for="siteUrl" class="form-label">Site URL</label>
<input type="text" class="form-control" name="siteUrl" id="siteUrl">
</div>
<div class="mb-3">
<label for="netlifySiteId" class="form-label">Netlify Site ID</label>
<input type="text" class="form-control" name="netlifySiteId" id="netlifySiteId">
</div>
<div class="mb-3">
<label for="netlifyAPIToken" class="form-label">Netlify API Token</label>
<input type="text" class="form-control" name="netlifyAPIToken" id="netlifyAPIToken">
</div>
<div class="mb-3 text-end">
<input type="submit" class="btn btn-primary" value="Finish">
</div>
</div>
</form>
</div>

12
views/layouts/bare.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Weiro</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/assets/main.css">
</head>
<body class="min-vh-100 d-flex flex-column">
{{ embed }}
</body>
</html>

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Weiro</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/assets/main.css">
<script src="/static/assets/main.js" type="module"></script>
</head>
<body class="min-vh-100 d-flex flex-column">
{{ embed }}
</body>
</html>

View file

@ -2,13 +2,16 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<title>Weiro</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/assets/main.css">
<script src="/static/assets/main.js" type="module"></script>
</head>
<body class="min-vh-100 d-flex flex-column">
{{ template "_common/nav" . }}
{{ embed }}
{{ template "_common/toast" . }}
</body>
</html>

19
views/login/login.html Normal file
View file

@ -0,0 +1,19 @@
<div class="mx-auto p-2" style="width: 400px; margin-block-start: 100px;">
<div class="text-center mb-3">
<h3>Weiro Login</h3>
</div>
<form action="/login" method="post">
<input type="hidden" name="_login_challenge" value="{{ .challenge }}">
<div class="mb-2">
<label for="username" class="form-label">Login</label>
<input type="text" class="form-control" name="username" id="username">
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" name="password" id="password">
</div>
<div class="mb-3 text-end">
<input type="submit" class="btn btn-primary" value="Login">
</div>
</form>
</div>

View file

@ -1,14 +1,23 @@
{{ $isPublished := ne .post.State 1 }}
<main class="flex-grow-1 position-relative">
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2">
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2"
data-controller="postedit"
data-action="keydown.meta+s->postedit#save keydown.meta+enter->postedit#publish"
data-postedit-save-action-value="{{ if $isPublished }}Update{{ else }}Save Draft{{ end }}">
<input type="hidden" name="guid" value="{{ .post.GUID }}">
<div class="mb-2">
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
</div>
<div>
<textarea name="body" class="form-control" rows="3">{{.post.Body}}</textarea>
<textarea data-postedit-target="bodyTextEdit" name="body" class="form-control" rows="3">{{.post.Body}}</textarea>
</div>
<div>
<input type="submit" class="btn btn-primary mt-2" value="Publish">
{{ if $isPublished }}
<input type="submit" name="action" class="btn btn-primary mt-2" value="Update">
{{ else }}
<input type="submit" name="action" class="btn btn-primary mt-2" value="Publish">
<input type="submit" name="action" class="btn btn-secondary mt-2" value="Save Draft">
{{ end }}
</div>
</form>
</main>

View file

@ -1,17 +1,62 @@
{{ $showingTrash := eq .req.Filter "deleted" }}
<main class="container">
<div class="my-4">
<div class="my-4 d-flex justify-content-between align-items-baseline">
<a href="/sites/{{ .site.ID }}/posts/new" class="btn btn-success">New Post</a>
<div>
<div class="btn-group" role="group" aria-label="First group">
{{ if $showingTrash }}
<a href="/sites/{{ .site.ID }}/posts" type="button" class="btn btn-secondary" title="Trash">🗑️</a>
{{ else }}
<a href="/sites/{{ .site.ID }}/posts?filter=deleted" type="button" class="btn btn-outline-secondary" title="Trash">🗑️</a>
{{ end }}
</div>
</div>
</div>
{{ range $i, $p := .posts }}
{{ if gt $i 0 }}<hr>{{ end }}
<div data-controller="postlist"
data-postlist-site-id-value="{{ $p.SiteID }}"
data-postlist-post-id-value="{{ $p.ID }}"
data-postlist-nano-summary-value="{{ $p.NanoSummary }}">
<div class="my-4">
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
{{ $p.Body | markdown }}
<div class="mb-3">
<a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/edit"
class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Edit</a>
<div class="mb-3 d-flex align-items-center">
{{ if eq .State 1 }}
<span class="text-muted">{{ $.user.FormatTime .UpdatedAt }}</span> <span class="ms-3 badge text-primary-emphasis bg-primary-subtle border border-primary-subtle">Draft</span>
{{ else }}
<span class="text-muted">{{ $.user.FormatTime .PublishedAt }}</span>
{{ end }}
</div>
<div class="mb-3 d-flex align-items-center">
{{ if $showingTrash }}
<span>
<a href="#" data-action="click->postlist#restorePost" class="btn btn-outline-secondary btn-sm">Restore</a>
<a href="#" data-action="click->postlist#deletePost" data-postlist-hard-delete-param="true"
class="btn btn-outline-danger btn-sm">Delete</a>
</span>
{{ else }}
<span>
<a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/"
class="btn btn-outline-secondary btn-sm">Edit</a>
<a href="#" data-action="click->postlist#deletePost"
class="btn btn-outline-secondary btn-sm">Trash</a>
</span>
{{ end }}
</div>
</div>
{{ if lt $i (sub (len $.posts) 1) }}<hr>{{ end }}
</div>
{{ else }}
<div class="h4 m-3 text-center">
{{ if $showingTrash }}
<div class="position-absolute top-50 start-50 translate-middle">🗑️<br>Trash is empty.</div>
{{ else }}
<div class="position-absolute top-50 start-50 translate-middle">🌱<br>No posts yet. Better get writing!</div>
{{ end }}
</div>
{{ end }}
</main>