19 changed files with 410 additions and 26 deletions
@ -0,0 +1,8 @@ |
|||
package constants |
|||
|
|||
// Misc constants.
|
|||
const ( |
|||
// Password values
|
|||
PasswordMinLength = 8 |
|||
BcryptCost = 14 |
|||
) |
@ -0,0 +1,7 @@ |
|||
package constants |
|||
|
|||
// CSRF protection constants.
|
|||
const ( |
|||
CSRFCookieName = "csrf_token" |
|||
CSRFFormName = "_csrf" |
|||
) |
@ -0,0 +1,48 @@ |
|||
package middleware |
|||
|
|||
import ( |
|||
"net/http" |
|||
"time" |
|||
|
|||
"git.kirsle.net/apps/gophertype/pkg/constants" |
|||
"git.kirsle.net/apps/gophertype/pkg/responses" |
|||
uuid "github.com/satori/go.uuid" |
|||
) |
|||
|
|||
// CSRF prevents Cross-Site Request Forgery.
|
|||
// All "POST" requests are required to have an "_csrf" variable passed in which
|
|||
// matches the "csrf_token" HTTP cookie with their request.
|
|||
func CSRF(next http.Handler) http.Handler { |
|||
middleware := func(w http.ResponseWriter, r *http.Request) { |
|||
// All requests: verify they have a CSRF cookie, create one if not.
|
|||
var token string |
|||
cookie, err := r.Cookie(constants.CSRFCookieName) |
|||
if err == nil { |
|||
token = cookie.Value |
|||
} |
|||
|
|||
// Generate a token cookie if not found.
|
|||
if len(token) < 8 || err != nil { |
|||
token = uuid.NewV4().String() |
|||
cookie = &http.Cookie{ |
|||
Name: constants.CSRFCookieName, |
|||
Value: token, |
|||
Expires: time.Now().Add(24 * time.Hour), |
|||
} |
|||
http.SetCookie(w, cookie) |
|||
} |
|||
|
|||
// POST requests: verify token from form parameter.
|
|||
if r.Method == http.MethodPost { |
|||
compare := r.FormValue(constants.CSRFFormName) |
|||
if compare != token { |
|||
responses.Panic(w, http.StatusForbidden, "CSRF token failure.") |
|||
return |
|||
} |
|||
} |
|||
|
|||
next.ServeHTTP(w, r) |
|||
} |
|||
|
|||
return http.HandlerFunc(middleware) |
|||
} |
@ -0,0 +1,72 @@ |
|||
package models |
|||
|
|||
import ( |
|||
"crypto/rand" |
|||
"encoding/base64" |
|||
|
|||
"github.com/jinzhu/gorm" |
|||
) |
|||
|
|||
// AppSetting singleton holds the app configuration.
|
|||
type AppSetting struct { |
|||
gorm.Model |
|||
|
|||
// Site information
|
|||
Title string |
|||
Description string |
|||
Email string // primary email for notifications
|
|||
NSFW bool |
|||
BaseURL string |
|||
|
|||
// Blog settings
|
|||
PostsPerPage int |
|||
|
|||
// Mail settings
|
|||
MailEnabled bool |
|||
MailSender string |
|||
MailHost string |
|||
MailPort int |
|||
MailUsername string |
|||
MailPassword string |
|||
|
|||
// Security
|
|||
SecretKey string `json:"-"` |
|||
} |
|||
|
|||
// GetSettings gets or creates the App Settings.
|
|||
func GetSettings() AppSetting { |
|||
var s AppSetting |
|||
r := DB.First(&s) |
|||
if r.Error != nil { |
|||
s = AppSetting{ |
|||
Title: "Untitled Site", |
|||
Description: "Just another web blog.", |
|||
SecretKey: MakeSecretKey(), |
|||
} |
|||
} |
|||
|
|||
if s.SecretKey == "" { |
|||
s.SecretKey = MakeSecretKey() |
|||
} |
|||
|
|||
return s |
|||
} |
|||
|
|||
// Save the settings to DB.
|
|||
func (s AppSetting) Save() error { |
|||
if DB.NewRecord(s) { |
|||
r := DB.Create(&s) |
|||
return r.Error |
|||
} |
|||
r := DB.Save(&s) |
|||
return r.Error |
|||
} |
|||
|
|||
// MakeSecretKey generates a secret key for signing HTTP cookies.
|
|||
func MakeSecretKey() string { |
|||
keyLength := 32 |
|||
|
|||
b := make([]byte, keyLength) |
|||
rand.Read(b) |
|||
return base64.URLEncoding.EncodeToString(b) |
|||
} |
@ -1,11 +1,67 @@ |
|||
package models |
|||
|
|||
import ( |
|||
"errors" |
|||
"fmt" |
|||
"strings" |
|||
|
|||
"git.kirsle.net/apps/gophertype/pkg/constants" |
|||
"github.com/jinzhu/gorm" |
|||
"golang.org/x/crypto/bcrypt" |
|||
) |
|||
|
|||
// User account for the site.
|
|||
type User struct { |
|||
ID int `json:"id"` |
|||
Username string `json:"username" gorm:"unique"` |
|||
Password string `json:"-"` |
|||
IsAdmin bool `json:"isAdmin"` |
|||
Name string `json:"name"` |
|||
Email string `json:"email"` |
|||
gorm.Model |
|||
Username string `json:"username" gorm:"unique_index"` |
|||
HashedPassword string `json:"-"` |
|||
IsAdmin bool `json:"isAdmin" gorm:"index"` |
|||
Name string `json:"name"` |
|||
Email string `json:"email" gorm:"index"` |
|||
} |
|||
|
|||
// Validate the User object has everything filled in. Fixes what it can,
|
|||
// returns an error if something is wrong. Ensures the HashedPassword is hashed.
|
|||
func (u *User) Validate() error { |
|||
u.Username = strings.TrimSpace(strings.ToLower(u.Username)) |
|||
u.Name = strings.TrimSpace(strings.ToLower(u.Name)) |
|||
|
|||
// Defaults
|
|||
if len(u.Name) == 0 { |
|||
u.Name = u.Username |
|||
} |
|||
|
|||
if len(u.Username) == 0 { |
|||
return errors.New("username is required") |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// SetPassword stores the hashed password for a user.
|
|||
func (u *User) SetPassword(password string) error { |
|||
hash, err := bcrypt.GenerateFromPassword([]byte(password), constants.BcryptCost) |
|||
if err != nil { |
|||
return fmt.Errorf("SetPassword: %s", err) |
|||
} |
|||
|
|||
u.HashedPassword = string(hash) |
|||
fmt.Printf("Set hashed password: %s", u.HashedPassword) |
|||
return nil |
|||
} |
|||
|
|||
// FirstAdmin returns the admin user with the lowest ID number.
|
|||
func FirstAdmin() (User, error) { |
|||
var user User |
|||
r := DB.First(&user, "is_admin", true) |
|||
return user, r.Error |
|||
} |
|||
|
|||
// CreateUser adds a new user to the database.
|
|||
func CreateUser(u User) error { |
|||
if err := u.Validate(); err != nil { |
|||
return err |
|||
} |
|||
|
|||
r := DB.Create(&u) |
|||
return r.Error |
|||
} |
|||
|
@ -1,9 +1,33 @@ |
|||
package responses |
|||
|
|||
import "net/http" |
|||
import ( |
|||
"log" |
|||
"net/http" |
|||
) |
|||
|
|||
// Panic gives a simple error with no template or anything fancy.
|
|||
func Panic(w http.ResponseWriter, code int, message string) { |
|||
w.WriteHeader(code) |
|||
w.Write([]byte(message)) |
|||
} |
|||
|
|||
// Error returns an error page.
|
|||
func Error(w http.ResponseWriter, r *http.Request, code int, message string) { |
|||
v := map[string]interface{}{ |
|||
"Message": message, |
|||
} |
|||
w.WriteHeader(code) |
|||
|
|||
if err := RenderTemplate(w, r, "_builtin/errors/generic.gohtml", v); err != nil { |
|||
log.Printf("responses.Error: failed to render a pretty error template: %s", err) |
|||
w.Write([]byte(message)) |
|||
} |
|||
} |
|||
|
|||
// NotFound returns an HTML 404 page.
|
|||
func NotFound(w http.ResponseWriter, r *http.Request) { |
|||
if err := RenderTemplate(w, r, "_builtin/errors/404.gohtml", nil); err != nil { |
|||
log.Printf("responses.NotFound: failed to render a pretty error template: %s", err) |
|||
w.Write([]byte("Not Found")) |
|||
} |
|||
} |
|||
|
@ -0,0 +1,27 @@ |
|||
package responses |
|||
|
|||
import ( |
|||
"fmt" |
|||
"html/template" |
|||
"net/http" |
|||
|
|||
"git.kirsle.net/apps/gophertype/pkg/constants" |
|||
) |
|||
|
|||
// TemplateFuncs available to all templates.
|
|||
func TemplateFuncs(r *http.Request) template.FuncMap { |
|||
return template.FuncMap{ |
|||
"CSRF": func() template.HTML { |
|||
fmt.Println("CSRF() func called") |
|||
token, _ := r.Cookie(constants.CSRFCookieName) |
|||
return template.HTML(fmt.Sprintf( |
|||
`<input type="hidden" name="%s" value="%s">`, |
|||
constants.CSRFFormName, |
|||
token.Value, |
|||
)) |
|||
}, |
|||
"TestFunction": func() template.HTML { |
|||
return template.HTML("Testing") |
|||
}, |
|||
} |
|||
} |
@ -0,0 +1,11 @@ |
|||
{{ define "title" }}An error has occurred{{ end }} |
|||
{{ define "content" }} |
|||
<h1>An error has occurred</h1> |
|||
|
|||
{{ if .Message }} |
|||
<div class="banner banner-danger"> |
|||
{{ .Message }} |
|||
</div> |
|||
{{ end }} |
|||
|
|||
{{ end }} |
@ -0,0 +1,6 @@ |
|||
{{ define "title" }}Not Found{{ end }} |
|||
{{ define "content" }} |
|||
<h1>Not Found</h1> |
|||
|
|||
The page you were looking for was not found. |
|||
{{ end }} |
@ -1,5 +1,66 @@ |
|||
{{ define "title" }}Initial Setup{{ end }} |
|||
|
|||
{{ define "content" }} |
|||
<h1>Initial Setup</h1> |
|||
|
|||
{{ if .Error }} |
|||
<div class="banner banner-danger mb-4"> |
|||
<strong>Error:</strong> {{ .Error }} |
|||
</div> |
|||
{{ end }} |
|||
|
|||
<p> |
|||
Welcome to Gophertype! Fill out the basic configuration below to set up the app. |
|||
</p> |
|||
|
|||
<form method="POST" action="/admin/setup"> |
|||
{{ CSRF }} |
|||
<div class="card mb-4"> |
|||
<div class="card-header"> |
|||
Create Administrator Login |
|||
</div> |
|||
<div class="card-body"> |
|||
<div class="form-row"> |
|||
<div class="form-group col-md-6"> |
|||
<label for="username">Username<span class="text-danger">*</span>:</label> |
|||
<input type="text" class="form-control" |
|||
name="username" |
|||
id="username" |
|||
placeholder="Admin" |
|||
required> |
|||
</div> |
|||
<div class="form-group col-md-6"> |
|||
<label for="username">Display Name:</label> |
|||
<input type="text" class="form-control" |
|||
name="name" |
|||
id="name" |
|||
placeholder="Soandso"> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-row"> |
|||
<div class="form-group col-md-6"> |
|||
<label for="password">Password<span class="text-danger">*</span>:</label> |
|||
<input type="password" class="form-control" |
|||
name="password" |
|||
id="password" |
|||
required> |
|||
</div> |
|||
<div class="form-group col-md-6"> |
|||
<label for="confirm">Confirm<span class="text-danger">*</span>:</label> |
|||
<input type="password" class="form-control" |
|||
name="password2" |
|||
id="confirm" |
|||
required> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-row"> |
|||
<div class="col"> |
|||
<button type="submit" |
|||
class="btn btn-primary">Continue</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
{{ end }} |
|||
|
@ -0,0 +1,10 @@ |
|||
# About Blog |
|||
|
|||
This is a simple web blog and content management system written in Go. |
|||
|
|||
## Features |
|||
|
|||
* Web blog |
|||
* Draft, Private Posts |
|||
* Page editor |
|||
* You can edit any page from the front-end. |
Loading…
Reference in new issue