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/main.js b/assets/js/main.js index 7cb8e33..6bca555 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -4,9 +4,11 @@ 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/go.mod b/go.mod index 504a4b5..3fee4a9 100644 --- a/go.mod +++ b/go.mod @@ -37,6 +37,7 @@ require ( github.com/go-openapi/strfmt v0.19.11 // indirect github.com/go-openapi/swag v0.19.12 // indirect github.com/go-openapi/validate v0.20.0 // indirect + github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect github.com/go-stack/stack v1.8.0 // indirect github.com/gofiber/fiber/v3 v3.1.0 // indirect github.com/gofiber/schema v1.7.0 // indirect diff --git a/go.sum b/go.sum index d91b49b..cd657c5 100644 --- a/go.sum +++ b/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.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= 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 index d9bfc0c..30ed0b4 100644 --- a/handlers/login.go +++ b/handlers/login.go @@ -66,6 +66,7 @@ func (lh *LoginHandler) DoLogin(c fiber.Ctx) error { } sess.Set("user_id", user.ID) + sess.Delete("_login_challenge") return c.Redirect().To("/") } diff --git a/handlers/middleware/user.go b/handlers/middleware/user.go index bbbef2a..8391b85 100644 --- a/handlers/middleware/user.go +++ b/handlers/middleware/user.go @@ -7,7 +7,27 @@ import ( "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 { sess := session.FromContext(c) userID, _ := sess.Get("user_id").(int64) diff --git a/main.go b/main.go index 349680e..040ba74 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ import ( "lmika.dev/lmika/weiro/services/auth" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" + "lmika.dev/lmika/weiro/services/sites" _ "modernc.org/sqlite" ) @@ -49,6 +50,7 @@ func main() { publisherSvc := publisher.New(dbp) publisherQueue := publisher.NewQueue(publisherSvc) postService := posts.New(dbp, publisherQueue) + siteService := sites.New(dbp) // CLI tools if *flagPasswd != "" && *flagUser != "" { @@ -120,6 +122,7 @@ func main() { }, })) + ih := handlers.IndexHandler{SiteService: siteService} lh := handlers.LoginHandler{Config: cfg, AuthService: authSvc} ph := handlers.PostsHandler{PostService: postService} @@ -127,7 +130,7 @@ func main() { app.Post("/login", lh.DoLogin) 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/new", ph.New) @@ -136,9 +139,10 @@ func main() { siteGroup.Patch("/posts/:postID", ph.Patch) siteGroup.Delete("/posts/:postID", ph.Delete) - app.Get("/", func(c fiber.Ctx) error { - return c.Redirect().To("/sites/1/posts") - }) + app.Get("/", middleware.OptionalUser(authSvc), ih.Index) + app.Get("/first-run", ih.FirstRun) + app.Post("/first-run", ih.FirstRunSubmit) + app.Get("/static/*", static.New("./static")) // TEMP diff --git a/models/sites.go b/models/sites.go index d83d126..3b2a6f4 100644 --- a/models/sites.go +++ b/models/sites.go @@ -1,5 +1,7 @@ package models +import "time" + type PublishTargetType int const ( @@ -11,10 +13,10 @@ const ( type Site struct { ID int64 OwnerID int64 + Created time.Time + Title string Tagline string - //Meta SiteMeta - //Posts []*Post } type SitePublishTarget struct { diff --git a/models/users.go b/models/users.go index b204e24..f901ac9 100644 --- a/models/users.go +++ b/models/users.go @@ -1,16 +1,20 @@ 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) { diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 6f49712..dd9aa92 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -29,14 +29,16 @@ type PublishTarget struct { } type Site struct { - ID int64 - OwnerID int64 - Title string - Tagline string + ID int64 + OwnerID int64 + 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/sites.sql.go b/providers/db/gen/sqlgen/sites.sql.go index 136cb38..e939bfb 100644 --- a/providers/db/gen/sqlgen/sites.sql.go +++ b/providers/db/gen/sqlgen/sites.sql.go @@ -7,32 +7,51 @@ 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, title, - tagline -) VALUES (?, ?, ?) + tagline, + created_at +) VALUES (?, ?, ?, ?) RETURNING id ` type InsertSiteParams struct { - OwnerID int64 - Title string - Tagline string + OwnerID int64 + 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.Title, + arg.Tagline, + arg.CreatedAt, + ) var id int64 err := row.Scan(&id) return id, err } 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) { @@ -43,12 +62,13 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) { &i.OwnerID, &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, title, tagline, created_at FROM sites WHERE owner_id = ? ORDER BY title ASC ` 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.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 4a9b56a..a70a3bf 100644 --- a/providers/db/gen/sqlgen/users.sql.go +++ b/providers/db/gen/sqlgen/users.sql.go @@ -10,40 +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 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) { row := q.db.QueryRowContext(ctx, selectUserByID, id) 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 } 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) { 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/sites.go b/providers/db/sites.go index eaf61fb..8336248 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" @@ -18,6 +19,7 @@ func (db *Provider) SelectSiteByID(ctx context.Context, id int64) (models.Site, OwnerID: row.OwnerID, Title: row.Title, Tagline: row.Tagline, + Created: time.Unix(row.CreatedAt, 0).UTC(), }, nil } @@ -34,6 +36,7 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ( OwnerID: row.OwnerID, Title: row.Title, Tagline: row.Tagline, + Created: time.Unix(row.CreatedAt, 0).UTC(), } } 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 { if site.ID == 0 { newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{ - OwnerID: site.OwnerID, - Title: site.Title, - Tagline: site.Tagline, + OwnerID: site.OwnerID, + Title: site.Title, + Tagline: site.Tagline, + CreatedAt: timeToInt(site.Created), }) if err != nil { return err @@ -56,3 +60,11 @@ 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 +} diff --git a/providers/db/users.go b/providers/db/users.go index 87f8034..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" @@ -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 @@ -58,5 +60,6 @@ func dbUserToUser(res sqlgen.User) (models.User, error) { ID: res.ID, Username: res.Username, PasswordHashed: pwdBytes, + Created: time.Unix(res.CreatedAt, 0).UTC(), }, nil } diff --git a/services/sites/services.go b/services/sites/services.go new file mode 100644 index 0000000..7bb6818 --- /dev/null +++ b/services/sites/services.go @@ -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 +} 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/sites.sql b/sql/queries/sites.sql index 0ea7567..5632928 100644 --- a/sql/queries/sites.sql +++ b/sql/queries/sites.sql @@ -1,5 +1,5 @@ -- 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 = ?; @@ -8,6 +8,10 @@ SELECT * FROM sites WHERE id = ?; INSERT INTO sites ( owner_id, 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; \ No newline at end of file diff --git a/sql/queries/users.sql b/sql/queries/users.sql index 3112a7a..1fd590c 100644 --- a/sql/queries/users.sql +++ b/sql/queries/users.sql @@ -5,7 +5,7 @@ SELECT * FROM users WHERE username = ? LIMIT 1; 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 e4735a2..22d3e60 100644 --- a/sql/schema/01_init.up.sql +++ b/sql/schema/01_init.up.sql @@ -1,15 +1,17 @@ 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, + title TEXT NOT NULL, + tagline TEXT NOT NULL, + created_at INTEGER NOT NULL, FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE ); diff --git a/views/index/first-run.html b/views/index/first-run.html new file mode 100644 index 0000000..239a80d --- /dev/null +++ b/views/index/first-run.html @@ -0,0 +1,52 @@ +