M cms.go => cms.go +13 -8
@@ 18,6 18,7 @@ var (
app *App
port = os.Getenv("PORT")
+ dbtype = os.Getenv("DBTYPE")
dbcreds = os.Getenv("DB")
url = os.Getenv("URL")
secret = os.Getenv("SECRET")
@@ 27,7 28,7 @@ type App struct {
log *log.Logger
space http.Handler
user http.Handler
- pong http.Handler
+ ping http.Handler
}
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ 39,13 40,16 @@ func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch parts[1] {
case "ping":
- a.pong.ServeHTTP(w, r)
+ a.ping.ServeHTTP(w, r)
return
case "":
fallthrough
case "user":
a.user.ServeHTTP(w, r)
return
+ case "space":
+ a.space.ServeHTTP(w, r)
+ return
}
http.NotFound(w, r)
@@ 57,6 61,7 @@ func init() {
db, err := db.New(
log.New(w, "[cms:db] ", 0),
+ dbtype,
dbcreds,
security.Default(secret),
)
@@ 69,16 74,16 @@ func init() {
}
app = &App{
- applogger,
- user.New(
- log.New(w, "[cms:user] ", 0),
+ log: applogger,
+ space: space.New(
+ log.New(w, "[cms:space] ", 0),
db,
),
- space.New(
- log.New(w, "[cms:space] ", 0),
+ user: user.New(
+ log.New(w, "[cms:user] ", 0),
db,
),
- ping.New(
+ ping: ping.New(
log.New(w, "[cms:ping] ", 0),
db,
),
M internal/c/space/space.go => internal/c/space/space.go +63 -0
@@ 1,11 1,19 @@
package space
import (
+ "fmt"
"log"
"net/http"
+ "strings"
"git.sr.ht/~evanj/cms/internal/c"
+ "git.sr.ht/~evanj/cms/internal/m/space"
"git.sr.ht/~evanj/cms/internal/m/user"
+ "git.sr.ht/~evanj/cms/internal/s/tmpl"
+)
+
+var (
+ spaceHTML = tmpl.MustParse("html/space.html")
)
type Space struct {
@@ 16,6 24,8 @@ type Space struct {
type dber interface {
UserGetFromToken(token string) (user.User, error)
+ SpaceNew(user user.User, name, desc string) (space.Space, error)
+ SpaceGet(user user.User, spaceID string) (space.Space, error)
}
func New(log *log.Logger, db dber) *Space {
@@ 26,6 36,59 @@ func New(log *log.Logger, db dber) *Space {
}
}
+func (s *Space) serve(w http.ResponseWriter, r *http.Request, spaceID string) {
+ user, err := s.GetCookieUser(w, r)
+ if err != nil {
+ s.Error(w, r, http.StatusBadRequest, "must be logged in to create space")
+ return
+ }
+
+ space, err := s.db.SpaceGet(user, spaceID)
+ if err != nil {
+ s.Error(w, r, http.StatusNotFound, "failed to find space")
+ return
+ }
+
+ s.HTML(w, r, spaceHTML, map[string]interface{}{
+ "User": user,
+ "Space": space,
+ })
+}
+
+func (s *Space) create(w http.ResponseWriter, r *http.Request) {
+ name := r.FormValue("name")
+ desc := r.FormValue("desc")
+
+ user, err := s.GetCookieUser(w, r)
+ if err != nil {
+ s.Error(w, r, http.StatusBadRequest, "must be logged in to create space")
+ return
+ }
+
+ space, err := s.db.SpaceNew(user, name, desc)
+ if err != nil {
+ s.log.Println(err)
+ s.Error(w, r, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ url := fmt.Sprintf("/space/%s", space.ID())
+ s.log.Println("successfully created new space for user", user.Name(), "redirecting to", url)
+ http.Redirect(w, r, url, http.StatusTemporaryRedirect)
+}
+
func (s *Space) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/space/new":
+ s.create(w, r)
+ return
+ }
+
+ parts := strings.Split(r.URL.Path, "/")
+ if len(parts) > 2 {
+ s.serve(w, r, parts[2])
+ return
+ }
+
http.NotFound(w, r)
}
M internal/c/user/user.go => internal/c/user/user.go +34 -5
@@ 3,8 3,10 @@ package user
import (
"log"
"net/http"
+ "strconv"
"git.sr.ht/~evanj/cms/internal/c"
+ "git.sr.ht/~evanj/cms/internal/m/space"
"git.sr.ht/~evanj/cms/internal/m/user"
"git.sr.ht/~evanj/cms/internal/s/tmpl"
)
@@ 23,6 25,7 @@ type dber interface {
UserNew(username, password, verifyPassword string) (user.User, error)
UserGet(username, password string) (user.User, error)
UserGetFromToken(token string) (user.User, error)
+ SpacesPerUser(user user.User, page int) ([]space.Space, error)
}
func New(log *log.Logger, db dber) *User {
@@ 60,7 63,7 @@ func (l *User) signup(w http.ResponseWriter, r *http.Request) {
user, err := l.db.UserNew(username, password, verify)
if err != nil {
l.log.Println(err)
- l.Error(w, r, http.StatusBadRequest, "invalid user credentials")
+ l.Error(w, r, http.StatusBadRequest, err.Error())
return
}
@@ 68,14 71,40 @@ func (l *User) signup(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
-func (l *User) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- switch r.URL.Path {
- case "/":
- user, _ := l.GetCookieUser(w, r)
+func (l *User) home(w http.ResponseWriter, r *http.Request) {
+ user, err := l.GetCookieUser(w, r)
+ if err != nil {
l.HTML(w, r, indexHTML, map[string]interface{}{
"User": user,
})
return
+
+ }
+
+ page, err := strconv.Atoi(r.URL.Query().Get("page"))
+ if err != nil || page < 1 {
+ page = 1
+ }
+
+ page-- // Show one to user but start counting at zero for us.
+
+ spaces, err := l.db.SpacesPerUser(user, page)
+ if err != nil {
+ l.Error(w, r, http.StatusInternalServerError, "filed to find spaces for user")
+ return
+ }
+
+ l.HTML(w, r, indexHTML, map[string]interface{}{
+ "User": user,
+ "Spaces": spaces,
+ })
+}
+
+func (l *User) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/":
+ l.home(w, r)
+ return
case "/user/login":
l.login(w, r)
return
M internal/s/db/db.go => internal/s/db/db.go +29 -5
@@ 8,6 8,10 @@ import (
_ "github.com/mattn/go-sqlite3"
)
+const (
+ perPage = 25
+)
+
type DB struct {
*sql.DB
log *log.Logger
@@ 21,8 25,8 @@ type securer interface {
HashCompare(salt, pass, hash string) error
}
-func New(log *log.Logger, creds string, sec securer) (*DB, error) {
- conn, err := sql.Open("sqlite3", creds)
+func New(log *log.Logger, typ, creds string, sec securer) (*DB, error) {
+ conn, err := sql.Open(typ, creds)
if err != nil {
return nil, err
}
@@ 37,7 41,10 @@ func New(log *log.Logger, creds string, sec securer) (*DB, error) {
}
func (db *DB) EnsureSetup() error {
- res, err := db.Exec(`
+ var _ interface{}
+
+ // user
+ _, _ = db.Exec(`
CREATE TABLE cms_user (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
NAME TEXT UNIQUE NOT NULL,
@@ 45,8 52,25 @@ func (db *DB) EnsureSetup() error {
);
`)
- _ = res
- _ = err
+ // space
+ _, _ = db.Exec(`
+ CREATE TABLE cms_space (
+ ID INTEGER PRIMARY KEY AUTOINCREMENT,
+ NAME TEXT NOT NULL,
+ DESC TEXT NOT NULL
+ );
+ `)
+
+ // user to space
+ _, _ = db.Exec(`
+ CREATE TABLE cms_user_to_space (
+ ID INTEGER PRIMARY KEY AUTOINCREMENT,
+ USER_ID INTEGER,
+ SPACE_ID INTEGER,
+ FOREIGN KEY(USER_ID) REFERENCES cms_user(ID),
+ FOREIGN KEY(SPACE_ID) REFERENCES cms_space(ID)
+ );
+ `)
return nil
}
A internal/s/db/space.go => internal/s/db/space.go +99 -0
@@ 0,0 1,99 @@
+package db
+
+import (
+ "fmt"
+
+ "git.sr.ht/~evanj/cms/internal/m/space"
+ "git.sr.ht/~evanj/cms/internal/m/user"
+)
+
+type Space struct {
+ id string
+ name string
+ desc string
+}
+
+var (
+ queryCreateNewSpace = `INSERT INTO cms_space (NAME, DESC) VALUES (?, ?);`
+ queryFindSpaceByID = `SELECT ID, NAME, DESC FROM cms_space WHERE ID = ?;`
+ queryDeleteSpaceByID = `DELETE FROM cms_space WHERE ID = ?;`
+ queryCreateNewUserToSpace = `INSERT INTO cms_user_to_space (USER_ID, SPACE_ID) VALUES (?, ?);`
+ queryFindUserToSpace = `SELECT SPACE_ID FROM cms_user_to_space WHERE USER_ID = ? AND SPACE_ID = ?;`
+ queryFindSpacesByUser = `SELECT DISTINCT cms_space.ID, NAME, DESC FROM cms_space JOIN cms_user_to_space ON cms_space.ID = cms_user_to_space.SPACE_ID WHERE USER_ID = ? LIMIT ? OFFSET ?;`
+)
+
+func (db *DB) SpaceNew(user user.User, name, desc string) (space.Space, error) {
+ res, err := db.Exec(queryCreateNewSpace, name, desc)
+ if err != nil {
+ db.log.Println("db.Exec", err)
+ return nil, fmt.Errorf("space '%s' already exists", name)
+ }
+
+ id, err := res.LastInsertId()
+ if err != nil {
+ db.log.Println("res.LastInsertId", err)
+ return nil, fmt.Errorf("failed to create space")
+ }
+
+ var space Space
+ if err := db.QueryRow(queryFindSpaceByID, id).Scan(&space.id, &space.name, &space.desc); err != nil {
+ db.log.Println("db.QueryRow", err)
+ return nil, fmt.Errorf("failed to find space created")
+ }
+
+ if _, err := db.Exec(queryCreateNewUserToSpace, user.ID(), space.ID()); err != nil {
+ db.log.Println("big problem, failed to connect user to space", err)
+ if _, err := db.Exec(queryDeleteSpaceByID, space.ID()); err != nil {
+ db.log.Println("even bigger problem, failed to delete orphan space", err)
+ }
+ return nil, fmt.Errorf("failed to attach space to user")
+ }
+
+ return &space, nil
+}
+
+func (db *DB) SpaceGet(user user.User, spaceID string) (space.Space, error) {
+ var id string
+
+ if err := db.QueryRow(queryFindUserToSpace, user.ID(), spaceID).Scan(&id); err != nil {
+ db.log.Println("db.QueryRow", err)
+ return nil, fmt.Errorf("failed to find space for user")
+ }
+
+ var space Space
+ err := db.QueryRow(queryFindSpaceByID, id).Scan(&space.id, &space.name, &space.desc)
+ if err != nil {
+ db.log.Println("db.Exec", err)
+ return nil, fmt.Errorf("failed to find space")
+ }
+
+ return &space, nil
+}
+
+func (db *DB) SpacesPerUser(user user.User, page int) ([]space.Space, error) {
+ var ret []space.Space
+
+ rows, err := db.Query(queryFindSpacesByUser, user.ID(), perPage, perPage*page)
+ if err != nil {
+ db.log.Println(err)
+ return ret, err
+ }
+
+ for rows.Next() {
+ var space Space
+ if err := rows.Scan(&space.id, &space.name, &space.desc); err != nil {
+ return nil, err
+ }
+ ret = append(ret, &space)
+ }
+
+ return ret, nil
+}
+
+func (s *Space) ID() string {
+ return s.id
+}
+
+func (s *Space) Name() string {
+ return s.name
+}
M internal/s/db/user.go => internal/s/db/user.go +1 -1
@@ 25,7 25,7 @@ var (
func (db *DB) UserNew(username, password, verifyPassword string) (user.User, error) {
if password != verifyPassword {
- return nil, fmt.Errorf("password do not match")
+ return nil, fmt.Errorf("passwords do not match")
}
hash, err := db.sec.HashCreate(username, password)
M internal/s/tmpl/html/index.html => internal/s/tmpl/html/index.html +15 -4
@@ 19,17 19,28 @@
</header>
<hr/>
<article>
- <h1>landing page</h1>
+
{{ if .User }}
- <p>Available Spaces</p>
- <p>TODO</p>
+ <p>Welcome back, {{ .User.Name }}.</p>
+
+ <p>Available Spaces:</p>
+
+ {{ if .Spaces }}
+ <ul>
+ {{ range .Spaces }}
+ <li><a href="/space/{{ .ID }}">{{ .Name }}</a></li>
+ {{ end }}
+ </ul>
+ {{ else }}
+ <p>You haven't created any spaces yet.</p>
+ {{ end }}
<form method=POST action='/space/new' enctype='multipart/form-data'>
<legend>Create Space</legend>
<input required type=text name=name placeholder=name />
- <input type=text name=description placeholder=description />
+ <input required type=text name=desc placeholder=description />
<input type=submit value=Go />
</form>
A internal/s/tmpl/html/space.html => internal/s/tmpl/html/space.html +118 -0
@@ 0,0 1,118 @@
+<!DOCTYPE html>
+<html lang=en>
+
+<head>
+ <meta charset="utf-8">
+ <title>CMS | {{ .Space.Name }}</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+</head>
+
+<body>
+ <style>
+ form input, form button { display: block; margin: 5px 0; }
+ form div input, form div button { display: inline-block; }
+ form > input { margin-top: 10px; }
+ </style>
+
+ <main>
+ <header>
+ <h1>CMS</h1>
+ <p>A flexible CMS for everyone.</p>
+ </header>
+ <hr/>
+ <article>
+
+ <nav>
+ <a href='/'>Back</a>
+ </nav>
+
+ <h1>Space: {{ .Space.Name }}</h1>
+
+ <form method=POST action='/contenttype/new' enctype='multipart/form-data'>
+ <legend>Create Content Type:</legend>
+ <input required type=hidden name=space value="{{ .Space.ID }}" />
+ <input required type=text name=name placeholder="content type name" />
+ <div id='first-fieldset'>
+ <input required type=text name="field_name_1" placeholder="field name" />
+ <select required name="field_type_1">
+ <option disabled selected value>Field Type</option>
+ <option>String Small</option>
+ <option>String Big</option>
+ <option>File</option>
+ <option>Reference</option>
+ </select>
+ </div>
+ <button id='add-fieldbtn'>Add Another Field</button>
+ <input type=submit value=Go />
+ </form>
+
+ {{ if .ContentTypes }}
+ <form method=POST action='/content/new' enctype='multipart/form-data'>
+ <legend>Create Content</legend>
+ <input required type=hidden name=space value="{{ .Space.ID }}" />
+ <select required name="contenttype">
+ <option disabled selected value>Content Type</option>
+ {{ range .ContentTypes }}
+ <option>{{ .Name }}</option>
+ {{ end }}
+ </select>
+ <input type=submit value=Go />
+ </form>
+ {{ else }}
+ <p>Create Content:</p>
+ <p>You haven't created any content types yet. To begin creating content
+ you must have at least one content type.</p>
+ {{ end }}
+
+
+ <p>Browse Content By Type:</p>
+ {{ if .ContentTypes }}
+ TODO
+ {{ else }}
+ <p>You haven't created any content types yet. To begin browsing content
+ content must exist.</p>
+ {{ end }}
+
+ </article>
+ <hr/>
+ <footer>
+ <center>© 2015-2020 Evan Jones</center>
+ </footer>
+ </main>
+
+ <script>
+ (function() {
+ var addFieldBtn = document.getElementById('add-fieldbtn')
+ var i = 1
+ addFieldBtn.addEventListener('click', function(e) {
+ i++
+ e.preventDefault()
+ e.stopPropagation()
+ var el = document.createElement('div')
+ el.innerHTML = `
+ <div>
+ <select required name="field_type_${i}">
+ <option disabled selected value>Field Type</option>
+ <option>String Small</option>
+ <option>String Big</option>
+ <option>File</option>
+ <option>Reference</option>
+ </select>
+ <input required type=text name="field_name_${i}" placeholder="field name" />
+ <button id='remove-fieldbtn_${i}'>Remove Field</button>
+ </div>
+ `
+ addFieldBtn.parentNode.insertBefore(el, addFieldBtn)
+ var removeFieldBtn = document.getElementById(`remove-fieldbtn_${i}`)
+ removeFieldBtn.addEventListener('click', function(e) {
+ i--
+ e.preventDefault()
+ e.stopPropagation()
+ el.parentNode.removeChild(el)
+ })
+ })
+ })();
+ </script>
+</body>
+
+</html>
M internal/s/tmpl/tmpls_embed.go => internal/s/tmpl/tmpls_embed.go +135 -4
@@ 28,17 28,28 @@ func init() {
</header>
<hr/>
<article>
- <h1>landing page</h1>
+
{{ if .User }}
- <p>Available Spaces</p>
- <p>TODO</p>
+ <p>Welcome back, {{ .User.Name }}.</p>
+
+ <p>Available Spaces:</p>
+
+ {{ if .Spaces }}
+ <ul>
+ {{ range .Spaces }}
+ <li><a href="/space/{{ .ID }}">{{ .Name }}</a></li>
+ {{ end }}
+ </ul>
+ {{ else }}
+ <p>You haven't created any spaces yet.</p>
+ {{ end }}
<form method=POST action='/space/new' enctype='multipart/form-data'>
<legend>Create Space</legend>
<input required type=text name=name placeholder=name />
- <input type=text name=description placeholder=description />
+ <input required type=text name=desc placeholder=description />
<input type=submit value=Go />
</form>
@@ 77,6 88,126 @@ func init() {
</html>
`
+ tmpls["html/space.html"] = `<!DOCTYPE html>
+<html lang=en>
+
+<head>
+ <meta charset="utf-8">
+ <title>CMS | {{ .Space.Name }}</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+</head>
+
+<body>
+ <style>
+ form input, form button { display: block; margin: 5px 0; }
+ form div input, form div button { display: inline-block; }
+ form > input { margin-top: 10px; }
+ </style>
+
+ <main>
+ <header>
+ <h1>CMS</h1>
+ <p>A flexible CMS for everyone.</p>
+ </header>
+ <hr/>
+ <article>
+
+ <nav>
+ <a href='/'>Back</a>
+ </nav>
+
+ <h1>Space: {{ .Space.Name }}</h1>
+
+ <form method=POST action='/contenttype/new' enctype='multipart/form-data'>
+ <legend>Create Content Type:</legend>
+ <input required type=hidden name=space value="{{ .Space.ID }}" />
+ <input required type=text name=name placeholder="content type name" />
+ <div id='first-fieldset'>
+ <input required type=text name="field_name_1" placeholder="field name" />
+ <select required name="field_type_1">
+ <option disabled selected value>Field Type</option>
+ <option>String Small</option>
+ <option>String Big</option>
+ <option>File</option>
+ <option>Reference</option>
+ </select>
+ </div>
+ <button id='add-fieldbtn'>Add Another Field</button>
+ <input type=submit value=Go />
+ </form>
+
+ {{ if .ContentTypes }}
+ <form method=POST action='/content/new' enctype='multipart/form-data'>
+ <legend>Create Content</legend>
+ <input required type=hidden name=space value="{{ .Space.ID }}" />
+ <select required name="contenttype">
+ <option disabled selected value>Content Type</option>
+ {{ range .ContentTypes }}
+ <option>{{ .Name }}</option>
+ {{ end }}
+ </select>
+ <input type=submit value=Go />
+ </form>
+ {{ else }}
+ <p>Create Content:</p>
+ <p>You haven't created any content types yet. To begin creating content
+ you must have at least one content type.</p>
+ {{ end }}
+
+
+ <p>Browse Content By Type:</p>
+ {{ if .ContentTypes }}
+ TODO
+ {{ else }}
+ <p>You haven't created any content types yet. To begin browsing content
+ content must exist.</p>
+ {{ end }}
+
+ </article>
+ <hr/>
+ <footer>
+ <center>© 2015-2020 Evan Jones</center>
+ </footer>
+ </main>
+
+ <script>
+ (function() {
+ var addFieldBtn = document.getElementById('add-fieldbtn')
+ var i = 1
+ addFieldBtn.addEventListener('click', function(e) {
+ i++
+ e.preventDefault()
+ e.stopPropagation()
+ var el = document.createElement('div')
+ el.innerHTML = ` + "`" + `
+ <div>
+ <select required name="field_type_${i}">
+ <option disabled selected value>Field Type</option>
+ <option>String Small</option>
+ <option>String Big</option>
+ <option>File</option>
+ <option>Reference</option>
+ </select>
+ <input required type=text name="field_name_${i}" placeholder="field name" />
+ <button id='remove-fieldbtn_${i}'>Remove Field</button>
+ </div>
+ ` + "`" + `
+ addFieldBtn.parentNode.insertBefore(el, addFieldBtn)
+ var removeFieldBtn = document.getElementById(` + "`" + `remove-fieldbtn_${i}` + "`" + `)
+ removeFieldBtn.addEventListener('click', function(e) {
+ i--
+ e.preventDefault()
+ e.stopPropagation()
+ el.parentNode.removeChild(el)
+ })
+ })
+ })();
+ </script>
+</body>
+
+</html>
+`
+
}
func Get(name string) (string, bool) {