Compare commits
10 Commits
ba6bf1a682
...
cd40158cc4
Author | SHA1 | Date | |
---|---|---|---|
|
cd40158cc4 | ||
|
01bfefac13 | ||
|
eeca8ca34c | ||
|
be85d936a9 | ||
|
6744d12001 | ||
|
6da4184eb0 | ||
|
52e1bbaa70 | ||
|
15d817c5d1 | ||
|
f5c642ba4e | ||
|
ba4f38b425 |
@ -11,6 +11,11 @@ import (
|
|||||||
"snippetbox.chaosfem.tw/internal/validator"
|
"snippetbox.chaosfem.tw/internal/validator"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ping ...
|
||||||
|
func ping(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
}
|
||||||
|
|
||||||
// home ...
|
// home ...
|
||||||
func (app *application) home(w http.ResponseWriter, r *http.Request) {
|
func (app *application) home(w http.ResponseWriter, r *http.Request) {
|
||||||
snippets, err := app.snippets.Latest()
|
snippets, err := app.snippets.Latest()
|
||||||
|
19
snippetbox/cmd/web/handlers_test.go
Normal file
19
snippetbox/cmd/web/handlers_test.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"snippetbox.chaosfem.tw/internal/assert"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPing(t *testing.T) {
|
||||||
|
app := newTestApplication(t)
|
||||||
|
|
||||||
|
ts := newTestServer(t, app.routes())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
statusCode, _, body := ts.get(t, "/ping")
|
||||||
|
|
||||||
|
assert.Equal(t, statusCode, http.StatusOK)
|
||||||
|
assert.Equal(t, body, "OK")
|
||||||
|
}
|
43
snippetbox/cmd/web/middleware_test.go
Normal file
43
snippetbox/cmd/web/middleware_test.go
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"snippetbox.chaosfem.tw/internal/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSecureHeaders(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
|
r, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("OK"))
|
||||||
|
})
|
||||||
|
|
||||||
|
secureHeaders(next).ServeHTTP(rr, r)
|
||||||
|
|
||||||
|
rs := rr.Result()
|
||||||
|
|
||||||
|
assert.Equal(t, rs.Header.Get("Content-Security-Policy"), "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com")
|
||||||
|
assert.Equal(t, rs.Header.Get("Referrer-Policy"), "origin-when-cross-origin")
|
||||||
|
assert.Equal(t, rs.Header.Get("X-Content-Type-Options"), "nosniff")
|
||||||
|
assert.Equal(t, rs.Header.Get("X-Frame-Options"), "deny")
|
||||||
|
assert.Equal(t, rs.Header.Get("X-XSS-Protection"), "0")
|
||||||
|
|
||||||
|
defer rs.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(rs.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, string(bytes.TrimSpace(body)), "OK")
|
||||||
|
}
|
@ -5,6 +5,8 @@ import (
|
|||||||
|
|
||||||
"github.com/julienschmidt/httprouter"
|
"github.com/julienschmidt/httprouter"
|
||||||
"github.com/justinas/alice"
|
"github.com/justinas/alice"
|
||||||
|
|
||||||
|
"snippetbox.chaosfem.tw/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// routes ...
|
// routes ...
|
||||||
@ -20,8 +22,10 @@ func (app *application) routes() http.Handler {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// setup server for static files
|
// setup server for static files
|
||||||
fileServer := http.FileServer(http.Dir("./ui/static"))
|
fileServer := http.FileServer(http.FS(ui.Files))
|
||||||
router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))
|
router.Handler(http.MethodGet, "/static/*filepath", fileServer)
|
||||||
|
|
||||||
|
router.HandlerFunc(http.MethodGet, "/ping", ping)
|
||||||
|
|
||||||
dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)
|
dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)
|
||||||
|
|
||||||
|
@ -2,10 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"snippetbox.chaosfem.tw/internal/models"
|
"snippetbox.chaosfem.tw/internal/models"
|
||||||
|
"snippetbox.chaosfem.tw/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
type templateData struct {
|
type templateData struct {
|
||||||
@ -20,7 +22,11 @@ type templateData struct {
|
|||||||
|
|
||||||
// humanDate ...
|
// humanDate ...
|
||||||
func humanDate(t time.Time) string {
|
func humanDate(t time.Time) string {
|
||||||
return t.Format("02 Jan 2006 at 15:04")
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.UTC().Format("02 Jan 2006 at 15:04")
|
||||||
}
|
}
|
||||||
|
|
||||||
var functions = template.FuncMap{
|
var functions = template.FuncMap{
|
||||||
@ -31,7 +37,7 @@ var functions = template.FuncMap{
|
|||||||
func newTemplateCache() (map[string]*template.Template, error) {
|
func newTemplateCache() (map[string]*template.Template, error) {
|
||||||
cache := map[string]*template.Template{}
|
cache := map[string]*template.Template{}
|
||||||
|
|
||||||
pages, err := filepath.Glob("./ui/html/pages/*.tmpl")
|
pages, err := fs.Glob(ui.Files, "html/pages/*.tmpl")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -39,17 +45,13 @@ func newTemplateCache() (map[string]*template.Template, error) {
|
|||||||
for _, page := range pages {
|
for _, page := range pages {
|
||||||
name := filepath.Base(page)
|
name := filepath.Base(page)
|
||||||
|
|
||||||
ts, err := template.New(name).Funcs(functions).ParseFiles("./ui/html/base.tmpl")
|
patterns := []string{
|
||||||
if err != nil {
|
"html/base.tmpl",
|
||||||
return nil, err
|
"html/partials/*.tmpl",
|
||||||
|
page,
|
||||||
}
|
}
|
||||||
|
|
||||||
ts, err = ts.ParseGlob("./ui/html/partials/*.tmpl")
|
ts, err := template.New(name).Funcs(functions).ParseFS(ui.Files, patterns...)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ts, err = ts.ParseFiles(page)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
39
snippetbox/cmd/web/templates_test.go
Normal file
39
snippetbox/cmd/web/templates_test.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"snippetbox.chaosfem.tw/internal/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHumanDate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tm time.Time
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "UTC",
|
||||||
|
tm: time.Date(2023, 3, 17, 10, 15, 0, 0, time.UTC),
|
||||||
|
want: "17 Mar 2023 at 10:15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty",
|
||||||
|
tm: time.Time{},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CET",
|
||||||
|
tm: time.Date(2023, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
|
||||||
|
want: "17 Mar 2023 at 09:15",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, humanDate(tt.tm), tt.want)
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
53
snippetbox/cmd/web/testutils_test.go
Normal file
53
snippetbox/cmd/web/testutils_test.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestApplication(t *testing.T) *application {
|
||||||
|
return &application{
|
||||||
|
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type testServer struct {
|
||||||
|
*httptest.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T, h http.Handler) *testServer {
|
||||||
|
ts := httptest.NewTLSServer(h)
|
||||||
|
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts.Client().Jar = jar
|
||||||
|
|
||||||
|
ts.Client().CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
return &testServer{ts}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ts *testServer) get(t *testing.T, urlPath string) (int, http.Header, string) {
|
||||||
|
rs, err := ts.Client().Get(ts.URL + urlPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer rs.Body.Close()
|
||||||
|
body, err := io.ReadAll(rs.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rs.StatusCode, rs.Header, string(bytes.TrimSpace(body))
|
||||||
|
}
|
13
snippetbox/internal/assert/assert.go
Normal file
13
snippetbox/internal/assert/assert.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package assert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Equal[T comparable](t *testing.T, actual, expected T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if actual != expected {
|
||||||
|
t.Errorf("got %v; want %v", actual, expected)
|
||||||
|
}
|
||||||
|
}
|
34
snippetbox/internal/models/mocks/snippets.go
Normal file
34
snippetbox/internal/models/mocks/snippets.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package mocks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"snippetbox.alexedwards.net/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var mockSnippet = models.Snippet{
|
||||||
|
ID: 1,
|
||||||
|
Title: "An old silent pond",
|
||||||
|
Content: "An old silent pond...",
|
||||||
|
Created: time.Now(),
|
||||||
|
Expires: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnippetModel struct{}
|
||||||
|
|
||||||
|
func (m *SnippetModel) Insert(title string, content string, expires int) (int, error) {
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SnippetModel) Get(id int) (models.Snippet, error) {
|
||||||
|
switch id {
|
||||||
|
case 1:
|
||||||
|
return mockSnippet, nil
|
||||||
|
default:
|
||||||
|
return models.Snippet{}, models.ErrNoRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *SnippetModel) Latest() ([]models.Snippet, error) {
|
||||||
|
return []models.Snippet{mockSnippet}, nil
|
||||||
|
}
|
31
snippetbox/internal/models/mocks/users.go
Normal file
31
snippetbox/internal/models/mocks/users.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package mocks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"snippetbox.alexedwards.net/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserModel struct{}
|
||||||
|
|
||||||
|
func (m *UserModel) Insert(name, email, password string) error {
|
||||||
|
switch email {
|
||||||
|
case "dupe@example.com":
|
||||||
|
return models.ErrDuplicateEmail
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) Authenticate(email, password string) (int, error) {
|
||||||
|
if email == "alice@example.com" && password == "pa$$word" {
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *UserModel) Exists(id int) (bool, error) {
|
||||||
|
switch id {
|
||||||
|
case 1:
|
||||||
|
return true, nil
|
||||||
|
default:
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
8
snippetbox/ui/efs.go
Normal file
8
snippetbox/ui/efs.go
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed "html" "static"
|
||||||
|
var Files embed.FS
|
Loading…
Reference in New Issue
Block a user