Compare commits
No commits in common. 'main' and 'drew/lets-go' have entirely different histories.
main
...
drew/lets-
@ -1,15 +0,0 @@
|
|||||||
kind: pipeline
|
|
||||||
type: docker
|
|
||||||
name: CI Test Pipeline
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Unit Tests
|
|
||||||
image: golang:1.23
|
|
||||||
privileged: true
|
|
||||||
commands:
|
|
||||||
- go test -v ./...
|
|
||||||
|
|
||||||
trigger:
|
|
||||||
event:
|
|
||||||
- pull_request
|
|
||||||
- push
|
|
@ -1,74 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/cookiejar"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.runcible.io/learning/ratchet/internal/model/mock"
|
|
||||||
"git.runcible.io/learning/ratchet/internal/server"
|
|
||||||
"github.com/alexedwards/scs/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create a newTestApplication helper which returns an instance of our
|
|
||||||
// application struct containing mocked dependencies.
|
|
||||||
func newTestApplication(t *testing.T) *server.RatchetApp {
|
|
||||||
|
|
||||||
//tc, err := server.InitTemplateCache()
|
|
||||||
tc, err := server.InitFSTemplateCache()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionManager := scs.New()
|
|
||||||
sessionManager.Lifetime = 12 * time.Hour
|
|
||||||
sessionManager.Cookie.Secure = true
|
|
||||||
|
|
||||||
rs := server.NewRatchetApp(slog.New(slog.NewTextHandler(io.Discard, nil)), tc, &mock.SnippetService{}, &mock.UserService{}, sessionManager)
|
|
||||||
return rs
|
|
||||||
}
|
|
||||||
|
|
||||||
// create out own test server with additional receiver functions for ease of testing
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any response cookies will be stored and sent on subsequent requests
|
|
||||||
ts.Client().Jar = jar
|
|
||||||
|
|
||||||
// Disable redirect-following for test server client by setting custom
|
|
||||||
// CheckRedirect function. Called whenever 3xx response. By returning a
|
|
||||||
// http.ErrUseLastResponse error it forces the client to immediately return
|
|
||||||
// the received response
|
|
||||||
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) {
|
|
||||||
resp, err := ts.Client().Get(ts.URL + urlPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return resp.StatusCode, resp.Header, string(body)
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
INSERT INTO users VALUES(
|
|
||||||
1337,
|
|
||||||
'tester',
|
|
||||||
'tester@example.com',
|
|
||||||
/* thisisinsecure */
|
|
||||||
'$2a$12$M51w5lWkveAOhwoanoCxO.hJe3s1m8qJuCzbzdETt0SThjpq4BPRq',
|
|
||||||
'2025-02-25 18:58:44',
|
|
||||||
'2025-02-25 18:58:44'
|
|
||||||
);
|
|
@ -1,52 +0,0 @@
|
|||||||
package integration
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.runcible.io/learning/ratchet/migrations"
|
|
||||||
)
|
|
||||||
|
|
||||||
const testerPasswd = "thisisinsecure"
|
|
||||||
|
|
||||||
func newTestDB(t *testing.T) *sql.DB {
|
|
||||||
dbFile, err := os.CreateTemp("", "ratchet-*.db")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlite3", dbFile.Name())
|
|
||||||
if err != nil {
|
|
||||||
db.Close()
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = migrations.Migrate(db)
|
|
||||||
if err != nil {
|
|
||||||
db.Close()
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
script, err := os.ReadFile("./testdata/seed.sql")
|
|
||||||
if err != nil {
|
|
||||||
db.Close()
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.Exec(string(script))
|
|
||||||
if err != nil {
|
|
||||||
db.Close()
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
db.Close()
|
|
||||||
err := os.Remove(dbFile.Name())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return db
|
|
||||||
}
|
|
@ -1,51 +0,0 @@
|
|||||||
package integration
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.runcible.io/learning/ratchet/internal/assert"
|
|
||||||
"git.runcible.io/learning/ratchet/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUserModelExists(t *testing.T) {
|
|
||||||
// Skip the test if the "-short" flag is provided when running the test.
|
|
||||||
if testing.Short() {
|
|
||||||
t.Skip("models: skipping model integration test")
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
userID int
|
|
||||||
want bool
|
|
||||||
}{{
|
|
||||||
name: "Valid ID",
|
|
||||||
userID: 1337,
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Zero ID",
|
|
||||||
userID: 0,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Zero ID",
|
|
||||||
userID: 2,
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
db := newTestDB(t)
|
|
||||||
|
|
||||||
userService := model.UserService{db}
|
|
||||||
|
|
||||||
exists, err := userService.Exists(test.userID)
|
|
||||||
|
|
||||||
assert.Equal(t, exists, test.want)
|
|
||||||
assert.NilError(t, err)
|
|
||||||
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
package mock
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.runcible.io/learning/ratchet/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
var mockSnippet = model.Snippet{
|
|
||||||
ID: 1,
|
|
||||||
Title: sql.NullString{String: "Hello golang mocking", Valid: true},
|
|
||||||
Content: sql.NullString{String: "Hello golang mocking", Valid: true},
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
ExpiresAt: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
type SnippetService struct{}
|
|
||||||
|
|
||||||
func (s *SnippetService) Insert(title, content string, expiresAt int) (int, error) {
|
|
||||||
return 2, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SnippetService) Get(id int) (model.Snippet, error) {
|
|
||||||
if id == mockSnippet.ID {
|
|
||||||
return mockSnippet, nil
|
|
||||||
} else {
|
|
||||||
return model.Snippet{}, model.ErrNoRecord
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SnippetService) Lastest() ([]model.Snippet, error) {
|
|
||||||
return []model.Snippet{mockSnippet}, nil
|
|
||||||
}
|
|
@ -1,34 +0,0 @@
|
|||||||
package mock
|
|
||||||
|
|
||||||
import "git.runcible.io/learning/ratchet/internal/model"
|
|
||||||
|
|
||||||
var MockEmail = "drew@fake.com"
|
|
||||||
var MockPassword = "thisisinsecure"
|
|
||||||
|
|
||||||
type UserService struct{}
|
|
||||||
|
|
||||||
func (u *UserService) Insert(name, email, password string) (int, error) {
|
|
||||||
switch email {
|
|
||||||
case "dupe@example.com":
|
|
||||||
return 0, model.ErrDuplicateEmail
|
|
||||||
default:
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserService) Authenticate(email, password string) (int, error) {
|
|
||||||
if email == MockEmail && password == MockPassword {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0, model.ErrInvalidCredentials
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserService) Exists(id int) (bool, error) {
|
|
||||||
switch id {
|
|
||||||
case 1:
|
|
||||||
return true, nil
|
|
||||||
default:
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,99 +1,75 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mattn/go-sqlite3"
|
"git.runcible.io/learning/ratchet/internal/apperror"
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int
|
ID int `json:"id"`
|
||||||
Name string
|
|
||||||
Email string
|
|
||||||
HashedPassword []byte
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserServiceInterface interface {
|
// User prefered name and email
|
||||||
Insert(name, email, password string) (int, error)
|
Name string `json:"name"`
|
||||||
Authenticate(email, password string) (int, error)
|
Email string `json:"email"`
|
||||||
Exists(id int) (bool, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODD add logger to service
|
// Randomly generated API key for use with the API
|
||||||
type UserService struct {
|
// "-" omits the key from serialization
|
||||||
DB *sql.DB
|
APIKey string `json:"-"`
|
||||||
}
|
|
||||||
|
|
||||||
func (u *UserService) Insert(name, email, password string) (int, error) {
|
// Timestamps for user creatation and last update
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
if err != nil {
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
stmt := `INSERT INTO users (name, email, hashed_password)
|
// List of associated Oauth authentication Objects
|
||||||
VALUES (?,?,?)`
|
// Not yet implemented
|
||||||
|
// Auths []*Auth `json:"auths"`
|
||||||
result, err := u.DB.Exec(stmt, name, email, string(hashedPassword))
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug(fmt.Sprintf("Error encounters on insert: %s", err.Error()))
|
|
||||||
// This is a assertion that err is of type sqlite3.Error. If it is ok is true.
|
|
||||||
if serr, ok := err.(sqlite3.Error); ok {
|
|
||||||
slog.Debug("Error is sqlite3.Error type.")
|
|
||||||
if serr.ExtendedCode == sqlite3.ErrConstraintUnique {
|
|
||||||
slog.Debug("Error is a unique contraint violation.")
|
|
||||||
return 0, ErrDuplicateEmail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lastId, err := result.LastInsertId()
|
func (u *User) Validate() error {
|
||||||
if err != nil {
|
if u.Name == "" {
|
||||||
slog.Debug("An error occured when retrieving insert result id.")
|
return apperror.Errorf(apperror.EINVALID, "User name required.")
|
||||||
return 0, err
|
|
||||||
}
|
}
|
||||||
slog.Debug(fmt.Sprintf("Inserted new user. User pk: %d", int(lastId)))
|
return nil
|
||||||
return int(lastId), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UserService) Authenticate(email, password string) (int, error) {
|
// UserService represents a service for managing users.
|
||||||
var id int
|
type UserService interface {
|
||||||
var hashedPassword []byte
|
// Retrieves a user by ID along with their associated auth objects
|
||||||
stmt := `SELECT id, hashed_password FROM users WHERE email == ?`
|
// Returns ENOTFOUND if user does not exist.
|
||||||
|
FindUserByID(ctx context.Context, id int) (*User, error)
|
||||||
err := u.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return 0, ErrInvalidCredentials
|
|
||||||
} else {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password))
|
// Retrieves a list of users by filter. Also returns total count of matching users
|
||||||
if err != nil {
|
// which may differ from retruned results if filter.Limit is specified.
|
||||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
FindUsers(ctc context.Context, filter UserFilter) ([]*User, int, error)
|
||||||
return 0, ErrInvalidCredentials
|
|
||||||
} else {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return id, nil
|
// Creates a new use. This is only used for testing since users are typically
|
||||||
|
// cretaed during the OAuth creation process in the AuthService.CreateAuth().
|
||||||
|
CreateUser(ctx context.Context, user *User) error
|
||||||
|
|
||||||
|
// Updates a user object. Returns EUNAUTHORIZED if current user is not
|
||||||
|
// the user that is being updated. Returns ENOTFOUND if the user does not exist.
|
||||||
|
UpdateUser(ctx context.Context, id int, upd UserUpdate) (*User, error)
|
||||||
|
|
||||||
|
// Permanently deletes a user and all owned application resources. Returns EUNAUTHORIZED
|
||||||
|
// if current user is not the user being deleted. Returns ENOTFOUND if the user
|
||||||
|
// does not exist.
|
||||||
|
DeleteUser(ctx context.Context, id int) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *UserService) Exists(id int) (bool, error) {
|
// UserFilter respresents a filter passed to FindUsers().
|
||||||
var exists bool
|
type UserFilter struct {
|
||||||
|
ID *int `json:"id"`
|
||||||
|
Email *string `json:"email"`
|
||||||
|
APIKey *string `json:"apiKey"`
|
||||||
|
|
||||||
stmt := "SELECT EXISTS(SELECT true FROM users WHERE id = ?)"
|
// Restrict to subset of results
|
||||||
|
Offset int `json:"offset"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
}
|
||||||
|
|
||||||
err := u.DB.QueryRow(stmt, id).Scan(&exists)
|
type UserUpdate struct {
|
||||||
return exists, err
|
Name *string `json:"name"`
|
||||||
|
Email *string `json:"email"`
|
||||||
}
|
}
|
||||||
|
@ -1,75 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.runcible.io/learning/ratchet/internal/apperror"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Userwtf struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
|
|
||||||
// User prefered name and email
|
|
||||||
Name string `json:"name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
|
|
||||||
// Randomly generated API key for use with the API
|
|
||||||
// "-" omits the key from serialization
|
|
||||||
APIKey string `json:"-"`
|
|
||||||
|
|
||||||
// Timestamps for user creatation and last update
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
|
|
||||||
// List of associated Oauth authentication Objects
|
|
||||||
// Not yet implemented
|
|
||||||
// Auths []*Auth `json:"auths"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *Userwtf) Validate() error {
|
|
||||||
if u.Name == "" {
|
|
||||||
return apperror.Errorf(apperror.EINVALID, "User name required.")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserService represents a service for managing users.
|
|
||||||
type UserServicewtf interface {
|
|
||||||
// Retrieves a user by ID along with their associated auth objects
|
|
||||||
// Returns ENOTFOUND if user does not exist.
|
|
||||||
FindUserByID(ctx context.Context, id int) (*Userwtf, error)
|
|
||||||
|
|
||||||
// Retrieves a list of users by filter. Also returns total count of matching users
|
|
||||||
// which may differ from retruned results if filter.Limit is specified.
|
|
||||||
FindUsers(ctc context.Context, filter UserFilter) ([]*Userwtf, int, error)
|
|
||||||
|
|
||||||
// Creates a new use. This is only used for testing since users are typically
|
|
||||||
// cretaed during the OAuth creation process in the AuthService.CreateAuth().
|
|
||||||
CreateUser(ctx context.Context, user *Userwtf) error
|
|
||||||
|
|
||||||
// Updates a user object. Returns EUNAUTHORIZED if current user is not
|
|
||||||
// the user that is being updated. Returns ENOTFOUND if the user does not exist.
|
|
||||||
UpdateUser(ctx context.Context, id int, upd UserUpdate) (*Userwtf, error)
|
|
||||||
|
|
||||||
// Permanently deletes a user and all owned application resources. Returns EUNAUTHORIZED
|
|
||||||
// if current user is not the user being deleted. Returns ENOTFOUND if the user
|
|
||||||
// does not exist.
|
|
||||||
DeleteUser(ctx context.Context, id int) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserFilter respresents a filter passed to FindUsers().
|
|
||||||
type UserFilter struct {
|
|
||||||
ID *int `json:"id"`
|
|
||||||
Email *string `json:"email"`
|
|
||||||
APIKey *string `json:"apiKey"`
|
|
||||||
|
|
||||||
// Restrict to subset of results
|
|
||||||
Offset int `json:"offset"`
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserUpdate struct {
|
|
||||||
Name *string `json:"name"`
|
|
||||||
Email *string `json:"email"`
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
type contextKey string
|
|
||||||
|
|
||||||
const isAuthenticatedKey = contextKey("isAuthenticated")
|
|
@ -1,66 +0,0 @@
|
|||||||
// test
|
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"git.runcible.io/learning/ratchet/internal/validator"
|
|
||||||
"github.com/go-playground/form/v4"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Define a snippetCreateForm struct to represent the form data and validation
|
|
||||||
// errors for the form fields. Note that all the struct fields are deliberately
|
|
||||||
// exported (i.e. start with a capital letter). This is because struct fields
|
|
||||||
// must be exported in order to be read by the html/template package when
|
|
||||||
// rendering the template.
|
|
||||||
//
|
|
||||||
// Remove the explicit FieldErrors struct field and instead embed the Validator
|
|
||||||
// struct. Embedding this means that our snippetCreateForm "inherits" all the
|
|
||||||
// fields and methods of our Validator struct (including the FieldErrors field).
|
|
||||||
//
|
|
||||||
// MOVING TO go-playground/form
|
|
||||||
// Update our snippetCreateForm struct to include struct tags which tell the
|
|
||||||
// decoder how to map HTML form values into the different struct fields. So, for
|
|
||||||
// example, here we're telling the decoder to store the value from the HTML form
|
|
||||||
// input with the name "title" in the Title field. The struct tag `form:"-"`
|
|
||||||
// tells the decoder to completely ignore a field during decoding.
|
|
||||||
type snippetCreateForm struct {
|
|
||||||
Title string `form:"title"`
|
|
||||||
Content string `form:"content"`
|
|
||||||
Expires int `form:"expires"`
|
|
||||||
validator.Validator `form:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type userSignupForm struct {
|
|
||||||
Name string `form:"name"`
|
|
||||||
Email string `form:"email"`
|
|
||||||
Password string `form:"password"`
|
|
||||||
validator.Validator `form:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type userLoginForm struct {
|
|
||||||
Email string `form:"email"`
|
|
||||||
Password string `form:"password"`
|
|
||||||
validator.Validator `form:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodePostForm(r *http.Request, fd *form.Decoder, dst any) error {
|
|
||||||
err := r.ParseForm()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = fd.Decode(dst, r.PostForm)
|
|
||||||
if err != nil {
|
|
||||||
var invalidDecoderError *form.InvalidDecoderError
|
|
||||||
|
|
||||||
if errors.As(err, &invalidDecoderError) {
|
|
||||||
// if called in the handler, recovery middleware
|
|
||||||
// will log and send 500 response back
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -1,77 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.runcible.io/learning/ratchet/internal/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCommonHeadersMiddleware(t *testing.T) {
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
|
|
||||||
r, err := http.NewRequest(http.MethodGet, "/", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mock http.Handler
|
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Write([]byte("OK"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Pass the mock HTTP handler to our commonHeaders middleware. Because
|
|
||||||
// commonHeaders *returns* a http.Handler we can call its ServeHTTP()
|
|
||||||
// method, passing in the http.ResponseRecorder and dummy http.Request to
|
|
||||||
// execute it.
|
|
||||||
CommonHeaderMiddleware(next).ServeHTTP(rr, r)
|
|
||||||
|
|
||||||
resp := rr.Result()
|
|
||||||
|
|
||||||
// Check that the middleware has correctly set the Content-Security-Policy
|
|
||||||
// header on the response.
|
|
||||||
expectedValue := "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com"
|
|
||||||
assert.Equal(t, resp.Header.Get("Content-Security-Policy"), expectedValue)
|
|
||||||
|
|
||||||
// Check that the middleware has correctly set the Referrer-Policy
|
|
||||||
// header on the response.
|
|
||||||
expectedValue = "origin-when-cross-origin"
|
|
||||||
assert.Equal(t, resp.Header.Get("Referrer-Policy"), expectedValue)
|
|
||||||
|
|
||||||
// Check that the middleware has correctly set the X-Content-Type-Options
|
|
||||||
// header on the response.
|
|
||||||
expectedValue = "nosniff"
|
|
||||||
assert.Equal(t, resp.Header.Get("X-Content-Type-Options"), expectedValue)
|
|
||||||
|
|
||||||
// Check that the middleware has correctly set the X-Frame-Options header
|
|
||||||
// on the response.
|
|
||||||
expectedValue = "deny"
|
|
||||||
assert.Equal(t, resp.Header.Get("X-Frame-Options"), expectedValue)
|
|
||||||
|
|
||||||
// Check that the middleware has correctly set the X-XSS-Protection header
|
|
||||||
// on the response
|
|
||||||
expectedValue = "0"
|
|
||||||
assert.Equal(t, resp.Header.Get("X-XSS-Protection"), expectedValue)
|
|
||||||
|
|
||||||
// Check that the middleware has correctly set the Server header on the
|
|
||||||
// response.
|
|
||||||
expectedValue = "Go"
|
|
||||||
assert.Equal(t, resp.Header.Get("Server"), expectedValue)
|
|
||||||
|
|
||||||
// Check that the middleware has correctly called the next handler in line
|
|
||||||
// and the response status code and body are as expected.
|
|
||||||
assert.Equal(t, resp.StatusCode, http.StatusOK)
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
body = bytes.TrimSpace(body)
|
|
||||||
|
|
||||||
assert.Equal(t, string(body), "OK")
|
|
||||||
}
|
|
@ -1,55 +1,29 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.runcible.io/learning/ratchet/ui"
|
"git.runcible.io/learning/ratchet/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func addBaseMiddleware(app *RatchetApp, next http.Handler, requireAuth bool) http.Handler {
|
func addRoutes(mux *http.ServeMux,
|
||||||
var h http.Handler
|
logger *slog.Logger,
|
||||||
h = next
|
tc *TemplateCache,
|
||||||
if requireAuth {
|
db *sql.DB,
|
||||||
h = RequireAuthenticationMiddleware(h, app.sessionManager)
|
snippetService *model.SnippetService) http.Handler {
|
||||||
}
|
|
||||||
h = AuthenticateMiddleware(h, app.sessionManager, app.userService)
|
|
||||||
h = NoSurfMiddleware(h)
|
|
||||||
h = app.sessionManager.LoadAndSave(h)
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *RatchetApp) Routes() http.Handler {
|
|
||||||
|
|
||||||
// TODO implement middleware that disables directory listings
|
|
||||||
// This line was superceded by using the embedded filesystem
|
|
||||||
// fileServer := http.FileServer(http.Dir("./ui/static/"))
|
|
||||||
router := http.NewServeMux()
|
|
||||||
|
|
||||||
// Subtree pattern for static assets
|
|
||||||
// This line was superceded by using the embedded filesystem
|
|
||||||
// router.Handle("GET /static/", http.StripPrefix("/static/", fileServer))
|
|
||||||
router.Handle("GET /static/", CacheHeaders(http.FileServerFS(ui.Files)))
|
|
||||||
|
|
||||||
router.Handle("GET /ping", PingHandler())
|
|
||||||
|
|
||||||
// /{$} is used to prevent subtree path patterns from acting like a wildcard
|
// /{$} is used to prevent subtree path patterns from acting like a wildcard
|
||||||
// resulting in this route requiring an exact match on "/" only
|
// resulting in this route requiring an exact match on "/" only
|
||||||
// You can only include one HTTP method in a route pattern if you choose
|
// You can only include one HTTP method in a route pattern if you choose
|
||||||
// GET will match GET & HEAD http request methods
|
// GET will match GET & HEAD http request methods
|
||||||
router.Handle("GET /{$}", addBaseMiddleware(a, handleHome(a.logger, a.templateCache, a.sessionManager, a.snippetService), false)) // might be time to swith to github.com/justinas/alice dynamic chain
|
mux.Handle("GET /{$}", handleHome(logger, tc, snippetService))
|
||||||
router.Handle("GET /snippet/view/{id}", addBaseMiddleware(a, handleSnippetView(a.logger, a.templateCache, a.sessionManager, a.snippetService), false))
|
mux.Handle("GET /snippet/view/{id}", handleSnippetView(logger, tc, snippetService))
|
||||||
router.Handle("GET /snippet/create", addBaseMiddleware(a, handleSnippetCreateGet(a.templateCache, a.sessionManager), true))
|
mux.Handle("GET /snippet/create", handleSnippetCreateGet())
|
||||||
router.Handle("POST /snippet/create", addBaseMiddleware(a, handleSnippetCreatePost(a.logger, a.templateCache, a.formDecoder, a.sessionManager, a.snippetService), true))
|
mux.Handle("POST /snippet/create", handleSnippetCreatePost(logger, tc, snippetService))
|
||||||
// mux.Handle("/something", handleSomething(logger, config))
|
// mux.Handle("/something", handleSomething(logger, config))
|
||||||
// mux.Handle("/healthz", handleHealthzPlease(logger))
|
// mux.Handle("/healthz", handleHealthzPlease(logger))
|
||||||
// mux.Handle("/", http.NotFoundHandler())
|
// mux.Handle("/", http.NotFoundHandler())
|
||||||
|
return mux
|
||||||
router.Handle("GET /user/signup", addBaseMiddleware(a, handleUserSignupGet(a.templateCache, a.sessionManager), false))
|
|
||||||
router.Handle("POST /user/signup", addBaseMiddleware(a, handleUserSignupPost(a.logger, a.templateCache, a.formDecoder, a.sessionManager, a.userService), false))
|
|
||||||
router.Handle("GET /user/login", addBaseMiddleware(a, handleUserLoginGet(a.templateCache, a.sessionManager), false))
|
|
||||||
router.Handle("POST /user/login", addBaseMiddleware(a, handleUserLoginPost(a.logger, a.templateCache, a.sessionManager, a.formDecoder, a.userService), false))
|
|
||||||
|
|
||||||
// Requires auth
|
|
||||||
router.Handle("POST /user/logout", addBaseMiddleware(a, handleUserLogoutPost(a.logger, a.sessionManager), true))
|
|
||||||
return RecoveryMiddleware(RequestLoggingMiddleware(CommonHeaderMiddleware(router), a.logger))
|
|
||||||
}
|
}
|
||||||
|
@ -1,32 +1,41 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"git.runcible.io/learning/ratchet/internal/model"
|
"git.runcible.io/learning/ratchet/internal/model"
|
||||||
"github.com/alexedwards/scs/v2"
|
|
||||||
"github.com/go-playground/form/v4"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type RatchetApp struct {
|
type RatchetServer struct {
|
||||||
|
http.Handler
|
||||||
|
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
templateCache *TemplateCache
|
templateCache *TemplateCache
|
||||||
//Services used by HTTP routes
|
//Services used by HTTP routes
|
||||||
snippetService model.SnippetServiceInterface
|
snippetService *model.SnippetService
|
||||||
userService model.UserServiceInterface
|
UserService model.UserService
|
||||||
formDecoder *form.Decoder
|
|
||||||
sessionManager *scs.SessionManager
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO this function presents some challenges because it both instantiates new data objects
|
func NewRatchetServer(logger *slog.Logger, tc *TemplateCache, db *sql.DB) *RatchetServer {
|
||||||
// and configures route / middleware setup
|
rs := new(RatchetServer)
|
||||||
func NewRatchetApp(logger *slog.Logger, tc *TemplateCache, snippetService model.SnippetServiceInterface, userService model.UserServiceInterface, sm *scs.SessionManager) *RatchetApp {
|
|
||||||
rs := new(RatchetApp)
|
|
||||||
rs.logger = logger
|
rs.logger = logger
|
||||||
rs.snippetService = snippetService
|
rs.snippetService = &model.SnippetService{DB: db}
|
||||||
rs.userService = userService
|
|
||||||
rs.formDecoder = form.NewDecoder()
|
|
||||||
rs.templateCache = tc
|
rs.templateCache = tc
|
||||||
rs.sessionManager = sm
|
// TODO implement middleware that disables directory listings
|
||||||
|
fileServer := http.FileServer(http.Dir("./ui/static/"))
|
||||||
|
router := http.NewServeMux()
|
||||||
|
|
||||||
|
// Subtree pattern for static assets
|
||||||
|
router.Handle("GET /static/", http.StripPrefix("/static/", fileServer))
|
||||||
|
|
||||||
|
// Mux Router implements the Handler interface. AKA it has a ServeHTTP receiver.
|
||||||
|
// SEE we can really clean things up by moving this into routes.go and handlers.go
|
||||||
|
wrappedMux := addRoutes(router, rs.logger, rs.templateCache, db, rs.snippetService)
|
||||||
|
rs.Handler = CommonHeaderMiddleware(wrappedMux)
|
||||||
|
rs.Handler = RequestLoggingMiddleware(rs.Handler, logger)
|
||||||
|
rs.Handler = RecoveryMiddleware(rs.Handler)
|
||||||
return rs
|
return rs
|
||||||
}
|
}
|
||||||
|
@ -1,49 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.runcible.io/learning/ratchet/internal/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHumanDate(t *testing.T) {
|
|
||||||
|
|
||||||
// Table driven test
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
tm time.Time
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "UTC",
|
|
||||||
tm: time.Date(2077, time.April, 12, 23, 0, 0, 0, time.UTC),
|
|
||||||
want: "12 Apr 2077 at 23:00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "UTC",
|
|
||||||
tm: time.Date(2025, time.March, 3, 2, 31, 0, 0, time.UTC),
|
|
||||||
want: "03 Mar 2025 at 02:31",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Empty",
|
|
||||||
tm: time.Time{},
|
|
||||||
want: "",
|
|
||||||
},
|
|
||||||
// CET is one hour ahead of UTC but we print in UTC
|
|
||||||
{
|
|
||||||
name: "CET",
|
|
||||||
tm: time.Date(2024, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
|
|
||||||
want: "17 Mar 2024 at 09:15",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
got := humanDate(test.tm)
|
|
||||||
|
|
||||||
assert.Equal(t, got, test.want)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,86 +0,0 @@
|
|||||||
// See also https://www.alexedwards.net/blog/validation-snippets-for-go
|
|
||||||
// for more validation snippets
|
|
||||||
//
|
|
||||||
// A NonFieldError for example would be "Your email or password is incorrect".
|
|
||||||
// more secure because it does not leak which field was in error. Used in the login
|
|
||||||
// form
|
|
||||||
|
|
||||||
package validator
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"unicode/utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Validator struct {
|
|
||||||
NonFieldErrors []string
|
|
||||||
FieldErrors map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valid() returns true if the FieldErrors map doesn't contain any entries.
|
|
||||||
func (v *Validator) Valid() bool {
|
|
||||||
return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Validator) AddNonFieldError(message string) {
|
|
||||||
v.NonFieldErrors = append(v.NonFieldErrors, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddFieldError() adds an error message to the FieldErrors map (so long as no
|
|
||||||
// entry already exists for the given key).
|
|
||||||
func (v *Validator) AddFieldError(key, message string) {
|
|
||||||
if v.FieldErrors == nil {
|
|
||||||
v.FieldErrors = make(map[string]string)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, exists := v.FieldErrors[key]; !exists {
|
|
||||||
v.FieldErrors[key] = message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckField() adds an error message to the FieldErrors map only if a
|
|
||||||
// validation check is not 'ok'.
|
|
||||||
func (v *Validator) CheckField(ok bool, key, message string) {
|
|
||||||
if !ok {
|
|
||||||
v.AddFieldError(key, message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotBlank() returns true if a value is not an empty string.
|
|
||||||
func NotBlank(value string) bool {
|
|
||||||
return strings.TrimSpace(value) != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// MinChars() returns true if the value contains equal to or greater than n characters
|
|
||||||
func MinChars(value string, n int) bool {
|
|
||||||
return utf8.RuneCountInString(value) >= n
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaxChars() returns true if a value contains no more than n characters.
|
|
||||||
func MaxChars(value string, n int) bool {
|
|
||||||
return utf8.RuneCountInString(value) <= n
|
|
||||||
}
|
|
||||||
|
|
||||||
// PermittedValue() returns true if a value is in a list of specific permitted
|
|
||||||
// values.
|
|
||||||
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
|
|
||||||
return slices.Contains(permittedValues, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the regexp.MustCompile() function to parse a regular expression pattern
|
|
||||||
// for sanity checking the format of an email address. This returns a pointer to
|
|
||||||
// a 'compiled' regexp.Regexp type, or panics in the event of an error. Parsing
|
|
||||||
// this pattern once at startup and storing the compiled *regexp.Regexp in a
|
|
||||||
// variable is more performant than re-parsing the pattern each time we need it.
|
|
||||||
// This pattern is recommended by the W3C and Web Hypertext Application Technology Working Group for validating email addresses
|
|
||||||
// https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
|
|
||||||
// Because the EmailRX regexp pattern is written as an interpreted string literal, we need to double-escape special characters in the regexp with \\ for it to work correctly
|
|
||||||
var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
|
|
||||||
|
|
||||||
// Matches() returns true if a value matches a provided compiled regular
|
|
||||||
// expression pattern.
|
|
||||||
func Matches(value string, rx *regexp.Regexp) bool {
|
|
||||||
return rx.MatchString(value)
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS sessions;
|
|
@ -1,7 +0,0 @@
|
|||||||
CREATE TABLE sessions (
|
|
||||||
token TEXT PRIMARY KEY,
|
|
||||||
data BLOB NOT NULL,
|
|
||||||
expiry REAL NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX sessions_expiry_idx ON sessions(expiry);
|
|
@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS users;
|
|
@ -1,16 +0,0 @@
|
|||||||
CREATE TABLE users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
hashed_password TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Add a trigger to keep timestamp updated.
|
|
||||||
CREATE TRIGGER users_update_timestamp
|
|
||||||
AFTER UPDATE ON users
|
|
||||||
FOR EACH ROW
|
|
||||||
BEGIN
|
|
||||||
UPDATE users SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
|
|
||||||
END;
|
|
@ -1,48 +0,0 @@
|
|||||||
package migrations
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"embed"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/golang-migrate/migrate/v4"
|
|
||||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
|
||||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
|
||||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed *.sql
|
|
||||||
var migrationFiles embed.FS
|
|
||||||
|
|
||||||
func Migrate(db *sql.DB) error {
|
|
||||||
// Create a database driver for the specific database type
|
|
||||||
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create database driver: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create an IFS source from the embedded files
|
|
||||||
source, err := iofs.New(migrationFiles, ".")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create migration source: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new migrate instance
|
|
||||||
m, err := migrate.NewWithInstance("iofs", source, "sqlite3", driver)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create migrate instance: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run migrations
|
|
||||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
|
||||||
return fmt.Errorf("failed to run migrations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// set WAL mode
|
|
||||||
_, err = db.Exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to set wall mode: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
package ui
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
// The below is actually a comment directive. Comment directives must be
|
|
||||||
// placed immediately above the variable used. The path provided is relative
|
|
||||||
// to the .go file it is in. You can only embed on global variables at a package
|
|
||||||
// level. Paths cannot contain . or .. or begin with / So you are effectively
|
|
||||||
// restricted to embedding files within the same directory as the .go file.
|
|
||||||
// You can provide multiple passes "static/css" "static/img" "static/js" to help with
|
|
||||||
// avoiding shipping things like a PostCSS(tailwind) css file.
|
|
||||||
// Can also us static/css/*.css. "all:static" will embed . and _ files too.
|
|
||||||
// Lastly the embedded file system is always rooted in the directory that contains
|
|
||||||
// the embed directive. So in this example the root is in our ui dir.
|
|
||||||
|
|
||||||
//go:embed "static" "html"
|
|
||||||
var Files embed.FS
|
|
@ -1,39 +0,0 @@
|
|||||||
{{define "title"}}Create a New Snippet{{end}}
|
|
||||||
|
|
||||||
{{define "main"}}
|
|
||||||
<form action='/snippet/create' method='POST'>
|
|
||||||
<!-- Include the CSRF token -->
|
|
||||||
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
|
|
||||||
<div>
|
|
||||||
<label>Title:</label>
|
|
||||||
<!-- Use the `with` action to render the value of .Form.FieldErrors.title
|
|
||||||
if it is not empty. -->
|
|
||||||
{{with .Form.FieldErrors.title}}
|
|
||||||
<label class='error'>{{.}}</label>
|
|
||||||
{{end}}
|
|
||||||
<input type='text' name='title' value="{{.Form.Title}}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Content:</label>
|
|
||||||
<!-- Likewise render the value of .Form.FieldErrors.content if it is not
|
|
||||||
empty. -->
|
|
||||||
{{with .Form.FieldErrors.content}}
|
|
||||||
<label class='error'>{{.}}</label>
|
|
||||||
{{end}}
|
|
||||||
<textarea name='content'>{{.Form.Content}}</textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Delete in:</label>
|
|
||||||
<!-- And render the value of .Form.FieldErrors.expires if it is not empty. -->
|
|
||||||
{{with .Form.FieldErrors.expires}}
|
|
||||||
<label class='error'>{{.}}</label>
|
|
||||||
{{end}}
|
|
||||||
<input type='radio' name='expires' value='365' {{if (eq .Form.Expires 365)}}checked{{end}}> One Year
|
|
||||||
<input type='radio' name='expires' value='7' {{if (eq .Form.Expires 7)}}checked{{end}}> One Week
|
|
||||||
<input type='radio' name='expires' value='1' {{if (eq .Form.Expires 1)}}checked{{end}}> One Day
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input type='submit' value='Publish snippet'>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
@ -1,30 +0,0 @@
|
|||||||
{{define "title"}}Login{{end}}
|
|
||||||
|
|
||||||
{{define "main"}}
|
|
||||||
<form action='/user/login' method='POST' novalidate>
|
|
||||||
<!-- Include the CSRF token -->
|
|
||||||
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
|
|
||||||
<!-- Notice that here we are looping over the NonFieldErrors and displaying
|
|
||||||
them, if any exist -->
|
|
||||||
{{range .Form.NonFieldErrors}}
|
|
||||||
<div class='error'>{{.}}</div>
|
|
||||||
{{end}}
|
|
||||||
<div>
|
|
||||||
<label>Email:</label>
|
|
||||||
{{with .Form.FieldErrors.email}}
|
|
||||||
<label class='error'>{{.}}</label>
|
|
||||||
{{end}}
|
|
||||||
<input type='email' name='email' value='{{.Form.Email}}'>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Password:</label>
|
|
||||||
{{with .Form.FieldErrors.password}}
|
|
||||||
<label class='error'>{{.}}</label>
|
|
||||||
{{end}}
|
|
||||||
<input type='password' name='password'>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input type='submit' value='Login'>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
@ -1,32 +0,0 @@
|
|||||||
{{ define "title"}}Signup{{end}}
|
|
||||||
|
|
||||||
{{define "main"}}
|
|
||||||
<form action='/user/signup' method='POST' novalidate>
|
|
||||||
<!-- Include the CSRF token -->
|
|
||||||
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
|
|
||||||
<div>
|
|
||||||
<label>Name:</label>
|
|
||||||
{{with .Form.FieldErrors.name}}
|
|
||||||
<label class='error'>{{.}}</label>
|
|
||||||
{{end}}
|
|
||||||
<input type='text' name='name' value='{{.Form.Name}}'>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Email:</label>
|
|
||||||
{{with .Form.FieldErrors.email}}
|
|
||||||
<label class='error'>{{.}}</label>
|
|
||||||
{{end}}
|
|
||||||
<input type='email' name='email' value='{{.Form.Email}}'>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Password:</label>
|
|
||||||
{{with .Form.FieldErrors.password}}
|
|
||||||
<label class='error'>{{.}}</label>
|
|
||||||
{{end}}
|
|
||||||
<input type='password' name='password'>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input type='submit' value='Signup'>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
@ -1,22 +1,5 @@
|
|||||||
{{define "nav" -}}
|
{{define "nav" -}}
|
||||||
<nav>
|
<nav>
|
||||||
<div>
|
|
||||||
<a href='/'>Home</a>
|
<a href='/'>Home</a>
|
||||||
{{ if .IsAuthenticated }}
|
|
||||||
<a href='/snippet/create'>Create snippet</a>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ if .IsAuthenticated }}
|
|
||||||
<form action='/user/logout' method='POST'>
|
|
||||||
<!-- Include the CSRF token -->
|
|
||||||
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
|
|
||||||
<button>Logout</button>
|
|
||||||
</form>
|
|
||||||
{{ else }}
|
|
||||||
<a href='/user/signup'>Signup</a>
|
|
||||||
<a href='/user/login'>Login</a>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
{{- end}}
|
{{- end}}
|
Loading…
Reference in New Issue