diff --git a/.gitignore b/.gitignore index adf8f72..e6c17e1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ # Go workspace file go.work +# sqlite3 databases +*.db diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go new file mode 100644 index 0000000..04be353 --- /dev/null +++ b/cmd/web/handlers.go @@ -0,0 +1,30 @@ +package main + +import ( + "net/http" +) + +func (app *application) home(w http.ResponseWriter, r *http.Request) { + app.render(w, r, http.StatusOK, "home.tmpl.html", templateData{}) +} + +func getUserRegister(w http.ResponseWriter, r *http.Request) { +} + +func postUserRegister(w http.ResponseWriter, r *http.Request) { +} + +func getUsersList(w http.ResponseWriter, r *http.Request) { +} + +func getUser(w http.ResponseWriter, r *http.Request) { +} + +func postGuestbooksCreate(w http.ResponseWriter, r* http.Request) { +} + +func getGuestbooksList(w http.ResponseWriter, r *http.Request) { +} + +func getGuestbook(w http.ResponseWriter, r *http.Request) { +} diff --git a/cmd/web/helpers.go b/cmd/web/helpers.go new file mode 100644 index 0000000..16d5bda --- /dev/null +++ b/cmd/web/helpers.go @@ -0,0 +1,35 @@ +package main + +import ( + "net/http" + "fmt" +) + +func (app *application) serverError(w http.ResponseWriter, r *http.Request, err error) { + var ( + method = r.Method + uri = r.URL.RequestURI() + ) + + app.logger.Error(err.Error(), "method", method, "uri", uri) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) +} + +func (app *application) clientError(w http.ResponseWriter, status int) { + http.Error(w, http.StatusText(status), status) +} + +func (app *application) render(w http.ResponseWriter, r *http.Request, status int, page string, data templateData) { + ts, ok := app.templateCache[page] + if !ok { + err := fmt.Errorf("the template %s does not exist", page) + app.serverError(w, r, err) + return + } + + w.WriteHeader(status) + err := ts.ExecuteTemplate(w, "base", data) + if err != nil { + app.serverError(w, r, err) + } +} diff --git a/cmd/web/main.go b/cmd/web/main.go new file mode 100644 index 0000000..8af985d --- /dev/null +++ b/cmd/web/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "database/sql" + "flag" + "log/slog" + "net/http" + "os" + "text/template" + + "git.32bit.cafe/32bitcafe/guestbook/internal/models" + _ "modernc.org/sqlite" +) + +type application struct { + logger *slog.Logger + templateCache map[string]*template.Template + guestbooks *models.GuestbookModel + users *models.UserModel + guestbookComments *models.GuestbookCommentModel +} + +func main() { + addr := flag.String("addr", ":3000", "HTTP network address") + dsn := flag.String("dsn", "guestbook.db", "data source name") + flag.Parse() + + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + db, err := openDB(*dsn) + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + defer db.Close() + + templateCache, err := newTemplateCache() + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } + + app := &application{ + templateCache: templateCache, + logger: logger, + guestbooks: &models.GuestbookModel{DB: db}, + users: &models.UserModel{DB: db}, + guestbookComments: &models.GuestbookCommentModel{DB: db}, + } + + logger.Info("Starting server on %s", slog.Any("addr", ":4000")) + + err = http.ListenAndServe(*addr, app.routes()); + logger.Error(err.Error()) + os.Exit(1) +} + +func openDB(dsn string) (*sql.DB, error) { + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + if err = db.Ping(); err != nil { + return nil, err + } + return db, nil +} diff --git a/cmd/web/routes.go b/cmd/web/routes.go new file mode 100644 index 0000000..c426ba3 --- /dev/null +++ b/cmd/web/routes.go @@ -0,0 +1,12 @@ +package main + +import ( + "net/http" +) + +func (app *application) routes() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/", app.home); + return mux +} + diff --git a/cmd/web/templates.go b/cmd/web/templates.go new file mode 100644 index 0000000..9e03d1a --- /dev/null +++ b/cmd/web/templates.go @@ -0,0 +1,51 @@ +package main + +import ( + "net/http" + "path/filepath" + "text/template" + "time" +) + +type templateData struct { + CurrentYear int +} + +func humanDate(t time.Time) string { + return t.Format("02 Jan 2006 at 15:04") +} + +var functions = template.FuncMap { + "humanDate": humanDate, +} + +func newTemplateCache() (map[string]*template.Template, error) { + cache := map[string]*template.Template{} + pages, err := filepath.Glob("./ui/html/pages/*.tmpl.html") + if err != nil { + return nil, err + } + for _, page := range pages { + name := filepath.Base(page) + ts, err := template.New(name).Funcs(functions).ParseFiles("./ui/html/base.tmpl.html") + if err != nil { + return nil, err + } + ts, err = ts.ParseGlob("./ui/html/partials/*.tmpl.html") + if err != nil { + return nil, err + } + ts, err = ts.ParseFiles(page) + if err != nil { + return nil, err + } + cache[name] = ts + } + return cache, nil +} + +func (app *application) newTemplateData(r *http.Request) *templateData { + return &templateData { + CurrentYear: time.Now().Year(), + } +} diff --git a/db/create-tables-sqlite.sql b/db/create-tables-sqlite.sql new file mode 100644 index 0000000..ddb41cc --- /dev/null +++ b/db/create-tables-sqlite.sql @@ -0,0 +1,38 @@ +CREATE TABLE users ( + Id blob(16) primary key, + Username varchar(32) NOT NULL, + Email varchar(256) NOT NULL, + IsDeleted boolean NOT NULL DEFAULT FALSE +) WITHOUT ROWID; + +CREATE TABLE guestbooks ( + Id blob(16) primary key, + SiteUrl varchar(512) NOT NULL, + UserId blob(16) NOT NULL, + IsDeleted boolean NOT NULL DEFAULT FALSE, + IsActive boolean NOT NULL DEFAULT TRUE, + FOREIGN KEY (UserId) REFERENCES users(Id) + ON DELETE RESTRICT + ON UPDATE RESTRICT +); + +CREATE TABLE guestbook_comments ( + Id blob(16) primary key, + GuestbookId blob(16) NOT NULL, + ParentId blob(16), + AuthorName varchar(256) NOT NULL, + AuthorEmail varchar(256) NOT NULL, + AuthorSite varchar(256), + CommentText text NOT NULL, + PageUrl varchar(256), + IsPublished boolean NOT NULL DEFAULT TRUE, + IsDeleted boolean NOT NULL DEFAULT FALSE, + FOREIGN KEY (GuestbookId) + REFERENCES guestbooks(Id) + ON DELETE RESTRICT + ON UPDATE RESTRICT, + FOREIGN KEY (ParentId) + REFERENCES guestbook_comments(Id) + ON DELETE RESTRICT + ON UPDATE RESTRICT +); diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..61161d3 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module git.32bit.cafe/32bitcafe/guestbook + +go 1.23.1 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/sqlite v1.33.1 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..db23520 --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= +modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/models/guestbook.go b/internal/models/guestbook.go new file mode 100644 index 0000000..09875ce --- /dev/null +++ b/internal/models/guestbook.go @@ -0,0 +1,47 @@ +package models + +import ( + "database/sql" + + "github.com/google/uuid" +) + +type Guestbook struct { + ID uuid.UUID + SiteUrl string + UserId uuid.UUID + IsDeleted bool + IsActive bool +} + +type GuestbookModel struct { + DB *sql.DB +} + +func (m *GuestbookModel) Insert(siteUrl string, userId uuid.UUID) (uuid.UUID, error) { + id := uuid.New() + stmt := `INSERT INTO guestbooks (Id, SiteUrl, UserId, IsDeleted, IsActive) + VALUES(?, ?, FALSE, TRUE)` + _, err := m.DB.Exec(stmt, id, siteUrl, userId) + if err != nil { + return uuid.UUID{}, err + } + return id, nil +} + +func (m *GuestbookModel) Get(id uuid.UUID) (Guestbook, error) { + stmt := `SELECT Id, SiteUrl, UserId, IsDeleted, IsActive FROM guestbooks + WHERE id = ?` + row := m.DB.QueryRow(stmt, id) + var g Guestbook + err := row.Scan(&g.ID, &g.SiteUrl, &g.UserId, &g.IsDeleted, &g.IsActive) + if err != nil { + return Guestbook{}, err + } + + return g, nil +} + +func (m *GuestbookModel) GetAll(userId uuid.UUID) ([]Guestbook, error) { + return []Guestbook{}, nil +} diff --git a/internal/models/guestbookcomment.go b/internal/models/guestbookcomment.go new file mode 100644 index 0000000..74685f8 --- /dev/null +++ b/internal/models/guestbookcomment.go @@ -0,0 +1,53 @@ +package models + +import ( + "database/sql" + + "github.com/google/uuid" +) + +type GuestbookComment struct { + ID uuid.UUID + GuestbookId uuid.UUID + ParentId uuid.UUID + AuthorName string + AuthorEmail string + AuthorSite string + CommentText string + PageUrl string + IsPublished bool + IsDeleted bool +} + +type GuestbookCommentModel struct { + DB *sql.DB +} + +func (m *GuestbookCommentModel) Insert(guestbookId, parentId uuid.UUID, authorName, + authorEmail, authorSite, commentText, pageUrl string, isPublished bool) (uuid.UUID, error) { + id := uuid.New() + stmt := `INSERT INTO guestbook_comments (Id, GuestbookId, ParentId, AuthorName, + AuthorEmail, AuthorSite, CommentText, PageUrl, IsPublished, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE)` + _, err := m.DB.Exec(stmt, id, guestbookId, parentId, authorName, authorEmail, + authorSite, commentText, pageUrl, isPublished) + if err != nil { + return uuid.UUID{}, err + } + return id, nil +} + +func (m *GuestbookCommentModel) Get(id uuid.UUID) (GuestbookComment, error) { + stmt := `SELECT Id, GuestbookId, ParentId, AuthorName, AuthorEmail, AuthorSite, + CommentText, PageUrl, IsPublished, IsDeleted FROM guestbook_comments WHERE id = ?` + row := m.DB.QueryRow(stmt, id) + var c GuestbookComment + err := row.Scan(&c.ID, &c.GuestbookId, &c.ParentId, &c.AuthorName, &c.AuthorEmail, &c.AuthorSite, &c.CommentText, &c.PageUrl, &c.IsPublished, &c.IsDeleted) + if err != nil { + return GuestbookComment{}, err + } + return c, nil +} + +func (m *GuestbookCommentModel) GetAll(guestbookId *uuid.UUID) { +} diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..c7e65cb --- /dev/null +++ b/internal/models/user.go @@ -0,0 +1,40 @@ +package models + +import ( + "database/sql" + + "github.com/google/uuid" +) + +type User struct { + ID uuid.UUID + Username string + Email string + IsDeleted bool +} + +type UserModel struct { + DB *sql.DB +} + +func (m *UserModel) Insert(username string, email string) (uuid.UUID, error) { + id := uuid.New() + stmt := `INSERT INTO users (Id, Username, Email, IsDeleted) + VALUES (?, ?, ?, FALSE)` + _, err := m.DB.Exec(stmt, id, username, email) + if err != nil { + return uuid.UUID{}, err + } + return id, nil +} + +func (m *UserModel) Get(id uuid.UUID) (User, error) { + stmt := `SELECT Id, Username, Email, IsDeleted FROM users WHERE id = ?` + row := m.DB.QueryRow(stmt, id) + var u User + err := row.Scan(&u.ID, &u.Username, &u.Email, &u.IsDeleted) + if err != nil { + return User{}, err + } + return u, nil +} diff --git a/ui/html/base.tmpl.html b/ui/html/base.tmpl.html new file mode 100644 index 0000000..674d9e6 --- /dev/null +++ b/ui/html/base.tmpl.html @@ -0,0 +1,23 @@ +{{ define "base" }} + + +
+There's nothing here yet
+{{ end }} diff --git a/ui/html/partials/nav.tmpl.html b/ui/html/partials/nav.tmpl.html new file mode 100644 index 0000000..9636855 --- /dev/null +++ b/ui/html/partials/nav.tmpl.html @@ -0,0 +1,5 @@ +{{ define "nav" }} + +{{ end }}