Compare commits
10 commits
aef3bb6a1e
...
97112d99dd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97112d99dd | ||
|
|
4a6b79db17 | ||
|
|
329de2f953 | ||
|
|
30d99eeb9e | ||
|
|
b7e0269e9d | ||
|
|
01c6e9de87 | ||
|
|
c943864edc | ||
|
|
44d35c6ccb | ||
|
|
4f7058bf36 | ||
|
|
3ea5823ca0 |
|
|
@ -1,6 +1,7 @@
|
||||||
root = "."
|
root = "."
|
||||||
testdata_dir = "testdata"
|
testdata_dir = "testdata"
|
||||||
tmp_dir = "build/tmp"
|
tmp_dir = "build/tmp"
|
||||||
|
env_files = [".env"]
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
args_bin = []
|
args_bin = []
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@ node_modules/
|
||||||
static/assets/
|
static/assets/
|
||||||
# Local Netlify folder
|
# Local Netlify folder
|
||||||
.netlify
|
.netlify
|
||||||
|
.env
|
||||||
|
|
|
||||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Build stage
|
||||||
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Install the build dependencies
|
||||||
|
RUN apk update && \
|
||||||
|
apk add nodejs npm make gcc libc-dev
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the frontend
|
||||||
|
RUN make frontend
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN CGO_ENABLED=1 GOOS=linux go build -o weiro .
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
WORKDIR /root/
|
||||||
|
|
||||||
|
# Copy the binary from builder
|
||||||
|
COPY --from=builder /app/weiro .
|
||||||
|
COPY --from=builder /app/static ./static
|
||||||
|
COPY --from=builder /app/views ./views
|
||||||
|
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["./weiro"]
|
||||||
8
_test-site/posts/2026/02/23-another-post-to.md
Normal file
8
_test-site/posts/2026/02/23-another-post-to.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
id: X-fIs5JROC49
|
||||||
|
title: ""
|
||||||
|
date: 2026-02-23T10:12:47Z
|
||||||
|
tags: []
|
||||||
|
slug: /2026/02/23/another-post-to
|
||||||
|
---
|
||||||
|
Another post to delete.
|
||||||
10
_test-site/posts/2026/02/23-be-a-comma.md
Normal file
10
_test-site/posts/2026/02/23-be-a-comma.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
---
|
||||||
|
id: Uk11zptnUi3A
|
||||||
|
title: ""
|
||||||
|
date: 2026-02-23T10:28:37Z
|
||||||
|
tags: []
|
||||||
|
slug: /2026/02/23/be-a-comma
|
||||||
|
---
|
||||||
|
Be a comma than a full stop.
|
||||||
|
|
||||||
|
Also, this will be deleted soon.
|
||||||
8
_test-site/posts/2026/02/23-i-should-soft.md
Normal file
8
_test-site/posts/2026/02/23-i-should-soft.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
id: hTF0-vhojyR7
|
||||||
|
title: ""
|
||||||
|
date: 2026-02-23T10:16:19Z
|
||||||
|
tags: []
|
||||||
|
slug: /2026/02/23/i-should-soft
|
||||||
|
---
|
||||||
|
I should soft delete.
|
||||||
8
_test-site/posts/2026/02/23-this-is-to.md
Normal file
8
_test-site/posts/2026/02/23-this-is-to.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
id: EWhQUasFRLfJ
|
||||||
|
title: ""
|
||||||
|
date: 2026-02-23T10:12:00Z
|
||||||
|
tags: []
|
||||||
|
slug: /2026/02/23/this-is-to
|
||||||
|
---
|
||||||
|
This is to be deleted.
|
||||||
10
_test-site/posts/2026/02/24-it-may-have.md
Normal file
10
_test-site/posts/2026/02/24-it-may-have.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
---
|
||||||
|
id: Ed6vIR86gspx
|
||||||
|
title: ""
|
||||||
|
date: 2026-02-24T10:55:39Z
|
||||||
|
tags: []
|
||||||
|
slug: /2026/02/24/it-may-have
|
||||||
|
---
|
||||||
|
It may have been an issue with the call to air? Hmm. Will need to make sure to check that is working correctly.
|
||||||
|
|
||||||
|
I am now updating this. I am also updating this too.
|
||||||
8
_test-site/posts/2026/02/24-it-will-even.md
Normal file
8
_test-site/posts/2026/02/24-it-will-even.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
id: bzAVD55SB2LE
|
||||||
|
title: ""
|
||||||
|
date: 2026-02-24T11:20:23Z
|
||||||
|
tags: []
|
||||||
|
slug: /2026/02/24/it-will-even
|
||||||
|
---
|
||||||
|
It will even do it for new posts.
|
||||||
12
_test-site/posts/2026/02/24-this-is-a.md
Normal file
12
_test-site/posts/2026/02/24-this-is-a.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
id: MFHzBhJwJCQ3
|
||||||
|
title: ""
|
||||||
|
date: 2026-02-24T11:20:11Z
|
||||||
|
tags: []
|
||||||
|
slug: /2026/02/24/this-is-a
|
||||||
|
---
|
||||||
|
This is a new post, and will be saved as a draft.
|
||||||
|
|
||||||
|
It's still a draft. But the minute I publish it, it will be updated as an updated post. You see? I'm still writing in this.
|
||||||
|
|
||||||
|
But the minute I press enter, it will always publish.
|
||||||
10
_test-site/posts/2026/02/24-this-was-a.md
Normal file
10
_test-site/posts/2026/02/24-this-was-a.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
---
|
||||||
|
id: -sU1lmmL7i56
|
||||||
|
title: ""
|
||||||
|
date: 2026-02-24T11:20:44Z
|
||||||
|
tags: []
|
||||||
|
slug: /2026/02/24/this-was-a
|
||||||
|
---
|
||||||
|
This was a draft.
|
||||||
|
|
||||||
|
But now it's a published post.
|
||||||
66
assets/js/controllers/firstrun.js
Normal file
66
assets/js/controllers/firstrun.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import { showToast } from "../services/toast";
|
||||||
|
|
||||||
|
export default class FirstRunController extends Controller {
|
||||||
|
static targets = ['pages'];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.pagesTargets.forEach((x) => x.classList.add('d-none'));
|
||||||
|
this.pagesTargets[0].classList.remove('d-none');
|
||||||
|
this.element.querySelector('input[name="username"]').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPage(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const currentIndex = this.pagesTargets.findIndex(x => !x.classList.contains('d-none'));
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this._validate(currentIndex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextPage = currentIndex + 1;
|
||||||
|
if (nextPage >= this.pagesTargets.length) {
|
||||||
|
this.element.querySelector('form').submit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pagesTargets[currentIndex].classList.add('d-none');
|
||||||
|
this.pagesTargets[nextPage].classList.remove('d-none');
|
||||||
|
|
||||||
|
if (nextPage === 1) {
|
||||||
|
this.element.querySelector('input[name="siteName"]').focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_validate(pageNumber) {
|
||||||
|
let newUsername = this.element.querySelector('input[name="username"]');
|
||||||
|
let newPassword1 = this.element.querySelector('input[name="password1"]');
|
||||||
|
let newPassword2 = this.element.querySelector('input[name="password2"]');
|
||||||
|
|
||||||
|
if (newUsername.value === '') {
|
||||||
|
alert('Please enter a username');
|
||||||
|
newUsername.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!newUsername.value.match(/^[a-zA-Z0-9_-]+$/)) {
|
||||||
|
alert('Please enter a username with letters, numbers, underscores, and dashes only');
|
||||||
|
newUsername.focus();
|
||||||
|
newUsername.select();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (newPassword1.value === '') {
|
||||||
|
alert('Please enter a password');
|
||||||
|
newPassword1.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (newPassword2.value !== newPassword1.value) {
|
||||||
|
alert('Passwords do not match');
|
||||||
|
newPassword2.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
assets/js/controllers/logout.js
Normal file
9
assets/js/controllers/logout.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class LogoutController extends Controller {
|
||||||
|
async logout(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
await fetch(`/logout`, { method: 'POST' });
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
80
assets/js/controllers/postedit.js
Normal file
80
assets/js/controllers/postedit.js
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import { showToast } from "../services/toast";
|
||||||
|
|
||||||
|
export default class PosteditController extends Controller {
|
||||||
|
static targets = ['bodyTextEdit'];
|
||||||
|
static values = {
|
||||||
|
saveAction: String,
|
||||||
|
};
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.bodyTextEditTarget.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._postForm(this.saveActionValue);
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
title: "💾 Post Saved",
|
||||||
|
body: (this.saveActionValue === "Save Draft") ? "Post saved as draft." : "Post updated.",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast({
|
||||||
|
title: "❌ Error",
|
||||||
|
body: "Unable to save post. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async publish(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._postForm("Publish");
|
||||||
|
|
||||||
|
window.location.href = this.element.getAttribute("action");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
showToast({
|
||||||
|
title: "❌ Error",
|
||||||
|
body: "Unable to publish post. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _postForm(action) {
|
||||||
|
if (this._isPosting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._isPosting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(this.element);
|
||||||
|
let data = Object.fromEntries(formData.entries());
|
||||||
|
data = {...data, action: action || 'save'};
|
||||||
|
|
||||||
|
const response = await fetch(this.element.getAttribute("action"), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} finally {
|
||||||
|
this._isPosting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
76
assets/js/controllers/postlist.js
Normal file
76
assets/js/controllers/postlist.js
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import { showToast } from "../services/toast";
|
||||||
|
|
||||||
|
export default class PostlistController extends Controller {
|
||||||
|
static values = {
|
||||||
|
siteId: Number,
|
||||||
|
postId: Number,
|
||||||
|
nanoSummary: String,
|
||||||
|
};
|
||||||
|
|
||||||
|
async deletePost(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
let isHardDelete = ev.params && ev.params.hardDelete;
|
||||||
|
if (isHardDelete) {
|
||||||
|
if (!confirm("Are you sure you want to delete this post?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let deleteQuery = isHardDelete ? '?hard=true' : '';
|
||||||
|
this.element.remove();
|
||||||
|
|
||||||
|
await fetch(`/sites/${this.siteIdValue}/posts/${this.postIdValue}${deleteQuery}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isHardDelete) {
|
||||||
|
showToast({
|
||||||
|
title: "🔥 Post Delete",
|
||||||
|
body: this.nanoSummaryValue,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast({
|
||||||
|
title: "🗑️ Sent To Trash",
|
||||||
|
body: this.nanoSummaryValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
title: "❌ Error",
|
||||||
|
body: "Failed to delete post. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async restorePost(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.element.remove();
|
||||||
|
await fetch(`/sites/${this.siteIdValue}/posts/${this.postIdValue}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'restore'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
title: "🗑️ Restored From Trash",
|
||||||
|
body: this.nanoSummaryValue,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
showToast({
|
||||||
|
title: "❌ Error",
|
||||||
|
body: "Failed to rstore post. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
assets/js/controllers/toast.js
Normal file
24
assets/js/controllers/toast.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Toast } from 'bootstrap/dist/js/bootstrap.js';
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class ToastController extends Controller {
|
||||||
|
static targets = ['title', 'body'];
|
||||||
|
|
||||||
|
initialize() {
|
||||||
|
this._toast = new Toast(this.element);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(ev) {
|
||||||
|
let toastDetails = ev.detail;
|
||||||
|
if (!toastDetails) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.titleTarget.innerText = toastDetails.title || "Title";
|
||||||
|
this.bodyTarget.innerText = toastDetails.body || "Body";
|
||||||
|
|
||||||
|
if (!this._toast.isShown()) {
|
||||||
|
this._toast.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
assets/js/main.js
Normal file
14
assets/js/main.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Application } from "@hotwired/stimulus";
|
||||||
|
|
||||||
|
import ToastController from "./controllers/toast";
|
||||||
|
import PostlistController from "./controllers/postlist";
|
||||||
|
import PosteditController from "./controllers/postedit";
|
||||||
|
import LogoutController from "./controllers/logout";
|
||||||
|
import FirstRunController from "./controllers/firstrun";
|
||||||
|
|
||||||
|
window.Stimulus = Application.start()
|
||||||
|
Stimulus.register("toast", ToastController);
|
||||||
|
Stimulus.register("postlist", PostlistController);
|
||||||
|
Stimulus.register("postedit", PosteditController);
|
||||||
|
Stimulus.register("logout", LogoutController);
|
||||||
|
Stimulus.register("first-run", FirstRunController);
|
||||||
6
assets/js/services/toast.js
Normal file
6
assets/js/services/toast.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export function showToast(details) {
|
||||||
|
let event = new CustomEvent('weiroToast', {
|
||||||
|
detail: details
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
118
cmds/pubtargets.go
Normal file
118
cmds/pubtargets.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package cmds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"lmika.dev/lmika/weiro/config"
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/lmika/weiro/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PubTargetsAdd() *cobra.Command {
|
||||||
|
var (
|
||||||
|
siteGUID string
|
||||||
|
targetType string
|
||||||
|
targetRef string
|
||||||
|
targetKey string
|
||||||
|
baseURL string
|
||||||
|
enabled bool
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "add",
|
||||||
|
Short: "Add a publication target",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svcs, err := services.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer svcs.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
site, err := svcs.DB.SelectSiteByGUID(ctx, siteGUID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := &models.SitePublishTarget{
|
||||||
|
SiteID: site.ID,
|
||||||
|
GUID: models.NewNanoID(),
|
||||||
|
Enabled: enabled,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
TargetType: targetType,
|
||||||
|
TargetRef: targetRef,
|
||||||
|
TargetKey: targetKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svcs.DB.SavePublishTarget(ctx, target); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Added publish target %s\n", target.GUID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&siteGUID, "site", "s", "", "Site GUID")
|
||||||
|
cmd.Flags().StringVarP(&targetType, "type", "t", "", "Target type (localfs, netlify)")
|
||||||
|
cmd.Flags().StringVarP(&targetRef, "ref", "r", "", "Target reference")
|
||||||
|
cmd.Flags().StringVarP(&targetKey, "key", "k", "", "Target key")
|
||||||
|
cmd.Flags().StringVarP(&baseURL, "url", "u", "", "Base URL")
|
||||||
|
cmd.Flags().BoolVar(&enabled, "enabled", true, "Enable target")
|
||||||
|
|
||||||
|
cmd.MarkFlagRequired("site")
|
||||||
|
cmd.MarkFlagRequired("type")
|
||||||
|
cmd.MarkFlagRequired("ref")
|
||||||
|
cmd.MarkFlagRequired("url")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func PubTargets() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "pubtargets <site-guid>",
|
||||||
|
Short: "Manage publication targets",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svcs, err := services.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer svcs.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
site, err := svcs.DB.SelectSiteByGUID(ctx, args[0])
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targets, err := svcs.DB.SelectPublishTargetsOfSite(ctx, site.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "GUID\tTARGET_TYPE\tENABLED\tTARGET_REF")
|
||||||
|
for _, target := range targets {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%v\t%s\n", target.GUID, target.TargetType, target.Enabled, target.TargetRef)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(PubTargetsAdd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
139
cmds/server.go
Normal file
139
cmds/server.go
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
package cmds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/gofiber/fiber/v3/extractors"
|
||||||
|
"github.com/gofiber/fiber/v3/middleware/session"
|
||||||
|
"github.com/gofiber/fiber/v3/middleware/static"
|
||||||
|
"github.com/gofiber/storage/sqlite3/v2"
|
||||||
|
fiber_html "github.com/gofiber/template/html/v3"
|
||||||
|
"github.com/gofiber/utils/v2"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/extension"
|
||||||
|
"lmika.dev/lmika/weiro/config"
|
||||||
|
"lmika.dev/lmika/weiro/handlers"
|
||||||
|
"lmika.dev/lmika/weiro/handlers/middleware"
|
||||||
|
"lmika.dev/lmika/weiro/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Root() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "weiro",
|
||||||
|
Short: "Weiro is a simple blogging platform",
|
||||||
|
Long: `Weiro is a simple blogging platform.
|
||||||
|
|
||||||
|
Starting weiro without any arguments will start the server.
|
||||||
|
`,
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svcs, err := services.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer svcs.Close()
|
||||||
|
|
||||||
|
svcs.PublisherQueue.Start(context.Background())
|
||||||
|
|
||||||
|
fiberTemplate := fiber_html.New("./views", ".html")
|
||||||
|
fiberTemplate.Funcmap["sub"] = func(x, y int) int { return x - y }
|
||||||
|
fiberTemplate.Funcmap["markdown"] = func() func(s string) template.HTML {
|
||||||
|
mdParser := goldmark.New(
|
||||||
|
goldmark.WithExtensions(extension.GFM),
|
||||||
|
)
|
||||||
|
return func(s string) template.HTML {
|
||||||
|
var sb strings.Builder
|
||||||
|
if err := mdParser.Convert([]byte(s), &sb); err != nil {
|
||||||
|
return template.HTML("Markdown error: " + html.EscapeString(err.Error()))
|
||||||
|
}
|
||||||
|
return template.HTML(sb.String())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Initialize custom config
|
||||||
|
store := sqlite3.New(sqlite3.Config{
|
||||||
|
Database: filepath.Join(cfg.DataDir, "./fiber.db"),
|
||||||
|
Table: "fiber_storage",
|
||||||
|
Reset: false,
|
||||||
|
GCInterval: 10 * time.Second,
|
||||||
|
MaxOpenConns: 100,
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
ConnMaxLifetime: 1 * time.Second,
|
||||||
|
})
|
||||||
|
|
||||||
|
app := fiber.New(fiber.Config{
|
||||||
|
Views: fiberTemplate,
|
||||||
|
ViewsLayout: "layouts/main",
|
||||||
|
PassLocalsToViews: true,
|
||||||
|
})
|
||||||
|
app.Use(session.New(session.Config{
|
||||||
|
// Storage
|
||||||
|
Storage: store,
|
||||||
|
|
||||||
|
// Security
|
||||||
|
CookieSecure: cfg.IsProd(),
|
||||||
|
CookieSameSite: "Lax",
|
||||||
|
|
||||||
|
// Session Management
|
||||||
|
IdleTimeout: 24 * time.Hour, // Inactivity timeout
|
||||||
|
AbsoluteTimeout: 7 * 24 * time.Hour, // Maximum session duration
|
||||||
|
|
||||||
|
// Cookie Settings
|
||||||
|
CookiePath: "/",
|
||||||
|
CookieDomain: cfg.SiteDomain,
|
||||||
|
CookieSessionOnly: false, // Persist across browser restarts
|
||||||
|
|
||||||
|
// Session ID
|
||||||
|
Extractor: extractors.FromCookie("__wro-session_id"),
|
||||||
|
KeyGenerator: utils.SecureToken,
|
||||||
|
|
||||||
|
// Error Handling
|
||||||
|
ErrorHandler: func(c fiber.Ctx, err error) {
|
||||||
|
log.Printf("Session error: %v", err)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
ih := handlers.IndexHandler{SiteService: svcs.Sites}
|
||||||
|
lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth}
|
||||||
|
ph := handlers.PostsHandler{PostService: svcs.Posts}
|
||||||
|
|
||||||
|
app.Get("/login", lh.Login)
|
||||||
|
app.Post("/login", lh.DoLogin)
|
||||||
|
app.Post("/logout", lh.Logout)
|
||||||
|
|
||||||
|
siteGroup := app.Group("/sites/:siteID", middleware.RequireUser(svcs.Auth), middleware.RequiresSite(svcs.Sites))
|
||||||
|
|
||||||
|
siteGroup.Get("/posts", ph.Index)
|
||||||
|
siteGroup.Get("/posts/new", ph.New)
|
||||||
|
siteGroup.Get("/posts/:postID", ph.Edit)
|
||||||
|
siteGroup.Post("/posts", ph.Update)
|
||||||
|
siteGroup.Patch("/posts/:postID", ph.Patch)
|
||||||
|
siteGroup.Delete("/posts/:postID", ph.Delete)
|
||||||
|
|
||||||
|
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
|
||||||
|
app.Get("/first-run", ih.FirstRun)
|
||||||
|
app.Post("/first-run", ih.FirstRunSubmit)
|
||||||
|
|
||||||
|
app.Get("/static/*", static.New("./static"))
|
||||||
|
|
||||||
|
if err := app.Listen(":3000"); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(Sites())
|
||||||
|
cmd.AddCommand(PubTargets())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
46
cmds/sites.go
Normal file
46
cmds/sites.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package cmds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"lmika.dev/lmika/weiro/config"
|
||||||
|
"lmika.dev/lmika/weiro/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Sites() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "sites",
|
||||||
|
Short: "Manage sites",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svcs, err := services.New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer svcs.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
sites, err := svcs.Sites.ListAllSitesWithOwners(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "GUID\tOWNER\tNAME")
|
||||||
|
for _, site := range sites {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\n", site.GUID, site.Username, site.Title)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
31
config/config.go
Normal file
31
config/config.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/Netflix/go-env"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DataDir string `env:"DATA_DIR"`
|
||||||
|
SiteDomain string `env:"SITE_DOMAIN"`
|
||||||
|
LoginLocked bool `env:"LOGIN_LOCKED,default=false"`
|
||||||
|
Env string `env:"ENV,default=prod"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig() (Config, error) {
|
||||||
|
cfg := Config{}
|
||||||
|
if _, err := env.UnmarshalFromEnviron(&cfg); err != nil {
|
||||||
|
return Config{}, fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) IsProd() bool {
|
||||||
|
return c.Env != "dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) DBName() string {
|
||||||
|
return filepath.Join(c.DataDir, "weiro.db")
|
||||||
|
}
|
||||||
11
esbuild.mjs
11
esbuild.mjs
|
|
@ -1,9 +1,16 @@
|
||||||
import * as esbuild from 'esbuild'
|
import * as esbuild from 'esbuild'
|
||||||
import {sassPlugin} from 'esbuild-sass-plugin'
|
import {sassPlugin} from 'esbuild-sass-plugin'
|
||||||
|
|
||||||
await esbuild.build({
|
await Promise.all([
|
||||||
|
esbuild.build({
|
||||||
entryPoints: ['./assets/css/main.scss'],
|
entryPoints: ['./assets/css/main.scss'],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
plugins: [sassPlugin()],
|
plugins: [sassPlugin()],
|
||||||
outfile: './static/assets/main.css',
|
outfile: './static/assets/main.css',
|
||||||
});
|
}),
|
||||||
|
esbuild.build({
|
||||||
|
entryPoints: ['./assets/js/main.js'],
|
||||||
|
bundle: true,
|
||||||
|
outfile: './static/assets/main.js',
|
||||||
|
})
|
||||||
|
]);
|
||||||
23
go.mod
23
go.mod
|
|
@ -18,6 +18,7 @@ require (
|
||||||
github.com/Azure/go-autorest/autorest/date v0.2.0 // indirect
|
github.com/Azure/go-autorest/autorest/date v0.2.0 // indirect
|
||||||
github.com/Azure/go-autorest/logger v0.1.0 // indirect
|
github.com/Azure/go-autorest/logger v0.1.0 // indirect
|
||||||
github.com/Azure/go-autorest/tracing v0.5.0 // indirect
|
github.com/Azure/go-autorest/tracing v0.5.0 // indirect
|
||||||
|
github.com/Netflix/go-env v0.1.2 // indirect
|
||||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
|
@ -36,22 +37,26 @@ require (
|
||||||
github.com/go-openapi/strfmt v0.19.11 // indirect
|
github.com/go-openapi/strfmt v0.19.11 // indirect
|
||||||
github.com/go-openapi/swag v0.19.12 // indirect
|
github.com/go-openapi/swag v0.19.12 // indirect
|
||||||
github.com/go-openapi/validate v0.20.0 // indirect
|
github.com/go-openapi/validate v0.20.0 // indirect
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/go-stack/stack v1.8.0 // indirect
|
github.com/go-stack/stack v1.8.0 // indirect
|
||||||
github.com/gofiber/fiber/v3 v3.0.0 // indirect
|
github.com/gofiber/fiber/v3 v3.1.0 // indirect
|
||||||
github.com/gofiber/schema v1.6.0 // indirect
|
github.com/gofiber/schema v1.7.0 // indirect
|
||||||
|
github.com/gofiber/storage/sqlite3/v2 v2.2.3 // indirect
|
||||||
github.com/gofiber/template v1.8.3 // indirect
|
github.com/gofiber/template v1.8.3 // indirect
|
||||||
github.com/gofiber/template/html/v3 v3.0.2 // indirect
|
github.com/gofiber/template/html/v3 v3.0.2 // indirect
|
||||||
github.com/gofiber/template/v2 v2.1.0 // indirect
|
github.com/gofiber/template/v2 v2.1.0 // indirect
|
||||||
github.com/gofiber/utils/v2 v2.0.0 // indirect
|
github.com/gofiber/utils/v2 v2.0.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.3 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect
|
github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect
|
||||||
github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f // indirect
|
github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f // indirect
|
||||||
github.com/mailru/easyjson v0.7.6 // indirect
|
github.com/mailru/easyjson v0.7.6 // indirect
|
||||||
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
|
github.com/matoous/go-nanoid/v2 v2.1.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.33 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.4.0 // indirect
|
github.com/mitchellh/mapstructure v1.4.0 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/netlify/open-api/v2 v2.49.1 // indirect
|
github.com/netlify/open-api/v2 v2.49.1 // indirect
|
||||||
|
|
@ -61,17 +66,19 @@ require (
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rsc/goversion v1.2.0 // indirect
|
github.com/rsc/goversion v1.2.0 // indirect
|
||||||
github.com/sirupsen/logrus v1.6.0 // indirect
|
github.com/sirupsen/logrus v1.6.0 // indirect
|
||||||
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/tinylib/msgp v1.6.3 // indirect
|
github.com/tinylib/msgp v1.6.3 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasthttp v1.69.0 // indirect
|
github.com/valyala/fasthttp v1.69.0 // indirect
|
||||||
go.mongodb.org/mongo-driver v1.4.4 // indirect
|
go.mongodb.org/mongo-driver v1.4.4 // indirect
|
||||||
go.uber.org/atomic v1.7.0 // indirect
|
go.uber.org/atomic v1.7.0 // indirect
|
||||||
go.uber.org/multierr v1.6.0 // indirect
|
go.uber.org/multierr v1.6.0 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/net v0.49.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|
|
||||||
35
go.sum
35
go.sum
|
|
@ -31,6 +31,8 @@ github.com/Azure/go-autorest/tracing v0.5.0 h1:TRn4WjSnkcSy5AEG3pnbtFSwNtwzjr4VY
|
||||||
github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
|
github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/Netflix/go-env v0.1.2 h1:0DRoLR9lECQ9Zqvkswuebm3jJ/2enaDX6Ei8/Z+EnK0=
|
||||||
|
github.com/Netflix/go-env v0.1.2/go.mod h1:WlIhYi++8FlKNJtrop1mjXYAJMzv1f43K4MqCoh0yGE=
|
||||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||||
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||||
|
|
@ -71,6 +73,7 @@ github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHo
|
||||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
|
@ -176,6 +179,8 @@ github.com/go-openapi/validate v0.19.12/go.mod h1:Rzou8hA/CBw8donlS6WNEUQupNvUZ0
|
||||||
github.com/go-openapi/validate v0.19.15/go.mod h1:tbn/fdOwYHgrhPBzidZfJC2MIVvs9GA7monOmWBbeCI=
|
github.com/go-openapi/validate v0.19.15/go.mod h1:tbn/fdOwYHgrhPBzidZfJC2MIVvs9GA7monOmWBbeCI=
|
||||||
github.com/go-openapi/validate v0.20.0 h1:pzutNCCBZGZlE+u8HD3JZyWdc/TVbtVwlWUp8/vgUKk=
|
github.com/go-openapi/validate v0.20.0 h1:pzutNCCBZGZlE+u8HD3JZyWdc/TVbtVwlWUp8/vgUKk=
|
||||||
github.com/go-openapi/validate v0.20.0/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0=
|
github.com/go-openapi/validate v0.20.0/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
|
|
@ -207,8 +212,14 @@ github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/V
|
||||||
github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
|
github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw=
|
||||||
github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
|
github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk=
|
||||||
github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY=
|
github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY=
|
||||||
|
github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY=
|
||||||
|
github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU=
|
||||||
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
|
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
|
||||||
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
|
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
|
||||||
|
github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg=
|
||||||
|
github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
|
||||||
|
github.com/gofiber/storage/sqlite3/v2 v2.2.3 h1:m3n80wUewnB5ruAV3Qq0mzIS+bwBrYYETo4N+fvBoow=
|
||||||
|
github.com/gofiber/storage/sqlite3/v2 v2.2.3/go.mod h1:F1w9BpQtU7BD5cCjlQnFIEjWHUaAcm9Hh5fuCpfG/OE=
|
||||||
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
|
github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
|
||||||
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
|
github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
|
||||||
github.com/gofiber/template/html/v3 v3.0.2 h1:/Fh8UcEsB4uhf1QWNbYaAOwXxSORebJ2zXkb5tgG/TI=
|
github.com/gofiber/template/html/v3 v3.0.2 h1:/Fh8UcEsB4uhf1QWNbYaAOwXxSORebJ2zXkb5tgG/TI=
|
||||||
|
|
@ -217,6 +228,8 @@ github.com/gofiber/template/v2 v2.1.0 h1:vrLY6uEW2HdioJm6J5FGUpYZuapVQhHciNz21XQ
|
||||||
github.com/gofiber/template/v2 v2.1.0/go.mod h1:ohgpR/Ng90nJbK+IyNzrgR/XpnBNt862/oTF5G7SAmE=
|
github.com/gofiber/template/v2 v2.1.0/go.mod h1:ohgpR/Ng90nJbK+IyNzrgR/XpnBNt862/oTF5G7SAmE=
|
||||||
github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
|
github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM=
|
||||||
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
|
github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE=
|
||||||
|
github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI=
|
||||||
|
github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0=
|
||||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
|
@ -283,6 +296,8 @@ github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0m
|
||||||
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||||
|
|
@ -302,6 +317,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
|
||||||
github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
|
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
|
||||||
|
|
@ -335,6 +352,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
|
||||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||||
|
|
@ -392,6 +411,7 @@ github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/rsc/goversion v1.2.0 h1:zVF4y5ciA/rw779S62bEAq4Yif1cBc/UwRkXJ2xZyT4=
|
github.com/rsc/goversion v1.2.0 h1:zVF4y5ciA/rw779S62bEAq4Yif1cBc/UwRkXJ2xZyT4=
|
||||||
github.com/rsc/goversion v1.2.0/go.mod h1:Tf/O0TQyfRvp7NelXAyfXYRKUO+LX3KNgXc8ALRUv4k=
|
github.com/rsc/goversion v1.2.0/go.mod h1:Tf/O0TQyfRvp7NelXAyfXYRKUO+LX3KNgXc8ALRUv4k=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||||
|
|
@ -410,10 +430,15 @@ github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTd
|
||||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||||
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||||
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
|
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
|
@ -464,6 +489,7 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/
|
||||||
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
|
||||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
|
@ -481,6 +507,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
|
|
@ -535,6 +563,8 @@ golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
|
@ -582,6 +612,8 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
|
@ -595,6 +627,8 @@ golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|
@ -629,6 +663,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
|
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
|
||||||
35
handlers/accepts.go
Normal file
35
handlers/accepts.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type acceptor struct {
|
||||||
|
canAccept func(ctx fiber.Ctx) bool
|
||||||
|
acceptFn func(ctx fiber.Ctx) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func accepts(ctx fiber.Ctx, acceptors ...acceptor) error {
|
||||||
|
for _, a := range acceptors {
|
||||||
|
if a.canAccept(ctx) {
|
||||||
|
return a.acceptFn(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fiber.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func json(fn func() any) acceptor {
|
||||||
|
return acceptor{
|
||||||
|
canAccept: func(ctx fiber.Ctx) bool { return ctx.AcceptsJSON() && !ctx.AcceptsHTML() },
|
||||||
|
acceptFn: func(ctx fiber.Ctx) error {
|
||||||
|
return ctx.Status(fiber.StatusOK).JSON(fn())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func html(fn func(ctx fiber.Ctx) error) acceptor {
|
||||||
|
return acceptor{
|
||||||
|
canAccept: func(ctx fiber.Ctx) bool { return ctx.AcceptsHTML() },
|
||||||
|
acceptFn: fn,
|
||||||
|
}
|
||||||
|
}
|
||||||
77
handlers/index.go
Normal file
77
handlers/index.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/gofiber/fiber/v3/middleware/session"
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/lmika/weiro/services/sites"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sitePath = regexp.MustCompile(`^/sites/([0-9]+)`)
|
||||||
|
|
||||||
|
type IndexHandler struct {
|
||||||
|
SiteService *sites.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h IndexHandler) Index(c fiber.Ctx) error {
|
||||||
|
hasUserAndSites, err := h.SiteService.HasUsersAsSites(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if !hasUserAndSites {
|
||||||
|
return c.Redirect().To("/first-run")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, hasUser := models.GetUser(c.Context())
|
||||||
|
if !hasUser {
|
||||||
|
return c.Redirect().To("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
if refUrl, rerr := url.Parse(c.Get("Referer")); rerr == nil {
|
||||||
|
if parts := sitePath.FindStringSubmatch(refUrl.Path); len(parts) == 2 {
|
||||||
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", parts[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
site, err := h.SiteService.BestSite(c.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", site.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h IndexHandler) FirstRun(c fiber.Ctx) error {
|
||||||
|
hasUserAndSites, err := h.SiteService.HasUsersAsSites(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if hasUserAndSites {
|
||||||
|
return errors.New("you already have a site")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Render("index/first-run", fiber.Map{}, "layouts/bare_with_scripts")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h IndexHandler) FirstRunSubmit(c fiber.Ctx) error {
|
||||||
|
var req sites.FirstRunRequest
|
||||||
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
|
return errors.Wrap(err, "failed to parse first run request")
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := session.FromContext(c)
|
||||||
|
if err := sess.Regenerate(); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, site, err := h.SiteService.FirstRun(c.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.Set("user_id", user.ID)
|
||||||
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", site.ID))
|
||||||
|
}
|
||||||
72
handlers/login.go
Normal file
72
handlers/login.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/gofiber/fiber/v3/middleware/session"
|
||||||
|
"lmika.dev/lmika/weiro/config"
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/lmika/weiro/services/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginHandler struct {
|
||||||
|
Config config.Config
|
||||||
|
AuthService *auth.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lh *LoginHandler) Login(c fiber.Ctx) error {
|
||||||
|
if lh.Config.LoginLocked {
|
||||||
|
return c.Status(fiber.StatusForbidden).SendString("Login is locked")
|
||||||
|
}
|
||||||
|
|
||||||
|
loginChallenge := models.NewNanoID()
|
||||||
|
|
||||||
|
sess := session.FromContext(c)
|
||||||
|
sess.Set("_login_challenge", loginChallenge)
|
||||||
|
|
||||||
|
c.Render("login/login", fiber.Map{
|
||||||
|
"challenge": loginChallenge,
|
||||||
|
}, "layouts/bare")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lh *LoginHandler) Logout(c fiber.Ctx) error {
|
||||||
|
sess := session.FromContext(c)
|
||||||
|
sess.Destroy()
|
||||||
|
return c.Redirect().To("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lh *LoginHandler) DoLogin(c fiber.Ctx) error {
|
||||||
|
var req struct {
|
||||||
|
Username string `form:"username"`
|
||||||
|
Password string `form:"password"`
|
||||||
|
LoginChallenge string `form:"_login_challenge"`
|
||||||
|
}
|
||||||
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).SendString("Failed to parse request body")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).SendString("Username and password are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := session.FromContext(c)
|
||||||
|
|
||||||
|
challenge, _ := sess.Get("_login_challenge").(string)
|
||||||
|
if challenge != req.LoginChallenge {
|
||||||
|
return c.Redirect().To("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := lh.AuthService.Login(c.Context(), req.Username, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sess.Regenerate(); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.Set("user_id", user.ID)
|
||||||
|
sess.Delete("_login_challenge")
|
||||||
|
|
||||||
|
return c.Redirect().To("/")
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,14 @@ package middleware
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
"lmika.dev/lmika/weiro/providers/db"
|
"lmika.dev/lmika/weiro/providers/db"
|
||||||
|
"lmika.dev/lmika/weiro/services/sites"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RequiresSite(db *db.Provider) func(c fiber.Ctx) error {
|
func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
siteIDStr := c.Params("siteID")
|
siteIDStr := c.Params("siteID")
|
||||||
if siteIDStr == "" {
|
if siteIDStr == "" {
|
||||||
|
|
@ -20,18 +22,15 @@ func RequiresSite(db *db.Provider) func(c fiber.Ctx) error {
|
||||||
return fiber.ErrBadRequest
|
return fiber.ErrBadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
user, ok := models.GetUser(c.Context())
|
site, err := sites.GetSiteByID(c.Context(), siteID)
|
||||||
if !ok {
|
|
||||||
return fiber.ErrUnauthorized
|
|
||||||
}
|
|
||||||
|
|
||||||
site, err := db.SelectSiteByID(c.Context(), siteID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.ErrNotFound
|
if errors.Is(err, models.UserRequiredError) {
|
||||||
}
|
|
||||||
|
|
||||||
if site.OwnerID != user.ID {
|
|
||||||
return fiber.ErrForbidden
|
return fiber.ErrForbidden
|
||||||
|
} else if errors.Is(err, models.PermissionError) || db.ErrorIsNoRows(err) {
|
||||||
|
return fiber.ErrNotFound
|
||||||
|
} else if errors.Is(err, models.NotFoundError) || db.ErrorIsNoRows(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Locals("site", site)
|
c.Locals("site", site)
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,47 @@
|
||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/gofiber/fiber/v3/middleware/session"
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/lmika/weiro/services/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AuthUser() func(c fiber.Ctx) error {
|
func OptionalUser(auth *auth.Service) func(c fiber.Ctx) error {
|
||||||
return func(c fiber.Ctx) error {
|
return func(c fiber.Ctx) error {
|
||||||
// TEMP - Actually do the auth here
|
sess := session.FromContext(c)
|
||||||
user := models.User{
|
userID, _ := sess.Get("user_id").(int64)
|
||||||
ID: 1,
|
if userID == 0 {
|
||||||
Username: "testuser",
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := auth.GetUser(c.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Locals("user", user)
|
||||||
|
c.SetContext(models.WithUser(c.Context(), user))
|
||||||
|
|
||||||
|
return c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func RequireUser(auth *auth.Service) func(c fiber.Ctx) error {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
sess := session.FromContext(c)
|
||||||
|
userID, _ := sess.Get("user_id").(int64)
|
||||||
|
if userID == 0 {
|
||||||
|
return c.Redirect().To("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := auth.GetUser(c.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.Redirect().To("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Locals("user", user)
|
c.Locals("user", user)
|
||||||
c.SetContext(models.WithUser(c.Context(), user))
|
c.SetContext(models.WithUser(c.Context(), user))
|
||||||
log.Printf("User %s authenticated", user.Username)
|
|
||||||
|
|
||||||
return c.Next()
|
return c.Next()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"github.com/gofiber/fiber/v3"
|
||||||
|
|
@ -14,19 +15,32 @@ type PostsHandler struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ph PostsHandler) Index(c fiber.Ctx) error {
|
func (ph PostsHandler) Index(c fiber.Ctx) error {
|
||||||
posts, err := ph.PostService.ListPosts(c.Context())
|
var req struct {
|
||||||
|
Filter string `query:"filter"`
|
||||||
|
}
|
||||||
|
if err := c.Bind().Query(&req); err != nil {
|
||||||
|
return fiber.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
posts, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return accepts(c, json(func() any {
|
||||||
|
return posts
|
||||||
|
}), html(func(c fiber.Ctx) error {
|
||||||
return c.Render("posts/index", fiber.Map{
|
return c.Render("posts/index", fiber.Map{
|
||||||
|
"req": req,
|
||||||
"posts": posts,
|
"posts": posts,
|
||||||
})
|
})
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ph PostsHandler) New(c fiber.Ctx) error {
|
func (ph PostsHandler) New(c fiber.Ctx) error {
|
||||||
p := models.Post{
|
p := models.Post{
|
||||||
GUID: models.NewNanoID(),
|
GUID: models.NewNanoID(),
|
||||||
|
State: models.StateDraft,
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Render("posts/edit", fiber.Map{
|
return c.Render("posts/edit", fiber.Map{
|
||||||
|
|
@ -49,9 +63,13 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return accepts(c, json(func() any {
|
||||||
|
return post
|
||||||
|
}), html(func(c fiber.Ctx) error {
|
||||||
return c.Render("posts/edit", fiber.Map{
|
return c.Render("posts/edit", fiber.Map{
|
||||||
"post": post,
|
"post": post,
|
||||||
})
|
})
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ph PostsHandler) Update(c fiber.Ctx) error {
|
func (ph PostsHandler) Update(c fiber.Ctx) error {
|
||||||
|
|
@ -60,10 +78,82 @@ func (ph PostsHandler) Update(c fiber.Ctx) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
post, err := ph.PostService.PublishPost(c.Context(), req)
|
post, err := ph.PostService.UpdatePost(c.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return accepts(c, json(func() any {
|
||||||
|
// TODO: should be created if brand new
|
||||||
|
return post
|
||||||
|
}), html(func(c fiber.Ctx) error {
|
||||||
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", post.SiteID))
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", post.SiteID))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ph PostsHandler) Patch(c fiber.Ctx) error {
|
||||||
|
log.Println("PATCH")
|
||||||
|
|
||||||
|
postIDStr := c.Params("postID")
|
||||||
|
if postIDStr == "" {
|
||||||
|
return fiber.ErrBadRequest
|
||||||
|
}
|
||||||
|
postID, err := strconv.ParseInt(postIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Request")
|
||||||
|
|
||||||
|
switch req.Action {
|
||||||
|
case "restore":
|
||||||
|
if err := ph.PostService.RestorePost(c.Context(), postID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fiber.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepts(c, json(func() any {
|
||||||
|
return struct{}{}
|
||||||
|
}), html(func(c fiber.Ctx) error {
|
||||||
|
|
||||||
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts"))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ph PostsHandler) Delete(c fiber.Ctx) error {
|
||||||
|
postIDStr := c.Params("postID")
|
||||||
|
if postIDStr == "" {
|
||||||
|
return fiber.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
postID, err := strconv.ParseInt(postIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fiber.ErrBadRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Hard bool `query:"hard"`
|
||||||
|
}
|
||||||
|
if err := c.Bind().Query(&req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ph.PostService.DeletePost(c.Context(), postID, req.Hard); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return accepts(c, json(func() any {
|
||||||
|
return fiber.Map{}
|
||||||
|
}), html(func(c fiber.Ctx) error {
|
||||||
|
return c.Redirect().To("/sites")
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
100
main.go
100
main.go
|
|
@ -1,106 +1,14 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html"
|
"os"
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"lmika.dev/lmika/weiro/cmds"
|
||||||
"github.com/gofiber/fiber/v3/middleware/static"
|
|
||||||
fiber_html "github.com/gofiber/template/html/v3"
|
|
||||||
"github.com/yuin/goldmark"
|
|
||||||
"github.com/yuin/goldmark/extension"
|
|
||||||
"lmika.dev/lmika/weiro/handlers"
|
|
||||||
"lmika.dev/lmika/weiro/handlers/middleware"
|
|
||||||
"lmika.dev/lmika/weiro/providers/db"
|
|
||||||
"lmika.dev/lmika/weiro/services/posts"
|
|
||||||
"lmika.dev/lmika/weiro/services/publisher"
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
dbp, err := db.New("build/weiro.db")
|
if err := cmds.Root().Execute(); err != nil {
|
||||||
if err != nil {
|
os.Exit(1)
|
||||||
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,3 +6,4 @@ var UserRequiredError = errors.New("user required")
|
||||||
var PermissionError = errors.New("permission denied")
|
var PermissionError = errors.New("permission denied")
|
||||||
var NotFoundError = errors.New("not found")
|
var NotFoundError = errors.New("not found")
|
||||||
var SiteRequiredError = errors.New("site required")
|
var SiteRequiredError = errors.New("site required")
|
||||||
|
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@ package models
|
||||||
import "github.com/matoous/go-nanoid/v2"
|
import "github.com/matoous/go-nanoid/v2"
|
||||||
|
|
||||||
func NewNanoID() string {
|
func NewNanoID() string {
|
||||||
id, _ := gonanoid.New(12)
|
id, _ := gonanoid.New(16)
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,36 @@ import (
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatePublished = iota
|
||||||
|
StateDraft
|
||||||
|
)
|
||||||
|
|
||||||
type Post struct {
|
type Post struct {
|
||||||
ID int64
|
ID int64 `json:"id"`
|
||||||
SiteID int64
|
SiteID int64 `json:"site_id"`
|
||||||
GUID string
|
State int `json:"state"`
|
||||||
Title string
|
GUID string `json:"guid"`
|
||||||
Body string
|
Title string `json:"title"`
|
||||||
Slug string
|
Body string `json:"body"`
|
||||||
CreatedAt time.Time
|
Slug string `json:"slug"`
|
||||||
PublishedAt time.Time
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||||
|
DeletedAt time.Time `json:"deleted_at,omitempty"`
|
||||||
|
PublishedAt time.Time `json:"published_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Post) NanoSummary() string {
|
||||||
|
if p.Title != "" {
|
||||||
|
return p.Title
|
||||||
|
}
|
||||||
|
firstWords := firstNWords(p.Body, 7, wordForSummary)
|
||||||
|
if firstWords == "" {
|
||||||
|
firstWords = "(no content)"
|
||||||
|
} else if len(firstWords) < len(p.Body) {
|
||||||
|
return firstWords + "..."
|
||||||
|
}
|
||||||
|
return firstWords
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Post) BestSlug() string {
|
func (p *Post) BestSlug() string {
|
||||||
|
|
@ -46,6 +67,10 @@ func (p *Post) BestSlug() string {
|
||||||
return fmt.Sprintf("/%s/%s", datePart, slugPath)
|
return fmt.Sprintf("/%s/%s", datePart, slugPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func wordForSummary(word string) string {
|
||||||
|
return word
|
||||||
|
}
|
||||||
|
|
||||||
func wordForSlug(word string) string {
|
func wordForSlug(word string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, c := range word {
|
for _, c := range word {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
type PublishTargetType int
|
type PublishTargetType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -8,18 +10,31 @@ const (
|
||||||
PublishTargetTypeNetlify PublishTargetType = 2
|
PublishTargetTypeNetlify PublishTargetType = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func ParsePublishTargetType(s string) (PublishTargetType, error) {
|
||||||
|
switch s {
|
||||||
|
case "localfs":
|
||||||
|
return PublishTargetTypeLocalFS, nil
|
||||||
|
case "netlify":
|
||||||
|
return PublishTargetTypeNetlify, nil
|
||||||
|
default:
|
||||||
|
return PublishTargetTypeNone, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Site struct {
|
type Site struct {
|
||||||
ID int64
|
ID int64
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
|
GUID string
|
||||||
|
Created time.Time
|
||||||
|
|
||||||
Title string
|
Title string
|
||||||
Tagline string
|
Tagline string
|
||||||
//Meta SiteMeta
|
|
||||||
//Posts []*Post
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SitePublishTarget struct {
|
type SitePublishTarget struct {
|
||||||
ID int64
|
ID int64
|
||||||
SiteID int64
|
SiteID int64
|
||||||
|
GUID string
|
||||||
Enabled bool
|
Enabled bool
|
||||||
|
|
||||||
BaseURL string
|
BaseURL string
|
||||||
|
|
@ -27,24 +42,3 @@ type SitePublishTarget struct {
|
||||||
TargetRef string
|
TargetRef string
|
||||||
TargetKey string
|
TargetKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
type SiteMeta struct {
|
|
||||||
Title string `yaml:"title"`
|
|
||||||
Tagline string `yaml:"tagline"`
|
|
||||||
BaseURL string `yaml:"base_url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type PostMeta struct {
|
|
||||||
ID string `yaml:"id"`
|
|
||||||
Title string `yaml:"title"`
|
|
||||||
Date time.Time `yaml:"date"`
|
|
||||||
Tags []string `yaml:"tags"`
|
|
||||||
Slug string `yaml:"slug"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Post struct {
|
|
||||||
Meta PostMeta
|
|
||||||
Content string
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,49 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ValidUserName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
Username string
|
Username string
|
||||||
PasswordHashed []byte
|
PasswordHashed []byte
|
||||||
|
TimeZone string
|
||||||
|
Created time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) SetPassword(pwd string) {
|
||||||
|
bcrypted, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
|
||||||
|
u.PasswordHashed = bcrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) CheckPassword(pwd string) bool {
|
||||||
|
err := bcrypt.CompareHashAndPassword(u.PasswordHashed, []byte(pwd))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) FormatTime(t time.Time) string {
|
||||||
|
if loc := getLocation(u.TimeZone); loc != nil {
|
||||||
|
return t.In(loc).Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
return t.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadedLocation = map[string]*time.Location{}
|
||||||
|
|
||||||
|
func getLocation(tz string) *time.Location {
|
||||||
|
if loc, ok := loadedLocation[tz]; ok {
|
||||||
|
return loc
|
||||||
|
}
|
||||||
|
loc, err := time.LoadLocation(tz)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.Local
|
||||||
|
}
|
||||||
|
loadedLocation[tz] = loc
|
||||||
|
return loc
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -5,6 +5,7 @@
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"esbuild-sass-plugin": "^3.6.0"
|
"esbuild-sass-plugin": "^3.6.0"
|
||||||
},
|
},
|
||||||
|
|
@ -435,6 +436,12 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@hotwired/stimulus": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@parcel/watcher": {
|
"node_modules/@parcel/watcher": {
|
||||||
"version": "2.5.6",
|
"version": "2.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"esbuild": "0.27.3"
|
"esbuild": "0.27.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"esbuild-sass-plugin": "^3.6.0"
|
"esbuild-sass-plugin": "^3.6.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,21 @@ package sqlgen
|
||||||
type Post struct {
|
type Post struct {
|
||||||
ID int64
|
ID int64
|
||||||
SiteID int64
|
SiteID int64
|
||||||
|
State int64
|
||||||
Guid string
|
Guid string
|
||||||
Title string
|
Title string
|
||||||
Body string
|
Body string
|
||||||
Slug string
|
Slug string
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
|
UpdatedAt int64
|
||||||
PublishedAt int64
|
PublishedAt int64
|
||||||
|
DeletedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublishTarget struct {
|
type PublishTarget struct {
|
||||||
ID int64
|
ID int64
|
||||||
SiteID int64
|
SiteID int64
|
||||||
|
Guid string
|
||||||
TargetType string
|
TargetType string
|
||||||
Enabled int64
|
Enabled int64
|
||||||
BaseUrl string
|
BaseUrl string
|
||||||
|
|
@ -28,12 +32,15 @@ type PublishTarget struct {
|
||||||
type Site struct {
|
type Site struct {
|
||||||
ID int64
|
ID int64
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
|
Guid string
|
||||||
Title string
|
Title string
|
||||||
Tagline string
|
Tagline string
|
||||||
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,46 +9,73 @@ import (
|
||||||
"context"
|
"context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hardDeletePost = `-- name: HardDeletePost :exec
|
||||||
|
DELETE FROM posts WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) HardDeletePost(ctx context.Context, id int64) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, hardDeletePost, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const insertPost = `-- name: InsertPost :one
|
const insertPost = `-- name: InsertPost :one
|
||||||
INSERT INTO posts (
|
INSERT INTO posts (
|
||||||
site_id,
|
site_id,
|
||||||
|
state,
|
||||||
guid,
|
guid,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
slug,
|
slug,
|
||||||
created_at,
|
created_at,
|
||||||
published_at
|
updated_at,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
published_at,
|
||||||
|
deleted_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertPostParams struct {
|
type InsertPostParams struct {
|
||||||
SiteID int64
|
SiteID int64
|
||||||
|
State int64
|
||||||
Guid string
|
Guid string
|
||||||
Title string
|
Title string
|
||||||
Body string
|
Body string
|
||||||
Slug string
|
Slug string
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
|
UpdatedAt int64
|
||||||
PublishedAt int64
|
PublishedAt int64
|
||||||
|
DeletedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) {
|
func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) {
|
||||||
row := q.db.QueryRowContext(ctx, insertPost,
|
row := q.db.QueryRowContext(ctx, insertPost,
|
||||||
arg.SiteID,
|
arg.SiteID,
|
||||||
|
arg.State,
|
||||||
arg.Guid,
|
arg.Guid,
|
||||||
arg.Title,
|
arg.Title,
|
||||||
arg.Body,
|
arg.Body,
|
||||||
arg.Slug,
|
arg.Slug,
|
||||||
arg.CreatedAt,
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
arg.PublishedAt,
|
arg.PublishedAt,
|
||||||
|
arg.DeletedAt,
|
||||||
)
|
)
|
||||||
var id int64
|
var id int64
|
||||||
err := row.Scan(&id)
|
err := row.Scan(&id)
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restorePost = `-- name: RestorePost :exec
|
||||||
|
UPDATE posts SET deleted_at = 0 WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) RestorePost(ctx context.Context, id int64) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, restorePost, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const selectPost = `-- name: SelectPost :one
|
const selectPost = `-- name: SelectPost :one
|
||||||
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE id = ? LIMIT 1
|
SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at FROM posts WHERE id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) {
|
func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) {
|
||||||
|
|
@ -57,18 +84,21 @@ func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) {
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.SiteID,
|
&i.SiteID,
|
||||||
|
&i.State,
|
||||||
&i.Guid,
|
&i.Guid,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Body,
|
&i.Body,
|
||||||
&i.Slug,
|
&i.Slug,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
&i.PublishedAt,
|
&i.PublishedAt,
|
||||||
|
&i.DeletedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectPostByGUID = `-- name: SelectPostByGUID :one
|
const selectPostByGUID = `-- name: SelectPostByGUID :one
|
||||||
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE guid = ? LIMIT 1
|
SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at FROM posts WHERE guid = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, error) {
|
func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, error) {
|
||||||
|
|
@ -77,22 +107,37 @@ func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, erro
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.SiteID,
|
&i.SiteID,
|
||||||
|
&i.State,
|
||||||
&i.Guid,
|
&i.Guid,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Body,
|
&i.Body,
|
||||||
&i.Slug,
|
&i.Slug,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
&i.PublishedAt,
|
&i.PublishedAt,
|
||||||
|
&i.DeletedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectPostsOfSite = `-- name: SelectPostsOfSite :many
|
const selectPostsOfSite = `-- name: SelectPostsOfSite :many
|
||||||
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10
|
SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at
|
||||||
|
FROM posts
|
||||||
|
WHERE site_id = ? AND (
|
||||||
|
CASE CAST (?2 AS TEXT)
|
||||||
|
WHEN 'deleted' THEN deleted_at > 0
|
||||||
|
ELSE deleted_at = 0
|
||||||
|
END
|
||||||
|
) ORDER BY created_at DESC LIMIT 10
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post, error) {
|
type SelectPostsOfSiteParams struct {
|
||||||
rows, err := q.db.QueryContext(ctx, selectPostsOfSite, siteID)
|
SiteID int64
|
||||||
|
PostFilter string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSiteParams) ([]Post, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, selectPostsOfSite, arg.SiteID, arg.PostFilter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -103,12 +148,15 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post,
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.SiteID,
|
&i.SiteID,
|
||||||
|
&i.State,
|
||||||
&i.Guid,
|
&i.Guid,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Body,
|
&i.Body,
|
||||||
&i.Slug,
|
&i.Slug,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
&i.PublishedAt,
|
&i.PublishedAt,
|
||||||
|
&i.DeletedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -123,29 +171,52 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post,
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const softDeletePost = `-- name: SoftDeletePost :exec
|
||||||
|
UPDATE posts SET deleted_at = ? WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type SoftDeletePostParams struct {
|
||||||
|
DeletedAt int64
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SoftDeletePost(ctx context.Context, arg SoftDeletePostParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, softDeletePost, arg.DeletedAt, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const updatePost = `-- name: UpdatePost :exec
|
const updatePost = `-- name: UpdatePost :exec
|
||||||
UPDATE posts SET
|
UPDATE posts SET
|
||||||
title = ?,
|
title = ?,
|
||||||
|
state = ?,
|
||||||
body = ?,
|
body = ?,
|
||||||
slug = ?,
|
slug = ?,
|
||||||
published_at = ?
|
updated_at = ?,
|
||||||
|
published_at = ?,
|
||||||
|
deleted_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdatePostParams struct {
|
type UpdatePostParams struct {
|
||||||
Title string
|
Title string
|
||||||
|
State int64
|
||||||
Body string
|
Body string
|
||||||
Slug string
|
Slug string
|
||||||
|
UpdatedAt int64
|
||||||
PublishedAt int64
|
PublishedAt int64
|
||||||
|
DeletedAt int64
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error {
|
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, updatePost,
|
_, err := q.db.ExecContext(ctx, updatePost,
|
||||||
arg.Title,
|
arg.Title,
|
||||||
|
arg.State,
|
||||||
arg.Body,
|
arg.Body,
|
||||||
arg.Slug,
|
arg.Slug,
|
||||||
|
arg.UpdatedAt,
|
||||||
arg.PublishedAt,
|
arg.PublishedAt,
|
||||||
|
arg.DeletedAt,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,19 @@ import (
|
||||||
const insertPublishTarget = `-- name: InsertPublishTarget :one
|
const insertPublishTarget = `-- name: InsertPublishTarget :one
|
||||||
INSERT INTO publish_targets (
|
INSERT INTO publish_targets (
|
||||||
site_id,
|
site_id,
|
||||||
|
guid,
|
||||||
target_type,
|
target_type,
|
||||||
enabled,
|
enabled,
|
||||||
base_url,
|
base_url,
|
||||||
target_ref,
|
target_ref,
|
||||||
target_key
|
target_key
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertPublishTargetParams struct {
|
type InsertPublishTargetParams struct {
|
||||||
SiteID int64
|
SiteID int64
|
||||||
|
Guid string
|
||||||
TargetType string
|
TargetType string
|
||||||
Enabled int64
|
Enabled int64
|
||||||
BaseUrl string
|
BaseUrl string
|
||||||
|
|
@ -33,6 +35,7 @@ type InsertPublishTargetParams struct {
|
||||||
func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTargetParams) (int64, error) {
|
func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTargetParams) (int64, error) {
|
||||||
row := q.db.QueryRowContext(ctx, insertPublishTarget,
|
row := q.db.QueryRowContext(ctx, insertPublishTarget,
|
||||||
arg.SiteID,
|
arg.SiteID,
|
||||||
|
arg.Guid,
|
||||||
arg.TargetType,
|
arg.TargetType,
|
||||||
arg.Enabled,
|
arg.Enabled,
|
||||||
arg.BaseUrl,
|
arg.BaseUrl,
|
||||||
|
|
@ -45,7 +48,7 @@ func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTarg
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectPublishTargetsOfSite = `-- name: SelectPublishTargetsOfSite :many
|
const selectPublishTargetsOfSite = `-- name: SelectPublishTargetsOfSite :many
|
||||||
SELECT id, site_id, target_type, enabled, base_url, target_ref, target_key FROM publish_targets WHERE site_id = ?
|
SELECT id, site_id, guid, target_type, enabled, base_url, target_ref, target_key FROM publish_targets WHERE site_id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64) ([]PublishTarget, error) {
|
func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64) ([]PublishTarget, error) {
|
||||||
|
|
@ -60,6 +63,7 @@ func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64)
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.SiteID,
|
&i.SiteID,
|
||||||
|
&i.Guid,
|
||||||
&i.TargetType,
|
&i.TargetType,
|
||||||
&i.Enabled,
|
&i.Enabled,
|
||||||
&i.BaseUrl,
|
&i.BaseUrl,
|
||||||
|
|
|
||||||
|
|
@ -7,32 +7,116 @@ package sqlgen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hasUsersAndSites = `-- name: HasUsersAndSites :one
|
||||||
|
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) HasUsersAndSites(ctx context.Context) (sql.NullBool, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, hasUsersAndSites)
|
||||||
|
var has_users_and_sites sql.NullBool
|
||||||
|
err := row.Scan(&has_users_and_sites)
|
||||||
|
return has_users_and_sites, err
|
||||||
|
}
|
||||||
|
|
||||||
const insertSite = `-- name: InsertSite :one
|
const insertSite = `-- name: InsertSite :one
|
||||||
INSERT INTO sites (
|
INSERT INTO sites (
|
||||||
owner_id,
|
owner_id,
|
||||||
|
guid,
|
||||||
title,
|
title,
|
||||||
tagline
|
tagline,
|
||||||
) VALUES (?, ?, ?)
|
created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertSiteParams struct {
|
type InsertSiteParams struct {
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
|
Guid string
|
||||||
Title string
|
Title string
|
||||||
Tagline string
|
Tagline string
|
||||||
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) {
|
func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) {
|
||||||
row := q.db.QueryRowContext(ctx, insertSite, arg.OwnerID, arg.Title, arg.Tagline)
|
row := q.db.QueryRowContext(ctx, insertSite,
|
||||||
|
arg.OwnerID,
|
||||||
|
arg.Guid,
|
||||||
|
arg.Title,
|
||||||
|
arg.Tagline,
|
||||||
|
arg.CreatedAt,
|
||||||
|
)
|
||||||
var id int64
|
var id int64
|
||||||
err := row.Scan(&id)
|
err := row.Scan(&id)
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectAllSitesWithOwners = `-- name: SelectAllSitesWithOwners :many
|
||||||
|
SELECT s.id, s.guid, s.title, s.owner_id, u.username
|
||||||
|
FROM sites s
|
||||||
|
JOIN users u ON s.owner_id = u.id
|
||||||
|
ORDER BY s.title ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type SelectAllSitesWithOwnersRow struct {
|
||||||
|
ID int64
|
||||||
|
Guid string
|
||||||
|
Title string
|
||||||
|
OwnerID int64
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) SelectAllSitesWithOwners(ctx context.Context) ([]SelectAllSitesWithOwnersRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, selectAllSitesWithOwners)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []SelectAllSitesWithOwnersRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i SelectAllSitesWithOwnersRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Title,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.Username,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectSiteByGUID = `-- name: SelectSiteByGUID :one
|
||||||
|
SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE guid = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, selectSiteByGUID, guid)
|
||||||
|
var i Site
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.OwnerID,
|
||||||
|
&i.Guid,
|
||||||
|
&i.Title,
|
||||||
|
&i.Tagline,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const selectSiteByID = `-- name: SelectSiteByID :one
|
const selectSiteByID = `-- name: SelectSiteByID :one
|
||||||
SELECT id, owner_id, title, tagline FROM sites WHERE id = ?
|
SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
|
func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
|
||||||
|
|
@ -41,14 +125,16 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.OwnerID,
|
&i.OwnerID,
|
||||||
|
&i.Guid,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Tagline,
|
&i.Tagline,
|
||||||
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many
|
const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many
|
||||||
SELECT id, owner_id, title, tagline FROM sites WHERE owner_id = ?
|
SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE owner_id = ? ORDER BY title ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) {
|
func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) {
|
||||||
|
|
@ -63,8 +149,10 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
&i.OwnerID,
|
&i.OwnerID,
|
||||||
|
&i.Guid,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Tagline,
|
&i.Tagline,
|
||||||
|
&i.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,29 +10,51 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const insertUser = `-- name: InsertUser :one
|
const insertUser = `-- name: InsertUser :one
|
||||||
INSERT INTO users (username, password) VALUES (?, ?) RETURNING id
|
INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertUserParams struct {
|
type InsertUserParams struct {
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (int64, error) {
|
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (int64, error) {
|
||||||
row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password)
|
row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password, arg.CreatedAt)
|
||||||
var id int64
|
var id int64
|
||||||
err := row.Scan(&id)
|
err := row.Scan(&id)
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectUserByID = `-- name: SelectUserByID :one
|
||||||
|
SELECT id, username, password, created_at FROM users WHERE id = ? LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) SelectUserByID(ctx context.Context, id int64) (User, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, selectUserByID, id)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Username,
|
||||||
|
&i.Password,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const selectUserByUsername = `-- name: SelectUserByUsername :one
|
const selectUserByUsername = `-- name: SelectUserByUsername :one
|
||||||
SELECT id, username, password FROM users WHERE username = ?
|
SELECT id, username, password, created_at FROM users WHERE username = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SelectUserByUsername(ctx context.Context, username string) (User, error) {
|
func (q *Queries) SelectUserByUsername(ctx context.Context, username string) (User, error) {
|
||||||
row := q.db.QueryRowContext(ctx, selectUserByUsername, username)
|
row := q.db.QueryRowContext(ctx, selectUserByUsername, username)
|
||||||
var i User
|
var i User
|
||||||
err := row.Scan(&i.ID, &i.Username, &i.Password)
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Username,
|
||||||
|
&i.Password,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,16 @@ import (
|
||||||
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64) ([]*models.Post, error) {
|
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) ([]*models.Post, error) {
|
||||||
rows, err := db.queries.SelectPostsOfSite(ctx, siteID)
|
var filter = ""
|
||||||
|
if showDeleted {
|
||||||
|
filter = "deleted"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.queries.SelectPostsOfSite(ctx, sqlgen.SelectPostsOfSiteParams{
|
||||||
|
SiteID: siteID,
|
||||||
|
PostFilter: filter,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -43,12 +51,15 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
|
||||||
if post.ID == 0 {
|
if post.ID == 0 {
|
||||||
newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{
|
newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{
|
||||||
SiteID: post.SiteID,
|
SiteID: post.SiteID,
|
||||||
|
State: int64(post.State),
|
||||||
Guid: post.GUID,
|
Guid: post.GUID,
|
||||||
Title: post.Title,
|
Title: post.Title,
|
||||||
Body: post.Body,
|
Body: post.Body,
|
||||||
Slug: post.Slug,
|
Slug: post.Slug,
|
||||||
CreatedAt: post.CreatedAt.Unix(),
|
CreatedAt: timeToInt(post.CreatedAt),
|
||||||
PublishedAt: post.PublishedAt.Unix(),
|
UpdatedAt: timeToInt(post.UpdatedAt),
|
||||||
|
PublishedAt: timeToInt(post.PublishedAt),
|
||||||
|
DeletedAt: timeToInt(post.DeletedAt),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -59,10 +70,13 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
|
||||||
|
|
||||||
return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{
|
return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{
|
||||||
ID: post.ID,
|
ID: post.ID,
|
||||||
|
State: int64(post.State),
|
||||||
Title: post.Title,
|
Title: post.Title,
|
||||||
Body: post.Body,
|
Body: post.Body,
|
||||||
Slug: post.Slug,
|
Slug: post.Slug,
|
||||||
PublishedAt: post.PublishedAt.Unix(),
|
UpdatedAt: timeToInt(post.UpdatedAt),
|
||||||
|
PublishedAt: timeToInt(post.PublishedAt),
|
||||||
|
DeletedAt: timeToInt(post.DeletedAt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,11 +84,21 @@ func dbPostToPost(row sqlgen.Post) *models.Post {
|
||||||
return &models.Post{
|
return &models.Post{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
SiteID: row.SiteID,
|
SiteID: row.SiteID,
|
||||||
|
State: int(row.State),
|
||||||
GUID: row.Guid,
|
GUID: row.Guid,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
Body: row.Body,
|
Body: row.Body,
|
||||||
Slug: row.Slug,
|
Slug: row.Slug,
|
||||||
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
|
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
|
||||||
|
UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(),
|
||||||
PublishedAt: time.Unix(row.PublishedAt, 0).UTC(),
|
PublishedAt: time.Unix(row.PublishedAt, 0).UTC(),
|
||||||
|
DeletedAt: time.Unix(row.DeletedAt, 0).UTC(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func timeToInt(t time.Time) int64 {
|
||||||
|
if t.IsZero() {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return t.Unix()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package db
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Southclaws/fault"
|
"github.com/Southclaws/fault"
|
||||||
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||||||
|
|
@ -38,3 +39,18 @@ func New(dbFile string) (*Provider, error) {
|
||||||
func (db *Provider) Close() error {
|
func (db *Provider) Close() error {
|
||||||
return db.drvr.Close()
|
return db.drvr.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *Provider) SoftDeletePost(ctx context.Context, postID int64) error {
|
||||||
|
return db.queries.SoftDeletePost(ctx, sqlgen.SoftDeletePostParams{
|
||||||
|
DeletedAt: time.Now().Unix(),
|
||||||
|
ID: postID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) HardDeletePost(ctx context.Context, postID int64) error {
|
||||||
|
return db.queries.HardDeletePost(ctx, postID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) RestorePost(ctx context.Context, postID int64) error {
|
||||||
|
return db.queries.RestorePost(ctx, postID)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ func TestProvider_Posts(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotZero(t, post.ID)
|
assert.NotZero(t, post.ID)
|
||||||
|
|
||||||
posts, err := p.SelectPostsOfSite(ctx, site.ID)
|
posts, err := p.SelectPostsOfSite(ctx, site.ID, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, posts, 1)
|
require.Len(t, posts, 1)
|
||||||
assert.Equal(t, post.ID, posts[0].ID)
|
assert.Equal(t, post.ID, posts[0].ID)
|
||||||
|
|
@ -205,7 +205,7 @@ func TestProvider_Posts(t *testing.T) {
|
||||||
require.NoError(t, p.SavePost(ctx, post1))
|
require.NoError(t, p.SavePost(ctx, post1))
|
||||||
require.NoError(t, p.SavePost(ctx, post2))
|
require.NoError(t, p.SavePost(ctx, post2))
|
||||||
|
|
||||||
posts, err := p.SelectPostsOfSite(ctx, site2.ID)
|
posts, err := p.SelectPostsOfSite(ctx, site2.ID, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, posts, 2)
|
require.Len(t, posts, 2)
|
||||||
assert.Equal(t, "New Post", posts[0].Title)
|
assert.Equal(t, "New Post", posts[0].Title)
|
||||||
|
|
@ -220,7 +220,7 @@ func TestProvider_Posts(t *testing.T) {
|
||||||
}
|
}
|
||||||
require.NoError(t, p.SaveSite(ctx, emptySite))
|
require.NoError(t, p.SaveSite(ctx, emptySite))
|
||||||
|
|
||||||
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID)
|
posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, posts)
|
assert.Empty(t, posts)
|
||||||
})
|
})
|
||||||
|
|
@ -248,6 +248,7 @@ func TestProvider_PublishTargets(t *testing.T) {
|
||||||
target := &models.SitePublishTarget{
|
target := &models.SitePublishTarget{
|
||||||
SiteID: site.ID,
|
SiteID: site.ID,
|
||||||
TargetType: "netlify",
|
TargetType: "netlify",
|
||||||
|
GUID: "target-001",
|
||||||
BaseURL: "https://example.netlify.app",
|
BaseURL: "https://example.netlify.app",
|
||||||
TargetRef: "netlify-site-123",
|
TargetRef: "netlify-site-123",
|
||||||
TargetKey: "secret-key",
|
TargetKey: "secret-key",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ func (db *Provider) SelectPublishTargetsOfSite(ctx context.Context, siteID int64
|
||||||
targets[i] = models.SitePublishTarget{
|
targets[i] = models.SitePublishTarget{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
SiteID: row.SiteID,
|
SiteID: row.SiteID,
|
||||||
|
GUID: row.Guid,
|
||||||
Enabled: row.Enabled != 0,
|
Enabled: row.Enabled != 0,
|
||||||
TargetType: row.TargetType,
|
TargetType: row.TargetType,
|
||||||
BaseURL: row.BaseUrl,
|
BaseURL: row.BaseUrl,
|
||||||
|
|
@ -38,6 +39,7 @@ func (db *Provider) SavePublishTarget(ctx context.Context, target *models.SitePu
|
||||||
newID, err := db.queries.InsertPublishTarget(ctx, sqlgen.InsertPublishTargetParams{
|
newID, err := db.queries.InsertPublishTarget(ctx, sqlgen.InsertPublishTargetParams{
|
||||||
SiteID: target.SiteID,
|
SiteID: target.SiteID,
|
||||||
TargetType: target.TargetType,
|
TargetType: target.TargetType,
|
||||||
|
Guid: target.GUID,
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
BaseUrl: target.BaseURL,
|
BaseUrl: target.BaseURL,
|
||||||
TargetRef: target.TargetRef,
|
TargetRef: target.TargetRef,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||||||
|
|
@ -13,12 +14,16 @@ func (db *Provider) SelectSiteByID(ctx context.Context, id int64) (models.Site,
|
||||||
return models.Site{}, err
|
return models.Site{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return models.Site{
|
return dbSiteToSite(row), nil
|
||||||
ID: row.ID,
|
}
|
||||||
OwnerID: row.OwnerID,
|
|
||||||
Title: row.Title,
|
func (db *Provider) SelectSiteByGUID(ctx context.Context, guid string) (models.Site, error) {
|
||||||
Tagline: row.Tagline,
|
row, err := db.queries.SelectSiteByGUID(ctx, guid)
|
||||||
}, nil
|
if err != nil {
|
||||||
|
return models.Site{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbSiteToSite(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]models.Site, error) {
|
func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]models.Site, error) {
|
||||||
|
|
@ -29,12 +34,7 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) (
|
||||||
|
|
||||||
sites := make([]models.Site, len(rows))
|
sites := make([]models.Site, len(rows))
|
||||||
for i, row := range rows {
|
for i, row := range rows {
|
||||||
sites[i] = models.Site{
|
sites[i] = dbSiteToSite(row)
|
||||||
ID: row.ID,
|
|
||||||
OwnerID: row.OwnerID,
|
|
||||||
Title: row.Title,
|
|
||||||
Tagline: row.Tagline,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return sites, nil
|
return sites, nil
|
||||||
}
|
}
|
||||||
|
|
@ -43,8 +43,10 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
|
||||||
if site.ID == 0 {
|
if site.ID == 0 {
|
||||||
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
|
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
|
||||||
OwnerID: site.OwnerID,
|
OwnerID: site.OwnerID,
|
||||||
|
Guid: site.GUID,
|
||||||
Title: site.Title,
|
Title: site.Title,
|
||||||
Tagline: site.Tagline,
|
Tagline: site.Tagline,
|
||||||
|
CreatedAt: timeToInt(site.Created),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -56,3 +58,49 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
|
||||||
// No update query defined in sqlgen yet
|
// No update query defined in sqlgen yet
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *Provider) HasUsersAndSites(ctx context.Context) (bool, error) {
|
||||||
|
nullBool, err := db.queries.HasUsersAndSites(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return nullBool.Valid && nullBool.Bool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteWithOwner struct {
|
||||||
|
ID int64
|
||||||
|
GUID string
|
||||||
|
Title string
|
||||||
|
OwnerID int64
|
||||||
|
Username string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) SelectAllSitesWithOwners(ctx context.Context) ([]SiteWithOwner, error) {
|
||||||
|
rows, err := db.queries.SelectAllSitesWithOwners(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sites := make([]SiteWithOwner, len(rows))
|
||||||
|
for i, row := range rows {
|
||||||
|
sites[i] = SiteWithOwner{
|
||||||
|
ID: row.ID,
|
||||||
|
GUID: row.Guid,
|
||||||
|
Title: row.Title,
|
||||||
|
OwnerID: row.OwnerID,
|
||||||
|
Username: row.Username,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sites, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dbSiteToSite(row sqlgen.Site) models.Site {
|
||||||
|
return models.Site{
|
||||||
|
ID: row.ID,
|
||||||
|
OwnerID: row.OwnerID,
|
||||||
|
GUID: row.Guid,
|
||||||
|
Title: row.Title,
|
||||||
|
Tagline: row.Tagline,
|
||||||
|
Created: time.Unix(row.CreatedAt, 0).UTC(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package db
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||||||
|
|
@ -14,16 +15,16 @@ func (db *Provider) SelectUserByUsername(ctx context.Context, username string) (
|
||||||
return models.User{}, err
|
return models.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
pwdBytes, err := base64.StdEncoding.DecodeString(res.Password)
|
return dbUserToUser(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *Provider) SelectUserByID(ctx context.Context, userID int64) (models.User, error) {
|
||||||
|
res, err := db.queries.SelectUserByID(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.User{}, err
|
return models.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return models.User{
|
return dbUserToUser(res)
|
||||||
ID: res.ID,
|
|
||||||
Username: res.Username,
|
|
||||||
PasswordHashed: pwdBytes,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
|
func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
|
||||||
|
|
@ -33,6 +34,7 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
|
||||||
newID, err := db.queries.InsertUser(ctx, sqlgen.InsertUserParams{
|
newID, err := db.queries.InsertUser(ctx, sqlgen.InsertUserParams{
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Password: hashedPassword,
|
Password: hashedPassword,
|
||||||
|
CreatedAt: timeToInt(user.Created),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -47,3 +49,17 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
|
||||||
Password: hashedPassword,
|
Password: hashedPassword,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dbUserToUser(res sqlgen.User) (models.User, error) {
|
||||||
|
pwdBytes, err := base64.StdEncoding.DecodeString(res.Password)
|
||||||
|
if err != nil {
|
||||||
|
return models.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return models.User{
|
||||||
|
ID: res.ID,
|
||||||
|
Username: res.Username,
|
||||||
|
PasswordHashed: pwdBytes,
|
||||||
|
Created: time.Unix(res.CreatedAt, 0).UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
46
services/auth/service.go
Normal file
46
services/auth/service.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/lmika/weiro/providers/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *db.Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *db.Provider) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Login(ctx context.Context, username, password string) (models.User, error) {
|
||||||
|
user, err := s.db.SelectUserByUsername(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return models.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.CheckPassword(password) {
|
||||||
|
return models.User{}, errors.New("invalid password")
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetUser(ctx context.Context, userID int64) (models.User, error) {
|
||||||
|
return s.db.SelectUserByID(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetPassword(ctx context.Context, username, password string) (models.User, error) {
|
||||||
|
user, err := s.db.SelectUserByUsername(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
return models.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user.SetPassword(password)
|
||||||
|
return user, s.db.SaveUser(ctx, &user)
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ package posts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
|
@ -12,9 +13,10 @@ type CreatePostParams struct {
|
||||||
GUID string `form:"guid" json:"guid"`
|
GUID string `form:"guid" json:"guid"`
|
||||||
Title string `form:"title" json:"title"`
|
Title string `form:"title" json:"title"`
|
||||||
Body string `form:"body" json:"body"`
|
Body string `form:"body" json:"body"`
|
||||||
|
Action string `form:"action" json:"action"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
|
func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
|
||||||
site, ok := models.GetSite(ctx)
|
site, ok := models.GetSite(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, models.SiteRequiredError
|
return nil, models.SiteRequiredError
|
||||||
|
|
@ -27,16 +29,27 @@ func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*mo
|
||||||
|
|
||||||
post.Title = params.Title
|
post.Title = params.Title
|
||||||
post.Body = params.Body
|
post.Body = params.Body
|
||||||
post.PublishedAt = time.Now()
|
post.UpdatedAt = time.Now()
|
||||||
post.Slug = post.BestSlug()
|
post.Slug = post.BestSlug()
|
||||||
|
oldState := post.State
|
||||||
|
|
||||||
|
switch strings.ToLower(params.Action) {
|
||||||
|
case "publish":
|
||||||
|
post.State = models.StatePublished
|
||||||
|
post.PublishedAt = time.Now()
|
||||||
|
case "save draft":
|
||||||
|
post.State = models.StateDraft
|
||||||
|
post.PublishedAt = time.Time{}
|
||||||
|
default:
|
||||||
|
// Leave unchanged
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.db.SavePost(ctx, post); err != nil {
|
if err := s.db.SavePost(ctx, post); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: do on separate thread
|
if oldState != post.State || post.State == models.StatePublished {
|
||||||
if err := s.publisher.Publish(ctx, site); err != nil {
|
s.publisher.Queue(site)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return post, nil
|
return post, nil
|
||||||
|
|
@ -59,6 +72,7 @@ func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, param
|
||||||
GUID: params.GUID,
|
GUID: params.GUID,
|
||||||
Title: params.Title,
|
Title: params.Title,
|
||||||
Body: params.Body,
|
Body: params.Body,
|
||||||
|
State: models.StateDraft,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
return post, nil
|
return post, nil
|
||||||
|
|
|
||||||
67
services/posts/delete.go
Normal file
67
services/posts/delete.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package posts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
deleteDebounce = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) DeletePost(ctx context.Context, pid int64, hardDelete bool) error {
|
||||||
|
post, site, err := s.fetchPostAndSite(ctx, pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if hardDelete && post.DeletedAt.Unix() > 0 {
|
||||||
|
delta := time.Now().Sub(post.DeletedAt)
|
||||||
|
if delta < deleteDebounce {
|
||||||
|
return models.DeleteDebounceError
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.HardDeletePost(ctx, post.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := s.db.SoftDeletePost(ctx, post.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publisher.Queue(site)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) RestorePost(ctx context.Context, pid int64) error {
|
||||||
|
post, site, err := s.fetchPostAndSite(ctx, pid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.RestorePost(ctx, post.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.publisher.Queue(site)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) fetchPostAndSite(ctx context.Context, pid int64) (*models.Post, models.Site, error) {
|
||||||
|
site, ok := models.GetSite(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, models.Site{}, models.SiteRequiredError
|
||||||
|
}
|
||||||
|
|
||||||
|
post, err := s.db.SelectPost(ctx, pid)
|
||||||
|
if err != nil {
|
||||||
|
return nil, models.Site{}, err
|
||||||
|
} else if post.SiteID != site.ID {
|
||||||
|
return nil, models.Site{}, models.NotFoundError
|
||||||
|
}
|
||||||
|
return post, site, nil
|
||||||
|
}
|
||||||
|
|
@ -6,13 +6,13 @@ import (
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) ListPosts(ctx context.Context) ([]*models.Post, error) {
|
func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) {
|
||||||
site, ok := models.GetSite(ctx)
|
site, ok := models.GetSite(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, models.SiteRequiredError
|
return nil, models.SiteRequiredError
|
||||||
}
|
}
|
||||||
|
|
||||||
posts, err := s.db.SelectPostsOfSite(ctx, site.ID)
|
posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -21,16 +21,9 @@ func (s *Service) ListPosts(ctx context.Context) ([]*models.Post, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) {
|
func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) {
|
||||||
site, ok := models.GetSite(ctx)
|
post, _, err := s.fetchPostAndSite(ctx, pid)
|
||||||
if !ok {
|
|
||||||
return nil, models.SiteRequiredError
|
|
||||||
}
|
|
||||||
|
|
||||||
post, err := s.db.SelectPost(ctx, pid)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if post.SiteID != site.ID {
|
|
||||||
return nil, models.NotFoundError
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return post, nil
|
return post, nil
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import (
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
db *db.Provider
|
db *db.Provider
|
||||||
publisher *publisher.Publisher
|
publisher *publisher.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(db *db.Provider, publisher *publisher.Publisher) *Service {
|
func New(db *db.Provider, publisher *publisher.Queue) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
db: db,
|
db: db,
|
||||||
publisher: publisher,
|
publisher: publisher,
|
||||||
|
|
|
||||||
44
services/publisher/pqueue.go
Normal file
44
services/publisher/pqueue.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package publisher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Queue struct {
|
||||||
|
publisher *Publisher
|
||||||
|
pending chan models.Site
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewQueue(publisher *Publisher) *Queue {
|
||||||
|
return &Queue{
|
||||||
|
publisher: publisher,
|
||||||
|
pending: make(chan models.Site, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) Queue(site models.Site) bool {
|
||||||
|
select {
|
||||||
|
case q.pending <- site:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queue) Start(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case site := <-q.pending:
|
||||||
|
if err := q.publisher.Publish(ctx, site); err != nil {
|
||||||
|
log.Printf("error publishing site: %v", err)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all content of site
|
// Fetch all content of site
|
||||||
posts, err := p.db.SelectPostsOfSite(ctx, site.ID)
|
posts, err := p.db.SelectPostsOfSite(ctx, site.ID, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
services/services.go
Normal file
47
services/services.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"lmika.dev/lmika/weiro/config"
|
||||||
|
"lmika.dev/lmika/weiro/providers/db"
|
||||||
|
"lmika.dev/lmika/weiro/services/auth"
|
||||||
|
"lmika.dev/lmika/weiro/services/posts"
|
||||||
|
"lmika.dev/lmika/weiro/services/publisher"
|
||||||
|
"lmika.dev/lmika/weiro/services/sites"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Services struct {
|
||||||
|
DB *db.Provider
|
||||||
|
Auth *auth.Service
|
||||||
|
Publisher *publisher.Publisher
|
||||||
|
PublisherQueue *publisher.Queue
|
||||||
|
Posts *posts.Service
|
||||||
|
Sites *sites.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.Config) (*Services, error) {
|
||||||
|
dbp, err := db.New(filepath.Join(cfg.DataDir, "weiro.db"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
authSvc := auth.New(dbp)
|
||||||
|
publisherSvc := publisher.New(dbp)
|
||||||
|
publisherQueue := publisher.NewQueue(publisherSvc)
|
||||||
|
postService := posts.New(dbp, publisherQueue)
|
||||||
|
siteService := sites.New(dbp)
|
||||||
|
|
||||||
|
return &Services{
|
||||||
|
DB: dbp,
|
||||||
|
Auth: authSvc,
|
||||||
|
Publisher: publisherSvc,
|
||||||
|
PublisherQueue: publisherQueue,
|
||||||
|
Posts: postService,
|
||||||
|
Sites: siteService,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Services) Close() error {
|
||||||
|
return s.DB.Close()
|
||||||
|
}
|
||||||
128
services/sites/services.go
Normal file
128
services/sites/services.go
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
package sites
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-ozzo/ozzo-validation/v4"
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/lmika/weiro/providers/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *db.Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(dbp *db.Provider) *Service {
|
||||||
|
return &Service{
|
||||||
|
db: dbp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HasUsersAsSites(ctx context.Context) (bool, error) {
|
||||||
|
return s.db.HasUsersAndSites(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, error) {
|
||||||
|
sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return models.Site{}, err
|
||||||
|
} else if len(sites) == 0 {
|
||||||
|
return models.Site{}, errors.New("no sites found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sites[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type FirstRunRequest struct {
|
||||||
|
Username string `form:"username"`
|
||||||
|
Password1 string `form:"password1"`
|
||||||
|
Password2 string `form:"password2"`
|
||||||
|
SiteName string `form:"siteName"`
|
||||||
|
SiteURL string `form:"siteUrl"`
|
||||||
|
NetlifySiteID string `form:"netlifySiteId"`
|
||||||
|
NetlifyAPIKey string `form:"netlifyAPIToken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (frr FirstRunRequest) Validate() error {
|
||||||
|
return validation.ValidateStruct(&frr,
|
||||||
|
validation.Field(&frr.Username, validation.Required, validation.Match(models.ValidUserName)),
|
||||||
|
validation.Field(&frr.Password1, validation.Required),
|
||||||
|
validation.Field(&frr.Password2, validation.Required, validation.By(stringEquals(frr.Password1))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser models.User, newSite models.Site, _ error) {
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
return newUser, newSite, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSite, err := s.db.HasUsersAndSites(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return newUser, newSite, err
|
||||||
|
} else if hasSite {
|
||||||
|
return newUser, newSite, errors.New("user and site already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
newUser = models.User{
|
||||||
|
Username: req.Username,
|
||||||
|
TimeZone: "UTC",
|
||||||
|
Created: time.Now(),
|
||||||
|
}
|
||||||
|
newUser.SetPassword(req.Password1)
|
||||||
|
if err := s.db.SaveUser(ctx, &newUser); err != nil {
|
||||||
|
return newUser, newSite, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newSite = models.Site{
|
||||||
|
Title: defaultIfEmpty(req.SiteName, "New Site"),
|
||||||
|
GUID: models.NewNanoID(),
|
||||||
|
OwnerID: newUser.ID,
|
||||||
|
Created: time.Now(),
|
||||||
|
}
|
||||||
|
if err := s.db.SaveSite(ctx, &newSite); err != nil {
|
||||||
|
return newUser, newSite, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNetlifyConfig := req.SiteURL != "" && req.NetlifySiteID != "" && req.NetlifyAPIKey != ""
|
||||||
|
if hasNetlifyConfig {
|
||||||
|
target := models.SitePublishTarget{
|
||||||
|
SiteID: newSite.ID,
|
||||||
|
Enabled: true,
|
||||||
|
GUID: models.NewNanoID(),
|
||||||
|
BaseURL: req.SiteURL,
|
||||||
|
TargetType: "netlify",
|
||||||
|
TargetRef: req.NetlifySiteID,
|
||||||
|
TargetKey: req.NetlifyAPIKey,
|
||||||
|
}
|
||||||
|
if err := s.db.SavePublishTarget(ctx, &target); err != nil {
|
||||||
|
return newUser, newSite, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newUser, newSite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, error) {
|
||||||
|
user, ok := models.GetUser(ctx)
|
||||||
|
if !ok {
|
||||||
|
return models.Site{}, models.UserRequiredError
|
||||||
|
}
|
||||||
|
|
||||||
|
site, err := s.db.SelectSiteByID(ctx, siteID)
|
||||||
|
if err != nil {
|
||||||
|
return models.Site{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if site.OwnerID != user.ID {
|
||||||
|
return models.Site{}, fiber.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
return site, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListAllSitesWithOwners(ctx context.Context) ([]db.SiteWithOwner, error) {
|
||||||
|
return s.db.SelectAllSitesWithOwners(ctx)
|
||||||
|
}
|
||||||
23
services/sites/utils.go
Normal file
23
services/sites/utils.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package sites
|
||||||
|
|
||||||
|
import (
|
||||||
|
"emperror.dev/errors"
|
||||||
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func stringEquals(str string) validation.RuleFunc {
|
||||||
|
return func(value interface{}) error {
|
||||||
|
s, _ := value.(string)
|
||||||
|
if s != str {
|
||||||
|
return errors.New("unexpected string")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultIfEmpty(value string, defaultValue string) string {
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
-- name: SelectPostsOfSite :many
|
-- name: SelectPostsOfSite :many
|
||||||
SELECT * FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10;
|
SELECT *
|
||||||
|
FROM posts
|
||||||
|
WHERE site_id = ? AND (
|
||||||
|
CASE CAST (sqlc.arg(post_filter) AS TEXT)
|
||||||
|
WHEN 'deleted' THEN deleted_at > 0
|
||||||
|
ELSE deleted_at = 0
|
||||||
|
END
|
||||||
|
) ORDER BY created_at DESC LIMIT 10;
|
||||||
|
|
||||||
-- name: SelectPost :one
|
-- name: SelectPost :one
|
||||||
SELECT * FROM posts WHERE id = ? LIMIT 1;
|
SELECT * FROM posts WHERE id = ? LIMIT 1;
|
||||||
|
|
@ -10,19 +17,34 @@ SELECT * FROM posts WHERE guid = ? LIMIT 1;
|
||||||
-- name: InsertPost :one
|
-- name: InsertPost :one
|
||||||
INSERT INTO posts (
|
INSERT INTO posts (
|
||||||
site_id,
|
site_id,
|
||||||
|
state,
|
||||||
guid,
|
guid,
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
slug,
|
slug,
|
||||||
created_at,
|
created_at,
|
||||||
published_at
|
updated_at,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
published_at,
|
||||||
|
deleted_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
|
|
||||||
-- name: UpdatePost :exec
|
-- name: UpdatePost :exec
|
||||||
UPDATE posts SET
|
UPDATE posts SET
|
||||||
title = ?,
|
title = ?,
|
||||||
|
state = ?,
|
||||||
body = ?,
|
body = ?,
|
||||||
slug = ?,
|
slug = ?,
|
||||||
published_at = ?
|
updated_at = ?,
|
||||||
|
published_at = ?,
|
||||||
|
deleted_at = ?
|
||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: SoftDeletePost :exec
|
||||||
|
UPDATE posts SET deleted_at = ? WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: RestorePost :exec
|
||||||
|
UPDATE posts SET deleted_at = 0 WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: HardDeletePost :exec
|
||||||
|
DELETE FROM posts WHERE id = ?;
|
||||||
|
|
@ -4,10 +4,11 @@ SELECT * FROM publish_targets WHERE site_id = ?;
|
||||||
-- name: InsertPublishTarget :one
|
-- name: InsertPublishTarget :one
|
||||||
INSERT INTO publish_targets (
|
INSERT INTO publish_targets (
|
||||||
site_id,
|
site_id,
|
||||||
|
guid,
|
||||||
target_type,
|
target_type,
|
||||||
enabled,
|
enabled,
|
||||||
base_url,
|
base_url,
|
||||||
target_ref,
|
target_ref,
|
||||||
target_key
|
target_key
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
|
|
@ -1,13 +1,27 @@
|
||||||
-- name: SelectSitesOwnedByUser :many
|
-- name: SelectSitesOwnedByUser :many
|
||||||
SELECT * FROM sites WHERE owner_id = ?;
|
SELECT * FROM sites WHERE owner_id = ? ORDER BY title ASC;
|
||||||
|
|
||||||
-- name: SelectSiteByID :one
|
-- name: SelectSiteByID :one
|
||||||
SELECT * FROM sites WHERE id = ?;
|
SELECT * FROM sites WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: SelectSiteByGUID :one
|
||||||
|
SELECT * FROM sites WHERE guid = ?;
|
||||||
|
|
||||||
-- name: InsertSite :one
|
-- name: InsertSite :one
|
||||||
INSERT INTO sites (
|
INSERT INTO sites (
|
||||||
owner_id,
|
owner_id,
|
||||||
|
guid,
|
||||||
title,
|
title,
|
||||||
tagline
|
tagline,
|
||||||
) VALUES (?, ?, ?)
|
created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
|
|
||||||
|
-- name: HasUsersAndSites :one
|
||||||
|
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites;
|
||||||
|
|
||||||
|
-- name: SelectAllSitesWithOwners :many
|
||||||
|
SELECT s.id, s.guid, s.title, s.owner_id, u.username
|
||||||
|
FROM sites s
|
||||||
|
JOIN users u ON s.owner_id = u.id
|
||||||
|
ORDER BY s.title ASC;
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
-- name: SelectUserByUsername :one
|
-- name: SelectUserByUsername :one
|
||||||
SELECT * FROM users WHERE username = ?;
|
SELECT * FROM users WHERE username = ? LIMIT 1;
|
||||||
|
|
||||||
|
-- name: SelectUserByID :one
|
||||||
|
SELECT * FROM users WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
-- name: InsertUser :one
|
-- name: InsertUser :one
|
||||||
INSERT INTO users (username, password) VALUES (?, ?) RETURNING id;
|
INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id;
|
||||||
|
|
||||||
-- name: UpdateUser :exec
|
-- name: UpdateUser :exec
|
||||||
UPDATE users SET username = ?, password = ? WHERE id = ?;
|
UPDATE users SET username = ?, password = ? WHERE id = ?;
|
||||||
|
|
@ -1,23 +1,28 @@
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
password TEXT NOT NULL
|
password TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX idx_users_username ON users (username);
|
CREATE UNIQUE INDEX idx_users_username ON users (username);
|
||||||
|
|
||||||
CREATE TABLE sites (
|
CREATE TABLE sites (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
owner_id INTEGER NOT NULL,
|
owner_id INTEGER NOT NULL,
|
||||||
|
guid TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
tagline TEXT NOT NULL,
|
tagline TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
|
||||||
FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE
|
FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_site_owner ON sites (owner_id);
|
CREATE INDEX idx_site_owner ON sites (owner_id);
|
||||||
|
CREATE UNIQUE INDEX idx_site_guid ON sites (guid);
|
||||||
|
|
||||||
CREATE TABLE publish_targets (
|
CREATE TABLE publish_targets (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
site_id INTEGER NOT NULL,
|
site_id INTEGER NOT NULL,
|
||||||
|
guid TEXT NOT NULL,
|
||||||
target_type TEXT NOT NULL,
|
target_type TEXT NOT NULL,
|
||||||
enabled INT NOT NULL,
|
enabled INT NOT NULL,
|
||||||
base_url TEXT NOT NULL,
|
base_url TEXT NOT NULL,
|
||||||
|
|
@ -25,16 +30,20 @@ CREATE TABLE publish_targets (
|
||||||
target_key TEXT NOT NULL
|
target_key TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_publish_targets_site ON publish_targets (site_id);
|
CREATE INDEX idx_publish_targets_site ON publish_targets (site_id);
|
||||||
|
CREATE UNIQUE INDEX idx_publish_targets_guid ON publish_targets (guid);
|
||||||
|
|
||||||
CREATE TABLE posts (
|
CREATE TABLE posts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
site_id INTEGER NOT NULL,
|
site_id INTEGER NOT NULL,
|
||||||
|
state INTEGER NOT NULL,
|
||||||
guid TEXT NOT NULL,
|
guid TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
slug TEXT NOT NULL,
|
slug TEXT NOT NULL,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
published_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL,
|
||||||
|
published_at INTEGER NOT NULL,
|
||||||
|
deleted_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_post_site ON posts (site_id);
|
CREATE INDEX idx_post_site ON posts (site_id);
|
||||||
CREATE UNIQUE INDEX idx_post_guid ON posts (guid);
|
CREATE UNIQUE INDEX idx_post_guid ON posts (guid);
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="#">Weiro</a>
|
<a class="navbar-brand" href="/">Weiro</a>
|
||||||
|
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link active" aria-current="page" href="#">Posts</a>
|
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/posts">Posts</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<form class="d-flex align-items-center" role="search">
|
<form class="d-flex align-items-center" role="search">
|
||||||
|
|
@ -18,8 +18,17 @@
|
||||||
</div>
|
</div>
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search"/>
|
<div class="nav-item dropdown">
|
||||||
<button class="btn btn-outline-success" type="submit">Search</button>
|
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
{{ .user.Username }}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li><a class="dropdown-item" href="#">Action</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#">Another action</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" href="#" data-controller="logout" data-action="logout#logout">Logout</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
8
views/_common/toast.html
Normal file
8
views/_common/toast.html
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<div class="toast position-fixed bottom-0 end-0" role="alert" aria-live="assertive" aria-atomic="true"
|
||||||
|
data-controller="toast" data-action="weiroToast@window->toast#showToast">
|
||||||
|
<div class="toast-header">
|
||||||
|
<strong class="me-auto" data-toast-target="title">Title</strong>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="toast-body" data-toast-target="body">Body</div>
|
||||||
|
</div>
|
||||||
52
views/index/first-run.html
Normal file
52
views/index/first-run.html
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<div class="mx-auto p-2" style="width: 400px; margin-block-start: 50px;" data-controller="first-run">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h4>Welcome to</h4>
|
||||||
|
<h1>Weiro</h1>
|
||||||
|
</div>
|
||||||
|
<form action="/first-run" method="post">
|
||||||
|
<div data-first-run-target="pages needs-validation">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<p>Please enter the username and password you'd like to use to login.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
<input type="text" class="form-control" name="username" id="username">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password1" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" name="password1" id="password1">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password2" class="form-label">Re-enter password</label>
|
||||||
|
<input type="password" class="form-control" name="password2" id="password2">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 text-end">
|
||||||
|
<button class="btn btn-primary" value="Next" data-action="click->first-run#nextPage">Next »</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-first-run-target="pages">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<p>Enter the details of your blog, if you know them.<br>All fields are optional, and can be changed later.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="siteName" class="form-label">Site Name</label>
|
||||||
|
<input type="text" class="form-control" name="siteName" id="siteName">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="siteUrl" class="form-label">Site URL</label>
|
||||||
|
<input type="text" class="form-control" name="siteUrl" id="siteUrl">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="netlifySiteId" class="form-label">Netlify Site ID</label>
|
||||||
|
<input type="text" class="form-control" name="netlifySiteId" id="netlifySiteId">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="netlifyAPIToken" class="form-label">Netlify API Token</label>
|
||||||
|
<input type="text" class="form-control" name="netlifyAPIToken" id="netlifyAPIToken">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 text-end">
|
||||||
|
<input type="submit" class="btn btn-primary" value="Finish">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
12
views/layouts/bare.html
Normal file
12
views/layouts/bare.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Weiro</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="/static/assets/main.css">
|
||||||
|
</head>
|
||||||
|
<body class="min-vh-100 d-flex flex-column">
|
||||||
|
{{ embed }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
views/layouts/bare_with_scripts.html
Normal file
13
views/layouts/bare_with_scripts.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Weiro</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="/static/assets/main.css">
|
||||||
|
<script src="/static/assets/main.js" type="module"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-vh-100 d-flex flex-column">
|
||||||
|
{{ embed }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -2,13 +2,16 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Title</title>
|
<title>Weiro</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<link rel="stylesheet" href="/static/assets/main.css">
|
<link rel="stylesheet" href="/static/assets/main.css">
|
||||||
|
<script src="/static/assets/main.js" type="module"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="min-vh-100 d-flex flex-column">
|
<body class="min-vh-100 d-flex flex-column">
|
||||||
{{ template "_common/nav" . }}
|
{{ template "_common/nav" . }}
|
||||||
|
|
||||||
{{ embed }}
|
{{ embed }}
|
||||||
|
|
||||||
|
{{ template "_common/toast" . }}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
19
views/login/login.html
Normal file
19
views/login/login.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<div class="mx-auto p-2" style="width: 400px; margin-block-start: 100px;">
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<h3>Weiro Login</h3>
|
||||||
|
</div>
|
||||||
|
<form action="/login" method="post">
|
||||||
|
<input type="hidden" name="_login_challenge" value="{{ .challenge }}">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="username" class="form-label">Login</label>
|
||||||
|
<input type="text" class="form-control" name="username" id="username">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" name="password" id="password">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 text-end">
|
||||||
|
<input type="submit" class="btn btn-primary" value="Login">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
|
{{ $isPublished := ne .post.State 1 }}
|
||||||
<main class="flex-grow-1 position-relative">
|
<main class="flex-grow-1 position-relative">
|
||||||
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2">
|
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2"
|
||||||
|
data-controller="postedit"
|
||||||
|
data-action="keydown.meta+s->postedit#save keydown.meta+enter->postedit#publish"
|
||||||
|
data-postedit-save-action-value="{{ if $isPublished }}Update{{ else }}Save Draft{{ end }}">
|
||||||
<input type="hidden" name="guid" value="{{ .post.GUID }}">
|
<input type="hidden" name="guid" value="{{ .post.GUID }}">
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
|
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<textarea name="body" class="form-control" rows="3">{{.post.Body}}</textarea>
|
<textarea data-postedit-target="bodyTextEdit" name="body" class="form-control" rows="3">{{.post.Body}}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input type="submit" class="btn btn-primary mt-2" value="Publish">
|
{{ if $isPublished }}
|
||||||
|
<input type="submit" name="action" class="btn btn-primary mt-2" value="Update">
|
||||||
|
{{ else }}
|
||||||
|
<input type="submit" name="action" class="btn btn-primary mt-2" value="Publish">
|
||||||
|
<input type="submit" name="action" class="btn btn-secondary mt-2" value="Save Draft">
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -1,17 +1,62 @@
|
||||||
|
{{ $showingTrash := eq .req.Filter "deleted" }}
|
||||||
<main class="container">
|
<main class="container">
|
||||||
<div class="my-4">
|
<div class="my-4 d-flex justify-content-between align-items-baseline">
|
||||||
<a href="/sites/{{ .site.ID }}/posts/new" class="btn btn-success">New Post</a>
|
<a href="/sites/{{ .site.ID }}/posts/new" class="btn btn-success">New Post</a>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="btn-group" role="group" aria-label="First group">
|
||||||
|
{{ if $showingTrash }}
|
||||||
|
<a href="/sites/{{ .site.ID }}/posts" type="button" class="btn btn-secondary" title="Trash">🗑️</a>
|
||||||
|
{{ else }}
|
||||||
|
<a href="/sites/{{ .site.ID }}/posts?filter=deleted" type="button" class="btn btn-outline-secondary" title="Trash">🗑️</a>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ range $i, $p := .posts }}
|
{{ range $i, $p := .posts }}
|
||||||
{{ if gt $i 0 }}<hr>{{ end }}
|
<div data-controller="postlist"
|
||||||
|
data-postlist-site-id-value="{{ $p.SiteID }}"
|
||||||
|
data-postlist-post-id-value="{{ $p.ID }}"
|
||||||
|
data-postlist-nano-summary-value="{{ $p.NanoSummary }}">
|
||||||
<div class="my-4">
|
<div class="my-4">
|
||||||
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
|
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
|
||||||
{{ $p.Body | markdown }}
|
{{ $p.Body | markdown }}
|
||||||
<div class="mb-3">
|
|
||||||
<a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/edit"
|
<div class="mb-3 d-flex align-items-center">
|
||||||
class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Edit</a>
|
{{ if eq .State 1 }}
|
||||||
|
<span class="text-muted">{{ $.user.FormatTime .UpdatedAt }}</span> <span class="ms-3 badge text-primary-emphasis bg-primary-subtle border border-primary-subtle">Draft</span>
|
||||||
|
{{ else }}
|
||||||
|
<span class="text-muted">{{ $.user.FormatTime .PublishedAt }}</span>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 d-flex align-items-center">
|
||||||
|
{{ if $showingTrash }}
|
||||||
|
<span>
|
||||||
|
<a href="#" data-action="click->postlist#restorePost" class="btn btn-outline-secondary btn-sm">Restore</a>
|
||||||
|
<a href="#" data-action="click->postlist#deletePost" data-postlist-hard-delete-param="true"
|
||||||
|
class="btn btn-outline-danger btn-sm">Delete</a>
|
||||||
|
</span>
|
||||||
|
{{ else }}
|
||||||
|
<span>
|
||||||
|
<a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/"
|
||||||
|
class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
|
<a href="#" data-action="click->postlist#deletePost"
|
||||||
|
class="btn btn-outline-secondary btn-sm">Trash</a>
|
||||||
|
</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ if lt $i (sub (len $.posts) 1) }}<hr>{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="h4 m-3 text-center">
|
||||||
|
{{ if $showingTrash }}
|
||||||
|
<div class="position-absolute top-50 start-50 translate-middle">🗑️<br>Trash is empty.</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="position-absolute top-50 start-50 translate-middle">🌱<br>No posts yet. Better get writing!</div>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</main>
|
</main>
|
||||||
Loading…
Reference in a new issue