Have got first run working and publishing to Netlify
This commit is contained in:
parent
b7e0269e9d
commit
30d99eeb9e
66
assets/js/controllers/firstrun.js
Normal file
66
assets/js/controllers/firstrun.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import { showToast } from "../services/toast";
|
||||||
|
|
||||||
|
export default class FirstRunController extends Controller {
|
||||||
|
static targets = ['pages'];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.pagesTargets.forEach((x) => x.classList.add('d-none'));
|
||||||
|
this.pagesTargets[0].classList.remove('d-none');
|
||||||
|
this.element.querySelector('input[name="username"]').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
nextPage(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const currentIndex = this.pagesTargets.findIndex(x => !x.classList.contains('d-none'));
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this._validate(currentIndex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextPage = currentIndex + 1;
|
||||||
|
if (nextPage >= this.pagesTargets.length) {
|
||||||
|
this.element.querySelector('form').submit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pagesTargets[currentIndex].classList.add('d-none');
|
||||||
|
this.pagesTargets[nextPage].classList.remove('d-none');
|
||||||
|
|
||||||
|
if (nextPage === 1) {
|
||||||
|
this.element.querySelector('input[name="siteName"]').focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_validate(pageNumber) {
|
||||||
|
let newUsername = this.element.querySelector('input[name="username"]');
|
||||||
|
let newPassword1 = this.element.querySelector('input[name="password1"]');
|
||||||
|
let newPassword2 = this.element.querySelector('input[name="password2"]');
|
||||||
|
|
||||||
|
if (newUsername.value === '') {
|
||||||
|
alert('Please enter a username');
|
||||||
|
newUsername.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!newUsername.value.match(/^[a-zA-Z0-9_-]+$/)) {
|
||||||
|
alert('Please enter a username with letters, numbers, underscores, and dashes only');
|
||||||
|
newUsername.focus();
|
||||||
|
newUsername.select();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (newPassword1.value === '') {
|
||||||
|
alert('Please enter a password');
|
||||||
|
newPassword1.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (newPassword2.value !== newPassword1.value) {
|
||||||
|
alert('Passwords do not match');
|
||||||
|
newPassword2.focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,9 +4,11 @@ import ToastController from "./controllers/toast";
|
||||||
import PostlistController from "./controllers/postlist";
|
import PostlistController from "./controllers/postlist";
|
||||||
import PosteditController from "./controllers/postedit";
|
import PosteditController from "./controllers/postedit";
|
||||||
import LogoutController from "./controllers/logout";
|
import LogoutController from "./controllers/logout";
|
||||||
|
import FirstRunController from "./controllers/firstrun";
|
||||||
|
|
||||||
window.Stimulus = Application.start()
|
window.Stimulus = Application.start()
|
||||||
Stimulus.register("toast", ToastController);
|
Stimulus.register("toast", ToastController);
|
||||||
Stimulus.register("postlist", PostlistController);
|
Stimulus.register("postlist", PostlistController);
|
||||||
Stimulus.register("postedit", PosteditController);
|
Stimulus.register("postedit", PosteditController);
|
||||||
Stimulus.register("logout", LogoutController);
|
Stimulus.register("logout", LogoutController);
|
||||||
|
Stimulus.register("first-run", FirstRunController);
|
||||||
1
go.mod
1
go.mod
|
|
@ -37,6 +37,7 @@ require (
|
||||||
github.com/go-openapi/strfmt v0.19.11 // indirect
|
github.com/go-openapi/strfmt v0.19.11 // indirect
|
||||||
github.com/go-openapi/swag v0.19.12 // indirect
|
github.com/go-openapi/swag v0.19.12 // indirect
|
||||||
github.com/go-openapi/validate v0.20.0 // indirect
|
github.com/go-openapi/validate v0.20.0 // indirect
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/go-stack/stack v1.8.0 // indirect
|
github.com/go-stack/stack v1.8.0 // indirect
|
||||||
github.com/gofiber/fiber/v3 v3.1.0 // indirect
|
github.com/gofiber/fiber/v3 v3.1.0 // indirect
|
||||||
github.com/gofiber/schema v1.7.0 // indirect
|
github.com/gofiber/schema v1.7.0 // indirect
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -178,6 +178,8 @@ github.com/go-openapi/validate v0.19.12/go.mod h1:Rzou8hA/CBw8donlS6WNEUQupNvUZ0
|
||||||
github.com/go-openapi/validate v0.19.15/go.mod h1:tbn/fdOwYHgrhPBzidZfJC2MIVvs9GA7monOmWBbeCI=
|
github.com/go-openapi/validate v0.19.15/go.mod h1:tbn/fdOwYHgrhPBzidZfJC2MIVvs9GA7monOmWBbeCI=
|
||||||
github.com/go-openapi/validate v0.20.0 h1:pzutNCCBZGZlE+u8HD3JZyWdc/TVbtVwlWUp8/vgUKk=
|
github.com/go-openapi/validate v0.20.0 h1:pzutNCCBZGZlE+u8HD3JZyWdc/TVbtVwlWUp8/vgUKk=
|
||||||
github.com/go-openapi/validate v0.20.0/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0=
|
github.com/go-openapi/validate v0.20.0/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||||
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||||
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
|
|
|
||||||
77
handlers/index.go
Normal file
77
handlers/index.go
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"github.com/gofiber/fiber/v3/middleware/session"
|
||||||
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
"lmika.dev/lmika/weiro/services/sites"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sitePath = regexp.MustCompile(`^/sites/([0-9]+)`)
|
||||||
|
|
||||||
|
type IndexHandler struct {
|
||||||
|
SiteService *sites.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h IndexHandler) Index(c fiber.Ctx) error {
|
||||||
|
hasUserAndSites, err := h.SiteService.HasUsersAsSites(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if !hasUserAndSites {
|
||||||
|
return c.Redirect().To("/first-run")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, hasUser := models.GetUser(c.Context())
|
||||||
|
if !hasUser {
|
||||||
|
return c.Redirect().To("/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
if refUrl, rerr := url.Parse(c.Get("Referer")); rerr == nil {
|
||||||
|
if parts := sitePath.FindStringSubmatch(refUrl.Path); len(parts) == 2 {
|
||||||
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", parts[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
site, err := h.SiteService.BestSite(c.Context(), user)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", site.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h IndexHandler) FirstRun(c fiber.Ctx) error {
|
||||||
|
hasUserAndSites, err := h.SiteService.HasUsersAsSites(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
} else if hasUserAndSites {
|
||||||
|
return errors.New("you already have a site")
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Render("index/first-run", fiber.Map{}, "layouts/bare_with_scripts")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h IndexHandler) FirstRunSubmit(c fiber.Ctx) error {
|
||||||
|
var req sites.FirstRunRequest
|
||||||
|
if err := c.Bind().Body(&req); err != nil {
|
||||||
|
return errors.Wrap(err, "failed to parse first run request")
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := session.FromContext(c)
|
||||||
|
if err := sess.Regenerate(); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, site, err := h.SiteService.FirstRun(c.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.Set("user_id", user.ID)
|
||||||
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", site.ID))
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,7 @@ func (lh *LoginHandler) DoLogin(c fiber.Ctx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
sess.Set("user_id", user.ID)
|
sess.Set("user_id", user.ID)
|
||||||
|
sess.Delete("_login_challenge")
|
||||||
|
|
||||||
return c.Redirect().To("/")
|
return c.Redirect().To("/")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,27 @@ import (
|
||||||
"lmika.dev/lmika/weiro/services/auth"
|
"lmika.dev/lmika/weiro/services/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AuthUser(auth *auth.Service) func(c fiber.Ctx) error {
|
func OptionalUser(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.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 {
|
return func(c fiber.Ctx) error {
|
||||||
sess := session.FromContext(c)
|
sess := session.FromContext(c)
|
||||||
userID, _ := sess.Get("user_id").(int64)
|
userID, _ := sess.Get("user_id").(int64)
|
||||||
|
|
|
||||||
12
main.go
12
main.go
|
|
@ -26,6 +26,7 @@ import (
|
||||||
"lmika.dev/lmika/weiro/services/auth"
|
"lmika.dev/lmika/weiro/services/auth"
|
||||||
"lmika.dev/lmika/weiro/services/posts"
|
"lmika.dev/lmika/weiro/services/posts"
|
||||||
"lmika.dev/lmika/weiro/services/publisher"
|
"lmika.dev/lmika/weiro/services/publisher"
|
||||||
|
"lmika.dev/lmika/weiro/services/sites"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -49,6 +50,7 @@ func main() {
|
||||||
publisherSvc := publisher.New(dbp)
|
publisherSvc := publisher.New(dbp)
|
||||||
publisherQueue := publisher.NewQueue(publisherSvc)
|
publisherQueue := publisher.NewQueue(publisherSvc)
|
||||||
postService := posts.New(dbp, publisherQueue)
|
postService := posts.New(dbp, publisherQueue)
|
||||||
|
siteService := sites.New(dbp)
|
||||||
|
|
||||||
// CLI tools
|
// CLI tools
|
||||||
if *flagPasswd != "" && *flagUser != "" {
|
if *flagPasswd != "" && *flagUser != "" {
|
||||||
|
|
@ -120,6 +122,7 @@ func main() {
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
ih := handlers.IndexHandler{SiteService: siteService}
|
||||||
lh := handlers.LoginHandler{Config: cfg, AuthService: authSvc}
|
lh := handlers.LoginHandler{Config: cfg, AuthService: authSvc}
|
||||||
ph := handlers.PostsHandler{PostService: postService}
|
ph := handlers.PostsHandler{PostService: postService}
|
||||||
|
|
||||||
|
|
@ -127,7 +130,7 @@ func main() {
|
||||||
app.Post("/login", lh.DoLogin)
|
app.Post("/login", lh.DoLogin)
|
||||||
app.Post("/logout", lh.Logout)
|
app.Post("/logout", lh.Logout)
|
||||||
|
|
||||||
siteGroup := app.Group("/sites/:siteID", middleware.AuthUser(authSvc), middleware.RequiresSite(dbp))
|
siteGroup := app.Group("/sites/:siteID", middleware.RequireUser(authSvc), middleware.RequiresSite(dbp))
|
||||||
|
|
||||||
siteGroup.Get("/posts", ph.Index)
|
siteGroup.Get("/posts", ph.Index)
|
||||||
siteGroup.Get("/posts/new", ph.New)
|
siteGroup.Get("/posts/new", ph.New)
|
||||||
|
|
@ -136,9 +139,10 @@ func main() {
|
||||||
siteGroup.Patch("/posts/:postID", ph.Patch)
|
siteGroup.Patch("/posts/:postID", ph.Patch)
|
||||||
siteGroup.Delete("/posts/:postID", ph.Delete)
|
siteGroup.Delete("/posts/:postID", ph.Delete)
|
||||||
|
|
||||||
app.Get("/", func(c fiber.Ctx) error {
|
app.Get("/", middleware.OptionalUser(authSvc), ih.Index)
|
||||||
return c.Redirect().To("/sites/1/posts")
|
app.Get("/first-run", ih.FirstRun)
|
||||||
})
|
app.Post("/first-run", ih.FirstRunSubmit)
|
||||||
|
|
||||||
app.Get("/static/*", static.New("./static"))
|
app.Get("/static/*", static.New("./static"))
|
||||||
|
|
||||||
// TEMP
|
// TEMP
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
type PublishTargetType int
|
type PublishTargetType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -11,10 +13,10 @@ const (
|
||||||
type Site struct {
|
type Site struct {
|
||||||
ID int64
|
ID int64
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
|
Created time.Time
|
||||||
|
|
||||||
Title string
|
Title string
|
||||||
Tagline string
|
Tagline string
|
||||||
//Meta SiteMeta
|
|
||||||
//Posts []*Post
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SitePublishTarget struct {
|
type SitePublishTarget struct {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ValidUserName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
Username string
|
Username string
|
||||||
PasswordHashed []byte
|
PasswordHashed []byte
|
||||||
TimeZone string
|
TimeZone string
|
||||||
|
Created time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) SetPassword(pwd string) {
|
func (u *User) SetPassword(pwd string) {
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,16 @@ type PublishTarget struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Site struct {
|
type Site struct {
|
||||||
ID int64
|
ID int64
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
Title string
|
Title string
|
||||||
Tagline string
|
Tagline string
|
||||||
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,32 +7,51 @@ package sqlgen
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const hasUsersAndSites = `-- name: HasUsersAndSites :one
|
||||||
|
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) HasUsersAndSites(ctx context.Context) (sql.NullBool, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, hasUsersAndSites)
|
||||||
|
var has_users_and_sites sql.NullBool
|
||||||
|
err := row.Scan(&has_users_and_sites)
|
||||||
|
return has_users_and_sites, err
|
||||||
|
}
|
||||||
|
|
||||||
const insertSite = `-- name: InsertSite :one
|
const insertSite = `-- name: InsertSite :one
|
||||||
INSERT INTO sites (
|
INSERT INTO sites (
|
||||||
owner_id,
|
owner_id,
|
||||||
title,
|
title,
|
||||||
tagline
|
tagline,
|
||||||
) VALUES (?, ?, ?)
|
created_at
|
||||||
|
) VALUES (?, ?, ?, ?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertSiteParams struct {
|
type InsertSiteParams struct {
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
Title string
|
Title string
|
||||||
Tagline string
|
Tagline string
|
||||||
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) {
|
func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) {
|
||||||
row := q.db.QueryRowContext(ctx, insertSite, arg.OwnerID, arg.Title, arg.Tagline)
|
row := q.db.QueryRowContext(ctx, insertSite,
|
||||||
|
arg.OwnerID,
|
||||||
|
arg.Title,
|
||||||
|
arg.Tagline,
|
||||||
|
arg.CreatedAt,
|
||||||
|
)
|
||||||
var id int64
|
var id int64
|
||||||
err := row.Scan(&id)
|
err := row.Scan(&id)
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectSiteByID = `-- name: SelectSiteByID :one
|
const selectSiteByID = `-- name: SelectSiteByID :one
|
||||||
SELECT id, owner_id, title, tagline FROM sites WHERE id = ?
|
SELECT id, owner_id, title, tagline, created_at FROM sites WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
|
func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
|
||||||
|
|
@ -43,12 +62,13 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
|
||||||
&i.OwnerID,
|
&i.OwnerID,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Tagline,
|
&i.Tagline,
|
||||||
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many
|
const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many
|
||||||
SELECT id, owner_id, title, tagline FROM sites WHERE owner_id = ?
|
SELECT id, owner_id, title, tagline, created_at FROM sites WHERE owner_id = ? ORDER BY title ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) {
|
func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) {
|
||||||
|
|
@ -65,6 +85,7 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]
|
||||||
&i.OwnerID,
|
&i.OwnerID,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Tagline,
|
&i.Tagline,
|
||||||
|
&i.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,40 +10,51 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const insertUser = `-- name: InsertUser :one
|
const insertUser = `-- name: InsertUser :one
|
||||||
INSERT INTO users (username, password) VALUES (?, ?) RETURNING id
|
INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertUserParams struct {
|
type InsertUserParams struct {
|
||||||
Username string
|
Username string
|
||||||
Password string
|
Password string
|
||||||
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (int64, error) {
|
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (int64, error) {
|
||||||
row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password)
|
row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password, arg.CreatedAt)
|
||||||
var id int64
|
var id int64
|
||||||
err := row.Scan(&id)
|
err := row.Scan(&id)
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectUserByID = `-- name: SelectUserByID :one
|
const selectUserByID = `-- name: SelectUserByID :one
|
||||||
SELECT id, username, password FROM users WHERE id = ? LIMIT 1
|
SELECT id, username, password, created_at FROM users WHERE id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SelectUserByID(ctx context.Context, id int64) (User, error) {
|
func (q *Queries) SelectUserByID(ctx context.Context, id int64) (User, error) {
|
||||||
row := q.db.QueryRowContext(ctx, selectUserByID, id)
|
row := q.db.QueryRowContext(ctx, selectUserByID, id)
|
||||||
var i User
|
var i User
|
||||||
err := row.Scan(&i.ID, &i.Username, &i.Password)
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Username,
|
||||||
|
&i.Password,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectUserByUsername = `-- name: SelectUserByUsername :one
|
const selectUserByUsername = `-- name: SelectUserByUsername :one
|
||||||
SELECT id, username, password FROM users WHERE username = ? LIMIT 1
|
SELECT id, username, password, created_at FROM users WHERE username = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) SelectUserByUsername(ctx context.Context, username string) (User, error) {
|
func (q *Queries) SelectUserByUsername(ctx context.Context, username string) (User, error) {
|
||||||
row := q.db.QueryRowContext(ctx, selectUserByUsername, username)
|
row := q.db.QueryRowContext(ctx, selectUserByUsername, username)
|
||||||
var i User
|
var i User
|
||||||
err := row.Scan(&i.ID, &i.Username, &i.Password)
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Username,
|
||||||
|
&i.Password,
|
||||||
|
&i.CreatedAt,
|
||||||
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||||||
|
|
@ -18,6 +19,7 @@ func (db *Provider) SelectSiteByID(ctx context.Context, id int64) (models.Site,
|
||||||
OwnerID: row.OwnerID,
|
OwnerID: row.OwnerID,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
Tagline: row.Tagline,
|
Tagline: row.Tagline,
|
||||||
|
Created: time.Unix(row.CreatedAt, 0).UTC(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,6 +36,7 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) (
|
||||||
OwnerID: row.OwnerID,
|
OwnerID: row.OwnerID,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
Tagline: row.Tagline,
|
Tagline: row.Tagline,
|
||||||
|
Created: time.Unix(row.CreatedAt, 0).UTC(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sites, nil
|
return sites, nil
|
||||||
|
|
@ -42,9 +45,10 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) (
|
||||||
func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
|
func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
|
||||||
if site.ID == 0 {
|
if site.ID == 0 {
|
||||||
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
|
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
|
||||||
OwnerID: site.OwnerID,
|
OwnerID: site.OwnerID,
|
||||||
Title: site.Title,
|
Title: site.Title,
|
||||||
Tagline: site.Tagline,
|
Tagline: site.Tagline,
|
||||||
|
CreatedAt: timeToInt(site.Created),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -56,3 +60,11 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
|
||||||
// No update query defined in sqlgen yet
|
// No update query defined in sqlgen yet
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *Provider) HasUsersAndSites(ctx context.Context) (bool, error) {
|
||||||
|
nullBool, err := db.queries.HasUsersAndSites(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return nullBool.Valid && nullBool.Bool, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package db
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"time"
|
||||||
|
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||||||
|
|
@ -31,8 +32,9 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
|
||||||
|
|
||||||
if user.ID == 0 {
|
if user.ID == 0 {
|
||||||
newID, err := db.queries.InsertUser(ctx, sqlgen.InsertUserParams{
|
newID, err := db.queries.InsertUser(ctx, sqlgen.InsertUserParams{
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Password: hashedPassword,
|
Password: hashedPassword,
|
||||||
|
CreatedAt: timeToInt(user.Created),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -58,5 +60,6 @@ func dbUserToUser(res sqlgen.User) (models.User, error) {
|
||||||
ID: res.ID,
|
ID: res.ID,
|
||||||
Username: res.Username,
|
Username: res.Username,
|
||||||
PasswordHashed: pwdBytes,
|
PasswordHashed: pwdBytes,
|
||||||
|
Created: time.Unix(res.CreatedAt, 0).UTC(),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
services/sites/services.go
Normal file
103
services/sites/services.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
package sites
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"emperror.dev/errors"
|
||||||
|
"github.com/go-ozzo/ozzo-validation/v4"
|
||||||
|
"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"),
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
}
|
||||||
23
services/sites/utils.go
Normal file
23
services/sites/utils.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package sites
|
||||||
|
|
||||||
|
import (
|
||||||
|
"emperror.dev/errors"
|
||||||
|
validation "github.com/go-ozzo/ozzo-validation/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func stringEquals(str string) validation.RuleFunc {
|
||||||
|
return func(value interface{}) error {
|
||||||
|
s, _ := value.(string)
|
||||||
|
if s != str {
|
||||||
|
return errors.New("unexpected string")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultIfEmpty(value string, defaultValue string) string {
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
-- name: SelectSitesOwnedByUser :many
|
-- name: SelectSitesOwnedByUser :many
|
||||||
SELECT * FROM sites WHERE owner_id = ?;
|
SELECT * FROM sites WHERE owner_id = ? ORDER BY title ASC;
|
||||||
|
|
||||||
-- name: SelectSiteByID :one
|
-- name: SelectSiteByID :one
|
||||||
SELECT * FROM sites WHERE id = ?;
|
SELECT * FROM sites WHERE id = ?;
|
||||||
|
|
@ -8,6 +8,10 @@ SELECT * FROM sites WHERE id = ?;
|
||||||
INSERT INTO sites (
|
INSERT INTO sites (
|
||||||
owner_id,
|
owner_id,
|
||||||
title,
|
title,
|
||||||
tagline
|
tagline,
|
||||||
) VALUES (?, ?, ?)
|
created_at
|
||||||
RETURNING id;
|
) VALUES (?, ?, ?, ?)
|
||||||
|
RETURNING id;
|
||||||
|
|
||||||
|
-- name: HasUsersAndSites :one
|
||||||
|
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites;
|
||||||
|
|
@ -5,7 +5,7 @@ SELECT * FROM users WHERE username = ? LIMIT 1;
|
||||||
SELECT * FROM users WHERE id = ? LIMIT 1;
|
SELECT * FROM users WHERE id = ? LIMIT 1;
|
||||||
|
|
||||||
-- name: InsertUser :one
|
-- name: InsertUser :one
|
||||||
INSERT INTO users (username, password) VALUES (?, ?) RETURNING id;
|
INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id;
|
||||||
|
|
||||||
-- name: UpdateUser :exec
|
-- name: UpdateUser :exec
|
||||||
UPDATE users SET username = ?, password = ? WHERE id = ?;
|
UPDATE users SET username = ?, password = ? WHERE id = ?;
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
password TEXT NOT NULL
|
password TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX idx_users_username ON users (username);
|
CREATE UNIQUE INDEX idx_users_username ON users (username);
|
||||||
|
|
||||||
CREATE TABLE sites (
|
CREATE TABLE sites (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
owner_id INTEGER NOT NULL,
|
owner_id INTEGER NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
tagline TEXT NOT NULL,
|
tagline TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
|
||||||
FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE
|
FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
|
||||||
52
views/index/first-run.html
Normal file
52
views/index/first-run.html
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<div class="mx-auto p-2" style="width: 400px; margin-block-start: 50px;" data-controller="first-run">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h4>Welcome to</h4>
|
||||||
|
<h1>Weiro</h1>
|
||||||
|
</div>
|
||||||
|
<form action="/first-run" method="post">
|
||||||
|
<div data-first-run-target="pages needs-validation">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<p>Please enter the username and password you'd like to use to login.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
<input type="text" class="form-control" name="username" id="username">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password1" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" name="password1" id="password1">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password2" class="form-label">Re-enter password</label>
|
||||||
|
<input type="password" class="form-control" name="password2" id="password2">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 text-end">
|
||||||
|
<button class="btn btn-primary" value="Next" data-action="click->first-run#nextPage">Next »</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div data-first-run-target="pages">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<p>Enter the details of your blog, if you know them.<br>All fields are optional, and can be changed later.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="siteName" class="form-label">Site Name</label>
|
||||||
|
<input type="text" class="form-control" name="siteName" id="siteName">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="siteUrl" class="form-label">Site URL</label>
|
||||||
|
<input type="text" class="form-control" name="siteUrl" id="siteUrl">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="netlifySiteId" class="form-label">Netlify Site ID</label>
|
||||||
|
<input type="text" class="form-control" name="netlifySiteId" id="netlifySiteId">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="netlifyAPIToken" class="form-label">Netlify API Token</label>
|
||||||
|
<input type="text" class="form-control" name="netlifyAPIToken" id="netlifyAPIToken">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 text-end">
|
||||||
|
<input type="submit" class="btn btn-primary" value="Finish">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
13
views/layouts/bare_with_scripts.html
Normal file
13
views/layouts/bare_with_scripts.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Weiro</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="/static/assets/main.css">
|
||||||
|
<script src="/static/assets/main.js" type="module"></script>
|
||||||
|
</head>
|
||||||
|
<body class="min-vh-100 d-flex flex-column">
|
||||||
|
{{ embed }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue