Compare commits
49 Commits
2e513bb91e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5aea734d2b | ||
|
|
69658a7bd4 | ||
|
|
577f902ab3 | ||
|
|
ec5ef9a5c1 | ||
|
|
79d465bd7b | ||
|
|
c3294de4c6 | ||
|
|
fc07515dbd | ||
|
|
8d3c025660 | ||
|
|
126ccfa715 | ||
|
|
b6cdb2b860 | ||
|
|
94345eff97 | ||
|
|
894d152aae | ||
|
|
dd4c74598d | ||
|
|
2824e51c29 | ||
|
|
91f5a63366 | ||
|
|
6eee11400c | ||
|
|
f252c2c801 | ||
|
|
3c7cb22298 | ||
|
|
b644c0384e | ||
|
|
b50cd42f4f | ||
|
|
c2883b9916 | ||
|
|
3bd8a8e1c7 | ||
|
|
3015b7318e | ||
|
|
b8fad1a63f | ||
|
|
8df6f1c6e9 | ||
|
|
5e98af52fe | ||
|
|
7ab49c9fc8 | ||
|
|
be06d0a5ca | ||
|
|
fbce0c006c | ||
|
|
fc1484aebe | ||
|
|
420b876a7a | ||
|
|
bdbda690a2 | ||
|
|
c999dce1fd | ||
|
|
a5827ae3d7 | ||
|
|
0f096ff94b | ||
|
|
dceefea953 | ||
|
|
c5833c6ddf | ||
|
|
e5288f0d33 | ||
|
|
e392f24256 | ||
|
|
35be6f27ed | ||
|
|
ab153d3b37 | ||
|
|
7a7042bc66 | ||
|
|
98683f382a | ||
|
|
9085959978 | ||
|
|
d291ceb5fd | ||
|
|
cfc4aa38cd | ||
|
|
00ac7cf707 | ||
|
|
2e88f6f9d4 | ||
|
|
e738975535 |
5
cmd/web/context.go
Normal file
5
cmd/web/context.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package main
|
||||
|
||||
type contextKey string
|
||||
|
||||
const isAuthenticatedContextKey = contextKey("isAuthenticated")
|
||||
334
cmd/web/handlers.go
Normal file
334
cmd/web/handlers.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.local.lab/Lbenedar/snippetbox/internal/models"
|
||||
"gitea.local.lab/Lbenedar/snippetbox/internal/validator"
|
||||
"gitea.local.lab/Lbenedar/snippetbox/ui"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
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:"-"`
|
||||
}
|
||||
|
||||
type accountViewForm struct {
|
||||
Name string `form:"name"`
|
||||
Email string `form:"email"`
|
||||
Joined string `form:"joined"`
|
||||
validator.Validator `form:"-"`
|
||||
}
|
||||
|
||||
type passwordChangeForm struct {
|
||||
CurrentPassword string `form:"curr_pass"`
|
||||
NewPassword string `form:"new_pass"`
|
||||
ConfirmPassword string `form:"conf_pass"`
|
||||
validator.Validator `form:"-"`
|
||||
}
|
||||
|
||||
func (app *application) render(w http.ResponseWriter, 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, err)
|
||||
return
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
err := ts.ExecuteTemplate(buf, "base", data)
|
||||
if err != nil {
|
||||
app.serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
buf.WriteTo(w)
|
||||
}
|
||||
|
||||
func (app *application) home(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
app.notFound(w)
|
||||
return
|
||||
}
|
||||
|
||||
snippets, err := app.snippets.Latest()
|
||||
if err != nil {
|
||||
app.serverError(w, err)
|
||||
return
|
||||
}
|
||||
data := app.newTemplateData(r)
|
||||
data.Snippets = snippets
|
||||
|
||||
app.render(w, http.StatusOK, "home.tmpl", data)
|
||||
}
|
||||
|
||||
func (app *application) snippetView(w http.ResponseWriter, r *http.Request) {
|
||||
params := httprouter.ParamsFromContext(r.Context())
|
||||
id, err := strconv.Atoi(params.ByName("id"))
|
||||
if err != nil || id < 1 {
|
||||
app.notFound(w)
|
||||
return
|
||||
}
|
||||
|
||||
snippet, err := app.snippets.Get(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrNoRecord) {
|
||||
app.notFound(w)
|
||||
} else {
|
||||
app.serverError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
data := app.newTemplateData(r)
|
||||
data.Snippet = snippet
|
||||
|
||||
app.render(w, http.StatusOK, "view.tmpl", data)
|
||||
}
|
||||
|
||||
func (app *application) snippetCreate(w http.ResponseWriter, r *http.Request) {
|
||||
data := app.newTemplateData(r)
|
||||
|
||||
data.Form = snippetCreateForm{
|
||||
Expires: 365,
|
||||
}
|
||||
|
||||
app.render(w, http.StatusOK, "create.tmpl", data)
|
||||
}
|
||||
|
||||
func (app *application) snippetCreatePost(w http.ResponseWriter, r *http.Request) {
|
||||
var form snippetCreateForm
|
||||
err := app.decodePostForm(r, &form)
|
||||
if err != nil {
|
||||
app.clientError(w, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form.CheckField(validator.NotBlank(form.Title), "title", "This field cannot be blank")
|
||||
form.CheckField(validator.MaxChars(form.Title, 100), "title", "This field cannot be more than 100 character long")
|
||||
form.CheckField(validator.NotBlank(form.Content), "content", "This field cannot be blank")
|
||||
form.CheckField(validator.PermittedValue(form.Expires, 1, 7, 365), "expires", "This field must equal 1, 7 and 365")
|
||||
|
||||
if !form.Valid() {
|
||||
data := app.newTemplateData(r)
|
||||
data.Form = form
|
||||
app.render(w, http.StatusUnprocessableEntity, "create.tmpl", data)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := app.snippets.Insert(form.Title, form.Content, form.Expires)
|
||||
if err != nil {
|
||||
app.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
app.sessionManager.Put(r.Context(), "flash", "Snippet successfully created!")
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("/snippet/view/%d", id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (app *application) userSignupPost(w http.ResponseWriter, r *http.Request) {
|
||||
var form userSignupForm
|
||||
err := app.decodePostForm(r, &form)
|
||||
|
||||
if err != nil {
|
||||
app.notFound(w)
|
||||
return
|
||||
}
|
||||
|
||||
form.CheckField(validator.NotBlank(form.Name), "name", "This field cannot be blank")
|
||||
form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
|
||||
form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
|
||||
form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
|
||||
form.CheckField(validator.MinChars(form.Password, 8), "password", "This field must be at least 8 character long")
|
||||
|
||||
if !form.Valid() {
|
||||
data := app.newTemplateData(r)
|
||||
data.Form = form
|
||||
app.render(w, http.StatusUnprocessableEntity, "signup.tmpl", data)
|
||||
return
|
||||
}
|
||||
|
||||
err = app.users.Insert(form.Name, form.Email, form.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrDuplicateEmail) {
|
||||
form.AddFieldError("email", "Email address already in use")
|
||||
|
||||
data := app.newTemplateData(r)
|
||||
data.Form = form
|
||||
app.render(w, http.StatusUnprocessableEntity, "signup.tmpl", data)
|
||||
} else {
|
||||
app.serverError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
app.sessionManager.Put(r.Context(), "flash", "Your singup was successful. Please log in.")
|
||||
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||
var form userLoginForm
|
||||
|
||||
err := app.decodePostForm(r, &form)
|
||||
if err != nil {
|
||||
app.clientError(w, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
|
||||
form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
|
||||
form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")
|
||||
|
||||
if !form.Valid() {
|
||||
data := app.newTemplateData(r)
|
||||
data.Form = form
|
||||
app.render(w, http.StatusUnprocessableEntity, "login.tmpl", data)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := app.users.Authenticate(form.Email, form.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrInvalidCredentials) {
|
||||
form.AddNonFieldError("Email or password is incorrect")
|
||||
|
||||
data := app.newTemplateData(r)
|
||||
data.Form = form
|
||||
app.render(w, http.StatusUnprocessableEntity, "login.tmpl", data)
|
||||
} else {
|
||||
app.serverError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
err = app.sessionManager.RenewToken(r.Context())
|
||||
if err != nil {
|
||||
app.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
app.sessionManager.Put(r.Context(), "authenticatedUserID", id)
|
||||
redirectPath := app.sessionManager.PopString(r.Context(), "redirectParent")
|
||||
if redirectPath != "" {
|
||||
http.Redirect(w, r, redirectPath, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/snippet/create", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (app *application) userLogoutPost(w http.ResponseWriter, r *http.Request) {
|
||||
err := app.sessionManager.RenewToken(r.Context())
|
||||
if err != nil {
|
||||
app.serverError(w, err)
|
||||
return
|
||||
}
|
||||
app.sessionManager.Remove(r.Context(), "authenticatedUserID")
|
||||
app.sessionManager.Put(r.Context(), "flash", "You've been logged out succefully!")
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (app *application) userSignup(w http.ResponseWriter, r *http.Request) {
|
||||
data := app.newTemplateData(r)
|
||||
data.Form = userSignupForm{}
|
||||
app.render(w, http.StatusOK, "signup.tmpl", data)
|
||||
}
|
||||
|
||||
func (app *application) userLogin(w http.ResponseWriter, r *http.Request) {
|
||||
data := app.newTemplateData(r)
|
||||
data.Form = userLoginForm{}
|
||||
app.render(w, http.StatusOK, "login.tmpl", data)
|
||||
}
|
||||
|
||||
func ping(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
func (app *application) about(w http.ResponseWriter, r *http.Request) {
|
||||
data := app.newTemplateData(r)
|
||||
text, err := fs.ReadFile(ui.Files, "static/text/about.txt")
|
||||
if err != nil {
|
||||
app.serverError(w, err)
|
||||
return
|
||||
}
|
||||
data.AboutText = string(text)
|
||||
app.render(w, http.StatusOK, "about.tmpl", data)
|
||||
}
|
||||
|
||||
func (app *application) accountView(w http.ResponseWriter, r *http.Request) {
|
||||
data := app.newTemplateData(r)
|
||||
id := app.sessionManager.Get(r.Context(), "authenticatedUserID").(int)
|
||||
|
||||
user, err := app.users.GetById(id)
|
||||
if err != nil {
|
||||
app.serverError(w, err)
|
||||
return
|
||||
}
|
||||
data.Form = accountViewForm{Name: user.Name, Email: user.Email, Joined: humanDate(user.Created)}
|
||||
app.render(w, http.StatusOK, "account.tmpl", data)
|
||||
}
|
||||
|
||||
func (app *application) accountChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
data := app.newTemplateData(r)
|
||||
data.Form = passwordChangeForm{}
|
||||
app.render(w, http.StatusOK, "password.tmpl", data)
|
||||
}
|
||||
|
||||
func (app *application) accountChangePasswordPost(w http.ResponseWriter, r *http.Request) {
|
||||
form := passwordChangeForm{}
|
||||
err := app.decodePostForm(r, &form)
|
||||
if err != nil {
|
||||
app.clientError(w, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
form.CheckField(validator.NotBlank(form.CurrentPassword), "curr_pass", "This field cannot be blank")
|
||||
form.CheckField(validator.NotBlank(form.NewPassword), "new_pass", "This field cannot be blank")
|
||||
form.CheckField(validator.MinChars(form.NewPassword, 8), "new_pass", "Minimum password length is 8 symbols")
|
||||
form.CheckField(validator.NotBlank(form.ConfirmPassword), "conf_pass", "This field cannot be blank")
|
||||
form.CheckField(strings.Compare(form.NewPassword, form.ConfirmPassword) == 0, "conf_pass", "Confirm password does not match")
|
||||
|
||||
if !form.Valid() {
|
||||
data := app.newTemplateData(r)
|
||||
data.Form = form
|
||||
app.render(w, http.StatusUnprocessableEntity, "password.tmpl", data)
|
||||
return
|
||||
}
|
||||
|
||||
id := app.sessionManager.Get(r.Context(), "authenticatedUserID").(int)
|
||||
err = app.users.ChangePassword(id, form.CurrentPassword, form.NewPassword)
|
||||
if err != nil {
|
||||
if errors.Is(err, models.ErrInvalidCredentials) {
|
||||
form.AddNonFieldError("Password is incorrect")
|
||||
|
||||
data := app.newTemplateData(r)
|
||||
data.Form = form
|
||||
app.render(w, http.StatusUnprocessableEntity, "password.tmpl", data)
|
||||
} else {
|
||||
app.serverError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
app.sessionManager.Put(r.Context(), "flash", "Password successfully changed!")
|
||||
http.Redirect(w, r, "/account/view", http.StatusSeeOther)
|
||||
}
|
||||
228
cmd/web/handlers_test.go
Normal file
228
cmd/web/handlers_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"gitea.local.lab/Lbenedar/snippetbox/internal/assert"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func TestSnippetView(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
|
||||
ts := newTestServer(t, app.routes())
|
||||
defer ts.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
urlPath string
|
||||
wantCode int
|
||||
wantBody string
|
||||
}{
|
||||
{
|
||||
name: "Valid ID",
|
||||
urlPath: "/snippet/view/1",
|
||||
wantCode: http.StatusOK,
|
||||
wantBody: "An old silent pond...",
|
||||
},
|
||||
{
|
||||
name: "Non-existend ID",
|
||||
urlPath: "/snippet/view/2",
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "Negative ID",
|
||||
urlPath: "/snippet/view/-1",
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "Decimal ID",
|
||||
urlPath: "/snippet/view/1.23",
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "String ID",
|
||||
urlPath: "/snippet/view/foo",
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "Empty ID",
|
||||
urlPath: "/snippet/view/",
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
code, _, body := ts.get(t, tt.urlPath)
|
||||
|
||||
assert.Equal(t, code, tt.wantCode)
|
||||
|
||||
if tt.wantBody != "" {
|
||||
assert.StringContains(t, body, tt.wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserSignup(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
ts := newTestServer(t, app.routes())
|
||||
defer ts.Close()
|
||||
|
||||
_, _, body := ts.get(t, "/user/signup")
|
||||
validCsrfToken := extractCSRFToken(t, body)
|
||||
t.Logf("CsrfToken: %s", validCsrfToken)
|
||||
|
||||
const (
|
||||
validName = "Bob"
|
||||
validPassword = "validPa$$word"
|
||||
validEmail = "bob@example.com"
|
||||
formTag = "<form action='/user/signup' method='POST' novalidate>"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
userName string
|
||||
userEmail string
|
||||
userPassword string
|
||||
csrfToken string
|
||||
wantCode int
|
||||
wantFormTag string
|
||||
}{
|
||||
{
|
||||
name: "Valid submission",
|
||||
userName: validName,
|
||||
userEmail: validEmail,
|
||||
userPassword: validPassword,
|
||||
csrfToken: validCsrfToken,
|
||||
wantCode: http.StatusSeeOther,
|
||||
},
|
||||
{
|
||||
name: "Invalid CSRF Token",
|
||||
userName: validName,
|
||||
userEmail: validEmail,
|
||||
userPassword: validPassword,
|
||||
csrfToken: "wrongToken",
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "Empty name",
|
||||
userName: "",
|
||||
userEmail: validEmail,
|
||||
userPassword: validPassword,
|
||||
csrfToken: validCsrfToken,
|
||||
wantCode: http.StatusUnprocessableEntity,
|
||||
wantFormTag: formTag,
|
||||
},
|
||||
{
|
||||
name: "Empty email",
|
||||
userName: validName,
|
||||
userEmail: "",
|
||||
userPassword: validPassword,
|
||||
csrfToken: validCsrfToken,
|
||||
wantCode: http.StatusUnprocessableEntity,
|
||||
wantFormTag: formTag,
|
||||
},
|
||||
{
|
||||
name: "Empty password",
|
||||
userName: validName,
|
||||
userEmail: validEmail,
|
||||
userPassword: "",
|
||||
csrfToken: validCsrfToken,
|
||||
wantCode: http.StatusUnprocessableEntity,
|
||||
wantFormTag: formTag,
|
||||
},
|
||||
{
|
||||
name: "Invalid email",
|
||||
userName: validName,
|
||||
userEmail: "bob@example.",
|
||||
userPassword: validPassword,
|
||||
csrfToken: validCsrfToken,
|
||||
wantCode: http.StatusUnprocessableEntity,
|
||||
wantFormTag: formTag,
|
||||
},
|
||||
{
|
||||
name: "Short password",
|
||||
userName: validName,
|
||||
userEmail: validEmail,
|
||||
userPassword: "pa$$",
|
||||
csrfToken: validCsrfToken,
|
||||
wantCode: http.StatusUnprocessableEntity,
|
||||
wantFormTag: formTag,
|
||||
},
|
||||
{
|
||||
name: "Duplicate email",
|
||||
userName: validName,
|
||||
userEmail: "dupe@example.com",
|
||||
userPassword: validPassword,
|
||||
csrfToken: validCsrfToken,
|
||||
wantCode: http.StatusUnprocessableEntity,
|
||||
wantFormTag: formTag,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
form := url.Values{}
|
||||
form.Add("name", tt.userName)
|
||||
form.Add("email", tt.userEmail)
|
||||
form.Add("password", tt.userPassword)
|
||||
form.Add("csrf_token", tt.csrfToken)
|
||||
|
||||
code, _, body := ts.postForm(t, "/user/signup", form)
|
||||
|
||||
assert.Equal(t, code, tt.wantCode)
|
||||
if tt.wantFormTag != "" {
|
||||
assert.StringContains(t, body, tt.wantFormTag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetCreate(t *testing.T) {
|
||||
app := newTestApplication(t)
|
||||
ts := newTestServer(t, app.routes())
|
||||
defer ts.Close()
|
||||
|
||||
const (
|
||||
validPassword = "validPa$$word"
|
||||
validEmail = "bob@example.com"
|
||||
)
|
||||
|
||||
t.Run("Unauthenticated", func(t *testing.T) {
|
||||
code, head, _ := ts.get(t, "/snippet/create")
|
||||
assert.Equal(t, code, http.StatusSeeOther)
|
||||
assert.Equal(t, head.Get("Location"), "/user/login")
|
||||
})
|
||||
|
||||
t.Run("Authenticated", func(t *testing.T) {
|
||||
_, _, body := ts.get(t, "/user/login")
|
||||
validCsrfToken := extractCSRFToken(t, body)
|
||||
t.Logf("CsrfToken: %s", validCsrfToken)
|
||||
|
||||
form := url.Values{}
|
||||
form.Add("email", validEmail)
|
||||
form.Add("password", validPassword)
|
||||
form.Add("csrf_token", validCsrfToken)
|
||||
code, _, _ := ts.postForm(t, "/user/login", form)
|
||||
t.Logf("Auth result: %d", code)
|
||||
|
||||
code, _, body = ts.get(t, "/snippet/create")
|
||||
assert.Equal(t, code, http.StatusOK)
|
||||
assert.StringContains(t, body, "<form action='/snippet/create' method='POST'>")
|
||||
})
|
||||
}
|
||||
56
cmd/web/helpers.go
Normal file
56
cmd/web/helpers.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/go-playground/form/v4"
|
||||
)
|
||||
|
||||
func (app *application) serverError(w http.ResponseWriter, err error) {
|
||||
trace := fmt.Sprintf("%s\n%s", err.Error(), debug.Stack())
|
||||
app.errorLog.Output(2, trace)
|
||||
|
||||
var errorText string
|
||||
if app.debug {
|
||||
errorText = trace
|
||||
} else {
|
||||
errorText = http.StatusText(http.StatusInternalServerError)
|
||||
}
|
||||
http.Error(w, errorText, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func (app *application) clientError(w http.ResponseWriter, status int) {
|
||||
http.Error(w, http.StatusText(status), status)
|
||||
}
|
||||
|
||||
func (app *application) notFound(w http.ResponseWriter) {
|
||||
app.clientError(w, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func (app *application) decodePostForm(r *http.Request, templateForm any) error {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = app.formDecoder.Decode(&templateForm, r.PostForm)
|
||||
if err != nil {
|
||||
var invalidDecoderError *form.InvalidDecoderError
|
||||
if errors.As(err, &invalidDecoderError) {
|
||||
panic("Invalid decoder error")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *application) isAuthenticated(r *http.Request) bool {
|
||||
isAuthenticated, ok := r.Context().Value(isAuthenticatedContextKey).(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return isAuthenticated
|
||||
}
|
||||
105
cmd/web/main.go
Normal file
105
cmd/web/main.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gitea.local.lab/Lbenedar/snippetbox/internal/models"
|
||||
|
||||
"github.com/alexedwards/scs/mysqlstore"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-playground/form/v4"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
type application struct {
|
||||
errorLog *log.Logger
|
||||
infoLog *log.Logger
|
||||
snippets models.SnippetModelInterface
|
||||
users models.UserModelInterface
|
||||
templateCache map[string]*template.Template
|
||||
formDecoder *form.Decoder
|
||||
sessionManager *scs.SessionManager
|
||||
debug bool
|
||||
}
|
||||
|
||||
type config struct {
|
||||
addr string
|
||||
staticDir string
|
||||
dsn string
|
||||
debug bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
var cfg config
|
||||
flag.StringVar(&cfg.addr, "addr", ":4000", "HTTP network address")
|
||||
flag.StringVar(&cfg.staticDir, "static-dir", "./ui/static", "Path to static assets")
|
||||
flag.StringVar(&cfg.dsn, "dsn", "web:pass@/snippetbox?parseTime=true", "MySQL data source name")
|
||||
flag.BoolVar(&cfg.debug, "debug", false, "Option to enable debug mode")
|
||||
flag.Parse()
|
||||
|
||||
errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)
|
||||
infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
|
||||
|
||||
db, err := openDB(cfg.dsn)
|
||||
if err != nil {
|
||||
errorLog.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
templateCache, err := newTemplateCache()
|
||||
if err != nil {
|
||||
errorLog.Fatal(err)
|
||||
}
|
||||
|
||||
formDecoder := form.NewDecoder()
|
||||
sessionManager := scs.New()
|
||||
sessionManager.Store = mysqlstore.New(db)
|
||||
sessionManager.Lifetime = 12 * time.Hour
|
||||
|
||||
app := &application{
|
||||
errorLog: errorLog,
|
||||
infoLog: infoLog,
|
||||
snippets: &models.SnippetModel{DB: db},
|
||||
users: &models.UserModel{DB: db},
|
||||
templateCache: templateCache,
|
||||
formDecoder: formDecoder,
|
||||
sessionManager: sessionManager,
|
||||
debug: cfg.debug,
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}, // TODO: read about CurvePreferences
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.addr,
|
||||
ErrorLog: errorLog,
|
||||
Handler: app.routes(),
|
||||
TLSConfig: tlsConfig,
|
||||
IdleTimeout: time.Minute,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
infoLog.Printf("Starting server on %s", cfg.addr)
|
||||
err = srv.ListenAndServeTLS("./tls/cert.pem", "./tls/key.pem")
|
||||
errorLog.Fatal(err)
|
||||
}
|
||||
|
||||
func openDB(dsn string) (*sql.DB, error) {
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = db.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
88
cmd/web/middleware.go
Normal file
88
cmd/web/middleware.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
func secureHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy",
|
||||
"default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com")
|
||||
w.Header().Set("Referrer-Policy", "origin-when-cross-origin")
|
||||
w.Header().Set("X-Content-Type-Options", "deny")
|
||||
w.Header().Set("X-Frame-Options", "nosniff")
|
||||
w.Header().Set("X-XSS-Protection", "0")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func noSurf(next http.Handler) http.Handler {
|
||||
csrfHanlder := nosurf.New(next)
|
||||
csrfHanlder.SetBaseCookie(http.Cookie{
|
||||
HttpOnly: false,
|
||||
Path: "/",
|
||||
Secure: false,
|
||||
})
|
||||
|
||||
return csrfHanlder
|
||||
}
|
||||
|
||||
func (app *application) logRequest(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
app.infoLog.Printf("%s - %s %s %s", r.RemoteAddr, r.Proto, r.Method, r.URL.RequestURI())
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) recoverPanic(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
w.Header().Set("Connection", "close")
|
||||
app.serverError(w, fmt.Errorf("%s", err))
|
||||
}
|
||||
}()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) requireAuthentication(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !app.isAuthenticated(r) {
|
||||
app.sessionManager.Put(r.Context(), "redirectParent", r.URL.Path)
|
||||
http.Redirect(w, r, "/user/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (app *application) authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")
|
||||
if id == 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
exists, err := app.users.Exists(id)
|
||||
if err != nil {
|
||||
app.serverError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if exists {
|
||||
ctx := context.WithValue(r.Context(), isAuthenticatedContextKey, true)
|
||||
r = r.WithContext(ctx)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
46
cmd/web/middleware_test.go
Normal file
46
cmd/web/middleware_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.local.lab/Lbenedar/snippetbox/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()
|
||||
|
||||
expectedValue := "default-src 'self'; style-src 'self' fonts.googleapis.com; font-src fonts.gstatic.com"
|
||||
assert.Equal(t, rs.Header.Get("Content-Security-Policy"), expectedValue)
|
||||
|
||||
expectedValue = "nosniff"
|
||||
assert.Equal(t, rs.Header.Get("X-Frame-Options"), expectedValue)
|
||||
|
||||
expectedValue = "0"
|
||||
assert.Equal(t, rs.Header.Get("X-XSS-Protection"), expectedValue)
|
||||
|
||||
assert.Equal(t, rs.StatusCode, http.StatusOK)
|
||||
|
||||
defer rs.Body.Close()
|
||||
body, err := io.ReadAll(rs.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bytes.TrimSpace(body)
|
||||
|
||||
assert.Equal(t, string(body), "OK")
|
||||
}
|
||||
46
cmd/web/routes.go
Normal file
46
cmd/web/routes.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"gitea.local.lab/Lbenedar/snippetbox/ui"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/justinas/alice"
|
||||
)
|
||||
|
||||
func (app *application) routes() http.Handler {
|
||||
router := httprouter.New()
|
||||
|
||||
router.NotFound = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
app.notFound(w)
|
||||
})
|
||||
|
||||
fileServer := http.FileServer(http.FS(ui.Files))
|
||||
router.Handler(http.MethodGet, "/static/*filepath", fileServer)
|
||||
|
||||
router.HandlerFunc(http.MethodGet, "/ping", ping)
|
||||
|
||||
dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)
|
||||
|
||||
router.Handler(http.MethodGet, "/", dynamic.ThenFunc(app.home))
|
||||
router.Handler(http.MethodGet, "/about", dynamic.ThenFunc(app.about))
|
||||
router.Handler(http.MethodGet, "/snippet/view/:id", dynamic.ThenFunc(app.snippetView))
|
||||
|
||||
router.Handler(http.MethodGet, "/user/signup", dynamic.ThenFunc(app.userSignup))
|
||||
router.Handler(http.MethodPost, "/user/signup", dynamic.ThenFunc(app.userSignupPost))
|
||||
router.Handler(http.MethodGet, "/user/login", dynamic.ThenFunc(app.userLogin))
|
||||
router.Handler(http.MethodPost, "/user/login", dynamic.ThenFunc(app.userLoginPost))
|
||||
|
||||
protected := dynamic.Append(app.requireAuthentication)
|
||||
|
||||
router.Handler(http.MethodGet, "/account/view", protected.ThenFunc(app.accountView))
|
||||
router.Handler(http.MethodGet, "/account/password", protected.ThenFunc(app.accountChangePassword))
|
||||
router.Handler(http.MethodPost, "/account/password", protected.ThenFunc(app.accountChangePasswordPost))
|
||||
router.Handler(http.MethodGet, "/snippet/create", protected.ThenFunc(app.snippetCreate))
|
||||
router.Handler(http.MethodPost, "/snippet/create", protected.ThenFunc(app.snippetCreatePost))
|
||||
router.Handler(http.MethodPost, "/user/logout", protected.ThenFunc(app.userLogoutPost))
|
||||
|
||||
standard := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
|
||||
return standard.Then(router)
|
||||
}
|
||||
70
cmd/web/templates.go
Normal file
70
cmd/web/templates.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.local.lab/Lbenedar/snippetbox/internal/models"
|
||||
"gitea.local.lab/Lbenedar/snippetbox/ui"
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
type templateData struct {
|
||||
CurrentYear int
|
||||
Snippet *models.Snippet
|
||||
Snippets []*models.Snippet
|
||||
Form any
|
||||
Flash string
|
||||
IsAuthenticated bool
|
||||
CSRFToken string
|
||||
AboutText string
|
||||
}
|
||||
|
||||
func humanDate(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("02 Jan 2006 at 15:04")
|
||||
}
|
||||
|
||||
var functions = template.FuncMap{
|
||||
"humanDate": humanDate,
|
||||
}
|
||||
|
||||
func (app *application) newTemplateData(r *http.Request) *templateData {
|
||||
return &templateData{
|
||||
CurrentYear: time.Now().Year(),
|
||||
Flash: app.sessionManager.PopString(r.Context(), "flash"),
|
||||
IsAuthenticated: app.isAuthenticated(r),
|
||||
CSRFToken: nosurf.Token(r),
|
||||
}
|
||||
}
|
||||
|
||||
func newTemplateCache() (map[string]*template.Template, error) {
|
||||
cache := map[string]*template.Template{}
|
||||
|
||||
pages, err := fs.Glob(ui.Files, "html/pages/*.tmpl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
name := filepath.Base(page)
|
||||
|
||||
patterns := []string{
|
||||
"html/base.tmpl",
|
||||
"html/partials/*.tmpl",
|
||||
page,
|
||||
}
|
||||
ts, err := template.New(name).Funcs(functions).ParseFS(ui.Files, patterns...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cache[name] = ts
|
||||
}
|
||||
return cache, nil
|
||||
}
|
||||
39
cmd/web/templates_test.go
Normal file
39
cmd/web/templates_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.local.lab/Lbenedar/snippetbox/internal/assert"
|
||||
)
|
||||
|
||||
func TestHumanDate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tm time.Time
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "UTC",
|
||||
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.UTC),
|
||||
want: "17 Mar 2022 at 10:15",
|
||||
},
|
||||
{
|
||||
name: "Empty",
|
||||
tm: time.Time{},
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "CET",
|
||||
tm: time.Date(2022, 3, 17, 10, 15, 0, 0, time.FixedZone("CET", 1*60*60)),
|
||||
want: "17 Mar 2022 at 09:15",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hd := humanDate(tt.tm)
|
||||
assert.Equal(t, hd, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
104
cmd/web/testutils_test.go
Normal file
104
cmd/web/testutils_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.local.lab/Lbenedar/snippetbox/internal/models/mocks"
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-playground/form/v4"
|
||||
)
|
||||
|
||||
var csrfTokenRX = regexp.MustCompile(`<input type='hidden' name='csrf_token' value='(.+)'>`)
|
||||
|
||||
func extractCSRFToken(t *testing.T, body string) string {
|
||||
t.Logf("Body: %s", body)
|
||||
matches := csrfTokenRX.FindStringSubmatch(body)
|
||||
if len(matches) < 2 {
|
||||
t.Fatal("no csrf token found in body")
|
||||
}
|
||||
|
||||
return html.UnescapeString(string(matches[1]))
|
||||
}
|
||||
|
||||
func newTestApplication(t *testing.T) *application {
|
||||
templateCache, err := newTemplateCache()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
formDecoder := form.NewDecoder()
|
||||
|
||||
sessionManager := scs.New()
|
||||
sessionManager.Lifetime = 12 * time.Hour
|
||||
sessionManager.Cookie.Secure = true
|
||||
|
||||
return &application{
|
||||
errorLog: log.New(io.Discard, "", 0),
|
||||
infoLog: log.New(io.Discard, "", 0),
|
||||
snippets: &mocks.SnippetModel{},
|
||||
users: &mocks.UserModel{},
|
||||
templateCache: templateCache,
|
||||
formDecoder: formDecoder,
|
||||
sessionManager: sessionManager,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
bytes.TrimSpace(body)
|
||||
|
||||
return rs.StatusCode, rs.Header, string(body)
|
||||
}
|
||||
|
||||
func (ts *testServer) postForm(t *testing.T, urlPath string, form url.Values) (int, http.Header, string) {
|
||||
rs, err := ts.Client().PostForm(ts.URL+urlPath, form)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer rs.Body.Close()
|
||||
body, err := io.ReadAll(rs.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bytes.TrimSpace(body)
|
||||
return rs.StatusCode, rs.Header, string(body)
|
||||
}
|
||||
12
go.mod
12
go.mod
@@ -1,3 +1,15 @@
|
||||
module gitea.local.lab/Lbenedar/snippetbox
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/alexedwards/scs/mysqlstore v0.0.0-20251002162104-209de6e426de // indirect
|
||||
github.com/alexedwards/scs/v2 v2.9.0 // indirect
|
||||
github.com/go-playground/form/v4 v4.3.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/julienschmidt/httprouter v1.3.0 // indirect
|
||||
github.com/justinas/alice v1.2.0 // indirect
|
||||
github.com/justinas/nosurf v1.2.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
)
|
||||
|
||||
19
go.sum
Normal file
19
go.sum
Normal file
@@ -0,0 +1,19 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/alexedwards/scs/mysqlstore v0.0.0-20251002162104-209de6e426de h1:/Y/iIFgV1Ofvk4Euv5gUQ74vgqFZOQ1wlJQ3yz/zYGs=
|
||||
github.com/alexedwards/scs/mysqlstore v0.0.0-20251002162104-209de6e426de/go.mod h1:p8jK3D80sw1PFrCSdlcJF1O75bp55HqbgDyyCLM0FrE=
|
||||
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
|
||||
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||
github.com/go-playground/form/v4 v4.3.0 h1:OVttojbQv2WNCs4P+VnjPtrt/+30Ipw4890W3OaFlvk=
|
||||
github.com/go-playground/form/v4 v4.3.0/go.mod h1:Cpe1iYJKoXb1vILRXEwxpWMGWyQuqplQ/4cvPecy+Jo=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
|
||||
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
|
||||
github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s=
|
||||
github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
30
internal/assert/assert.go
Normal file
30
internal/assert/assert.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package assert
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Equal[T comparable](t *testing.T, actual, expected T) {
|
||||
t.Helper()
|
||||
|
||||
if actual != expected {
|
||||
t.Errorf("got: %v; want: %v", actual, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func StringContains(t *testing.T, actual, expectedString string) {
|
||||
t.Helper()
|
||||
|
||||
if !strings.Contains(actual, expectedString) {
|
||||
t.Errorf("got: %q; expected to contain: %q", actual, expectedString)
|
||||
}
|
||||
}
|
||||
|
||||
func NilError(t *testing.T, actual error) {
|
||||
t.Helper()
|
||||
|
||||
if actual != nil {
|
||||
t.Errorf("got: %v; expected: nil", actual)
|
||||
}
|
||||
}
|
||||
9
internal/models/errors.go
Normal file
9
internal/models/errors.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package models
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNoRecord = errors.New("models: no matching record found")
|
||||
ErrInvalidCredentials = errors.New("models: invalid credentials")
|
||||
ErrDuplicateEmail = errors.New("models: duplicate email")
|
||||
)
|
||||
34
internal/models/mocks/snippets.go
Normal file
34
internal/models/mocks/snippets.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gitea.local.lab/Lbenedar/snippetbox/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 nil, models.ErrNoRecord
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SnippetModel) Latest() ([]*models.Snippet, error) {
|
||||
return []*models.Snippet{mockSnippet}, nil
|
||||
}
|
||||
39
internal/models/mocks/users.go
Normal file
39
internal/models/mocks/users.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package mocks
|
||||
|
||||
import "gitea.local.lab/Lbenedar/snippetbox/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
|
||||
}
|
||||
|
||||
return 0, models.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
func (m *UserModel) Exists(id int) (bool, error) {
|
||||
switch id {
|
||||
case 1:
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *UserModel) GetById(id int) (*models.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *UserModel) ChangePassword(id int, curr_pass, new_pass string) error {
|
||||
return nil
|
||||
}
|
||||
78
internal/models/snippets.go
Normal file
78
internal/models/snippets.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Snippet struct {
|
||||
ID int
|
||||
Title string
|
||||
Content string
|
||||
Created time.Time
|
||||
Expires time.Time
|
||||
}
|
||||
type SnippetModelInterface interface {
|
||||
Insert(title string, content string, expires int) (int, error)
|
||||
Get(id int) (*Snippet, error)
|
||||
Latest() ([]*Snippet, error)
|
||||
}
|
||||
type SnippetModel struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func (m *SnippetModel) Insert(title string, content string, expires int) (int, error) {
|
||||
stmt := `INSERT INTO snippets (title, content, created, expires)
|
||||
VALUES (?, ?, UTC_TIMESTAMP(), DATE_ADD(UTC_TIMESTAMP(), INTERVAL ? DAY))`
|
||||
|
||||
result, err := m.DB.Exec(stmt, title, content, expires)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(id), nil
|
||||
}
|
||||
|
||||
func (m *SnippetModel) Get(id int) (*Snippet, error) {
|
||||
s := &Snippet{}
|
||||
stmt := `SELECT id, title, content, created, expires FROM snippets
|
||||
WHERE expires > UTC_TIMESTAMP() AND id = ?`
|
||||
|
||||
err := m.DB.QueryRow(stmt, id).Scan(&s.ID, &s.Title, &s.Content, &s.Created, &s.Expires)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNoRecord
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *SnippetModel) Latest() ([]*Snippet, error) {
|
||||
stmt := `SELECT id, title, content, created, expires FROM snippets
|
||||
WHERE expires > UTC_TIMESTAMP() ORDER BY id DESC LIMIT 10`
|
||||
|
||||
rows, err := m.DB.Query(stmt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
snippets := []*Snippet{}
|
||||
for rows.Next() {
|
||||
s := &Snippet{}
|
||||
err = rows.Scan(&s.ID, &s.Title, &s.Content, &s.Created, &s.Expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snippets = append(snippets, s)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return snippets, nil
|
||||
}
|
||||
26
internal/models/testdata/setup.sql
vendored
Normal file
26
internal/models/testdata/setup.sql
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE snippets (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||
title VARCHAR(100) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created DATETIME NOT NULL,
|
||||
expires DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_snippets_created ON snippets(created);
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
hashed_password CHAR(60) NOT NULL,
|
||||
created DATETIME NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE users ADD CONSTRAINT users_uc_email UNIQUE (email);
|
||||
|
||||
INSERT INTO users (name, email, hashed_password, created) VALUES (
|
||||
'Alice Jones',
|
||||
'alice@example.com',
|
||||
'$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG',
|
||||
'2022-01-01 10:00:00'
|
||||
);
|
||||
3
internal/models/testdata/teardown.sql
vendored
Normal file
3
internal/models/testdata/teardown.sql
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP TABLE users;
|
||||
|
||||
DROP TABLE snippets;
|
||||
37
internal/models/testutils_test.go
Normal file
37
internal/models/testutils_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newTestDB(t *testing.T) *sql.DB {
|
||||
db, err := sql.Open("mysql", "test_web:pass@/test_snippetbox?parseTime=true&multiStatements=true")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
script, err := os.ReadFile("./testdata/setup.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec(string(script))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
script, err := os.ReadFile("./testdata/teardown.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = db.Exec(string(script))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
db.Close()
|
||||
})
|
||||
return db
|
||||
}
|
||||
129
internal/models/users.go
Normal file
129
internal/models/users.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int
|
||||
Name string
|
||||
Email string
|
||||
HashedPassword []byte
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
type UserModelInterface interface {
|
||||
Insert(name, email, password string) error
|
||||
Authenticate(email, password string) (int, error)
|
||||
Exists(id int) (bool, error)
|
||||
GetById(id int) (*User, error)
|
||||
ChangePassword(id int, curr_pass, new_pass string) error
|
||||
}
|
||||
|
||||
type UserModel struct {
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func (m *UserModel) Insert(name, email, password string) error {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stmt := `INSERT INTO users (name, email, hashed_password, created)
|
||||
VALUES(?, ?, ?, UTC_TIMESTAMP())`
|
||||
|
||||
_, err = m.DB.Exec(stmt, name, email, string(hashedPassword))
|
||||
if err != nil {
|
||||
var mySQLError *mysql.MySQLError
|
||||
if errors.As(err, &mySQLError) {
|
||||
if mySQLError.Number == 1062 && strings.Contains(mySQLError.Message, "users_uc_email") {
|
||||
return ErrDuplicateEmail
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *UserModel) Authenticate(email, password string) (int, error) {
|
||||
var id int
|
||||
var hashedPassword []byte
|
||||
|
||||
stmt := `SELECT id, hashed_password FROM users WHERE email = ?`
|
||||
|
||||
err := m.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, ErrInvalidCredentials
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password))
|
||||
if err != nil {
|
||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return 0, ErrInvalidCredentials
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (m *UserModel) Exists(id int) (bool, error) {
|
||||
var exists bool
|
||||
|
||||
stmt := `SELECT EXISTS(SELECT true FROM users WHERE id = ?)`
|
||||
|
||||
err := m.DB.QueryRow(stmt, id).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (a *UserModel) GetById(id int) (*User, error) {
|
||||
acc := User{}
|
||||
|
||||
stmt := `SELECT name, email, created FROM users WHERE id = ?`
|
||||
err := a.DB.QueryRow(stmt, id).Scan(&acc.Name, &acc.Email, &acc.Created)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &acc, nil
|
||||
}
|
||||
|
||||
func (a *UserModel) ChangePassword(id int, curr_pass, new_pass string) error {
|
||||
hashedNewPassword, err := bcrypt.GenerateFromPassword([]byte(new_pass), 12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var hashedDBPassword []byte
|
||||
stmt := `SELECT hashed_password FROM users WHERE id = ?`
|
||||
err = a.DB.QueryRow(stmt, id).Scan(&hashedDBPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = bcrypt.CompareHashAndPassword(hashedDBPassword, []byte(curr_pass))
|
||||
if err != nil {
|
||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
stmt = `UPDATE users SET hashed_password = ? WHERE id = ?`
|
||||
_, err = a.DB.Exec(stmt, hashedNewPassword, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
45
internal/models/users_test.go
Normal file
45
internal/models/users_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.local.lab/Lbenedar/snippetbox/internal/assert"
|
||||
)
|
||||
|
||||
func TestUserModelExists(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("models: skipping integration test")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
userID int
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "Valid ID",
|
||||
userID: 1,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Zero ID",
|
||||
userID: 0,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Non-existent ID",
|
||||
userID: 2,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db := newTestDB(t)
|
||||
m := UserModel{DB: db}
|
||||
|
||||
exists, err := m.Exists(tt.userID)
|
||||
assert.Equal(t, exists, tt.want)
|
||||
assert.NilError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
62
internal/validator/validator.go
Normal file
62
internal/validator/validator.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type Validator struct {
|
||||
NonFieldErrors []string
|
||||
FieldErrors map[string]string
|
||||
}
|
||||
|
||||
func (v *Validator) Valid() bool {
|
||||
return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0
|
||||
}
|
||||
|
||||
func (v *Validator) AddFieldError(key, message string) {
|
||||
if v.FieldErrors == nil {
|
||||
v.FieldErrors = make(map[string]string)
|
||||
}
|
||||
if _, exist := v.FieldErrors[key]; !exist {
|
||||
v.FieldErrors[key] = message
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) AddNonFieldError(message string) {
|
||||
v.NonFieldErrors = append(v.NonFieldErrors, message)
|
||||
}
|
||||
|
||||
func (v *Validator) CheckField(ok bool, key, message string) {
|
||||
if !ok {
|
||||
v.AddFieldError(key, message)
|
||||
}
|
||||
}
|
||||
|
||||
func NotBlank(value string) bool {
|
||||
return strings.TrimSpace(value) != ""
|
||||
}
|
||||
|
||||
func MaxChars(value string, n int) bool {
|
||||
return utf8.RuneCountInString(value) <= n
|
||||
}
|
||||
|
||||
func PermittedValue[T comparable](value T, permittedValues ...T) bool {
|
||||
for i := range permittedValues {
|
||||
if value == permittedValues[i] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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])?)*$")
|
||||
|
||||
func MinChars(value string, n int) bool {
|
||||
return utf8.RuneCountInString(value) >= n
|
||||
}
|
||||
|
||||
func Matches(value string, rx *regexp.Regexp) bool {
|
||||
return rx.MatchString(value)
|
||||
}
|
||||
45
main.go
45
main.go
@@ -1,45 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func home(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Write([]byte("Hello from Snippetbox"))
|
||||
}
|
||||
|
||||
func snippetView(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.Atoi(r.URL.Query().Get("id"))
|
||||
if err != nil || id < 1 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Display a specific snippet with ID %d...", id)
|
||||
}
|
||||
|
||||
func snippetCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Write([]byte("Create a new snippet"))
|
||||
}
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", home)
|
||||
mux.HandleFunc("/snippet/view", snippetView)
|
||||
mux.HandleFunc("/snippet/create", snippetCreate)
|
||||
|
||||
log.Print("Starting server on :4000")
|
||||
err := http.ListenAndServe(":4000", mux)
|
||||
log.Fatal(err)
|
||||
}
|
||||
18
tls/cert.pem
Normal file
18
tls/cert.pem
Normal file
@@ -0,0 +1,18 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIC+TCCAeGgAwIBAgIQcOLtQY/t+q3a9ve2Lk3fmDANBgkqhkiG9w0BAQsFADAS
|
||||
MRAwDgYDVQQKEwdBY21lIENvMB4XDTI2MDMwNTE0NTcyM1oXDTI3MDMwNTE0NTcy
|
||||
M1owEjEQMA4GA1UEChMHQWNtZSBDbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
|
||||
AQoCggEBALfXzj8grHwtMacXPpvyXWjhiHhPGQRwruGUWDTx0S32Zz/oNakkYaAE
|
||||
Sz++MxAJiu4E0mhCc0XFfwurMU1kJqQO8kZVAvd2d3HkpgX+PXuShYOXZoUaNC2H
|
||||
FdjmyYLrNMSDVPF5krotwaG553zFNMsOo+WYecqS6LtllhBGvuw+xIKZ7RJGKPD5
|
||||
Stc5ejmhz+pVq3q3Fm/0caBlCXUxI2aqCDra2sI/glgPKFF1xYWtwSu5iFZggpRf
|
||||
1jWblKA8Jir7fnDQKKdtY3Nbhj/QeDdHUeFWM29igZWO459O7rI4SEafCPRznsf6
|
||||
V1IKdMq1loo1hPGKmaK/gt3XZOph7scCAwEAAaNLMEkwDgYDVR0PAQH/BAQDAgWg
|
||||
MBMGA1UdJQQMMAoGCCsGAQUFBwMBMAwGA1UdEwEB/wQCMAAwFAYDVR0RBA0wC4IJ
|
||||
bG9jYWxob3N0MA0GCSqGSIb3DQEBCwUAA4IBAQBsFR9RwmLaNTjKfy7svedx/Zgy
|
||||
GSf+fAlzoWk8CMoCDWkrdJsi8iYmAcRPJbm8i8y5pXLR1vKMa3zmxsFcw0qpkRSu
|
||||
pnCRlixbml7gnuyYC5SMXfF8cFS5IGNm6U7hKP+wDoARGXhJWWs3qjrneEgGiOfk
|
||||
GSM2fsM9aoehbj5Cv69f87hk96arWD5b3vLedZafNpX+ZpM9OTh+NKZzbAhyAHw5
|
||||
/8hyHi96YOpzTDLqj4IQ3DH3yA32vP3DjnjHH1LUF9W2POGIZZ+o8GHmL6m/2ltX
|
||||
W+7SmACujCU5RY93cLmH7liUtqBxOUvmN8uAMXnpdxU8KcYFmglTFT9o083S
|
||||
-----END CERTIFICATE-----
|
||||
28
tls/key.pem
Normal file
28
tls/key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC3184/IKx8LTGn
|
||||
Fz6b8l1o4Yh4TxkEcK7hlFg08dEt9mc/6DWpJGGgBEs/vjMQCYruBNJoQnNFxX8L
|
||||
qzFNZCakDvJGVQL3dndx5KYF/j17koWDl2aFGjQthxXY5smC6zTEg1TxeZK6LcGh
|
||||
ued8xTTLDqPlmHnKkui7ZZYQRr7sPsSCme0SRijw+UrXOXo5oc/qVat6txZv9HGg
|
||||
ZQl1MSNmqgg62trCP4JYDyhRdcWFrcEruYhWYIKUX9Y1m5SgPCYq+35w0CinbWNz
|
||||
W4Y/0Hg3R1HhVjNvYoGVjuOfTu6yOEhGnwj0c57H+ldSCnTKtZaKNYTxipmiv4Ld
|
||||
12TqYe7HAgMBAAECggEAF2y1eGCy6Z2ekoJJNXijw03dZCr77o9nERIkTRa0xwz6
|
||||
6e8uCy+6CrgRWH1lJzl4DMzrfGJfKrg38GYvQYOt0sCeySxi1OIw5P+z0darwdeR
|
||||
EIvVgcctd9GbDIiXi4lpr1Jmm+AYPIXBAtjbsI+2IttqMcKEXjXq1pnijY9eG17K
|
||||
eSjIF+/JomBXqUGB2/+ySAdPDJckqdXKtVklQFmkfacVX5Xv1nbU5uC0LkRs0GkE
|
||||
Y1PKy2V/TddBynS+lyesmWDLCTHVUBp2NjD5Y0tfHDzo50Xog8rFcargK4phembA
|
||||
Xkqe/D2rRFq9nSfpwboVvb22+wSD+gbOSfYdDNnN3QKBgQDDk/aZ9+cxIBJbaZdd
|
||||
48Gwa9Qeh6n0lMDGNVt2UIJlczA5nqy/95BaNmI71cEoVCevSNItR8BLZcDQhv0R
|
||||
hZ7J7lt3L7bPB6aShm+61np6iQKg+vsrK2qU6k9FKU4YRVgpwGFd10jQvE0fDISp
|
||||
d80WQazRiaRCV4kSNJb82y3C4wKBgQDwo7xJcH1cs+LXZ9V871t2Vo6zMmnBDtRP
|
||||
ZbUkjIFYFavWl0vQQK1eENth5luB5glRaX9BsrkWYl45k964sfmJo1QvmV01c3Qn
|
||||
1TujTdgWEJ1oWERScu0MpVpDx9Moh/A8iT7e8Y2Yp+8xvq17cXKA/mg2jHk31q1k
|
||||
8SkD0/LVzQKBgCBCdbir1Wya96wwXJFWEgrBnnlysyvupWWMQ4ved8O4HkpCzAfW
|
||||
E+9tbQKlnXjDeNBG2LQzU5qcLBO5UGDlg22XbWrZafP4NReSKTfOTOGNW+ulumxC
|
||||
exAZHf4wc/s45PPuEaFi81XK2YW3kOJLKn7zUkg1xexTd/6SwhzvIjs1AoGAKjb/
|
||||
K/8A7wdbryA3EpDHAc6TgBpC9SxN0JPuIDhJ5JMAr0ehdCo0f8EDS3xm5zXcwpx4
|
||||
R6U71RJypzUqqEoIlb3CYgtMj7juUqKUsMRSOSS3CHwbmD8zGLnVSPjAMWcPnP8S
|
||||
uXe0uGgVjSt/MZ2oUbrPu2oPRd1yU7f+0v4Wpq0CgYAdSyFoODxnd0gJ/U3EwZCM
|
||||
jNDuxjZzw5qQmM03sZposzUQaccd3cyw53qp5PuTOFp1V8Y//NrWmrA7Ti57fx3K
|
||||
Zn8gXsmNwuNcbTf7KfVsVvLm9K+vi0hZvQDFLsoZ5Q3aeD6vh7W9PwnObRSCc/9g
|
||||
qgGzmZavUnaNukwkFyyJew==
|
||||
-----END PRIVATE KEY-----
|
||||
8
ui/efs.go
Normal file
8
ui/efs.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
)
|
||||
|
||||
//go:embed "html" "static"
|
||||
var Files embed.FS
|
||||
28
ui/html/base.tmpl
Normal file
28
ui/html/base.tmpl
Normal file
@@ -0,0 +1,28 @@
|
||||
{{define "base"}}
|
||||
<!doctype html>
|
||||
<html lang='en'>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<title>{{template "title" .}} - Snippetbox</title>
|
||||
<link rel='stylesheet' href='/static/css/main.css'>
|
||||
<link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
|
||||
<link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700'>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href='/'>Snippetbox</a></h1>
|
||||
</header>
|
||||
{{template "nav" .}}
|
||||
<main>
|
||||
{{with .Flash}}
|
||||
<div class='flash'>{{.}}</div>
|
||||
{{end}}
|
||||
{{template "main" .}}
|
||||
</main>
|
||||
<footer>
|
||||
Powered by <a href='https://golang.org/'>Go</a> in {{.CurrentYear}}
|
||||
</footer>
|
||||
<script src="/static/js/main.js" type="text/javascript"></script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
8
ui/html/pages/about.tmpl
Normal file
8
ui/html/pages/about.tmpl
Normal file
@@ -0,0 +1,8 @@
|
||||
{{define "title"}}About{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<h2>About</h2>
|
||||
<div class='about'>
|
||||
{{.AboutText}}
|
||||
</div>
|
||||
{{end}}
|
||||
28
ui/html/pages/account.tmpl
Normal file
28
ui/html/pages/account.tmpl
Normal file
@@ -0,0 +1,28 @@
|
||||
{{define "title"}}Account{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<h2>Account</h2>
|
||||
{{if .Form}}
|
||||
<table>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>{{.Form.Name}}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>{{.Form.Email}}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Joined</th>
|
||||
<th>{{.Form.Joined}}</th>
|
||||
</tr>
|
||||
</table>
|
||||
{{else}}
|
||||
<p>There's nothing to see here... yet!</p>
|
||||
{{end}}
|
||||
<form action='/account/password' method='GET'>
|
||||
<div>
|
||||
<input type='submit' value='Change password'>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
33
ui/html/pages/create.tmpl
Normal file
33
ui/html/pages/create.tmpl
Normal file
@@ -0,0 +1,33 @@
|
||||
{{define "title"}}Create a New Snippet{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<form action='/snippet/create' method='POST'>
|
||||
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
|
||||
<div>
|
||||
<label>Title:</label>
|
||||
{{with .Form.FieldErrors.title}}
|
||||
<label class='error'>{{.}}</label>
|
||||
{{end}}
|
||||
<input type='text' name='title' value='{{.Form.Title}}'>
|
||||
</div>
|
||||
<div>
|
||||
<label>Content:</label>
|
||||
{{with .Form.FieldErrors.content}}
|
||||
<label class='error'>{{.}}</label>
|
||||
{{end}}
|
||||
<textarea name='content'>{{.Form.Content}}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label>Delete in:</label>
|
||||
{{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}}
|
||||
23
ui/html/pages/home.tmpl
Normal file
23
ui/html/pages/home.tmpl
Normal file
@@ -0,0 +1,23 @@
|
||||
{{define "title"}}Home{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<h2>Latest Snippets</h2>
|
||||
{{if .Snippets}}
|
||||
<table>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Created</th>
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
{{range .Snippets}}
|
||||
<tr>
|
||||
<td><a href='/snippet/view/{{.ID}}'>{{.Title}}</a></td>
|
||||
<td>{{humanDate .Created}}</td>
|
||||
<td>#{{.ID}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{else}}
|
||||
<p>There's nothing to see here... yet!</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
27
ui/html/pages/login.tmpl
Normal file
27
ui/html/pages/login.tmpl
Normal file
@@ -0,0 +1,27 @@
|
||||
{{define "title"}}Login{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<form action='/user/login' method='POST' novalidate>
|
||||
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
|
||||
{{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}}
|
||||
35
ui/html/pages/password.tmpl
Normal file
35
ui/html/pages/password.tmpl
Normal file
@@ -0,0 +1,35 @@
|
||||
{{define "title"}}Change Password{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<h2>Change Password</h2>
|
||||
<form action='/account/password' method='POST' novalidate>
|
||||
<input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
|
||||
{{range .Form.NonFieldErrors}}
|
||||
<div class='error'>{{.}}</div>
|
||||
{{end}}
|
||||
<div>
|
||||
<label>Current password:</label>
|
||||
{{with .Form.FieldErrors.curr_pass}}
|
||||
<label class='error'>{{.}}</label>
|
||||
{{end}}
|
||||
<input type='password' name='curr_pass' value=''>
|
||||
</div>
|
||||
<div>
|
||||
<label>New password:</label>
|
||||
{{with .Form.FieldErrors.new_pass}}
|
||||
<label class='error'>{{.}}</label>
|
||||
{{end}}
|
||||
<input type='password' name='new_pass'>
|
||||
</div>
|
||||
<div>
|
||||
<label>Confirm new password:</label>
|
||||
{{with .Form.FieldErrors.conf_pass}}
|
||||
<label class='error'>{{.}}</label>
|
||||
{{end}}
|
||||
<input type='password' name='conf_pass'>
|
||||
</div>
|
||||
<div>
|
||||
<input type='submit' value='Change password'>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
31
ui/html/pages/signup.tmpl
Normal file
31
ui/html/pages/signup.tmpl
Normal file
@@ -0,0 +1,31 @@
|
||||
{{define "title"}}Signup{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<form action='/user/signup' method='POST' novalidate>
|
||||
<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}}
|
||||
18
ui/html/pages/view.tmpl
Normal file
18
ui/html/pages/view.tmpl
Normal file
@@ -0,0 +1,18 @@
|
||||
{{define "title"}}Snippet ${{.Snippet.ID}}{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
{{with .Snippet}}
|
||||
<div class='snippet'>
|
||||
<div class='metadata'>
|
||||
<strong>{{.Title}}</strong>
|
||||
<span>#{{.ID}}</span>
|
||||
</div>
|
||||
<pre><code>{{.Content}}</code></pre>
|
||||
<div class='metadata'>
|
||||
<time>Created: {{humanDate .Created}}</time>
|
||||
<time>Expires: {{humanDate .Expires}}</time>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
23
ui/html/partials/nav.tmpl
Normal file
23
ui/html/partials/nav.tmpl
Normal file
@@ -0,0 +1,23 @@
|
||||
{{define "nav"}}
|
||||
<nav>
|
||||
<div>
|
||||
<a href='/'>Home</a>
|
||||
<a href='/about'>About</a>
|
||||
{{if .IsAuthenticated}}
|
||||
<a href='/snippet/create'>Create snippet</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div>
|
||||
{{if .IsAuthenticated}}
|
||||
<a href='/account/view'>Account</a>
|
||||
<form action='/user/logout' method='POST'>
|
||||
<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>
|
||||
{{end}}
|
||||
313
ui/static/css/main.css
Normal file
313
ui/static/css/main.css
Normal file
@@ -0,0 +1,313 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
font-family: "Ubuntu Mono", monospace;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1.5;
|
||||
background-color: #F1F3F6;
|
||||
color: #34495E;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
header, nav, main, footer {
|
||||
padding: 2px calc((100% - 800px) / 2) 0;
|
||||
}
|
||||
|
||||
main {
|
||||
margin-top: 54px;
|
||||
margin-bottom: 54px;
|
||||
min-height: calc(100vh - 345px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
h1 a {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
background-image: url("/static/img/logo.png");
|
||||
background-repeat: no-repeat;
|
||||
background-position: 0px 0px;
|
||||
height: 36px;
|
||||
padding-left: 50px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
h1 a:hover {
|
||||
text-decoration: none;
|
||||
color: #34495E;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 22px;
|
||||
margin-bottom: 36px;
|
||||
position: relative;
|
||||
top: -9px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #62CB31;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #4EB722;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
textarea, input:not([type="submit"]) {
|
||||
font-size: 18px;
|
||||
font-family: "Ubuntu Mono", monospace;
|
||||
}
|
||||
|
||||
header {
|
||||
background-image: -webkit-linear-gradient(left, #34495e, #34495e 25%, #9b59b6 25%, #9b59b6 35%, #3498db 35%, #3498db 45%, #62cb31 45%, #62cb31 55%, #ffb606 55%, #ffb606 65%, #e67e22 65%, #e67e22 75%, #e74c3c 85%, #e74c3c 85%, #c0392b 85%, #c0392b 100%);
|
||||
background-image: -moz-linear-gradient(left, #34495e, #34495e 25%, #9b59b6 25%, #9b59b6 35%, #3498db 35%, #3498db 45%, #62cb31 45%, #62cb31 55%, #ffb606 55%, #ffb606 65%, #e67e22 65%, #e67e22 75%, #e74c3c 85%, #e74c3c 85%, #c0392b 85%, #c0392b 100%);
|
||||
background-image: -ms-linear-gradient(left, #34495e, #34495e 25%, #9b59b6 25%, #9b59b6 35%, #3498db 35%, #3498db 45%, #62cb31 45%, #62cb31 55%, #ffb606 55%, #ffb606 65%, #e67e22 65%, #e67e22 75%, #e74c3c 85%, #e74c3c 85%, #c0392b 85%, #c0392b 100%);
|
||||
background-image: linear-gradient(to right, #34495e, #34495e 25%, #9b59b6 25%, #9b59b6 35%, #3498db 35%, #3498db 45%, #62cb31 45%, #62cb31 55%, #ffb606 55%, #ffb606 65%, #e67e22 65%, #e67e22 75%, #e74c3c 85%, #e74c3c 85%, #c0392b 85%, #c0392b 100%);
|
||||
background-size: 100% 6px;
|
||||
background-repeat: no-repeat;
|
||||
border-bottom: 1px solid #E4E5E7;
|
||||
overflow: auto;
|
||||
padding-top: 33px;
|
||||
padding-bottom: 27px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header a {
|
||||
color: #34495E;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav {
|
||||
border-bottom: 1px solid #E4E5E7;
|
||||
padding-top: 17px;
|
||||
padding-bottom: 15px;
|
||||
background: #F7F9FA;
|
||||
height: 60px;
|
||||
color: #6A6C6F;
|
||||
}
|
||||
|
||||
nav a {
|
||||
margin-right: 1.5em;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
nav form {
|
||||
display: inline-block;
|
||||
margin-left: 1.5em;
|
||||
}
|
||||
|
||||
nav div {
|
||||
width: 50%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
nav div:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
nav div:last-child a {
|
||||
margin-left: 1.5em;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
nav a.live {
|
||||
color: #34495E;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
nav a.live:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav a.live:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: relative;
|
||||
left: calc(50% - 7px);
|
||||
top: 9px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #F7F9FA;
|
||||
border-left: 1px solid #E4E5E7;
|
||||
border-bottom: 1px solid #E4E5E7;
|
||||
-moz-transform: rotate(45deg);
|
||||
-webkit-transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
a.button, input[type="submit"] {
|
||||
background-color: #62CB31;
|
||||
border-radius: 3px;
|
||||
color: #FFFFFF;
|
||||
padding: 18px 27px;
|
||||
border: none;
|
||||
display: inline-block;
|
||||
margin-top: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
a.button:hover, input[type="submit"]:hover {
|
||||
background-color: #4EB722;
|
||||
color: #FFFFFF;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
form div {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
form div:last-child {
|
||||
border-top: 1px dashed #E4E5E7;
|
||||
}
|
||||
|
||||
form input[type="radio"] {
|
||||
margin-left: 18px;
|
||||
}
|
||||
|
||||
form input[type="text"], form input[type="password"], form input[type="email"] {
|
||||
padding: 0.75em 18px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form input[type=text], form input[type="password"], form input[type="email"], textarea {
|
||||
color: #6A6C6F;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid #E4E5E7;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
form label {
|
||||
display: inline-block;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #C0392B;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.error + textarea, .error + input {
|
||||
border-color: #C0392B !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
textarea {
|
||||
padding: 18px;
|
||||
width: 100%;
|
||||
height: 266px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
color: #62CB31;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
color: #4EB722;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.snippet {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E4E5E7;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.snippet pre {
|
||||
padding: 18px;
|
||||
border-top: 1px solid #E4E5E7;
|
||||
border-bottom: 1px solid #E4E5E7;
|
||||
}
|
||||
|
||||
.snippet .metadata {
|
||||
background-color: #F7F9FA;
|
||||
color: #6A6C6F;
|
||||
padding: 0.75em 18px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.snippet .metadata span {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.snippet .metadata strong {
|
||||
color: #34495E;
|
||||
}
|
||||
|
||||
.snippet .metadata time {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.snippet .metadata time:first-child {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.snippet .metadata time:last-child {
|
||||
float: right;
|
||||
}
|
||||
|
||||
div.flash {
|
||||
color: #FFFFFF;
|
||||
font-weight: bold;
|
||||
background-color: #34495E;
|
||||
padding: 18px;
|
||||
margin-bottom: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
div.error {
|
||||
color: #FFFFFF;
|
||||
background-color: #C0392B;
|
||||
padding: 18px;
|
||||
margin-bottom: 36px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table {
|
||||
background: white;
|
||||
border: 1px solid #E4E5E7;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
td, th {
|
||||
text-align: left;
|
||||
padding: 9px 18px;
|
||||
}
|
||||
|
||||
th:last-child, td:last-child {
|
||||
text-align: right;
|
||||
color: #6A6C6F;
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid #E4E5E7;
|
||||
}
|
||||
|
||||
tr:nth-child(2n) {
|
||||
background-color: #F7F9FA;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid #E4E5E7;
|
||||
padding-top: 17px;
|
||||
padding-bottom: 15px;
|
||||
background: #F7F9FA;
|
||||
height: 60px;
|
||||
color: #6A6C6F;
|
||||
text-align: center;
|
||||
}
|
||||
BIN
ui/static/img/favicon.ico
Normal file
BIN
ui/static/img/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
ui/static/img/logo.png
Normal file
BIN
ui/static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
8
ui/static/js/main.js
Normal file
8
ui/static/js/main.js
Normal file
@@ -0,0 +1,8 @@
|
||||
var navLinks = document.querySelectorAll("nav a");
|
||||
for (var i = 0; i < navLinks.length; i++) {
|
||||
var link = navLinks[i]
|
||||
if (link.getAttribute('href') == window.location.pathname) {
|
||||
link.classList.add("live");
|
||||
break;
|
||||
}
|
||||
}
|
||||
1
ui/static/text/about.txt
Normal file
1
ui/static/text/about.txt
Normal file
@@ -0,0 +1 @@
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus magna quam, eleifend a metus at, tincidunt commodo enim. Integer ac mattis ipsum. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Maecenas non purus ullamcorper, tristique diam vel, sagittis neque. Fusce quam dolor, accumsan non leo sed, auctor rutrum felis. Duis semper tristique tellus, sed sagittis est lobortis nec. Curabitur porttitor lacus eget semper sodales. Duis diam nisl, maximus eu felis id, placerat pulvinar erat. Etiam elementum elit tellus, at auctor ante accumsan vitae. Nulla elit magna, molestie vitae nibh ut, vehicula accumsan ipsum. Ut id consequat ipsum, volutpat volutpat orci. Aenean rhoncus tellus lacus, nec tempor risus interdum sed. Proin quis quam in metus imperdiet lobortis. Duis euismod sem enim, volutpat mollis tortor posuere sed. Morbi condimentum eget dolor at accumsan.
|
||||
Reference in New Issue
Block a user