diff --git a/.air.toml b/.air.toml index 1a1d829..1d0892a 100644 --- a/.air.toml +++ b/.air.toml @@ -1,6 +1,7 @@ root = "." testdata_dir = "testdata" tmp_dir = "build/tmp" +env_files = [".env"] [build] args_bin = [] @@ -10,7 +11,7 @@ tmp_dir = "build/tmp" exclude_dir = ["static", "build", "node_modules"] exclude_file = [] exclude_regex = ["_test.go"] - exclude_unchanged = false + exclude_unchanged = false follow_symlink = false full_bin = "" include_dir = [] diff --git a/.gitignore b/.gitignore index 9117a62..c8b64a2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules/ static/assets/ # Local Netlify folder .netlify +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..95faeea --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/_test-site/posts/2026/02/23-another-post-to.md b/_test-site/posts/2026/02/23-another-post-to.md new file mode 100644 index 0000000..aaa3b4a --- /dev/null +++ b/_test-site/posts/2026/02/23-another-post-to.md @@ -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. \ No newline at end of file diff --git a/_test-site/posts/2026/02/23-be-a-comma.md b/_test-site/posts/2026/02/23-be-a-comma.md new file mode 100644 index 0000000..102c87c --- /dev/null +++ b/_test-site/posts/2026/02/23-be-a-comma.md @@ -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. \ No newline at end of file diff --git a/_test-site/posts/2026/02/23-i-should-soft.md b/_test-site/posts/2026/02/23-i-should-soft.md new file mode 100644 index 0000000..f53706a --- /dev/null +++ b/_test-site/posts/2026/02/23-i-should-soft.md @@ -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. \ No newline at end of file diff --git a/_test-site/posts/2026/02/23-this-is-to.md b/_test-site/posts/2026/02/23-this-is-to.md new file mode 100644 index 0000000..f4aedf5 --- /dev/null +++ b/_test-site/posts/2026/02/23-this-is-to.md @@ -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. \ No newline at end of file diff --git a/_test-site/posts/2026/02/24-it-may-have.md b/_test-site/posts/2026/02/24-it-may-have.md new file mode 100644 index 0000000..a5cb835 --- /dev/null +++ b/_test-site/posts/2026/02/24-it-may-have.md @@ -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. \ No newline at end of file diff --git a/_test-site/posts/2026/02/24-it-will-even.md b/_test-site/posts/2026/02/24-it-will-even.md new file mode 100644 index 0000000..6abd989 --- /dev/null +++ b/_test-site/posts/2026/02/24-it-will-even.md @@ -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. \ No newline at end of file diff --git a/_test-site/posts/2026/02/24-this-is-a.md b/_test-site/posts/2026/02/24-this-is-a.md new file mode 100644 index 0000000..6497e96 --- /dev/null +++ b/_test-site/posts/2026/02/24-this-is-a.md @@ -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. \ No newline at end of file diff --git a/_test-site/posts/2026/02/24-this-was-a.md b/_test-site/posts/2026/02/24-this-was-a.md new file mode 100644 index 0000000..00439fe --- /dev/null +++ b/_test-site/posts/2026/02/24-this-was-a.md @@ -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. \ No newline at end of file diff --git a/assets/js/controllers/firstrun.js b/assets/js/controllers/firstrun.js new file mode 100644 index 0000000..400d86d --- /dev/null +++ b/assets/js/controllers/firstrun.js @@ -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; + } +} \ No newline at end of file diff --git a/assets/js/controllers/logout.js b/assets/js/controllers/logout.js new file mode 100644 index 0000000..385570f --- /dev/null +++ b/assets/js/controllers/logout.js @@ -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'; + } +} \ No newline at end of file diff --git a/assets/js/controllers/postedit.js b/assets/js/controllers/postedit.js new file mode 100644 index 0000000..54679c5 --- /dev/null +++ b/assets/js/controllers/postedit.js @@ -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; + } + } + + +} \ No newline at end of file diff --git a/assets/js/controllers/postlist.js b/assets/js/controllers/postlist.js new file mode 100644 index 0000000..82a319f --- /dev/null +++ b/assets/js/controllers/postlist.js @@ -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.", + }); + } + } +} diff --git a/assets/js/controllers/toast.js b/assets/js/controllers/toast.js new file mode 100644 index 0000000..1655dbf --- /dev/null +++ b/assets/js/controllers/toast.js @@ -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(); + } + } +} diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..6bca555 --- /dev/null +++ b/assets/js/main.js @@ -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); \ No newline at end of file diff --git a/assets/js/services/toast.js b/assets/js/services/toast.js new file mode 100644 index 0000000..7f810ec --- /dev/null +++ b/assets/js/services/toast.js @@ -0,0 +1,6 @@ +export function showToast(details) { + let event = new CustomEvent('weiroToast', { + detail: details + }); + window.dispatchEvent(event); +} \ No newline at end of file diff --git a/cmds/pubtargets.go b/cmds/pubtargets.go new file mode 100644 index 0000000..bb74e3b --- /dev/null +++ b/cmds/pubtargets.go @@ -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 ", + 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 +} diff --git a/cmds/server.go b/cmds/server.go new file mode 100644 index 0000000..ccf11f7 --- /dev/null +++ b/cmds/server.go @@ -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 +} diff --git a/cmds/sites.go b/cmds/sites.go new file mode 100644 index 0000000..8e7dfea --- /dev/null +++ b/cmds/sites.go @@ -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 +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..38cde39 --- /dev/null +++ b/config/config.go @@ -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") +} diff --git a/esbuild.mjs b/esbuild.mjs index 188a9b1..2aabc8e 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -1,9 +1,16 @@ import * as esbuild from 'esbuild' import {sassPlugin} from 'esbuild-sass-plugin' -await esbuild.build({ - entryPoints: ['./assets/css/main.scss'], - bundle: true, - plugins: [sassPlugin()], - outfile: './static/assets/main.css', -}); \ No newline at end of file +await Promise.all([ + esbuild.build({ + entryPoints: ['./assets/css/main.scss'], + bundle: true, + plugins: [sassPlugin()], + outfile: './static/assets/main.css', + }), + esbuild.build({ + entryPoints: ['./assets/js/main.js'], + bundle: true, + outfile: './static/assets/main.js', + }) +]); \ No newline at end of file diff --git a/go.mod b/go.mod index 2db351c..c6e73c1 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/Azure/go-autorest/autorest/date v0.2.0 // indirect github.com/Azure/go-autorest/logger v0.1.0 // indirect github.com/Azure/go-autorest/tracing v0.5.0 // indirect + github.com/Netflix/go-env v0.1.2 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/andybalholm/brotli v1.2.0 // indirect @@ -36,22 +37,26 @@ require ( github.com/go-openapi/strfmt v0.19.11 // indirect github.com/go-openapi/swag v0.19.12 // indirect github.com/go-openapi/validate v0.20.0 // indirect + github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect github.com/go-stack/stack v1.8.0 // indirect - github.com/gofiber/fiber/v3 v3.0.0 // indirect - github.com/gofiber/schema v1.6.0 // indirect + github.com/gofiber/fiber/v3 v3.1.0 // indirect + github.com/gofiber/schema v1.7.0 // indirect + github.com/gofiber/storage/sqlite3/v2 v2.2.3 // indirect github.com/gofiber/template v1.8.3 // indirect github.com/gofiber/template/html/v3 v3.0.2 // indirect github.com/gofiber/template/v2 v2.1.0 // indirect - github.com/gofiber/utils/v2 v2.0.0 // indirect + github.com/gofiber/utils/v2 v2.0.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect - github.com/klauspost/compress v1.18.3 // indirect + github.com/klauspost/compress v1.18.4 // indirect github.com/konsorten/go-windows-terminal-sequences v1.0.3 // indirect github.com/lmika/gopkgs v0.0.0-20240408110817-a02f6fc67d1f // indirect github.com/mailru/easyjson v0.7.6 // indirect github.com/matoous/go-nanoid/v2 v2.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.33 // indirect github.com/mitchellh/mapstructure v1.4.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/netlify/open-api/v2 v2.49.1 // indirect @@ -61,17 +66,19 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rsc/goversion v1.2.0 // indirect github.com/sirupsen/logrus v1.6.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/tinylib/msgp v1.6.3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect go.mongodb.org/mongo-driver v1.4.4 // indirect go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect - golang.org/x/crypto v0.47.0 // indirect + golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 1cf5ed2..6f783dd 100644 --- a/go.sum +++ b/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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Netflix/go-env v0.1.2 h1:0DRoLR9lECQ9Zqvkswuebm3jJ/2enaDX6Ei8/Z+EnK0= +github.com/Netflix/go-env v0.1.2/go.mod h1:WlIhYi++8FlKNJtrop1mjXYAJMzv1f43K4MqCoh0yGE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= @@ -71,6 +73,7 @@ github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHo github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -176,6 +179,8 @@ github.com/go-openapi/validate v0.19.12/go.mod h1:Rzou8hA/CBw8donlS6WNEUQupNvUZ0 github.com/go-openapi/validate v0.19.15/go.mod h1:tbn/fdOwYHgrhPBzidZfJC2MIVvs9GA7monOmWBbeCI= github.com/go-openapi/validate v0.20.0 h1:pzutNCCBZGZlE+u8HD3JZyWdc/TVbtVwlWUp8/vgUKk= github.com/go-openapi/validate v0.20.0/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -207,8 +212,14 @@ github.com/gobuffalo/packr/v2 v2.2.0/go.mod h1:CaAwI0GPIAv+5wKLtv8Afwl+Cm78K/I/V github.com/gobuffalo/syncx v0.0.0-20190224160051-33c29581e754/go.mod h1:HhnNqWY95UYwwW3uSASeV7vtgYkT2t16hJgV3AEPUpw= github.com/gofiber/fiber/v3 v3.0.0 h1:GPeCG8X60L42wLKrzgeewDHBr6pE6veAvwaXsqD3Xjk= github.com/gofiber/fiber/v3 v3.0.0/go.mod h1:kVZiO/AwyT5Pq6PgC8qRCJ+j/BHrMy5jNw1O9yH38aY= +github.com/gofiber/fiber/v3 v3.1.0 h1:1p4I820pIa+FGxfwWuQZ5rAyX0WlGZbGT6Hnuxt6hKY= +github.com/gofiber/fiber/v3 v3.1.0/go.mod h1:n2nYQovvL9z3Too/FGOfgtERjW3GQcAUqgfoezGBZdU= github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY= github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s= +github.com/gofiber/schema v1.7.0 h1:yNM+FNRZjyYEli9Ey0AXRBrAY9jTnb+kmGs3lJGPvKg= +github.com/gofiber/schema v1.7.0/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/storage/sqlite3/v2 v2.2.3 h1:m3n80wUewnB5ruAV3Qq0mzIS+bwBrYYETo4N+fvBoow= +github.com/gofiber/storage/sqlite3/v2 v2.2.3/go.mod h1:F1w9BpQtU7BD5cCjlQnFIEjWHUaAcm9Hh5fuCpfG/OE= github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= github.com/gofiber/template/html/v3 v3.0.2 h1:/Fh8UcEsB4uhf1QWNbYaAOwXxSORebJ2zXkb5tgG/TI= @@ -217,6 +228,8 @@ github.com/gofiber/template/v2 v2.1.0 h1:vrLY6uEW2HdioJm6J5FGUpYZuapVQhHciNz21XQ github.com/gofiber/template/v2 v2.1.0/go.mod h1:ohgpR/Ng90nJbK+IyNzrgR/XpnBNt862/oTF5G7SAmE= github.com/gofiber/utils/v2 v2.0.0 h1:SCC3rpsEDWupFSHtc0RKxg/BKgV0s1qKfZg9Jv6D0sM= github.com/gofiber/utils/v2 v2.0.0/go.mod h1:xF9v89FfmbrYqI/bQUGN7gR8ZtXot2jxnZvmAUtiavE= +github.com/gofiber/utils/v2 v2.0.2 h1:ShRRssz0F3AhTlAQcuEj54OEDtWF7+HJDwEi/aa6QLI= +github.com/gofiber/utils/v2 v2.0.2/go.mod h1:+9Ub4NqQ+IaJoTliq5LfdmOJAA/Hzwf4pXOxOa3RrJ0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -283,6 +296,8 @@ github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0m github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= @@ -302,6 +317,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/compress v1.9.5/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= @@ -335,6 +352,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= +github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= @@ -392,6 +411,7 @@ github.com/rogpeppe/go-internal v1.2.2/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rsc/goversion v1.2.0 h1:zVF4y5ciA/rw779S62bEAq4Yif1cBc/UwRkXJ2xZyT4= github.com/rsc/goversion v1.2.0/go.mod h1:Tf/O0TQyfRvp7NelXAyfXYRKUO+LX3KNgXc8ALRUv4k= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= @@ -410,10 +430,15 @@ github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTd github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -464,6 +489,7 @@ go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/ go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -481,6 +507,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -535,6 +563,8 @@ golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -582,6 +612,8 @@ golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -595,6 +627,8 @@ golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -629,6 +663,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/handlers/accepts.go b/handlers/accepts.go new file mode 100644 index 0000000..f4dd3bf --- /dev/null +++ b/handlers/accepts.go @@ -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, + } +} diff --git a/handlers/index.go b/handlers/index.go new file mode 100644 index 0000000..6062237 --- /dev/null +++ b/handlers/index.go @@ -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)) +} diff --git a/handlers/login.go b/handlers/login.go new file mode 100644 index 0000000..30ed0b4 --- /dev/null +++ b/handlers/login.go @@ -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("/") +} diff --git a/handlers/middleware/site.go b/handlers/middleware/site.go index 0cc2b7a..54211bc 100644 --- a/handlers/middleware/site.go +++ b/handlers/middleware/site.go @@ -3,12 +3,14 @@ package middleware import ( "strconv" + "emperror.dev/errors" "github.com/gofiber/fiber/v3" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/services/sites" ) -func RequiresSite(db *db.Provider) func(c fiber.Ctx) error { +func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error { return func(c fiber.Ctx) error { siteIDStr := c.Params("siteID") if siteIDStr == "" { @@ -20,18 +22,15 @@ func RequiresSite(db *db.Provider) func(c fiber.Ctx) error { return fiber.ErrBadRequest } - user, ok := models.GetUser(c.Context()) - if !ok { - return fiber.ErrUnauthorized - } - - site, err := db.SelectSiteByID(c.Context(), siteID) + site, err := sites.GetSiteByID(c.Context(), siteID) if err != nil { - return fiber.ErrNotFound - } - - if site.OwnerID != user.ID { - return fiber.ErrForbidden + if errors.Is(err, models.UserRequiredError) { + return fiber.ErrForbidden + } else if errors.Is(err, models.PermissionError) || db.ErrorIsNoRows(err) { + return fiber.ErrNotFound + } else if errors.Is(err, models.NotFoundError) || db.ErrorIsNoRows(err) { + return err + } } c.Locals("site", site) diff --git a/handlers/middleware/user.go b/handlers/middleware/user.go index d36891d..8391b85 100644 --- a/handlers/middleware/user.go +++ b/handlers/middleware/user.go @@ -1,23 +1,47 @@ package middleware import ( - "log" - "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/session" "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/auth" ) -func AuthUser() func(c fiber.Ctx) error { +func OptionalUser(auth *auth.Service) func(c fiber.Ctx) error { return func(c fiber.Ctx) error { - // TEMP - Actually do the auth here - user := models.User{ - ID: 1, - Username: "testuser", + sess := session.FromContext(c) + userID, _ := sess.Get("user_id").(int64) + if userID == 0 { + return c.Next() + } + + user, err := auth.GetUser(c.Context(), userID) + if err != nil { + return c.Next() + } + + c.Locals("user", user) + c.SetContext(models.WithUser(c.Context(), user)) + + return c.Next() + } +} + +func RequireUser(auth *auth.Service) func(c fiber.Ctx) error { + return func(c fiber.Ctx) error { + sess := session.FromContext(c) + userID, _ := sess.Get("user_id").(int64) + if userID == 0 { + return c.Redirect().To("/login") + } + + user, err := auth.GetUser(c.Context(), userID) + if err != nil { + return c.Redirect().To("/login") } c.Locals("user", user) c.SetContext(models.WithUser(c.Context(), user)) - log.Printf("User %s authenticated", user.Username) return c.Next() } diff --git a/handlers/posts.go b/handlers/posts.go index 82d9aa0..041ec9d 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "log" "strconv" "github.com/gofiber/fiber/v3" @@ -14,19 +15,32 @@ type PostsHandler struct { } func (ph PostsHandler) Index(c fiber.Ctx) error { - posts, err := ph.PostService.ListPosts(c.Context()) + var req struct { + Filter string `query:"filter"` + } + if err := c.Bind().Query(&req); err != nil { + return fiber.ErrBadRequest + } + + posts, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted") if err != nil { return err } - return c.Render("posts/index", fiber.Map{ - "posts": posts, - }) + return accepts(c, json(func() any { + return posts + }), html(func(c fiber.Ctx) error { + return c.Render("posts/index", fiber.Map{ + "req": req, + "posts": posts, + }) + })) } func (ph PostsHandler) New(c fiber.Ctx) error { p := models.Post{ - GUID: models.NewNanoID(), + GUID: models.NewNanoID(), + State: models.StateDraft, } return c.Render("posts/edit", fiber.Map{ @@ -49,9 +63,13 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error { return err } - return c.Render("posts/edit", fiber.Map{ - "post": post, - }) + return accepts(c, json(func() any { + return post + }), html(func(c fiber.Ctx) error { + return c.Render("posts/edit", fiber.Map{ + "post": post, + }) + })) } func (ph PostsHandler) Update(c fiber.Ctx) error { @@ -60,10 +78,82 @@ func (ph PostsHandler) Update(c fiber.Ctx) error { return err } - post, err := ph.PostService.PublishPost(c.Context(), req) + post, err := ph.PostService.UpdatePost(c.Context(), req) if err != nil { return err } - return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", post.SiteID)) + return accepts(c, json(func() any { + // TODO: should be created if brand new + return post + }), html(func(c fiber.Ctx) error { + return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", post.SiteID)) + })) +} + +func (ph PostsHandler) Patch(c fiber.Ctx) error { + log.Println("PATCH") + + postIDStr := c.Params("postID") + if postIDStr == "" { + return fiber.ErrBadRequest + } + postID, err := strconv.ParseInt(postIDStr, 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + var req struct { + Action string `json:"action"` + } + if err := c.Bind().Body(&req); err != nil { + return err + } + + log.Println("Request") + + switch req.Action { + case "restore": + if err := ph.PostService.RestorePost(c.Context(), postID); err != nil { + return err + } + default: + return fiber.ErrBadRequest + } + + return accepts(c, json(func() any { + return struct{}{} + }), html(func(c fiber.Ctx) error { + + return c.Redirect().To(fmt.Sprintf("/sites/%v/posts")) + })) +} + +func (ph PostsHandler) Delete(c fiber.Ctx) error { + postIDStr := c.Params("postID") + if postIDStr == "" { + return fiber.ErrBadRequest + } + + postID, err := strconv.ParseInt(postIDStr, 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + var req struct { + Hard bool `query:"hard"` + } + if err := c.Bind().Query(&req); err != nil { + return err + } + + if err := ph.PostService.DeletePost(c.Context(), postID, req.Hard); err != nil { + return err + } + + return accepts(c, json(func() any { + return fiber.Map{} + }), html(func(c fiber.Ctx) error { + return c.Redirect().To("/sites") + })) } diff --git a/main.go b/main.go index fbd9c31..823bff5 100644 --- a/main.go +++ b/main.go @@ -1,106 +1,14 @@ package main import ( - "html" - "html/template" - "log" - "strings" + "os" - "github.com/gofiber/fiber/v3" - "github.com/gofiber/fiber/v3/middleware/static" - fiber_html "github.com/gofiber/template/html/v3" - "github.com/yuin/goldmark" - "github.com/yuin/goldmark/extension" - "lmika.dev/lmika/weiro/handlers" - "lmika.dev/lmika/weiro/handlers/middleware" - "lmika.dev/lmika/weiro/providers/db" - "lmika.dev/lmika/weiro/services/posts" - "lmika.dev/lmika/weiro/services/publisher" + "lmika.dev/lmika/weiro/cmds" _ "modernc.org/sqlite" ) func main() { - dbp, err := db.New("build/weiro.db") - if err != nil { - log.Fatal(err) + if err := cmds.Root().Execute(); err != nil { + os.Exit(1) } - defer dbp.Close() - - publisherSvc := publisher.New(dbp) - - postService := posts.New(dbp, publisherSvc) - - //user, err := dbp.SelectUserByUsername(context.Background(), "testuser") - //if err != nil { - // user = models.User{ - // Username: "testuser", - // PasswordHashed: []byte("changeme"), - // } - // if err := dbp.SaveUser(context.Background(), &user); err != nil { - // log.Fatal(err) - // } - //} - - fiberTemplate := fiber_html.New("./views", ".html") - fiberTemplate.Funcmap["markdown"] = func() func(s string) template.HTML { - mdParser := goldmark.New( - goldmark.WithExtensions(extension.GFM), - ) - return func(s string) template.HTML { - var sb strings.Builder - if err := mdParser.Convert([]byte(s), &sb); err != nil { - return template.HTML("Markdown error: " + html.EscapeString(err.Error())) - } - return template.HTML(sb.String()) - } - }() - - app := fiber.New(fiber.Config{ - Views: fiberTemplate, - ViewsLayout: "layouts/main", - PassLocalsToViews: true, - }) - - siteGroup := app.Group("/sites/:siteID", middleware.AuthUser(), middleware.RequiresSite(dbp)) - - ph := handlers.PostsHandler{PostService: postService} - - siteGroup.Get("/posts", ph.Index) - siteGroup.Get("/posts/new", ph.New) - siteGroup.Get("/posts/:postID/edit", ph.Edit) - siteGroup.Post("/posts", ph.Update) - - app.Get("/", func(c fiber.Ctx) error { - return c.Redirect().To("/sites/1/posts") - }) - app.Get("/static/*", static.New("./static")) - - // TEMP - // - /* - dbp.SaveUser(context.Background(), &models.User{Username: "testuser"}) - - ctx := models.WithUser(context.Background(), models.User{ID: 1}) - site, err := importer.New(dbp).Import(ctx, "_test-site") - if err != nil { - log.Fatal(err) - } - - target := models.SitePublishTarget{ - SiteID: site.ID, - BaseURL: "https://jolly-boba-9e2486.netlify.app", - TargetType: "netlify", - TargetRef: "55c878a7-189e-42cf-aa02-5c60908143f3", - TargetKey: os.Getenv("NETLIFY_AUTH_TOKEN"), - } - - if err := dbp.SavePublishTarget(ctx, &target); err != nil { - log.Fatal(err) - } - */ - //if err := publisherSvc.Publish(ctx, site.ID); err != nil { - // log.Fatal(err) - //} - - log.Fatal(app.Listen(":3000")) } diff --git a/models/errors.go b/models/errors.go index 4a23c08..997a952 100644 --- a/models/errors.go +++ b/models/errors.go @@ -6,3 +6,4 @@ var UserRequiredError = errors.New("user required") var PermissionError = errors.New("permission denied") var NotFoundError = errors.New("not found") var SiteRequiredError = errors.New("site required") +var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds") diff --git a/models/ids.go b/models/ids.go index 58dd928..c5c1cbd 100644 --- a/models/ids.go +++ b/models/ids.go @@ -3,6 +3,6 @@ package models import "github.com/matoous/go-nanoid/v2" func NewNanoID() string { - id, _ := gonanoid.New(12) + id, _ := gonanoid.New(16) return id } diff --git a/models/posts.go b/models/posts.go index 16184d6..e129a57 100644 --- a/models/posts.go +++ b/models/posts.go @@ -8,15 +8,36 @@ import ( "unicode" ) +const ( + StatePublished = iota + StateDraft +) + type Post struct { - ID int64 - SiteID int64 - GUID string - Title string - Body string - Slug string - CreatedAt time.Time - PublishedAt time.Time + ID int64 `json:"id"` + SiteID int64 `json:"site_id"` + State int `json:"state"` + GUID string `json:"guid"` + Title string `json:"title"` + Body string `json:"body"` + Slug string `json:"slug"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + DeletedAt time.Time `json:"deleted_at,omitempty"` + PublishedAt time.Time `json:"published_at,omitempty"` +} + +func (p *Post) NanoSummary() string { + if p.Title != "" { + return p.Title + } + firstWords := firstNWords(p.Body, 7, wordForSummary) + if firstWords == "" { + firstWords = "(no content)" + } else if len(firstWords) < len(p.Body) { + return firstWords + "..." + } + return firstWords } func (p *Post) BestSlug() string { @@ -46,6 +67,10 @@ func (p *Post) BestSlug() string { return fmt.Sprintf("/%s/%s", datePart, slugPath) } +func wordForSummary(word string) string { + return word +} + func wordForSlug(word string) string { var sb strings.Builder for _, c := range word { diff --git a/models/sites.go b/models/sites.go index d83d126..42b8a3c 100644 --- a/models/sites.go +++ b/models/sites.go @@ -1,5 +1,7 @@ package models +import "time" + type PublishTargetType int const ( @@ -8,18 +10,31 @@ const ( PublishTargetTypeNetlify PublishTargetType = 2 ) +func ParsePublishTargetType(s string) (PublishTargetType, error) { + switch s { + case "localfs": + return PublishTargetTypeLocalFS, nil + case "netlify": + return PublishTargetTypeNetlify, nil + default: + return PublishTargetTypeNone, nil + } +} + type Site struct { ID int64 OwnerID int64 + GUID string + Created time.Time + Title string Tagline string - //Meta SiteMeta - //Posts []*Post } type SitePublishTarget struct { ID int64 SiteID int64 + GUID string Enabled bool BaseURL string @@ -27,24 +42,3 @@ type SitePublishTarget struct { TargetRef string TargetKey string } - -/* -type SiteMeta struct { - Title string `yaml:"title"` - Tagline string `yaml:"tagline"` - BaseURL string `yaml:"base_url"` -} - -type PostMeta struct { - ID string `yaml:"id"` - Title string `yaml:"title"` - Date time.Time `yaml:"date"` - Tags []string `yaml:"tags"` - Slug string `yaml:"slug"` -} - -type Post struct { - Meta PostMeta - Content string -} -*/ diff --git a/models/users.go b/models/users.go index 13c6452..f901ac9 100644 --- a/models/users.go +++ b/models/users.go @@ -1,7 +1,49 @@ package models +import ( + "regexp" + "time" + + "golang.org/x/crypto/bcrypt" +) + +var ValidUserName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + type User struct { ID int64 Username string PasswordHashed []byte + TimeZone string + Created time.Time +} + +func (u *User) SetPassword(pwd string) { + bcrypted, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) + u.PasswordHashed = bcrypted +} + +func (u User) CheckPassword(pwd string) bool { + err := bcrypt.CompareHashAndPassword(u.PasswordHashed, []byte(pwd)) + return err == nil +} + +func (u User) FormatTime(t time.Time) string { + if loc := getLocation(u.TimeZone); loc != nil { + return t.In(loc).Format("2006-01-02 15:04:05") + } + return t.Format("2006-01-02 15:04:05") +} + +var loadedLocation = map[string]*time.Location{} + +func getLocation(tz string) *time.Location { + if loc, ok := loadedLocation[tz]; ok { + return loc + } + loc, err := time.LoadLocation(tz) + if err != nil { + loc = time.Local + } + loadedLocation[tz] = loc + return loc } diff --git a/package-lock.json b/package-lock.json index ffe8353..c4f391c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@hotwired/stimulus": "^3.2.2", "bootstrap": "^5.3.8", "esbuild-sass-plugin": "^3.6.0" }, @@ -435,6 +436,12 @@ "node": ">=18" } }, + "node_modules/@hotwired/stimulus": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz", + "integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==", + "license": "MIT" + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", diff --git a/package.json b/package.json index 34b5b2f..64e6fca 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "esbuild": "0.27.3" }, "dependencies": { + "@hotwired/stimulus": "^3.2.2", "bootstrap": "^5.3.8", "esbuild-sass-plugin": "^3.6.0" } diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 5d8f3b5..5317ef2 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -7,17 +7,21 @@ package sqlgen type Post struct { ID int64 SiteID int64 + State int64 Guid string Title string Body string Slug string CreatedAt int64 + UpdatedAt int64 PublishedAt int64 + DeletedAt int64 } type PublishTarget struct { ID int64 SiteID int64 + Guid string TargetType string Enabled int64 BaseUrl string @@ -26,14 +30,17 @@ type PublishTarget struct { } type Site struct { - ID int64 - OwnerID int64 - Title string - Tagline string + ID int64 + OwnerID int64 + Guid string + Title string + Tagline string + CreatedAt int64 } type User struct { - ID int64 - Username string - Password string + ID int64 + Username string + Password string + CreatedAt int64 } diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index adeefde..e4b00f0 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -9,46 +9,73 @@ import ( "context" ) +const hardDeletePost = `-- name: HardDeletePost :exec +DELETE FROM posts WHERE id = ? +` + +func (q *Queries) HardDeletePost(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, hardDeletePost, id) + return err +} + const insertPost = `-- name: InsertPost :one INSERT INTO posts ( site_id, + state, guid, title, body, slug, created_at, - published_at -) VALUES (?, ?, ?, ?, ?, ?, ?) + updated_at, + published_at, + deleted_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id ` type InsertPostParams struct { SiteID int64 + State int64 Guid string Title string Body string Slug string CreatedAt int64 + UpdatedAt int64 PublishedAt int64 + DeletedAt int64 } func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) { row := q.db.QueryRowContext(ctx, insertPost, arg.SiteID, + arg.State, arg.Guid, arg.Title, arg.Body, arg.Slug, arg.CreatedAt, + arg.UpdatedAt, arg.PublishedAt, + arg.DeletedAt, ) var id int64 err := row.Scan(&id) return id, err } +const restorePost = `-- name: RestorePost :exec +UPDATE posts SET deleted_at = 0 WHERE id = ? +` + +func (q *Queries) RestorePost(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, restorePost, id) + return err +} + const selectPost = `-- name: SelectPost :one -SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE id = ? LIMIT 1 +SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at FROM posts WHERE id = ? LIMIT 1 ` func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) { @@ -57,18 +84,21 @@ func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) { err := row.Scan( &i.ID, &i.SiteID, + &i.State, &i.Guid, &i.Title, &i.Body, &i.Slug, &i.CreatedAt, + &i.UpdatedAt, &i.PublishedAt, + &i.DeletedAt, ) return i, err } const selectPostByGUID = `-- name: SelectPostByGUID :one -SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE guid = ? LIMIT 1 +SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at FROM posts WHERE guid = ? LIMIT 1 ` func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, error) { @@ -77,22 +107,37 @@ func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, erro err := row.Scan( &i.ID, &i.SiteID, + &i.State, &i.Guid, &i.Title, &i.Body, &i.Slug, &i.CreatedAt, + &i.UpdatedAt, &i.PublishedAt, + &i.DeletedAt, ) return i, err } const selectPostsOfSite = `-- name: SelectPostsOfSite :many -SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10 +SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at +FROM posts +WHERE site_id = ? AND ( + CASE CAST (?2 AS TEXT) + WHEN 'deleted' THEN deleted_at > 0 + ELSE deleted_at = 0 + END +) ORDER BY created_at DESC LIMIT 10 ` -func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post, error) { - rows, err := q.db.QueryContext(ctx, selectPostsOfSite, siteID) +type SelectPostsOfSiteParams struct { + SiteID int64 + PostFilter string +} + +func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSiteParams) ([]Post, error) { + rows, err := q.db.QueryContext(ctx, selectPostsOfSite, arg.SiteID, arg.PostFilter) if err != nil { return nil, err } @@ -103,12 +148,15 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post, if err := rows.Scan( &i.ID, &i.SiteID, + &i.State, &i.Guid, &i.Title, &i.Body, &i.Slug, &i.CreatedAt, + &i.UpdatedAt, &i.PublishedAt, + &i.DeletedAt, ); err != nil { return nil, err } @@ -123,29 +171,52 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post, return items, nil } +const softDeletePost = `-- name: SoftDeletePost :exec +UPDATE posts SET deleted_at = ? WHERE id = ? +` + +type SoftDeletePostParams struct { + DeletedAt int64 + ID int64 +} + +func (q *Queries) SoftDeletePost(ctx context.Context, arg SoftDeletePostParams) error { + _, err := q.db.ExecContext(ctx, softDeletePost, arg.DeletedAt, arg.ID) + return err +} + const updatePost = `-- name: UpdatePost :exec UPDATE posts SET title = ?, + state = ?, body = ?, slug = ?, - published_at = ? + updated_at = ?, + published_at = ?, + deleted_at = ? WHERE id = ? ` type UpdatePostParams struct { Title string + State int64 Body string Slug string + UpdatedAt int64 PublishedAt int64 + DeletedAt int64 ID int64 } func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error { _, err := q.db.ExecContext(ctx, updatePost, arg.Title, + arg.State, arg.Body, arg.Slug, + arg.UpdatedAt, arg.PublishedAt, + arg.DeletedAt, arg.ID, ) return err diff --git a/providers/db/gen/sqlgen/pubtargets.sql.go b/providers/db/gen/sqlgen/pubtargets.sql.go index 49ca66a..cd5cfa6 100644 --- a/providers/db/gen/sqlgen/pubtargets.sql.go +++ b/providers/db/gen/sqlgen/pubtargets.sql.go @@ -12,17 +12,19 @@ import ( const insertPublishTarget = `-- name: InsertPublishTarget :one INSERT INTO publish_targets ( site_id, + guid, target_type, enabled, base_url, target_ref, target_key -) VALUES (?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id ` type InsertPublishTargetParams struct { SiteID int64 + Guid string TargetType string Enabled int64 BaseUrl string @@ -33,6 +35,7 @@ type InsertPublishTargetParams struct { func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTargetParams) (int64, error) { row := q.db.QueryRowContext(ctx, insertPublishTarget, arg.SiteID, + arg.Guid, arg.TargetType, arg.Enabled, arg.BaseUrl, @@ -45,7 +48,7 @@ func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTarg } const selectPublishTargetsOfSite = `-- name: SelectPublishTargetsOfSite :many -SELECT id, site_id, target_type, enabled, base_url, target_ref, target_key FROM publish_targets WHERE site_id = ? +SELECT id, site_id, guid, target_type, enabled, base_url, target_ref, target_key FROM publish_targets WHERE site_id = ? ` func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64) ([]PublishTarget, error) { @@ -60,6 +63,7 @@ func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64) if err := rows.Scan( &i.ID, &i.SiteID, + &i.Guid, &i.TargetType, &i.Enabled, &i.BaseUrl, diff --git a/providers/db/gen/sqlgen/sites.sql.go b/providers/db/gen/sqlgen/sites.sql.go index 136cb38..fd3d3c6 100644 --- a/providers/db/gen/sqlgen/sites.sql.go +++ b/providers/db/gen/sqlgen/sites.sql.go @@ -7,32 +7,116 @@ package sqlgen import ( "context" + "database/sql" ) +const hasUsersAndSites = `-- name: HasUsersAndSites :one +SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites +` + +func (q *Queries) HasUsersAndSites(ctx context.Context) (sql.NullBool, error) { + row := q.db.QueryRowContext(ctx, hasUsersAndSites) + var has_users_and_sites sql.NullBool + err := row.Scan(&has_users_and_sites) + return has_users_and_sites, err +} + const insertSite = `-- name: InsertSite :one INSERT INTO sites ( owner_id, + guid, title, - tagline -) VALUES (?, ?, ?) + tagline, + created_at +) VALUES (?, ?, ?, ?, ?) RETURNING id ` type InsertSiteParams struct { - OwnerID int64 - Title string - Tagline string + OwnerID int64 + Guid string + Title string + Tagline string + CreatedAt int64 } func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) { - row := q.db.QueryRowContext(ctx, insertSite, arg.OwnerID, arg.Title, arg.Tagline) + row := q.db.QueryRowContext(ctx, insertSite, + arg.OwnerID, + arg.Guid, + arg.Title, + arg.Tagline, + arg.CreatedAt, + ) var id int64 err := row.Scan(&id) return id, err } +const selectAllSitesWithOwners = `-- name: SelectAllSitesWithOwners :many +SELECT s.id, s.guid, s.title, s.owner_id, u.username +FROM sites s +JOIN users u ON s.owner_id = u.id +ORDER BY s.title ASC +` + +type SelectAllSitesWithOwnersRow struct { + ID int64 + Guid string + Title string + OwnerID int64 + Username string +} + +func (q *Queries) SelectAllSitesWithOwners(ctx context.Context) ([]SelectAllSitesWithOwnersRow, error) { + rows, err := q.db.QueryContext(ctx, selectAllSitesWithOwners) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SelectAllSitesWithOwnersRow + for rows.Next() { + var i SelectAllSitesWithOwnersRow + if err := rows.Scan( + &i.ID, + &i.Guid, + &i.Title, + &i.OwnerID, + &i.Username, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const selectSiteByGUID = `-- name: SelectSiteByGUID :one +SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE guid = ? +` + +func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, error) { + row := q.db.QueryRowContext(ctx, selectSiteByGUID, guid) + var i Site + err := row.Scan( + &i.ID, + &i.OwnerID, + &i.Guid, + &i.Title, + &i.Tagline, + &i.CreatedAt, + ) + return i, err +} + const selectSiteByID = `-- name: SelectSiteByID :one -SELECT id, owner_id, title, tagline FROM sites WHERE id = ? +SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE id = ? ` func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) { @@ -41,14 +125,16 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) { err := row.Scan( &i.ID, &i.OwnerID, + &i.Guid, &i.Title, &i.Tagline, + &i.CreatedAt, ) return i, err } const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many -SELECT id, owner_id, title, tagline FROM sites WHERE owner_id = ? +SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE owner_id = ? ORDER BY title ASC ` func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) { @@ -63,8 +149,10 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([] if err := rows.Scan( &i.ID, &i.OwnerID, + &i.Guid, &i.Title, &i.Tagline, + &i.CreatedAt, ); err != nil { return nil, err } diff --git a/providers/db/gen/sqlgen/users.sql.go b/providers/db/gen/sqlgen/users.sql.go index 48b1e53..a70a3bf 100644 --- a/providers/db/gen/sqlgen/users.sql.go +++ b/providers/db/gen/sqlgen/users.sql.go @@ -10,29 +10,51 @@ import ( ) const insertUser = `-- name: InsertUser :one -INSERT INTO users (username, password) VALUES (?, ?) RETURNING id +INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id ` type InsertUserParams struct { - Username string - Password string + Username string + Password string + CreatedAt int64 } func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (int64, error) { - row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password) + row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password, arg.CreatedAt) var id int64 err := row.Scan(&id) return id, err } +const selectUserByID = `-- name: SelectUserByID :one +SELECT id, username, password, created_at FROM users WHERE id = ? LIMIT 1 +` + +func (q *Queries) SelectUserByID(ctx context.Context, id int64) (User, error) { + row := q.db.QueryRowContext(ctx, selectUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.CreatedAt, + ) + return i, err +} + const selectUserByUsername = `-- name: SelectUserByUsername :one -SELECT id, username, password FROM users WHERE username = ? +SELECT id, username, password, created_at FROM users WHERE username = ? LIMIT 1 ` func (q *Queries) SelectUserByUsername(ctx context.Context, username string) (User, error) { row := q.db.QueryRowContext(ctx, selectUserByUsername, username) var i User - err := row.Scan(&i.ID, &i.Username, &i.Password) + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.CreatedAt, + ) return i, err } diff --git a/providers/db/posts.go b/providers/db/posts.go index 57dfbe8..04a7a3e 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -8,8 +8,16 @@ import ( "lmika.dev/lmika/weiro/providers/db/gen/sqlgen" ) -func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64) ([]*models.Post, error) { - rows, err := db.queries.SelectPostsOfSite(ctx, siteID) +func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) ([]*models.Post, error) { + var filter = "" + if showDeleted { + filter = "deleted" + } + + rows, err := db.queries.SelectPostsOfSite(ctx, sqlgen.SelectPostsOfSiteParams{ + SiteID: siteID, + PostFilter: filter, + }) if err != nil { return nil, err } @@ -43,12 +51,15 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error { if post.ID == 0 { newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{ SiteID: post.SiteID, + State: int64(post.State), Guid: post.GUID, Title: post.Title, Body: post.Body, Slug: post.Slug, - CreatedAt: post.CreatedAt.Unix(), - PublishedAt: post.PublishedAt.Unix(), + CreatedAt: timeToInt(post.CreatedAt), + UpdatedAt: timeToInt(post.UpdatedAt), + PublishedAt: timeToInt(post.PublishedAt), + DeletedAt: timeToInt(post.DeletedAt), }) if err != nil { return err @@ -59,10 +70,13 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error { return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{ ID: post.ID, + State: int64(post.State), Title: post.Title, Body: post.Body, Slug: post.Slug, - PublishedAt: post.PublishedAt.Unix(), + UpdatedAt: timeToInt(post.UpdatedAt), + PublishedAt: timeToInt(post.PublishedAt), + DeletedAt: timeToInt(post.DeletedAt), }) } @@ -70,11 +84,21 @@ func dbPostToPost(row sqlgen.Post) *models.Post { return &models.Post{ ID: row.ID, SiteID: row.SiteID, + State: int(row.State), GUID: row.Guid, Title: row.Title, Body: row.Body, Slug: row.Slug, CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), + UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(), PublishedAt: time.Unix(row.PublishedAt, 0).UTC(), + DeletedAt: time.Unix(row.DeletedAt, 0).UTC(), } } + +func timeToInt(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} diff --git a/providers/db/provider.go b/providers/db/provider.go index b061b32..eda0513 100644 --- a/providers/db/provider.go +++ b/providers/db/provider.go @@ -3,6 +3,7 @@ package db import ( "context" "database/sql" + "time" "github.com/Southclaws/fault" "lmika.dev/lmika/weiro/providers/db/gen/sqlgen" @@ -38,3 +39,18 @@ func New(dbFile string) (*Provider, error) { func (db *Provider) Close() error { return db.drvr.Close() } + +func (db *Provider) SoftDeletePost(ctx context.Context, postID int64) error { + return db.queries.SoftDeletePost(ctx, sqlgen.SoftDeletePostParams{ + DeletedAt: time.Now().Unix(), + ID: postID, + }) +} + +func (db *Provider) HardDeletePost(ctx context.Context, postID int64) error { + return db.queries.HardDeletePost(ctx, postID) +} + +func (db *Provider) RestorePost(ctx context.Context, postID int64) error { + return db.queries.RestorePost(ctx, postID) +} diff --git a/providers/db/provider_test.go b/providers/db/provider_test.go index f4788cd..4781d61 100644 --- a/providers/db/provider_test.go +++ b/providers/db/provider_test.go @@ -158,7 +158,7 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, err) assert.NotZero(t, post.ID) - posts, err := p.SelectPostsOfSite(ctx, site.ID) + posts, err := p.SelectPostsOfSite(ctx, site.ID, false) require.NoError(t, err) require.Len(t, posts, 1) assert.Equal(t, post.ID, posts[0].ID) @@ -205,7 +205,7 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, p.SavePost(ctx, post1)) require.NoError(t, p.SavePost(ctx, post2)) - posts, err := p.SelectPostsOfSite(ctx, site2.ID) + posts, err := p.SelectPostsOfSite(ctx, site2.ID, false) require.NoError(t, err) require.Len(t, posts, 2) assert.Equal(t, "New Post", posts[0].Title) @@ -220,7 +220,7 @@ func TestProvider_Posts(t *testing.T) { } require.NoError(t, p.SaveSite(ctx, emptySite)) - posts, err := p.SelectPostsOfSite(ctx, emptySite.ID) + posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false) require.NoError(t, err) assert.Empty(t, posts) }) @@ -248,6 +248,7 @@ func TestProvider_PublishTargets(t *testing.T) { target := &models.SitePublishTarget{ SiteID: site.ID, TargetType: "netlify", + GUID: "target-001", BaseURL: "https://example.netlify.app", TargetRef: "netlify-site-123", TargetKey: "secret-key", diff --git a/providers/db/pubtargets.go b/providers/db/pubtargets.go index 74b0b26..dcfb25f 100644 --- a/providers/db/pubtargets.go +++ b/providers/db/pubtargets.go @@ -18,6 +18,7 @@ func (db *Provider) SelectPublishTargetsOfSite(ctx context.Context, siteID int64 targets[i] = models.SitePublishTarget{ ID: row.ID, SiteID: row.SiteID, + GUID: row.Guid, Enabled: row.Enabled != 0, TargetType: row.TargetType, BaseURL: row.BaseUrl, @@ -38,6 +39,7 @@ func (db *Provider) SavePublishTarget(ctx context.Context, target *models.SitePu newID, err := db.queries.InsertPublishTarget(ctx, sqlgen.InsertPublishTargetParams{ SiteID: target.SiteID, TargetType: target.TargetType, + Guid: target.GUID, Enabled: enabled, BaseUrl: target.BaseURL, TargetRef: target.TargetRef, diff --git a/providers/db/sites.go b/providers/db/sites.go index eaf61fb..f878e45 100644 --- a/providers/db/sites.go +++ b/providers/db/sites.go @@ -2,6 +2,7 @@ package db import ( "context" + "time" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/providers/db/gen/sqlgen" @@ -13,12 +14,16 @@ func (db *Provider) SelectSiteByID(ctx context.Context, id int64) (models.Site, return models.Site{}, err } - return models.Site{ - ID: row.ID, - OwnerID: row.OwnerID, - Title: row.Title, - Tagline: row.Tagline, - }, nil + return dbSiteToSite(row), nil +} + +func (db *Provider) SelectSiteByGUID(ctx context.Context, guid string) (models.Site, error) { + row, err := db.queries.SelectSiteByGUID(ctx, guid) + if err != nil { + return models.Site{}, err + } + + return dbSiteToSite(row), nil } func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]models.Site, error) { @@ -29,12 +34,7 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ( sites := make([]models.Site, len(rows)) for i, row := range rows { - sites[i] = models.Site{ - ID: row.ID, - OwnerID: row.OwnerID, - Title: row.Title, - Tagline: row.Tagline, - } + sites[i] = dbSiteToSite(row) } return sites, nil } @@ -42,9 +42,11 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ( func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error { if site.ID == 0 { newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{ - OwnerID: site.OwnerID, - Title: site.Title, - Tagline: site.Tagline, + OwnerID: site.OwnerID, + Guid: site.GUID, + Title: site.Title, + Tagline: site.Tagline, + CreatedAt: timeToInt(site.Created), }) if err != nil { return err @@ -56,3 +58,49 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error { // No update query defined in sqlgen yet return nil } + +func (db *Provider) HasUsersAndSites(ctx context.Context) (bool, error) { + nullBool, err := db.queries.HasUsersAndSites(ctx) + if err != nil { + return false, err + } + return nullBool.Valid && nullBool.Bool, nil +} + +type SiteWithOwner struct { + ID int64 + GUID string + Title string + OwnerID int64 + Username string +} + +func (db *Provider) SelectAllSitesWithOwners(ctx context.Context) ([]SiteWithOwner, error) { + rows, err := db.queries.SelectAllSitesWithOwners(ctx) + if err != nil { + return nil, err + } + + sites := make([]SiteWithOwner, len(rows)) + for i, row := range rows { + sites[i] = SiteWithOwner{ + ID: row.ID, + GUID: row.Guid, + Title: row.Title, + OwnerID: row.OwnerID, + Username: row.Username, + } + } + return sites, nil +} + +func dbSiteToSite(row sqlgen.Site) models.Site { + return models.Site{ + ID: row.ID, + OwnerID: row.OwnerID, + GUID: row.Guid, + Title: row.Title, + Tagline: row.Tagline, + Created: time.Unix(row.CreatedAt, 0).UTC(), + } +} diff --git a/providers/db/users.go b/providers/db/users.go index 73b3590..18e0bc6 100644 --- a/providers/db/users.go +++ b/providers/db/users.go @@ -3,6 +3,7 @@ package db import ( "context" "encoding/base64" + "time" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/providers/db/gen/sqlgen" @@ -14,16 +15,16 @@ func (db *Provider) SelectUserByUsername(ctx context.Context, username string) ( return models.User{}, err } - pwdBytes, err := base64.StdEncoding.DecodeString(res.Password) + return dbUserToUser(res) +} + +func (db *Provider) SelectUserByID(ctx context.Context, userID int64) (models.User, error) { + res, err := db.queries.SelectUserByID(ctx, userID) if err != nil { return models.User{}, err } - return models.User{ - ID: res.ID, - Username: res.Username, - PasswordHashed: pwdBytes, - }, nil + return dbUserToUser(res) } func (db *Provider) SaveUser(ctx context.Context, user *models.User) error { @@ -31,8 +32,9 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error { if user.ID == 0 { newID, err := db.queries.InsertUser(ctx, sqlgen.InsertUserParams{ - Username: user.Username, - Password: hashedPassword, + Username: user.Username, + Password: hashedPassword, + CreatedAt: timeToInt(user.Created), }) if err != nil { return err @@ -47,3 +49,17 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error { Password: hashedPassword, }) } + +func dbUserToUser(res sqlgen.User) (models.User, error) { + pwdBytes, err := base64.StdEncoding.DecodeString(res.Password) + if err != nil { + return models.User{}, err + } + + return models.User{ + ID: res.ID, + Username: res.Username, + PasswordHashed: pwdBytes, + Created: time.Unix(res.CreatedAt, 0).UTC(), + }, nil +} diff --git a/services/auth/service.go b/services/auth/service.go new file mode 100644 index 0000000..bb958e3 --- /dev/null +++ b/services/auth/service.go @@ -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) +} diff --git a/services/posts/create.go b/services/posts/create.go index 191dbbc..1dc69a1 100644 --- a/services/posts/create.go +++ b/services/posts/create.go @@ -2,6 +2,7 @@ package posts import ( "context" + "strings" "time" "lmika.dev/lmika/weiro/models" @@ -9,12 +10,13 @@ import ( ) type CreatePostParams struct { - GUID string `form:"guid" json:"guid"` - Title string `form:"title" json:"title"` - Body string `form:"body" json:"body"` + GUID string `form:"guid" json:"guid"` + Title string `form:"title" json:"title"` + Body string `form:"body" json:"body"` + Action string `form:"action" json:"action"` } -func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*models.Post, error) { +func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) { site, ok := models.GetSite(ctx) if !ok { return nil, models.SiteRequiredError @@ -27,16 +29,27 @@ func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*mo post.Title = params.Title post.Body = params.Body - post.PublishedAt = time.Now() + post.UpdatedAt = time.Now() post.Slug = post.BestSlug() + oldState := post.State + + switch strings.ToLower(params.Action) { + case "publish": + post.State = models.StatePublished + post.PublishedAt = time.Now() + case "save draft": + post.State = models.StateDraft + post.PublishedAt = time.Time{} + default: + // Leave unchanged + } if err := s.db.SavePost(ctx, post); err != nil { return nil, err } - // TODO: do on separate thread - if err := s.publisher.Publish(ctx, site); err != nil { - return nil, err + if oldState != post.State || post.State == models.StatePublished { + s.publisher.Queue(site) } return post, nil @@ -59,6 +72,7 @@ func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, param GUID: params.GUID, Title: params.Title, Body: params.Body, + State: models.StateDraft, CreatedAt: time.Now(), } return post, nil diff --git a/services/posts/delete.go b/services/posts/delete.go new file mode 100644 index 0000000..df220ef --- /dev/null +++ b/services/posts/delete.go @@ -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 +} diff --git a/services/posts/list.go b/services/posts/list.go index ba0d7a7..e94b24b 100644 --- a/services/posts/list.go +++ b/services/posts/list.go @@ -6,13 +6,13 @@ import ( "lmika.dev/lmika/weiro/models" ) -func (s *Service) ListPosts(ctx context.Context) ([]*models.Post, error) { +func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) { site, ok := models.GetSite(ctx) if !ok { return nil, models.SiteRequiredError } - posts, err := s.db.SelectPostsOfSite(ctx, site.ID) + posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted) if err != nil { return nil, err } @@ -21,16 +21,9 @@ func (s *Service) ListPosts(ctx context.Context) ([]*models.Post, error) { } func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) { - site, ok := models.GetSite(ctx) - if !ok { - return nil, models.SiteRequiredError - } - - post, err := s.db.SelectPost(ctx, pid) + post, _, err := s.fetchPostAndSite(ctx, pid) if err != nil { return nil, err - } else if post.SiteID != site.ID { - return nil, models.NotFoundError } return post, nil diff --git a/services/posts/service.go b/services/posts/service.go index c0b19d7..b7bb433 100644 --- a/services/posts/service.go +++ b/services/posts/service.go @@ -7,10 +7,10 @@ import ( type Service struct { db *db.Provider - publisher *publisher.Publisher + publisher *publisher.Queue } -func New(db *db.Provider, publisher *publisher.Publisher) *Service { +func New(db *db.Provider, publisher *publisher.Queue) *Service { return &Service{ db: db, publisher: publisher, diff --git a/services/publisher/pqueue.go b/services/publisher/pqueue.go new file mode 100644 index 0000000..38608f3 --- /dev/null +++ b/services/publisher/pqueue.go @@ -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 + } + } + }() +} diff --git a/services/publisher/service.go b/services/publisher/service.go index fe43854..2cc47c8 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -35,7 +35,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { } // Fetch all content of site - posts, err := p.db.SelectPostsOfSite(ctx, site.ID) + posts, err := p.db.SelectPostsOfSite(ctx, site.ID, false) if err != nil { return err } diff --git a/services/services.go b/services/services.go new file mode 100644 index 0000000..edf52cd --- /dev/null +++ b/services/services.go @@ -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() +} diff --git a/services/sites/services.go b/services/sites/services.go new file mode 100644 index 0000000..22e3916 --- /dev/null +++ b/services/sites/services.go @@ -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) +} diff --git a/services/sites/utils.go b/services/sites/utils.go new file mode 100644 index 0000000..af0af63 --- /dev/null +++ b/services/sites/utils.go @@ -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 +} diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index 2a95e08..3f740bf 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -1,5 +1,12 @@ -- name: SelectPostsOfSite :many -SELECT * FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10; +SELECT * +FROM posts +WHERE site_id = ? AND ( + CASE CAST (sqlc.arg(post_filter) AS TEXT) + WHEN 'deleted' THEN deleted_at > 0 + ELSE deleted_at = 0 + END +) ORDER BY created_at DESC LIMIT 10; -- name: SelectPost :one SELECT * FROM posts WHERE id = ? LIMIT 1; @@ -10,19 +17,34 @@ SELECT * FROM posts WHERE guid = ? LIMIT 1; -- name: InsertPost :one INSERT INTO posts ( site_id, + state, guid, title, body, slug, created_at, - published_at -) VALUES (?, ?, ?, ?, ?, ?, ?) + updated_at, + published_at, + deleted_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id; -- name: UpdatePost :exec UPDATE posts SET title = ?, + state = ?, body = ?, slug = ?, - published_at = ? -WHERE id = ?; \ No newline at end of file + updated_at = ?, + published_at = ?, + deleted_at = ? +WHERE id = ?; + +-- name: SoftDeletePost :exec +UPDATE posts SET deleted_at = ? WHERE id = ?; + +-- name: RestorePost :exec +UPDATE posts SET deleted_at = 0 WHERE id = ?; + +-- name: HardDeletePost :exec +DELETE FROM posts WHERE id = ?; \ No newline at end of file diff --git a/sql/queries/pubtargets.sql b/sql/queries/pubtargets.sql index e77ef8b..f3fdabd 100644 --- a/sql/queries/pubtargets.sql +++ b/sql/queries/pubtargets.sql @@ -4,10 +4,11 @@ SELECT * FROM publish_targets WHERE site_id = ?; -- name: InsertPublishTarget :one INSERT INTO publish_targets ( site_id, + guid, target_type, enabled, base_url, target_ref, target_key -) VALUES (?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id; \ No newline at end of file diff --git a/sql/queries/sites.sql b/sql/queries/sites.sql index 0ea7567..92e7ccb 100644 --- a/sql/queries/sites.sql +++ b/sql/queries/sites.sql @@ -1,13 +1,27 @@ -- name: SelectSitesOwnedByUser :many -SELECT * FROM sites WHERE owner_id = ?; +SELECT * FROM sites WHERE owner_id = ? ORDER BY title ASC; -- name: SelectSiteByID :one SELECT * FROM sites WHERE id = ?; +-- name: SelectSiteByGUID :one +SELECT * FROM sites WHERE guid = ?; + -- name: InsertSite :one INSERT INTO sites ( owner_id, + guid, title, - tagline -) VALUES (?, ?, ?) -RETURNING id; \ No newline at end of file + tagline, + created_at +) VALUES (?, ?, ?, ?, ?) +RETURNING id; + +-- name: HasUsersAndSites :one +SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites; + +-- name: SelectAllSitesWithOwners :many +SELECT s.id, s.guid, s.title, s.owner_id, u.username +FROM sites s +JOIN users u ON s.owner_id = u.id +ORDER BY s.title ASC; \ No newline at end of file diff --git a/sql/queries/users.sql b/sql/queries/users.sql index ec4c69d..1fd590c 100644 --- a/sql/queries/users.sql +++ b/sql/queries/users.sql @@ -1,8 +1,11 @@ -- name: SelectUserByUsername :one -SELECT * FROM users WHERE username = ?; +SELECT * FROM users WHERE username = ? LIMIT 1; + +-- name: SelectUserByID :one +SELECT * FROM users WHERE id = ? LIMIT 1; -- name: InsertUser :one -INSERT INTO users (username, password) VALUES (?, ?) RETURNING id; +INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id; -- name: UpdateUser :exec UPDATE users SET username = ?, password = ? WHERE id = ?; \ No newline at end of file diff --git a/sql/schema/01_init.up.sql b/sql/schema/01_init.up.sql index 0c0e1e2..cc76a5b 100644 --- a/sql/schema/01_init.up.sql +++ b/sql/schema/01_init.up.sql @@ -1,40 +1,49 @@ CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT NOT NULL, - password TEXT NOT NULL + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + password TEXT NOT NULL, + created_at INTEGER NOT NULL ); CREATE UNIQUE INDEX idx_users_username ON users (username); CREATE TABLE sites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL, - title TEXT NOT NULL, - tagline TEXT NOT NULL, + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL, + guid TEXT NOT NULL, + title TEXT NOT NULL, + tagline TEXT NOT NULL, + created_at INTEGER NOT NULL, FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE INDEX idx_site_owner ON sites (owner_id); +CREATE UNIQUE INDEX idx_site_guid ON sites (guid); CREATE TABLE publish_targets ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER NOT NULL, - target_type TEXT NOT NULL, - enabled INT NOT NULL, + guid TEXT NOT NULL, + target_type TEXT NOT NULL, + enabled INT NOT NULL, base_url TEXT NOT NULL, target_ref TEXT NOT NULL, target_key TEXT NOT NULL ); CREATE INDEX idx_publish_targets_site ON publish_targets (site_id); +CREATE UNIQUE INDEX idx_publish_targets_guid ON publish_targets (guid); CREATE TABLE posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER NOT NULL, + state INTEGER NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL, slug TEXT NOT NULL, created_at INTEGER NOT NULL, - published_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + published_at INTEGER NOT NULL, + deleted_at INTEGER NOT NULL ); CREATE INDEX idx_post_site ON posts (site_id); CREATE UNIQUE INDEX idx_post_guid ON posts (guid); \ No newline at end of file diff --git a/views/_common/nav.html b/views/_common/nav.html index 9446f20..8729c09 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -1,6 +1,6 @@