~evanj/cms

7fc5d7258891c1c58ba07d1e9035517327be7a19 — Evan M Jones 8 months ago 34b3875
WIP(*): Project init.
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) {