Compare commits
No commits in common. "97112d99dd9a3e79c80075c54892ca1a2ba84901" and "aef3bb6a1e53c7fd795bd07b45e98a18a5e5bcc3" have entirely different histories.
97112d99dd
...
aef3bb6a1e
|
|
@ -1,7 +1,6 @@
|
|||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "build/tmp"
|
||||
env_files = [".env"]
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,4 +4,3 @@ node_modules/
|
|||
static/assets/
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
.env
|
||||
|
|
|
|||
40
Dockerfile
40
Dockerfile
|
|
@ -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"]
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export function showToast(details) {
|
||||
let event = new CustomEvent('weiroToast', {
|
||||
detail: details
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
139
cmds/server.go
139
cmds/server.go
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
11
esbuild.mjs
11
esbuild.mjs
|
|
@ -1,16 +1,9 @@
|
|||
import * as esbuild from 'esbuild'
|
||||
import {sassPlugin} from 'esbuild-sass-plugin'
|
||||
|
||||
await Promise.all([
|
||||
esbuild.build({
|
||||
await esbuild.build({
|
||||
entryPoints: ['./assets/css/main.scss'],
|
||||
bundle: true,
|
||||
plugins: [sassPlugin()],
|
||||
outfile: './static/assets/main.css',
|
||||
}),
|
||||
esbuild.build({
|
||||
entryPoints: ['./assets/js/main.js'],
|
||||
bundle: true,
|
||||
outfile: './static/assets/main.js',
|
||||
})
|
||||
]);
|
||||
});
|
||||
23
go.mod
23
go.mod
|
|
@ -18,7 +18,6 @@ require (
|
|||
github.com/Azure/go-autorest/autorest/date v0.2.0 // indirect
|
||||
github.com/Azure/go-autorest/logger v0.1.0 // indirect
|
||||
github.com/Azure/go-autorest/tracing v0.5.0 // indirect
|
||||
github.com/Netflix/go-env v0.1.2 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
|
|
@ -37,26 +36,22 @@ require (
|
|||
github.com/go-openapi/strfmt v0.19.11 // indirect
|
||||
github.com/go-openapi/swag v0.19.12 // indirect
|
||||
github.com/go-openapi/validate v0.20.0 // indirect
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||
github.com/go-stack/stack v1.8.0 // indirect
|
||||
github.com/gofiber/fiber/v3 v3.1.0 // indirect
|
||||
github.com/gofiber/schema v1.7.0 // indirect
|
||||
github.com/gofiber/storage/sqlite3/v2 v2.2.3 // indirect
|
||||
github.com/gofiber/fiber/v3 v3.0.0 // indirect
|
||||
github.com/gofiber/schema v1.6.0 // indirect
|
||||
github.com/gofiber/template v1.8.3 // indirect
|
||||
github.com/gofiber/template/html/v3 v3.0.2 // indirect
|
||||
github.com/gofiber/template/v2 v2.1.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.2 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.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/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/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.33 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/netlify/open-api/v2 v2.49.1 // indirect
|
||||
|
|
@ -66,19 +61,17 @@ require (
|
|||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rsc/goversion v1.2.0 // indirect
|
||||
github.com/sirupsen/logrus v1.6.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/tinylib/msgp v1.6.3 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||
go.mongodb.org/mongo-driver v1.4.4 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/multierr v1.6.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
|
|
|||
35
go.sum
35
go.sum
|
|
@ -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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/Netflix/go-env v0.1.2 h1:0DRoLR9lECQ9Zqvkswuebm3jJ/2enaDX6Ei8/Z+EnK0=
|
||||
github.com/Netflix/go-env v0.1.2/go.mod h1:WlIhYi++8FlKNJtrop1mjXYAJMzv1f43K4MqCoh0yGE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
|
|
@ -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-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
|
|
@ -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.20.0 h1:pzutNCCBZGZlE+u8HD3JZyWdc/TVbtVwlWUp8/vgUKk=
|
||||
github.com/go-openapi/validate v0.20.0/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
|
|
@ -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/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
|
||||
github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY=
|
||||
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
|
||||
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
|
||||
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
|
||||
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
|
||||
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
|
||||
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
|
||||
github.com/gofiber/storage/sqlite3/v2 v2.2.3 h1:m3n80wUewnB5ruAV3Qq0mzIS+bwBrYYETo4N+fvBoow=
|
||||
github.com/gofiber/storage/sqlite3/v2 v2.2.3/go.mod h1:F1w9BpQtU7BD5cCjlQnFIEjWHUaAcm9Hh5fuCpfG/OE=
|
||||
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
|
||||
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
|
||||
github.com/gofiber/template/html/v3 v3.0.2 h1:/Fh8UcEsB4uhf1QWNbYaAOwXxSORebJ2zXkb5tgG/TI=
|
||||
|
|
@ -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/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
|
||||
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
|
||||
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
|
||||
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
|
|
@ -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/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
|
|
@ -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.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
|
||||
|
|
@ -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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
|
|
@ -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/rsc/goversion v1.2.0 h1:zVF4y5ciA/rw779S62bEAq4Yif1cBc/UwRkXJ2xZyT4=
|
||||
github.com/rsc/goversion v1.2.0/go.mod h1:Tf/O0TQyfRvp7NelXAyfXYRKUO+LX3KNgXc8ALRUv4k=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
|
|
@ -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.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
|
|
@ -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/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
|
@ -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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
|
|
@ -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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
|
|
@ -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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
|
|
@ -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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
@ -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/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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("/")
|
||||
}
|
||||
|
|
@ -3,14 +3,12 @@ package middleware
|
|||
import (
|
||||
"strconv"
|
||||
|
||||
"emperror.dev/errors"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"lmika.dev/lmika/weiro/models"
|
||||
"lmika.dev/lmika/weiro/providers/db"
|
||||
"lmika.dev/lmika/weiro/services/sites"
|
||||
)
|
||||
|
||||
func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error {
|
||||
func RequiresSite(db *db.Provider) func(c fiber.Ctx) error {
|
||||
return func(c fiber.Ctx) error {
|
||||
siteIDStr := c.Params("siteID")
|
||||
if siteIDStr == "" {
|
||||
|
|
@ -22,15 +20,18 @@ func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error {
|
|||
return fiber.ErrBadRequest
|
||||
}
|
||||
|
||||
site, err := sites.GetSiteByID(c.Context(), siteID)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.UserRequiredError) {
|
||||
return fiber.ErrForbidden
|
||||
} else if errors.Is(err, models.PermissionError) || db.ErrorIsNoRows(err) {
|
||||
return fiber.ErrNotFound
|
||||
} else if errors.Is(err, models.NotFoundError) || db.ErrorIsNoRows(err) {
|
||||
return err
|
||||
user, ok := models.GetUser(c.Context())
|
||||
if !ok {
|
||||
return fiber.ErrUnauthorized
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,47 +1,23 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/session"
|
||||
"lmika.dev/lmika/weiro/models"
|
||||
"lmika.dev/lmika/weiro/services/auth"
|
||||
)
|
||||
|
||||
func OptionalUser(auth *auth.Service) func(c fiber.Ctx) error {
|
||||
func AuthUser() 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.Next()
|
||||
}
|
||||
|
||||
user, err := auth.GetUser(c.Context(), userID)
|
||||
if err != nil {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
c.Locals("user", user)
|
||||
c.SetContext(models.WithUser(c.Context(), user))
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireUser(auth *auth.Service) func(c fiber.Ctx) error {
|
||||
return func(c fiber.Ctx) error {
|
||||
sess := session.FromContext(c)
|
||||
userID, _ := sess.Get("user_id").(int64)
|
||||
if userID == 0 {
|
||||
return c.Redirect().To("/login")
|
||||
}
|
||||
|
||||
user, err := auth.GetUser(c.Context(), userID)
|
||||
if err != nil {
|
||||
return c.Redirect().To("/login")
|
||||
// TEMP - Actually do the auth here
|
||||
user := models.User{
|
||||
ID: 1,
|
||||
Username: "testuser",
|
||||
}
|
||||
|
||||
c.Locals("user", user)
|
||||
c.SetContext(models.WithUser(c.Context(), user))
|
||||
log.Printf("User %s authenticated", user.Username)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package handlers
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
|
|
@ -15,32 +14,19 @@ type PostsHandler struct {
|
|||
}
|
||||
|
||||
func (ph PostsHandler) Index(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
Filter string `query:"filter"`
|
||||
}
|
||||
if err := c.Bind().Query(&req); err != nil {
|
||||
return fiber.ErrBadRequest
|
||||
}
|
||||
|
||||
posts, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted")
|
||||
posts, err := ph.PostService.ListPosts(c.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return accepts(c, json(func() any {
|
||||
return posts
|
||||
}), html(func(c fiber.Ctx) error {
|
||||
return c.Render("posts/index", fiber.Map{
|
||||
"req": req,
|
||||
"posts": posts,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func (ph PostsHandler) New(c fiber.Ctx) error {
|
||||
p := models.Post{
|
||||
GUID: models.NewNanoID(),
|
||||
State: models.StateDraft,
|
||||
}
|
||||
|
||||
return c.Render("posts/edit", fiber.Map{
|
||||
|
|
@ -63,13 +49,9 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error {
|
|||
return err
|
||||
}
|
||||
|
||||
return accepts(c, json(func() any {
|
||||
return post
|
||||
}), html(func(c fiber.Ctx) error {
|
||||
return c.Render("posts/edit", fiber.Map{
|
||||
"post": post,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func (ph PostsHandler) Update(c fiber.Ctx) error {
|
||||
|
|
@ -78,82 +60,10 @@ func (ph PostsHandler) Update(c fiber.Ctx) error {
|
|||
return err
|
||||
}
|
||||
|
||||
post, err := ph.PostService.UpdatePost(c.Context(), req)
|
||||
post, err := ph.PostService.PublishPost(c.Context(), req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return accepts(c, json(func() any {
|
||||
// TODO: should be created if brand new
|
||||
return post
|
||||
}), html(func(c fiber.Ctx) error {
|
||||
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", post.SiteID))
|
||||
}))
|
||||
}
|
||||
|
||||
func (ph PostsHandler) Patch(c fiber.Ctx) error {
|
||||
log.Println("PATCH")
|
||||
|
||||
postIDStr := c.Params("postID")
|
||||
if postIDStr == "" {
|
||||
return fiber.ErrBadRequest
|
||||
}
|
||||
postID, err := strconv.ParseInt(postIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return fiber.ErrBadRequest
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("Request")
|
||||
|
||||
switch req.Action {
|
||||
case "restore":
|
||||
if err := ph.PostService.RestorePost(c.Context(), postID); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fiber.ErrBadRequest
|
||||
}
|
||||
|
||||
return accepts(c, json(func() any {
|
||||
return struct{}{}
|
||||
}), html(func(c fiber.Ctx) error {
|
||||
|
||||
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts"))
|
||||
}))
|
||||
}
|
||||
|
||||
func (ph PostsHandler) Delete(c fiber.Ctx) error {
|
||||
postIDStr := c.Params("postID")
|
||||
if postIDStr == "" {
|
||||
return fiber.ErrBadRequest
|
||||
}
|
||||
|
||||
postID, err := strconv.ParseInt(postIDStr, 10, 64)
|
||||
if err != nil {
|
||||
return fiber.ErrBadRequest
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Hard bool `query:"hard"`
|
||||
}
|
||||
if err := c.Bind().Query(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ph.PostService.DeletePost(c.Context(), postID, req.Hard); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return accepts(c, json(func() any {
|
||||
return fiber.Map{}
|
||||
}), html(func(c fiber.Ctx) error {
|
||||
return c.Redirect().To("/sites")
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
100
main.go
100
main.go
|
|
@ -1,14 +1,106 @@
|
|||
package main
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cmds.Root().Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
dbp, err := db.New("build/weiro.db")
|
||||
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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,3 @@ var UserRequiredError = errors.New("user required")
|
|||
var PermissionError = errors.New("permission denied")
|
||||
var NotFoundError = errors.New("not found")
|
||||
var SiteRequiredError = errors.New("site required")
|
||||
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")
|
||||
|
|
|
|||
|
|
@ -3,6 +3,6 @@ package models
|
|||
import "github.com/matoous/go-nanoid/v2"
|
||||
|
||||
func NewNanoID() string {
|
||||
id, _ := gonanoid.New(16)
|
||||
id, _ := gonanoid.New(12)
|
||||
return id
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,36 +8,15 @@ import (
|
|||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
StatePublished = iota
|
||||
StateDraft
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
ID int64 `json:"id"`
|
||||
SiteID int64 `json:"site_id"`
|
||||
State int `json:"state"`
|
||||
GUID string `json:"guid"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Slug string `json:"slug"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
DeletedAt time.Time `json:"deleted_at,omitempty"`
|
||||
PublishedAt time.Time `json:"published_at,omitempty"`
|
||||
}
|
||||
|
||||
func (p *Post) NanoSummary() string {
|
||||
if p.Title != "" {
|
||||
return p.Title
|
||||
}
|
||||
firstWords := firstNWords(p.Body, 7, wordForSummary)
|
||||
if firstWords == "" {
|
||||
firstWords = "(no content)"
|
||||
} else if len(firstWords) < len(p.Body) {
|
||||
return firstWords + "..."
|
||||
}
|
||||
return firstWords
|
||||
ID int64
|
||||
SiteID int64
|
||||
GUID string
|
||||
Title string
|
||||
Body string
|
||||
Slug string
|
||||
CreatedAt time.Time
|
||||
PublishedAt time.Time
|
||||
}
|
||||
|
||||
func (p *Post) BestSlug() string {
|
||||
|
|
@ -67,10 +46,6 @@ func (p *Post) BestSlug() string {
|
|||
return fmt.Sprintf("/%s/%s", datePart, slugPath)
|
||||
}
|
||||
|
||||
func wordForSummary(word string) string {
|
||||
return word
|
||||
}
|
||||
|
||||
func wordForSlug(word string) string {
|
||||
var sb strings.Builder
|
||||
for _, c := range word {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type PublishTargetType int
|
||||
|
||||
const (
|
||||
|
|
@ -10,31 +8,18 @@ const (
|
|||
PublishTargetTypeNetlify PublishTargetType = 2
|
||||
)
|
||||
|
||||
func ParsePublishTargetType(s string) (PublishTargetType, error) {
|
||||
switch s {
|
||||
case "localfs":
|
||||
return PublishTargetTypeLocalFS, nil
|
||||
case "netlify":
|
||||
return PublishTargetTypeNetlify, nil
|
||||
default:
|
||||
return PublishTargetTypeNone, nil
|
||||
}
|
||||
}
|
||||
|
||||
type Site struct {
|
||||
ID int64
|
||||
OwnerID int64
|
||||
GUID string
|
||||
Created time.Time
|
||||
|
||||
Title string
|
||||
Tagline string
|
||||
//Meta SiteMeta
|
||||
//Posts []*Post
|
||||
}
|
||||
|
||||
type SitePublishTarget struct {
|
||||
ID int64
|
||||
SiteID int64
|
||||
GUID string
|
||||
Enabled bool
|
||||
|
||||
BaseURL string
|
||||
|
|
@ -42,3 +27,24 @@ type SitePublishTarget struct {
|
|||
TargetRef string
|
||||
TargetKey string
|
||||
}
|
||||
|
||||
/*
|
||||
type SiteMeta struct {
|
||||
Title string `yaml:"title"`
|
||||
Tagline string `yaml:"tagline"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
}
|
||||
|
||||
type PostMeta struct {
|
||||
ID string `yaml:"id"`
|
||||
Title string `yaml:"title"`
|
||||
Date time.Time `yaml:"date"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Slug string `yaml:"slug"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
Meta PostMeta
|
||||
Content string
|
||||
}
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,49 +1,7 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var ValidUserName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHashed []byte
|
||||
TimeZone string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
func (u *User) SetPassword(pwd string) {
|
||||
bcrypted, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
|
||||
u.PasswordHashed = bcrypted
|
||||
}
|
||||
|
||||
func (u User) CheckPassword(pwd string) bool {
|
||||
err := bcrypt.CompareHashAndPassword(u.PasswordHashed, []byte(pwd))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (u User) FormatTime(t time.Time) string {
|
||||
if loc := getLocation(u.TimeZone); loc != nil {
|
||||
return t.In(loc).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
var loadedLocation = map[string]*time.Location{}
|
||||
|
||||
func getLocation(tz string) *time.Location {
|
||||
if loc, ok := loadedLocation[tz]; ok {
|
||||
return loc
|
||||
}
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
loc = time.Local
|
||||
}
|
||||
loadedLocation[tz] = loc
|
||||
return loc
|
||||
}
|
||||
|
|
|
|||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -5,7 +5,6 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"bootstrap": "^5.3.8",
|
||||
"esbuild-sass-plugin": "^3.6.0"
|
||||
},
|
||||
|
|
@ -436,12 +435,6 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@hotwired/stimulus": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
|
||||
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
"esbuild": "0.27.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"bootstrap": "^5.3.8",
|
||||
"esbuild-sass-plugin": "^3.6.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,21 +7,17 @@ package sqlgen
|
|||
type Post struct {
|
||||
ID int64
|
||||
SiteID int64
|
||||
State int64
|
||||
Guid string
|
||||
Title string
|
||||
Body string
|
||||
Slug string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
PublishedAt int64
|
||||
DeletedAt int64
|
||||
}
|
||||
|
||||
type PublishTarget struct {
|
||||
ID int64
|
||||
SiteID int64
|
||||
Guid string
|
||||
TargetType string
|
||||
Enabled int64
|
||||
BaseUrl string
|
||||
|
|
@ -32,15 +28,12 @@ type PublishTarget struct {
|
|||
type Site struct {
|
||||
ID int64
|
||||
OwnerID int64
|
||||
Guid string
|
||||
Title string
|
||||
Tagline string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
Password string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,73 +9,46 @@ import (
|
|||
"context"
|
||||
)
|
||||
|
||||
const hardDeletePost = `-- name: HardDeletePost :exec
|
||||
DELETE FROM posts WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) HardDeletePost(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, hardDeletePost, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertPost = `-- name: InsertPost :one
|
||||
INSERT INTO posts (
|
||||
site_id,
|
||||
state,
|
||||
guid,
|
||||
title,
|
||||
body,
|
||||
slug,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at,
|
||||
deleted_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
published_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type InsertPostParams struct {
|
||||
SiteID int64
|
||||
State int64
|
||||
Guid string
|
||||
Title string
|
||||
Body string
|
||||
Slug string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
PublishedAt int64
|
||||
DeletedAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertPost,
|
||||
arg.SiteID,
|
||||
arg.State,
|
||||
arg.Guid,
|
||||
arg.Title,
|
||||
arg.Body,
|
||||
arg.Slug,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
arg.PublishedAt,
|
||||
arg.DeletedAt,
|
||||
)
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const restorePost = `-- name: RestorePost :exec
|
||||
UPDATE posts SET deleted_at = 0 WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) RestorePost(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, restorePost, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const selectPost = `-- name: SelectPost :one
|
||||
SELECT id, site_id, 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) {
|
||||
|
|
@ -84,21 +57,18 @@ func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) {
|
|||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SiteID,
|
||||
&i.State,
|
||||
&i.Guid,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Slug,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishedAt,
|
||||
&i.DeletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const selectPostByGUID = `-- name: SelectPostByGUID :one
|
||||
SELECT id, site_id, 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) {
|
||||
|
|
@ -107,37 +77,22 @@ func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, erro
|
|||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SiteID,
|
||||
&i.State,
|
||||
&i.Guid,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Slug,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishedAt,
|
||||
&i.DeletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const selectPostsOfSite = `-- name: SelectPostsOfSite :many
|
||||
SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at
|
||||
FROM posts
|
||||
WHERE site_id = ? AND (
|
||||
CASE CAST (?2 AS TEXT)
|
||||
WHEN 'deleted' THEN deleted_at > 0
|
||||
ELSE deleted_at = 0
|
||||
END
|
||||
) ORDER BY created_at DESC LIMIT 10
|
||||
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10
|
||||
`
|
||||
|
||||
type SelectPostsOfSiteParams struct {
|
||||
SiteID int64
|
||||
PostFilter string
|
||||
}
|
||||
|
||||
func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSiteParams) ([]Post, error) {
|
||||
rows, err := q.db.QueryContext(ctx, selectPostsOfSite, arg.SiteID, arg.PostFilter)
|
||||
func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post, error) {
|
||||
rows, err := q.db.QueryContext(ctx, selectPostsOfSite, siteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -148,15 +103,12 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSitePa
|
|||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SiteID,
|
||||
&i.State,
|
||||
&i.Guid,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Slug,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishedAt,
|
||||
&i.DeletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -171,52 +123,29 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSitePa
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const softDeletePost = `-- name: SoftDeletePost :exec
|
||||
UPDATE posts SET deleted_at = ? WHERE id = ?
|
||||
`
|
||||
|
||||
type SoftDeletePostParams struct {
|
||||
DeletedAt int64
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) SoftDeletePost(ctx context.Context, arg SoftDeletePostParams) error {
|
||||
_, err := q.db.ExecContext(ctx, softDeletePost, arg.DeletedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updatePost = `-- name: UpdatePost :exec
|
||||
UPDATE posts SET
|
||||
title = ?,
|
||||
state = ?,
|
||||
body = ?,
|
||||
slug = ?,
|
||||
updated_at = ?,
|
||||
published_at = ?,
|
||||
deleted_at = ?
|
||||
published_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdatePostParams struct {
|
||||
Title string
|
||||
State int64
|
||||
Body string
|
||||
Slug string
|
||||
UpdatedAt int64
|
||||
PublishedAt int64
|
||||
DeletedAt int64
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updatePost,
|
||||
arg.Title,
|
||||
arg.State,
|
||||
arg.Body,
|
||||
arg.Slug,
|
||||
arg.UpdatedAt,
|
||||
arg.PublishedAt,
|
||||
arg.DeletedAt,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -12,19 +12,17 @@ import (
|
|||
const insertPublishTarget = `-- name: InsertPublishTarget :one
|
||||
INSERT INTO publish_targets (
|
||||
site_id,
|
||||
guid,
|
||||
target_type,
|
||||
enabled,
|
||||
base_url,
|
||||
target_ref,
|
||||
target_key
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type InsertPublishTargetParams struct {
|
||||
SiteID int64
|
||||
Guid string
|
||||
TargetType string
|
||||
Enabled int64
|
||||
BaseUrl string
|
||||
|
|
@ -35,7 +33,6 @@ type InsertPublishTargetParams struct {
|
|||
func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTargetParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertPublishTarget,
|
||||
arg.SiteID,
|
||||
arg.Guid,
|
||||
arg.TargetType,
|
||||
arg.Enabled,
|
||||
arg.BaseUrl,
|
||||
|
|
@ -48,7 +45,7 @@ func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTarg
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -63,7 +60,6 @@ func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64)
|
|||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SiteID,
|
||||
&i.Guid,
|
||||
&i.TargetType,
|
||||
&i.Enabled,
|
||||
&i.BaseUrl,
|
||||
|
|
|
|||
|
|
@ -7,116 +7,32 @@ package sqlgen
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const hasUsersAndSites = `-- name: HasUsersAndSites :one
|
||||
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites
|
||||
`
|
||||
|
||||
func (q *Queries) HasUsersAndSites(ctx context.Context) (sql.NullBool, error) {
|
||||
row := q.db.QueryRowContext(ctx, hasUsersAndSites)
|
||||
var has_users_and_sites sql.NullBool
|
||||
err := row.Scan(&has_users_and_sites)
|
||||
return has_users_and_sites, err
|
||||
}
|
||||
|
||||
const insertSite = `-- name: InsertSite :one
|
||||
INSERT INTO sites (
|
||||
owner_id,
|
||||
guid,
|
||||
title,
|
||||
tagline,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
tagline
|
||||
) VALUES (?, ?, ?)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type InsertSiteParams struct {
|
||||
OwnerID int64
|
||||
Guid string
|
||||
Title string
|
||||
Tagline string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertSite,
|
||||
arg.OwnerID,
|
||||
arg.Guid,
|
||||
arg.Title,
|
||||
arg.Tagline,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
row := q.db.QueryRowContext(ctx, insertSite, arg.OwnerID, arg.Title, arg.Tagline)
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const selectAllSitesWithOwners = `-- name: SelectAllSitesWithOwners :many
|
||||
SELECT s.id, s.guid, s.title, s.owner_id, u.username
|
||||
FROM sites s
|
||||
JOIN users u ON s.owner_id = u.id
|
||||
ORDER BY s.title ASC
|
||||
`
|
||||
|
||||
type SelectAllSitesWithOwnersRow struct {
|
||||
ID int64
|
||||
Guid string
|
||||
Title string
|
||||
OwnerID int64
|
||||
Username string
|
||||
}
|
||||
|
||||
func (q *Queries) SelectAllSitesWithOwners(ctx context.Context) ([]SelectAllSitesWithOwnersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, selectAllSitesWithOwners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []SelectAllSitesWithOwnersRow
|
||||
for rows.Next() {
|
||||
var i SelectAllSitesWithOwnersRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Guid,
|
||||
&i.Title,
|
||||
&i.OwnerID,
|
||||
&i.Username,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const selectSiteByGUID = `-- name: SelectSiteByGUID :one
|
||||
SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE guid = ?
|
||||
`
|
||||
|
||||
func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, error) {
|
||||
row := q.db.QueryRowContext(ctx, selectSiteByGUID, guid)
|
||||
var i Site
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.OwnerID,
|
||||
&i.Guid,
|
||||
&i.Title,
|
||||
&i.Tagline,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const selectSiteByID = `-- name: SelectSiteByID :one
|
||||
SELECT id, owner_id, 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) {
|
||||
|
|
@ -125,16 +41,14 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
|
|||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.OwnerID,
|
||||
&i.Guid,
|
||||
&i.Title,
|
||||
&i.Tagline,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many
|
||||
SELECT id, owner_id, 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) {
|
||||
|
|
@ -149,10 +63,8 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]
|
|||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.OwnerID,
|
||||
&i.Guid,
|
||||
&i.Title,
|
||||
&i.Tagline,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,51 +10,29 @@ import (
|
|||
)
|
||||
|
||||
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 {
|
||||
Username string
|
||||
Password string
|
||||
CreatedAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password, arg.CreatedAt)
|
||||
row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password)
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
const selectUserByID = `-- name: SelectUserByID :one
|
||||
SELECT id, username, password, created_at FROM users WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) SelectUserByID(ctx context.Context, id int64) (User, error) {
|
||||
row := q.db.QueryRowContext(ctx, selectUserByID, id)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.Password,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const selectUserByUsername = `-- name: SelectUserByUsername :one
|
||||
SELECT id, username, password, 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) {
|
||||
row := q.db.QueryRowContext(ctx, selectUserByUsername, username)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.Password,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
err := row.Scan(&i.ID, &i.Username, &i.Password)
|
||||
return i, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,16 +8,8 @@ import (
|
|||
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||||
)
|
||||
|
||||
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) ([]*models.Post, error) {
|
||||
var filter = ""
|
||||
if showDeleted {
|
||||
filter = "deleted"
|
||||
}
|
||||
|
||||
rows, err := db.queries.SelectPostsOfSite(ctx, sqlgen.SelectPostsOfSiteParams{
|
||||
SiteID: siteID,
|
||||
PostFilter: filter,
|
||||
})
|
||||
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64) ([]*models.Post, error) {
|
||||
rows, err := db.queries.SelectPostsOfSite(ctx, siteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -51,15 +43,12 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
|
|||
if post.ID == 0 {
|
||||
newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{
|
||||
SiteID: post.SiteID,
|
||||
State: int64(post.State),
|
||||
Guid: post.GUID,
|
||||
Title: post.Title,
|
||||
Body: post.Body,
|
||||
Slug: post.Slug,
|
||||
CreatedAt: timeToInt(post.CreatedAt),
|
||||
UpdatedAt: timeToInt(post.UpdatedAt),
|
||||
PublishedAt: timeToInt(post.PublishedAt),
|
||||
DeletedAt: timeToInt(post.DeletedAt),
|
||||
CreatedAt: post.CreatedAt.Unix(),
|
||||
PublishedAt: post.PublishedAt.Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -70,13 +59,10 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
|
|||
|
||||
return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{
|
||||
ID: post.ID,
|
||||
State: int64(post.State),
|
||||
Title: post.Title,
|
||||
Body: post.Body,
|
||||
Slug: post.Slug,
|
||||
UpdatedAt: timeToInt(post.UpdatedAt),
|
||||
PublishedAt: timeToInt(post.PublishedAt),
|
||||
DeletedAt: timeToInt(post.DeletedAt),
|
||||
PublishedAt: post.PublishedAt.Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -84,21 +70,11 @@ func dbPostToPost(row sqlgen.Post) *models.Post {
|
|||
return &models.Post{
|
||||
ID: row.ID,
|
||||
SiteID: row.SiteID,
|
||||
State: int(row.State),
|
||||
GUID: row.Guid,
|
||||
Title: row.Title,
|
||||
Body: row.Body,
|
||||
Slug: row.Slug,
|
||||
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
|
||||
UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(),
|
||||
PublishedAt: time.Unix(row.PublishedAt, 0).UTC(),
|
||||
DeletedAt: time.Unix(row.DeletedAt, 0).UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func timeToInt(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package db
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/Southclaws/fault"
|
||||
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||||
|
|
@ -39,18 +38,3 @@ func New(dbFile string) (*Provider, error) {
|
|||
func (db *Provider) Close() error {
|
||||
return db.drvr.Close()
|
||||
}
|
||||
|
||||
func (db *Provider) SoftDeletePost(ctx context.Context, postID int64) error {
|
||||
return db.queries.SoftDeletePost(ctx, sqlgen.SoftDeletePostParams{
|
||||
DeletedAt: time.Now().Unix(),
|
||||
ID: postID,
|
||||
})
|
||||
}
|
||||
|
||||
func (db *Provider) HardDeletePost(ctx context.Context, postID int64) error {
|
||||
return db.queries.HardDeletePost(ctx, postID)
|
||||
}
|
||||
|
||||
func (db *Provider) RestorePost(ctx context.Context, postID int64) error {
|
||||
return db.queries.RestorePost(ctx, postID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ func TestProvider_Posts(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.NotZero(t, post.ID)
|
||||
|
||||
posts, err := p.SelectPostsOfSite(ctx, site.ID, false)
|
||||
posts, err := p.SelectPostsOfSite(ctx, site.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, posts, 1)
|
||||
assert.Equal(t, post.ID, posts[0].ID)
|
||||
|
|
@ -205,7 +205,7 @@ func TestProvider_Posts(t *testing.T) {
|
|||
require.NoError(t, p.SavePost(ctx, post1))
|
||||
require.NoError(t, p.SavePost(ctx, post2))
|
||||
|
||||
posts, err := p.SelectPostsOfSite(ctx, site2.ID, false)
|
||||
posts, err := p.SelectPostsOfSite(ctx, site2.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, posts, 2)
|
||||
assert.Equal(t, "New Post", posts[0].Title)
|
||||
|
|
@ -220,7 +220,7 @@ func TestProvider_Posts(t *testing.T) {
|
|||
}
|
||||
require.NoError(t, p.SaveSite(ctx, emptySite))
|
||||
|
||||
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false)
|
||||
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, posts)
|
||||
})
|
||||
|
|
@ -248,7 +248,6 @@ func TestProvider_PublishTargets(t *testing.T) {
|
|||
target := &models.SitePublishTarget{
|
||||
SiteID: site.ID,
|
||||
TargetType: "netlify",
|
||||
GUID: "target-001",
|
||||
BaseURL: "https://example.netlify.app",
|
||||
TargetRef: "netlify-site-123",
|
||||
TargetKey: "secret-key",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ func (db *Provider) SelectPublishTargetsOfSite(ctx context.Context, siteID int64
|
|||
targets[i] = models.SitePublishTarget{
|
||||
ID: row.ID,
|
||||
SiteID: row.SiteID,
|
||||
GUID: row.Guid,
|
||||
Enabled: row.Enabled != 0,
|
||||
TargetType: row.TargetType,
|
||||
BaseURL: row.BaseUrl,
|
||||
|
|
@ -39,7 +38,6 @@ func (db *Provider) SavePublishTarget(ctx context.Context, target *models.SitePu
|
|||
newID, err := db.queries.InsertPublishTarget(ctx, sqlgen.InsertPublishTargetParams{
|
||||
SiteID: target.SiteID,
|
||||
TargetType: target.TargetType,
|
||||
Guid: target.GUID,
|
||||
Enabled: enabled,
|
||||
BaseUrl: target.BaseURL,
|
||||
TargetRef: target.TargetRef,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package db
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"lmika.dev/lmika/weiro/models"
|
||||
"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 dbSiteToSite(row), nil
|
||||
}
|
||||
|
||||
func (db *Provider) SelectSiteByGUID(ctx context.Context, guid string) (models.Site, error) {
|
||||
row, err := db.queries.SelectSiteByGUID(ctx, guid)
|
||||
if err != nil {
|
||||
return models.Site{}, err
|
||||
}
|
||||
|
||||
return dbSiteToSite(row), nil
|
||||
return models.Site{
|
||||
ID: row.ID,
|
||||
OwnerID: row.OwnerID,
|
||||
Title: row.Title,
|
||||
Tagline: row.Tagline,
|
||||
}, nil
|
||||
}
|
||||
|
||||
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))
|
||||
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
|
||||
}
|
||||
|
|
@ -43,10 +43,8 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
|
|||
if site.ID == 0 {
|
||||
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
|
||||
OwnerID: site.OwnerID,
|
||||
Guid: site.GUID,
|
||||
Title: site.Title,
|
||||
Tagline: site.Tagline,
|
||||
CreatedAt: timeToInt(site.Created),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -58,49 +56,3 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
|
|||
// No update query defined in sqlgen yet
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *Provider) HasUsersAndSites(ctx context.Context) (bool, error) {
|
||||
nullBool, err := db.queries.HasUsersAndSites(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return nullBool.Valid && nullBool.Bool, nil
|
||||
}
|
||||
|
||||
type SiteWithOwner struct {
|
||||
ID int64
|
||||
GUID string
|
||||
Title string
|
||||
OwnerID int64
|
||||
Username string
|
||||
}
|
||||
|
||||
func (db *Provider) SelectAllSitesWithOwners(ctx context.Context) ([]SiteWithOwner, error) {
|
||||
rows, err := db.queries.SelectAllSitesWithOwners(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sites := make([]SiteWithOwner, len(rows))
|
||||
for i, row := range rows {
|
||||
sites[i] = SiteWithOwner{
|
||||
ID: row.ID,
|
||||
GUID: row.Guid,
|
||||
Title: row.Title,
|
||||
OwnerID: row.OwnerID,
|
||||
Username: row.Username,
|
||||
}
|
||||
}
|
||||
return sites, nil
|
||||
}
|
||||
|
||||
func dbSiteToSite(row sqlgen.Site) models.Site {
|
||||
return models.Site{
|
||||
ID: row.ID,
|
||||
OwnerID: row.OwnerID,
|
||||
GUID: row.Guid,
|
||||
Title: row.Title,
|
||||
Tagline: row.Tagline,
|
||||
Created: time.Unix(row.CreatedAt, 0).UTC(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package db
|
|||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"time"
|
||||
|
||||
"lmika.dev/lmika/weiro/models"
|
||||
"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 dbUserToUser(res)
|
||||
}
|
||||
|
||||
func (db *Provider) SelectUserByID(ctx context.Context, userID int64) (models.User, error) {
|
||||
res, err := db.queries.SelectUserByID(ctx, userID)
|
||||
pwdBytes, err := base64.StdEncoding.DecodeString(res.Password)
|
||||
if err != nil {
|
||||
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 {
|
||||
|
|
@ -34,7 +33,6 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
|
|||
newID, err := db.queries.InsertUser(ctx, sqlgen.InsertUserParams{
|
||||
Username: user.Username,
|
||||
Password: hashedPassword,
|
||||
CreatedAt: timeToInt(user.Created),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -49,17 +47,3 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
|
|||
Password: hashedPassword,
|
||||
})
|
||||
}
|
||||
|
||||
func dbUserToUser(res sqlgen.User) (models.User, error) {
|
||||
pwdBytes, err := base64.StdEncoding.DecodeString(res.Password)
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
|
||||
return models.User{
|
||||
ID: res.ID,
|
||||
Username: res.Username,
|
||||
PasswordHashed: pwdBytes,
|
||||
Created: time.Unix(res.CreatedAt, 0).UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ package posts
|
|||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lmika.dev/lmika/weiro/models"
|
||||
|
|
@ -13,10 +12,9 @@ type CreatePostParams struct {
|
|||
GUID string `form:"guid" json:"guid"`
|
||||
Title string `form:"title" json:"title"`
|
||||
Body string `form:"body" json:"body"`
|
||||
Action string `form:"action" json:"action"`
|
||||
}
|
||||
|
||||
func (s *Service) 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)
|
||||
if !ok {
|
||||
return nil, models.SiteRequiredError
|
||||
|
|
@ -29,27 +27,16 @@ func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*mod
|
|||
|
||||
post.Title = params.Title
|
||||
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()
|
||||
case "save draft":
|
||||
post.State = models.StateDraft
|
||||
post.PublishedAt = time.Time{}
|
||||
default:
|
||||
// Leave unchanged
|
||||
}
|
||||
post.Slug = post.BestSlug()
|
||||
|
||||
if err := s.db.SavePost(ctx, post); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if oldState != post.State || post.State == models.StatePublished {
|
||||
s.publisher.Queue(site)
|
||||
// TODO: do on separate thread
|
||||
if err := s.publisher.Publish(ctx, site); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return post, nil
|
||||
|
|
@ -72,7 +59,6 @@ func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, param
|
|||
GUID: params.GUID,
|
||||
Title: params.Title,
|
||||
Body: params.Body,
|
||||
State: models.StateDraft,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
return post, nil
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -6,13 +6,13 @@ import (
|
|||
"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)
|
||||
if !ok {
|
||||
return nil, models.SiteRequiredError
|
||||
}
|
||||
|
||||
posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted)
|
||||
posts, err := s.db.SelectPostsOfSite(ctx, site.ID)
|
||||
if err != nil {
|
||||
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) {
|
||||
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 {
|
||||
return nil, err
|
||||
} else if post.SiteID != site.ID {
|
||||
return nil, models.NotFoundError
|
||||
}
|
||||
|
||||
return post, nil
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import (
|
|||
|
||||
type Service struct {
|
||||
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{
|
||||
db: db,
|
||||
publisher: publisher,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
|
|||
}
|
||||
|
||||
// Fetch all content of site
|
||||
posts, err := p.db.SelectPostsOfSite(ctx, site.ID, false)
|
||||
posts, err := p.db.SelectPostsOfSite(ctx, site.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,12 +1,5 @@
|
|||
-- name: SelectPostsOfSite :many
|
||||
SELECT *
|
||||
FROM posts
|
||||
WHERE site_id = ? AND (
|
||||
CASE CAST (sqlc.arg(post_filter) AS TEXT)
|
||||
WHEN 'deleted' THEN deleted_at > 0
|
||||
ELSE deleted_at = 0
|
||||
END
|
||||
) ORDER BY created_at DESC LIMIT 10;
|
||||
SELECT * FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10;
|
||||
|
||||
-- name: SelectPost :one
|
||||
SELECT * FROM posts WHERE id = ? LIMIT 1;
|
||||
|
|
@ -17,34 +10,19 @@ SELECT * FROM posts WHERE guid = ? LIMIT 1;
|
|||
-- name: InsertPost :one
|
||||
INSERT INTO posts (
|
||||
site_id,
|
||||
state,
|
||||
guid,
|
||||
title,
|
||||
body,
|
||||
slug,
|
||||
created_at,
|
||||
updated_at,
|
||||
published_at,
|
||||
deleted_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
published_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id;
|
||||
|
||||
-- name: UpdatePost :exec
|
||||
UPDATE posts SET
|
||||
title = ?,
|
||||
state = ?,
|
||||
body = ?,
|
||||
slug = ?,
|
||||
updated_at = ?,
|
||||
published_at = ?,
|
||||
deleted_at = ?
|
||||
published_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: SoftDeletePost :exec
|
||||
UPDATE posts SET deleted_at = ? WHERE id = ?;
|
||||
|
||||
-- name: RestorePost :exec
|
||||
UPDATE posts SET deleted_at = 0 WHERE id = ?;
|
||||
|
||||
-- name: HardDeletePost :exec
|
||||
DELETE FROM posts WHERE id = ?;
|
||||
|
|
@ -4,11 +4,10 @@ SELECT * FROM publish_targets WHERE site_id = ?;
|
|||
-- name: InsertPublishTarget :one
|
||||
INSERT INTO publish_targets (
|
||||
site_id,
|
||||
guid,
|
||||
target_type,
|
||||
enabled,
|
||||
base_url,
|
||||
target_ref,
|
||||
target_key
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
RETURNING id;
|
||||
|
|
@ -1,27 +1,13 @@
|
|||
-- name: SelectSitesOwnedByUser :many
|
||||
SELECT * FROM sites WHERE owner_id = ? ORDER BY title ASC;
|
||||
SELECT * FROM sites WHERE owner_id = ?;
|
||||
|
||||
-- name: SelectSiteByID :one
|
||||
SELECT * FROM sites WHERE id = ?;
|
||||
|
||||
-- name: SelectSiteByGUID :one
|
||||
SELECT * FROM sites WHERE guid = ?;
|
||||
|
||||
-- name: InsertSite :one
|
||||
INSERT INTO sites (
|
||||
owner_id,
|
||||
guid,
|
||||
title,
|
||||
tagline,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
tagline
|
||||
) VALUES (?, ?, ?)
|
||||
RETURNING id;
|
||||
|
||||
-- name: HasUsersAndSites :one
|
||||
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites;
|
||||
|
||||
-- name: SelectAllSitesWithOwners :many
|
||||
SELECT s.id, s.guid, s.title, s.owner_id, u.username
|
||||
FROM sites s
|
||||
JOIN users u ON s.owner_id = u.id
|
||||
ORDER BY s.title ASC;
|
||||
|
|
@ -1,11 +1,8 @@
|
|||
-- name: SelectUserByUsername :one
|
||||
SELECT * FROM users WHERE username = ? LIMIT 1;
|
||||
|
||||
-- name: SelectUserByID :one
|
||||
SELECT * FROM users WHERE id = ? LIMIT 1;
|
||||
SELECT * FROM users WHERE username = ?;
|
||||
|
||||
-- name: InsertUser :one
|
||||
INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id;
|
||||
INSERT INTO users (username, password) VALUES (?, ?) RETURNING id;
|
||||
|
||||
-- name: UpdateUser :exec
|
||||
UPDATE users SET username = ?, password = ? WHERE id = ?;
|
||||
|
|
@ -1,28 +1,23 @@
|
|||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
password TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_users_username ON users (username);
|
||||
|
||||
CREATE TABLE sites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id INTEGER NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
tagline TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
|
||||
FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_site_owner ON sites (owner_id);
|
||||
CREATE UNIQUE INDEX idx_site_guid ON sites (guid);
|
||||
|
||||
CREATE TABLE publish_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
enabled INT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
|
|
@ -30,20 +25,16 @@ CREATE TABLE publish_targets (
|
|||
target_key TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_publish_targets_site ON publish_targets (site_id);
|
||||
CREATE UNIQUE INDEX idx_publish_targets_guid ON publish_targets (guid);
|
||||
|
||||
CREATE TABLE posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER NOT NULL,
|
||||
state INTEGER NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
published_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER NOT NULL
|
||||
published_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_post_site ON posts (site_id);
|
||||
CREATE UNIQUE INDEX idx_post_guid ON posts (guid);
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/">Weiro</a>
|
||||
<a class="navbar-brand" href="#">Weiro</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/posts">Posts</a>
|
||||
<a class="nav-link active" aria-current="page" href="#">Posts</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="d-flex align-items-center" role="search">
|
||||
|
|
@ -18,17 +18,8 @@
|
|||
</div>
|
||||
-->
|
||||
|
||||
<div class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
{{ .user.Username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="#">Action</a></li>
|
||||
<li><a class="dropdown-item" href="#">Another action</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#" data-controller="logout" data-action="logout#logout">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search"/>
|
||||
<button class="btn btn-outline-success" type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -2,16 +2,13 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Weiro</title>
|
||||
<title>Title</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/static/assets/main.css">
|
||||
<script src="/static/assets/main.js" type="module"></script>
|
||||
</head>
|
||||
<body class="min-vh-100 d-flex flex-column">
|
||||
{{ template "_common/nav" . }}
|
||||
|
||||
{{ embed }}
|
||||
|
||||
{{ template "_common/toast" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,23 +1,14 @@
|
|||
{{ $isPublished := ne .post.State 1 }}
|
||||
<main class="flex-grow-1 position-relative">
|
||||
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2"
|
||||
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 }}">
|
||||
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2">
|
||||
<input type="hidden" name="guid" value="{{ .post.GUID }}">
|
||||
<div class="mb-2">
|
||||
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
|
||||
</div>
|
||||
<div>
|
||||
<textarea 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>
|
||||
{{ if $isPublished }}
|
||||
<input type="submit" name="action" class="btn btn-primary mt-2" value="Update">
|
||||
{{ else }}
|
||||
<input type="submit" name="action" class="btn btn-primary mt-2" value="Publish">
|
||||
<input type="submit" name="action" class="btn btn-secondary mt-2" value="Save Draft">
|
||||
{{ end }}
|
||||
<input type="submit" class="btn btn-primary mt-2" value="Publish">
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
|
@ -1,62 +1,17 @@
|
|||
{{ $showingTrash := eq .req.Filter "deleted" }}
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<div class="btn-group" role="group" aria-label="First group">
|
||||
{{ if $showingTrash }}
|
||||
<a href="/sites/{{ .site.ID }}/posts" type="button" class="btn btn-secondary" title="Trash">🗑️</a>
|
||||
{{ else }}
|
||||
<a href="/sites/{{ .site.ID }}/posts?filter=deleted" type="button" class="btn btn-outline-secondary" title="Trash">🗑️</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ range $i, $p := .posts }}
|
||||
<div data-controller="postlist"
|
||||
data-postlist-site-id-value="{{ $p.SiteID }}"
|
||||
data-postlist-post-id-value="{{ $p.ID }}"
|
||||
data-postlist-nano-summary-value="{{ $p.NanoSummary }}">
|
||||
{{ if gt $i 0 }}<hr>{{ end }}
|
||||
<div class="my-4">
|
||||
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
|
||||
{{ $p.Body | markdown }}
|
||||
|
||||
<div class="mb-3 d-flex align-items-center">
|
||||
{{ if eq .State 1 }}
|
||||
<span class="text-muted">{{ $.user.FormatTime .UpdatedAt }}</span> <span class="ms-3 badge text-primary-emphasis bg-primary-subtle border border-primary-subtle">Draft</span>
|
||||
{{ else }}
|
||||
<span class="text-muted">{{ $.user.FormatTime .PublishedAt }}</span>
|
||||
{{ end }}
|
||||
<div class="mb-3">
|
||||
<a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/edit"
|
||||
class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Edit</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 d-flex align-items-center">
|
||||
{{ if $showingTrash }}
|
||||
<span>
|
||||
<a href="#" data-action="click->postlist#restorePost" class="btn btn-outline-secondary btn-sm">Restore</a>
|
||||
<a href="#" data-action="click->postlist#deletePost" data-postlist-hard-delete-param="true"
|
||||
class="btn btn-outline-danger btn-sm">Delete</a>
|
||||
</span>
|
||||
{{ else }}
|
||||
<span>
|
||||
<a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/"
|
||||
class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||
<a href="#" data-action="click->postlist#deletePost"
|
||||
class="btn btn-outline-secondary btn-sm">Trash</a>
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ if lt $i (sub (len $.posts) 1) }}<hr>{{ end }}
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="h4 m-3 text-center">
|
||||
{{ if $showingTrash }}
|
||||
<div class="position-absolute top-50 start-50 translate-middle">🗑️<br>Trash is empty.</div>
|
||||
{{ else }}
|
||||
<div class="position-absolute top-50 start-50 translate-middle">🌱<br>No posts yet. Better get writing!</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</main>
|
||||
Loading…
Reference in a new issue