Compare commits

..

No commits in common. "97112d99dd9a3e79c80075c54892ca1a2ba84901" and "aef3bb6a1e53c7fd795bd07b45e98a18a5e5bcc3" have entirely different histories.

74 changed files with 294 additions and 2223 deletions

View file

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

1
.gitignore vendored
View file

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

View file

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

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

View file

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

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

View file

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

View file

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

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

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

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

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

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

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

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

@ -1,24 +0,0 @@
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();
}
}
}

View file

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

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

View file

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

View file

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

View file

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

View file

@ -1,31 +0,0 @@
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,16 +1,9 @@
import * as esbuild from 'esbuild' import * as esbuild from 'esbuild'
import {sassPlugin} from 'esbuild-sass-plugin' import {sassPlugin} from 'esbuild-sass-plugin'
await Promise.all([ await esbuild.build({
esbuild.build({
entryPoints: ['./assets/css/main.scss'], entryPoints: ['./assets/css/main.scss'],
bundle: true, bundle: true,
plugins: [sassPlugin()], plugins: [sassPlugin()],
outfile: './static/assets/main.css', 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,7 +18,6 @@ require (
github.com/Azure/go-autorest/autorest/date v0.2.0 // indirect 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/logger v0.1.0 // indirect
github.com/Azure/go-autorest/tracing v0.5.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/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
@ -37,26 +36,22 @@ require (
github.com/go-openapi/strfmt v0.19.11 // indirect github.com/go-openapi/strfmt v0.19.11 // indirect
github.com/go-openapi/swag v0.19.12 // indirect github.com/go-openapi/swag v0.19.12 // indirect
github.com/go-openapi/validate v0.20.0 // 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/go-stack/stack v1.8.0 // indirect
github.com/gofiber/fiber/v3 v3.1.0 // indirect github.com/gofiber/fiber/v3 v3.0.0 // indirect
github.com/gofiber/schema v1.7.0 // indirect github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/storage/sqlite3/v2 v2.2.3 // indirect
github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/template v1.8.3 // indirect
github.com/gofiber/template/html/v3 v3.0.2 // indirect github.com/gofiber/template/html/v3 v3.0.2 // indirect
github.com/gofiber/template/v2 v2.1.0 // indirect github.com/gofiber/template/v2 v2.1.0 // indirect
github.com/gofiber/utils/v2 v2.0.2 // indirect github.com/gofiber/utils/v2 v2.0.0 // indirect
github.com/google/uuid v1.6.0 // 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/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/compress v1.18.3 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect
github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f // indirect github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f // indirect
github.com/mailru/easyjson v0.7.6 // indirect github.com/mailru/easyjson v0.7.6 // indirect
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/mitchellh/mapstructure v1.4.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/netlify/open-api/v2 v2.49.1 // indirect github.com/netlify/open-api/v2 v2.49.1 // indirect
@ -66,19 +61,17 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rsc/goversion v1.2.0 // indirect github.com/rsc/goversion v1.2.0 // indirect
github.com/sirupsen/logrus v1.6.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/tinylib/msgp v1.6.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect
go.mongodb.org/mongo-driver v1.4.4 // indirect go.mongodb.org/mongo-driver v1.4.4 // indirect
go.uber.org/atomic v1.7.0 // indirect go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect go.uber.org/multierr v1.6.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.33.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.67.6 // indirect modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

35
go.sum
View file

@ -31,8 +31,6 @@ 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/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/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/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/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.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
@ -73,7 +71,6 @@ 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-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/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/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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -179,8 +176,6 @@ 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.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 h1:pzutNCCBZGZlE+u8HD3JZyWdc/TVbtVwlWUp8/vgUKk=
github.com/go-openapi/validate v0.20.0/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0= 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-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 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@ -212,14 +207,8 @@ 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/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 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY= 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 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= 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 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= 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= github.com/gofiber/template/html/v3 v3.0.2 h1:/Fh8UcEsB4uhf1QWNbYaAOwXxSORebJ2zXkb5tgG/TI=
@ -228,8 +217,6 @@ 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/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 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE= 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.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 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= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@ -296,8 +283,6 @@ 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/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= 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.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/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 v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
@ -317,8 +302,6 @@ 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.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= 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.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.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.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
@ -352,8 +335,6 @@ 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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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/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/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
@ -411,7 +392,6 @@ 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/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 h1:zVF4y5ciA/rw779S62bEAq4Yif1cBc/UwRkXJ2xZyT4=
github.com/rsc/goversion v1.2.0/go.mod h1:Tf/O0TQyfRvp7NelXAyfXYRKUO+LX3KNgXc8ALRUv4k= 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/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/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= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
@ -430,15 +410,10 @@ 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.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.3.1/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 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.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= 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.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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/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.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -489,7 +464,6 @@ 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 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 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-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-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@ -507,8 +481,6 @@ 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.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 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= 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-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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -563,8 +535,6 @@ 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.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 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= 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-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-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -612,8 +582,6 @@ 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.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= 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.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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@ -627,8 +595,6 @@ 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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= 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.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-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/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= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -663,7 +629,6 @@ 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 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= 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.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-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View file

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

View file

@ -1,77 +0,0 @@
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))
}

View file

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

View file

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

View file

@ -2,7 +2,6 @@ package handlers
import ( import (
"fmt" "fmt"
"log"
"strconv" "strconv"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
@ -15,32 +14,19 @@ type PostsHandler struct {
} }
func (ph PostsHandler) Index(c fiber.Ctx) error { func (ph PostsHandler) Index(c fiber.Ctx) error {
var req struct { posts, err := ph.PostService.ListPosts(c.Context())
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 { if err != nil {
return err return err
} }
return accepts(c, json(func() any {
return posts
}), html(func(c fiber.Ctx) error {
return c.Render("posts/index", fiber.Map{ return c.Render("posts/index", fiber.Map{
"req": req,
"posts": posts, "posts": posts,
}) })
}))
} }
func (ph PostsHandler) New(c fiber.Ctx) error { func (ph PostsHandler) New(c fiber.Ctx) error {
p := models.Post{ p := models.Post{
GUID: models.NewNanoID(), GUID: models.NewNanoID(),
State: models.StateDraft,
} }
return c.Render("posts/edit", fiber.Map{ return c.Render("posts/edit", fiber.Map{
@ -63,13 +49,9 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error {
return err return err
} }
return accepts(c, json(func() any {
return post
}), html(func(c fiber.Ctx) error {
return c.Render("posts/edit", fiber.Map{ return c.Render("posts/edit", fiber.Map{
"post": post, "post": post,
}) })
}))
} }
func (ph PostsHandler) Update(c fiber.Ctx) error { func (ph PostsHandler) Update(c fiber.Ctx) error {
@ -78,82 +60,10 @@ func (ph PostsHandler) Update(c fiber.Ctx) error {
return err return err
} }
post, err := ph.PostService.UpdatePost(c.Context(), req) post, err := ph.PostService.PublishPost(c.Context(), req)
if err != nil { if err != nil {
return err 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)) 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,14 +1,106 @@
package main package main
import ( import (
"os" "html"
"html/template"
"log"
"strings"
"lmika.dev/lmika/weiro/cmds" "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"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
func main() { func main() {
if err := cmds.Root().Execute(); err != nil { dbp, err := db.New("build/weiro.db")
os.Exit(1) if err != nil {
log.Fatal(err)
} }
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,4 +6,3 @@ var UserRequiredError = errors.New("user required")
var PermissionError = errors.New("permission denied") var PermissionError = errors.New("permission denied")
var NotFoundError = errors.New("not found") var NotFoundError = errors.New("not found")
var SiteRequiredError = errors.New("site required") 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" import "github.com/matoous/go-nanoid/v2"
func NewNanoID() string { func NewNanoID() string {
id, _ := gonanoid.New(16) id, _ := gonanoid.New(12)
return id return id
} }

View file

@ -8,36 +8,15 @@ import (
"unicode" "unicode"
) )
const (
StatePublished = iota
StateDraft
)
type Post struct { type Post struct {
ID int64 `json:"id"` ID int64
SiteID int64 `json:"site_id"` SiteID int64
State int `json:"state"` GUID string
GUID string `json:"guid"` Title string
Title string `json:"title"` Body string
Body string `json:"body"` Slug string
Slug string `json:"slug"` CreatedAt time.Time
CreatedAt time.Time `json:"created_at"` PublishedAt time.Time
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 { func (p *Post) BestSlug() string {
@ -67,10 +46,6 @@ func (p *Post) BestSlug() string {
return fmt.Sprintf("/%s/%s", datePart, slugPath) return fmt.Sprintf("/%s/%s", datePart, slugPath)
} }
func wordForSummary(word string) string {
return word
}
func wordForSlug(word string) string { func wordForSlug(word string) string {
var sb strings.Builder var sb strings.Builder
for _, c := range word { for _, c := range word {

View file

@ -1,7 +1,5 @@
package models package models
import "time"
type PublishTargetType int type PublishTargetType int
const ( const (
@ -10,31 +8,18 @@ const (
PublishTargetTypeNetlify PublishTargetType = 2 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 { type Site struct {
ID int64 ID int64
OwnerID int64 OwnerID int64
GUID string
Created time.Time
Title string Title string
Tagline string Tagline string
//Meta SiteMeta
//Posts []*Post
} }
type SitePublishTarget struct { type SitePublishTarget struct {
ID int64 ID int64
SiteID int64 SiteID int64
GUID string
Enabled bool Enabled bool
BaseURL string BaseURL string
@ -42,3 +27,24 @@ type SitePublishTarget struct {
TargetRef string TargetRef string
TargetKey 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,49 +1,7 @@
package models package models
import (
"regexp"
"time"
"golang.org/x/crypto/bcrypt"
)
var ValidUserName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
type User struct { type User struct {
ID int64 ID int64
Username string Username string
PasswordHashed []byte 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,7 +5,6 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@hotwired/stimulus": "^3.2.2",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"esbuild-sass-plugin": "^3.6.0" "esbuild-sass-plugin": "^3.6.0"
}, },
@ -436,12 +435,6 @@
"node": ">=18" "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": { "node_modules/@parcel/watcher": {
"version": "2.5.6", "version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",

View file

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

View file

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

View file

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

View file

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

View file

@ -7,116 +7,32 @@ package sqlgen
import ( import (
"context" "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 const insertSite = `-- name: InsertSite :one
INSERT INTO sites ( INSERT INTO sites (
owner_id, owner_id,
guid,
title, title,
tagline, tagline
created_at ) VALUES (?, ?, ?)
) VALUES (?, ?, ?, ?, ?)
RETURNING id RETURNING id
` `
type InsertSiteParams struct { type InsertSiteParams struct {
OwnerID int64 OwnerID int64
Guid string
Title string Title string
Tagline string Tagline string
CreatedAt int64
} }
func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) { func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertSite, row := q.db.QueryRowContext(ctx, insertSite, arg.OwnerID, arg.Title, arg.Tagline)
arg.OwnerID,
arg.Guid,
arg.Title,
arg.Tagline,
arg.CreatedAt,
)
var id int64 var id int64
err := row.Scan(&id) err := row.Scan(&id)
return id, err 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 const selectSiteByID = `-- name: SelectSiteByID :one
SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE id = ? SELECT id, owner_id, title, tagline FROM sites WHERE id = ?
` `
func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) { func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
@ -125,16 +41,14 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.OwnerID, &i.OwnerID,
&i.Guid,
&i.Title, &i.Title,
&i.Tagline, &i.Tagline,
&i.CreatedAt,
) )
return i, err return i, err
} }
const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many
SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE owner_id = ? ORDER BY title ASC SELECT id, owner_id, title, tagline FROM sites WHERE owner_id = ?
` `
func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) { func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) {
@ -149,10 +63,8 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.OwnerID, &i.OwnerID,
&i.Guid,
&i.Title, &i.Title,
&i.Tagline, &i.Tagline,
&i.CreatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View file

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

View file

@ -8,16 +8,8 @@ import (
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen" "lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
) )
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) ([]*models.Post, error) { func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64) ([]*models.Post, error) {
var filter = "" rows, err := db.queries.SelectPostsOfSite(ctx, siteID)
if showDeleted {
filter = "deleted"
}
rows, err := db.queries.SelectPostsOfSite(ctx, sqlgen.SelectPostsOfSiteParams{
SiteID: siteID,
PostFilter: filter,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -51,15 +43,12 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
if post.ID == 0 { if post.ID == 0 {
newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{ newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{
SiteID: post.SiteID, SiteID: post.SiteID,
State: int64(post.State),
Guid: post.GUID, Guid: post.GUID,
Title: post.Title, Title: post.Title,
Body: post.Body, Body: post.Body,
Slug: post.Slug, Slug: post.Slug,
CreatedAt: timeToInt(post.CreatedAt), CreatedAt: post.CreatedAt.Unix(),
UpdatedAt: timeToInt(post.UpdatedAt), PublishedAt: post.PublishedAt.Unix(),
PublishedAt: timeToInt(post.PublishedAt),
DeletedAt: timeToInt(post.DeletedAt),
}) })
if err != nil { if err != nil {
return err return err
@ -70,13 +59,10 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{ return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{
ID: post.ID, ID: post.ID,
State: int64(post.State),
Title: post.Title, Title: post.Title,
Body: post.Body, Body: post.Body,
Slug: post.Slug, Slug: post.Slug,
UpdatedAt: timeToInt(post.UpdatedAt), PublishedAt: post.PublishedAt.Unix(),
PublishedAt: timeToInt(post.PublishedAt),
DeletedAt: timeToInt(post.DeletedAt),
}) })
} }
@ -84,21 +70,11 @@ func dbPostToPost(row sqlgen.Post) *models.Post {
return &models.Post{ return &models.Post{
ID: row.ID, ID: row.ID,
SiteID: row.SiteID, SiteID: row.SiteID,
State: int(row.State),
GUID: row.Guid, GUID: row.Guid,
Title: row.Title, Title: row.Title,
Body: row.Body, Body: row.Body,
Slug: row.Slug, Slug: row.Slug,
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(),
PublishedAt: time.Unix(row.PublishedAt, 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,7 +3,6 @@ package db
import ( import (
"context" "context"
"database/sql" "database/sql"
"time"
"github.com/Southclaws/fault" "github.com/Southclaws/fault"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen" "lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
@ -39,18 +38,3 @@ func New(dbFile string) (*Provider, error) {
func (db *Provider) Close() error { func (db *Provider) Close() error {
return db.drvr.Close() 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) require.NoError(t, err)
assert.NotZero(t, post.ID) assert.NotZero(t, post.ID)
posts, err := p.SelectPostsOfSite(ctx, site.ID, false) posts, err := p.SelectPostsOfSite(ctx, site.ID)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, posts, 1) require.Len(t, posts, 1)
assert.Equal(t, post.ID, posts[0].ID) 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, post1))
require.NoError(t, p.SavePost(ctx, post2)) require.NoError(t, p.SavePost(ctx, post2))
posts, err := p.SelectPostsOfSite(ctx, site2.ID, false) posts, err := p.SelectPostsOfSite(ctx, site2.ID)
require.NoError(t, err) require.NoError(t, err)
require.Len(t, posts, 2) require.Len(t, posts, 2)
assert.Equal(t, "New Post", posts[0].Title) 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)) require.NoError(t, p.SaveSite(ctx, emptySite))
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false) posts, err := p.SelectPostsOfSite(ctx, emptySite.ID)
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, posts) assert.Empty(t, posts)
}) })
@ -248,7 +248,6 @@ func TestProvider_PublishTargets(t *testing.T) {
target := &models.SitePublishTarget{ target := &models.SitePublishTarget{
SiteID: site.ID, SiteID: site.ID,
TargetType: "netlify", TargetType: "netlify",
GUID: "target-001",
BaseURL: "https://example.netlify.app", BaseURL: "https://example.netlify.app",
TargetRef: "netlify-site-123", TargetRef: "netlify-site-123",
TargetKey: "secret-key", TargetKey: "secret-key",

View file

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

View file

@ -2,7 +2,6 @@ package db
import ( import (
"context" "context"
"time"
"lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen" "lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
@ -14,16 +13,12 @@ func (db *Provider) SelectSiteByID(ctx context.Context, id int64) (models.Site,
return models.Site{}, err return models.Site{}, err
} }
return dbSiteToSite(row), nil return models.Site{
} ID: row.ID,
OwnerID: row.OwnerID,
func (db *Provider) SelectSiteByGUID(ctx context.Context, guid string) (models.Site, error) { Title: row.Title,
row, err := db.queries.SelectSiteByGUID(ctx, guid) Tagline: row.Tagline,
if err != nil { }, nil
return models.Site{}, err
}
return dbSiteToSite(row), nil
} }
func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]models.Site, error) { func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]models.Site, error) {
@ -34,7 +29,12 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) (
sites := make([]models.Site, len(rows)) sites := make([]models.Site, len(rows))
for i, row := range rows { for i, row := range rows {
sites[i] = dbSiteToSite(row) sites[i] = models.Site{
ID: row.ID,
OwnerID: row.OwnerID,
Title: row.Title,
Tagline: row.Tagline,
}
} }
return sites, nil return sites, nil
} }
@ -43,10 +43,8 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
if site.ID == 0 { if site.ID == 0 {
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{ newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
OwnerID: site.OwnerID, OwnerID: site.OwnerID,
Guid: site.GUID,
Title: site.Title, Title: site.Title,
Tagline: site.Tagline, Tagline: site.Tagline,
CreatedAt: timeToInt(site.Created),
}) })
if err != nil { if err != nil {
return err return err
@ -58,49 +56,3 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
// No update query defined in sqlgen yet // No update query defined in sqlgen yet
return nil 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,7 +3,6 @@ package db
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"time"
"lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen" "lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
@ -15,16 +14,16 @@ func (db *Provider) SelectUserByUsername(ctx context.Context, username string) (
return models.User{}, err return models.User{}, err
} }
return dbUserToUser(res) pwdBytes, err := base64.StdEncoding.DecodeString(res.Password)
}
func (db *Provider) SelectUserByID(ctx context.Context, userID int64) (models.User, error) {
res, err := db.queries.SelectUserByID(ctx, userID)
if err != nil { if err != nil {
return models.User{}, err return models.User{}, err
} }
return dbUserToUser(res) return models.User{
ID: res.ID,
Username: res.Username,
PasswordHashed: pwdBytes,
}, nil
} }
func (db *Provider) SaveUser(ctx context.Context, user *models.User) error { func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
@ -34,7 +33,6 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
newID, err := db.queries.InsertUser(ctx, sqlgen.InsertUserParams{ newID, err := db.queries.InsertUser(ctx, sqlgen.InsertUserParams{
Username: user.Username, Username: user.Username,
Password: hashedPassword, Password: hashedPassword,
CreatedAt: timeToInt(user.Created),
}) })
if err != nil { if err != nil {
return err return err
@ -49,17 +47,3 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
Password: hashedPassword, 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
}

View file

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

View file

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

View file

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

View file

@ -1,44 +0,0 @@
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 // Fetch all content of site
posts, err := p.db.SelectPostsOfSite(ctx, site.ID, false) posts, err := p.db.SelectPostsOfSite(ctx, site.ID)
if err != nil { if err != nil {
return err return err
} }

View file

@ -1,47 +0,0 @@
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()
}

View file

@ -1,128 +0,0 @@
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)
}

View file

@ -1,23 +0,0 @@
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,12 +1,5 @@
-- name: SelectPostsOfSite :many -- name: SelectPostsOfSite :many
SELECT * SELECT * FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10;
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 -- name: SelectPost :one
SELECT * FROM posts WHERE id = ? LIMIT 1; SELECT * FROM posts WHERE id = ? LIMIT 1;
@ -17,34 +10,19 @@ SELECT * FROM posts WHERE guid = ? LIMIT 1;
-- name: InsertPost :one -- name: InsertPost :one
INSERT INTO posts ( INSERT INTO posts (
site_id, site_id,
state,
guid, guid,
title, title,
body, body,
slug, slug,
created_at, created_at,
updated_at, published_at
published_at, ) VALUES (?, ?, ?, ?, ?, ?, ?)
deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id; RETURNING id;
-- name: UpdatePost :exec -- name: UpdatePost :exec
UPDATE posts SET UPDATE posts SET
title = ?, title = ?,
state = ?,
body = ?, body = ?,
slug = ?, slug = ?,
updated_at = ?, published_at = ?
published_at = ?,
deleted_at = ?
WHERE id = ?; 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,11 +4,10 @@ SELECT * FROM publish_targets WHERE site_id = ?;
-- name: InsertPublishTarget :one -- name: InsertPublishTarget :one
INSERT INTO publish_targets ( INSERT INTO publish_targets (
site_id, site_id,
guid,
target_type, target_type,
enabled, enabled,
base_url, base_url,
target_ref, target_ref,
target_key target_key
) VALUES (?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
RETURNING id; RETURNING id;

View file

@ -1,27 +1,13 @@
-- name: SelectSitesOwnedByUser :many -- name: SelectSitesOwnedByUser :many
SELECT * FROM sites WHERE owner_id = ? ORDER BY title ASC; SELECT * FROM sites WHERE owner_id = ?;
-- name: SelectSiteByID :one -- name: SelectSiteByID :one
SELECT * FROM sites WHERE id = ?; SELECT * FROM sites WHERE id = ?;
-- name: SelectSiteByGUID :one
SELECT * FROM sites WHERE guid = ?;
-- name: InsertSite :one -- name: InsertSite :one
INSERT INTO sites ( INSERT INTO sites (
owner_id, owner_id,
guid,
title, title,
tagline, tagline
created_at ) VALUES (?, ?, ?)
) VALUES (?, ?, ?, ?, ?)
RETURNING id; 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,11 +1,8 @@
-- name: SelectUserByUsername :one -- name: SelectUserByUsername :one
SELECT * FROM users WHERE username = ? LIMIT 1; SELECT * FROM users WHERE username = ?;
-- name: SelectUserByID :one
SELECT * FROM users WHERE id = ? LIMIT 1;
-- name: InsertUser :one -- name: InsertUser :one
INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id; INSERT INTO users (username, password) VALUES (?, ?) RETURNING id;
-- name: UpdateUser :exec -- name: UpdateUser :exec
UPDATE users SET username = ?, password = ? WHERE id = ?; UPDATE users SET username = ?, password = ? WHERE id = ?;

View file

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

View file

@ -1,6 +1,6 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary"> <nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid"> <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"> <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> <span class="navbar-toggler-icon"></span>
@ -8,7 +8,7 @@
<div class="collapse navbar-collapse" id="navbarSupportedContent"> <div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/posts">Posts</a> <a class="nav-link active" aria-current="page" href="#">Posts</a>
</li> </li>
</ul> </ul>
<form class="d-flex align-items-center" role="search"> <form class="d-flex align-items-center" role="search">
@ -18,17 +18,8 @@
</div> </div>
--> -->
<div class="nav-item dropdown"> <input class="form-control me-2" type="search" placeholder="Search" aria-label="Search"/>
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-outline-success" type="submit">Search</button>
{{ .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> </form>
</div> </div>
</div> </div>

View file

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

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

View file

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

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

View file

@ -1,19 +0,0 @@
<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,23 +1,14 @@
{{ $isPublished := ne .post.State 1 }}
<main class="flex-grow-1 position-relative"> <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 }}"> <input type="hidden" name="guid" value="{{ .post.GUID }}">
<div class="mb-2"> <div class="mb-2">
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}"> <input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
</div> </div>
<div> <div>
<textarea data-postedit-target="bodyTextEdit" name="body" class="form-control" rows="3">{{.post.Body}}</textarea> <textarea name="body" class="form-control" rows="3">{{.post.Body}}</textarea>
</div> </div>
<div> <div>
{{ if $isPublished }} <input type="submit" class="btn btn-primary mt-2" value="Publish">
<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> </div>
</form> </form>
</main> </main>

View file

@ -1,62 +1,17 @@
{{ $showingTrash := eq .req.Filter "deleted" }}
<main class="container"> <main class="container">
<div class="my-4 d-flex justify-content-between align-items-baseline"> <div class="my-4">
<a href="/sites/{{ .site.ID }}/posts/new" class="btn btn-success">New Post</a> <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> </div>
{{ range $i, $p := .posts }} {{ range $i, $p := .posts }}
<div data-controller="postlist" {{ if gt $i 0 }}<hr>{{ end }}
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"> <div class="my-4">
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }} {{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
{{ $p.Body | markdown }} {{ $p.Body | markdown }}
<div class="mb-3">
<div class="mb-3 d-flex align-items-center"> <a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/edit"
{{ if eq .State 1 }} class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Edit</a>
<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>
<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> </div>
{{ end }} {{ end }}
</main> </main>