├── secrets └── .keep ├── db ├── backup │ └── .keep └── migrate │ ├── .keep │ └── Create-Tables.sql.tmpl ├── src ├── users │ ├── assets │ │ ├── scripts │ │ │ └── users.js │ │ ├── styles │ │ │ └── users.css │ │ └── images │ │ │ └── .keep │ ├── views │ │ ├── create.html.got │ │ ├── update.html.got │ │ ├── show.html.got │ │ ├── password_sent.html.got │ │ ├── password_reset_mail.html.got │ │ ├── login.html.got │ │ ├── row.html.got │ │ ├── password_reset.html.got │ │ ├── form.html.got │ │ └── index.html.got │ ├── actions │ │ ├── logout.go │ │ ├── destroy.go │ │ ├── show.go │ │ ├── index.go │ │ ├── create.go │ │ ├── update.go │ │ ├── login.go │ │ ├── password.go │ │ └── actions_test.go │ ├── query.go │ ├── role.go │ ├── users_test.go │ └── users.go ├── app │ ├── views │ │ ├── includes.html.got │ │ ├── home.html.got │ │ ├── meta.html.got │ │ ├── admin.html.got │ │ ├── footer.html.got │ │ ├── error.html.got │ │ ├── header.html.got │ │ └── layout.html.got │ ├── home.go │ ├── auth.go │ ├── routes.go │ ├── assets │ │ ├── styles │ │ │ ├── admin.css │ │ │ └── app.css │ │ └── scripts │ │ │ ├── app.js │ │ │ └── adom.js │ ├── app_test.go │ ├── handlers.go │ ├── setup.go │ └── bootstrap.go └── lib │ ├── templates │ └── fragmenta_resources │ │ ├── assets │ │ ├── scripts │ │ │ └── fragmenta_resources.js │ │ ├── images │ │ │ └── .keep │ │ └── styles │ │ │ └── fragmenta_resources.css │ │ ├── views │ │ ├── create.html.got.tmpl │ │ ├── update.html.got.tmpl │ │ ├── show.html.got.tmpl │ │ ├── form.html.got.tmpl │ │ ├── row.html.got.tmpl │ │ └── index.html.got.tmpl │ │ ├── query.go.tmpl │ │ ├── actions │ │ ├── show.go.tmpl │ │ ├── destroy.go.tmpl │ │ ├── index.go.tmpl │ │ ├── create.go.tmpl │ │ ├── update.go.tmpl │ │ └── actions_test.go.tmpl │ │ ├── fragmenta_resources.go.tmpl │ │ └── fragmenta_resources_test.go.tmpl │ ├── mail │ ├── views │ │ ├── template.html.got │ │ └── layout.html.got │ ├── email.go │ ├── mail.go │ └── adapters │ │ └── sendgrid │ │ └── sendgrid.go │ ├── editable │ ├── views │ │ ├── example.html.got │ │ ├── editable-toolbar-basic.html.got │ │ ├── editable-toolbar.html.got │ │ └── editable-toolbar-full.html.got │ └── assets │ │ ├── styles │ │ └── editable.css │ │ └── scripts │ │ └── editable.js │ ├── status │ ├── status_test.go │ └── status.go │ ├── resource │ ├── urls.go │ ├── query.go │ ├── validate.go │ ├── resource.go │ ├── resource_test.go │ └── tests.go │ └── auth │ ├── authenticate.go │ └── authenticate_test.go ├── .gitignore ├── public ├── favicon.ico ├── assets │ ├── images │ │ └── app │ │ │ └── logo.png │ ├── styles │ │ ├── normalize.min.css │ │ └── app-58a0afbf47fbb0647dc34dff83732be2509c4c7e.min.css │ └── scripts │ │ └── app-d0d2a631d0b5b2e52b6def992031e9fd1309a66d.min.js └── robots.txt ├── bin └── deploy ├── server_test.go ├── LICENSE ├── server.go └── README.md /secrets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /db/backup/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /db/migrate/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/users/assets/scripts/users.js: -------------------------------------------------------------------------------- 1 | /* JS for users */ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/* 2 | db/backup/* 3 | secrets/* 4 | log/* 5 | -------------------------------------------------------------------------------- /src/users/assets/styles/users.css: -------------------------------------------------------------------------------- 1 | /* CSS Styles for users */ -------------------------------------------------------------------------------- /src/users/assets/images/.keep: -------------------------------------------------------------------------------- 1 | # this file exists to ensure git doesn't ignore the empty folder -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fragmenta/fragmenta-app/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/app/views/includes.html.got: -------------------------------------------------------------------------------- 1 | {{ style "normalize.min" }} 2 | {{ style "app" }} 3 | 4 | {{ script "app" }} 5 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/assets/scripts/fragmenta_resources.js: -------------------------------------------------------------------------------- 1 | /* JS for [[ .fragmenta_resources ]] */ -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/assets/images/.keep: -------------------------------------------------------------------------------- 1 | # this file exists to ensure git doesn't ignore the empty folder -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/assets/styles/fragmenta_resources.css: -------------------------------------------------------------------------------- 1 | /* CSS Styles for [[ .fragmenta_resources ]] */ -------------------------------------------------------------------------------- /public/assets/images/app/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fragmenta/fragmenta-app/HEAD/public/assets/images/app/logo.png -------------------------------------------------------------------------------- /src/app/views/home.html.got: -------------------------------------------------------------------------------- 1 |
2 |

{{.title}}

3 |

A bare-bones app template

4 |
-------------------------------------------------------------------------------- /src/lib/mail/views/template.html.got: -------------------------------------------------------------------------------- 1 | Hi {{.name}},
2 |
3 | {{.message}}
4 |
5 | Thanks,
6 |
7 | {{ .from }}
-------------------------------------------------------------------------------- /src/users/views/create.html.got: -------------------------------------------------------------------------------- 1 |
2 |

Create User

3 | {{ template "users/views/form.html.got" . }} 4 |
5 | -------------------------------------------------------------------------------- /src/users/views/update.html.got: -------------------------------------------------------------------------------- 1 |
2 |

Update User

3 | {{ template "users/views/form.html.got" . }} 4 |
5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/wc/norobots.html 2 | # Spiders (uncomment to ban all) 3 | # User-Agent: * 4 | # Disallow: / 5 | 6 | -------------------------------------------------------------------------------- /src/users/views/show.html.got: -------------------------------------------------------------------------------- 1 |
2 |

{{ .user.Name }}

3 |
4 |

Name: {{ .user.Name }}

5 | 6 |
7 |
8 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/views/create.html.got.tmpl: -------------------------------------------------------------------------------- 1 |
2 |

Create [[ .Fragmenta_Resource ]]

3 | {{ template "[[ .fragmenta_resources ]]/views/form.html.got" . }} 4 |
5 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/views/update.html.got.tmpl: -------------------------------------------------------------------------------- 1 |
2 |

Update [[ .Fragmenta_Resource ]]

3 | {{ template "[[ .fragmenta_resources ]]/views/form.html.got" . }} 4 |
5 | -------------------------------------------------------------------------------- /src/users/views/password_sent.html.got: -------------------------------------------------------------------------------- 1 | ]
2 |
3 |

You have mail!

4 |

We've send you a password reset link, please open your email and click the link.

5 |
6 |
7 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/views/show.html.got.tmpl: -------------------------------------------------------------------------------- 1 |
2 |

{{ .[[ .fragmenta_resource ]].Name }}

3 |
4 |

Name: {{ .[[ .fragmenta_resource ]].Name }}

5 | 6 |
7 |
8 | -------------------------------------------------------------------------------- /src/users/views/password_reset_mail.html.got: -------------------------------------------------------------------------------- 1 |

Hi {{.name}},

2 | 3 |

Someone (hopefully you) has requested a password reset for your account. Follow the link below to set a new password: Reset Password

4 | 5 |

Any problems with this email? Please contact us to let us know.

6 | -------------------------------------------------------------------------------- /src/app/views/meta.html.got: -------------------------------------------------------------------------------- 1 | 2 | {{ .meta_title }} 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/lib/mail/views/layout.html.got: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |
9 | 10 |
11 | {{ .content }} 12 |
13 | 14 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/app/views/admin.html.got: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/users/views/login.html.got: -------------------------------------------------------------------------------- 1 |
2 |

Login

3 |
4 | 5 | {{ field "Email" "email" "" "text" }} 6 | {{ field "Password" "password" "" "password" "type=password" }} 7 | 8 |
9 | 10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /src/app/views/footer.html.got: -------------------------------------------------------------------------------- 1 |

Made with Fragmenta

2 | -------------------------------------------------------------------------------- /src/lib/editable/views/example.html.got: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | {{ template "lib/editable/views/editable-toolbar.html.got" }} 5 | 6 |
{{html .page.Text}}
7 |
-------------------------------------------------------------------------------- /src/users/actions/logout.go: -------------------------------------------------------------------------------- 1 | package useractions 2 | 3 | import ( 4 | "github.com/fragmenta/auth" 5 | "github.com/fragmenta/router" 6 | ) 7 | 8 | // HandleLogout clears the current user's session /users/logout 9 | func HandleLogout(context router.Context) error { 10 | 11 | // Clear the current session cookie 12 | auth.ClearSession(context.Writer()) 13 | 14 | // Redirect to home 15 | return router.Redirect(context, "/") 16 | } 17 | -------------------------------------------------------------------------------- /src/app/views/error.html.got: -------------------------------------------------------------------------------- 1 |
2 |

{{.title}}

3 |

{{.message}}

4 | {{ if .file }} 5 |

File:{{.file}}

6 | {{ end }} 7 | {{ if .error }} 8 |

 9 |       Error:{{.error}}
10 |       
11 | {{ end }} 12 | {{ if .current_user.Anon }} 13 |

Login

14 | {{ end }} 15 |
-------------------------------------------------------------------------------- /src/app/home.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/fragmenta/router" 5 | "github.com/fragmenta/view" 6 | ) 7 | 8 | // HandleShowHome serves our home page with a simple template. 9 | // This function might be moved over to src/pages if you have a pages resource. 10 | func homeHandler(context router.Context) error { 11 | view := view.New(context) 12 | view.AddKey("title", "Fragmenta app") 13 | view.Template("app/views/home.html.got") 14 | return view.Render() 15 | } 16 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/views/form.html.got.tmpl: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | Cancel 6 |
7 | 8 |
9 | {{ field "Name" "name" .[[ .fragmenta_resource ]].Name }} 10 |
11 | 12 |
-------------------------------------------------------------------------------- /src/users/views/row.html.got: -------------------------------------------------------------------------------- 1 | {{ if not .user.ID }} 2 | 3 | Id 4 | Updated 5 | Status 6 | Actions 7 | 8 | {{ else }} 9 | 10 | {{ .user.ID }} 11 | {{ time .user.UpdatedAt }} 12 | {{ .user.StatusDisplay }} 13 | Edit User 14 | 15 | {{ end }} -------------------------------------------------------------------------------- /src/users/views/password_reset.html.got: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Reset your password

4 |

Please enter your email below, and we'll send you a password reset link.

5 |
6 | {{ field "Email" "email" "" "text" "placeholder=example@example.com"}} 7 |
8 | 9 |
10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /bin/deploy: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Upload to this destination 4 | SERVER="example.com" 5 | 6 | # Let the user know 7 | echo "Uploading to $SERVER..."; 8 | 9 | # Upload files using rsync 10 | rsync -rlhzv --update -e ssh --exclude='.*' \ 11 | "$GOPATH/src/github.com/fragmenta/fragmenta-app" \ 12 | "core@$SERVER:/srv/www/fragmenta-app" 13 | 14 | # Restart the service on server 15 | ssh -t core@$SERVER /bin/bash <<'EOT' 16 | sudo systemctl restart fragmenta.service 17 | EOT 18 | 19 | echo "Completed" 20 | exit 0; -------------------------------------------------------------------------------- /src/app/views/header.html.got: -------------------------------------------------------------------------------- 1 | {{ if .current_user.Admin }} 2 | {{ template "app/views/admin.html.got" . }} 3 | {{ end }} 4 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/editable/views/editable-toolbar-basic.html.got: -------------------------------------------------------------------------------- 1 | 9 |
-------------------------------------------------------------------------------- /src/app/views/layout.html.got: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ template "app/views/meta.html.got" . }} 5 | {{ template "app/views/includes.html.got" . }} 6 | 7 | 8 | 9 |
10 | {{ template "app/views/header.html.got" . }} 11 |
12 | 13 |
14 | {{ if .warning }} 15 |
{{.warning}}
16 | {{ end }} 17 | {{ .content }} 18 |
19 | 20 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/lib/status/status_test.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // Resource embeds ResourceStatus 8 | type resource struct { 9 | ResourceStatus 10 | } 11 | 12 | // TestOptions tests our options are functional when embedded in a resource. 13 | func TestOptions(t *testing.T) { 14 | 15 | r := &resource{} 16 | 17 | options := r.StatusOptions() 18 | if len(options) < 0 { 19 | t.Fatalf("status: failed to get status options") 20 | } 21 | 22 | r.Status = Published 23 | if r.StatusDisplay() != "Published" { 24 | t.Fatalf("status: failed to get status published") 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/views/row.html.got.tmpl: -------------------------------------------------------------------------------- 1 | {{ if not .[[ .fragmenta_resource ]].ID }} 2 | 3 | Id 4 | Updated 5 | Status 6 | Actions 7 | 8 | {{ else }} 9 | 10 | {{ .[[ .fragmenta_resource ]].ID }} 11 | {{ time .[[ .fragmenta_resource ]].UpdatedAt }} 12 | {{ .[[ .fragmenta_resource ]].StatusDisplay }} 13 | Edit [[ .Fragmenta_Resource ]] 14 | 15 | {{ end }} -------------------------------------------------------------------------------- /src/users/views/form.html.got: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | Cancel 6 |
7 | 8 |
9 | {{ select "Status" "status" .user.Status .user.StatusOptions }} 10 | {{ select "Role" "role" .user.Role .user.RoleOptions }} 11 | {{ field "Name" "name" .user.Name }} 12 | {{ field "Email" "email" .user.Email }} 13 | {{ field "Password" "password" "" "password" "type=password" }} 14 |
15 | 16 |
-------------------------------------------------------------------------------- /db/migrate/Create-Tables.sql.tmpl: -------------------------------------------------------------------------------- 1 | /* Setup tables for cms */ 2 | CREATE TABLE fragmenta_metadata ( 3 | id SERIAL NOT NULL, 4 | updated_at timestamp, 5 | fragmenta_version text, 6 | migration_version text, 7 | status int 8 | ); 9 | 10 | ALTER TABLE fragmenta_metadata OWNER TO "[[.fragmenta_db_user]]"; 11 | 12 | DROP TABLE IF EXISTS users; 13 | CREATE TABLE users ( 14 | id SERIAL NOT NULL, 15 | created_at timestamp, 16 | updated_at timestamp, 17 | status integer, 18 | role integer, 19 | name text, 20 | email text, 21 | password_hash text, 22 | password_reset_token text, 23 | password_reset_at timestamp 24 | ); 25 | ALTER TABLE users OWNER TO "[[.fragmenta_db_user]]"; 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/users/query.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/fragmenta/query" 5 | 6 | "github.com/fragmenta/fragmenta-app/src/lib/status" 7 | ) 8 | 9 | // Query returns a new query for users with a default order. 10 | func Query() *query.Query { 11 | return query.New(TableName, KeyName).Order(Order) 12 | } 13 | 14 | // Where returns a new query for users with the format and arguments supplied. 15 | func Where(format string, args ...interface{}) *query.Query { 16 | return Query().Where(format, args...) 17 | } 18 | 19 | // Published returns a query for all users with status >= published. 20 | func Published() *query.Query { 21 | return Query().Where("status>=?", status.Published) 22 | } 23 | -------------------------------------------------------------------------------- /src/users/views/index.html.got: -------------------------------------------------------------------------------- 1 |
2 |

Users

3 | 4 |
5 |
6 | Add User 7 | 8 |
9 |
10 | 11 |
12 | 13 | {{ $0 := . }} 14 | {{ template "users/views/row.html.got" empty }} 15 | {{ range $i,$m := .users }} 16 | {{ set $0 "i" $i }} 17 | {{ set $0 "user" $m }} 18 | {{ template "users/views/row.html.got" $0 }} 19 | {{ end }} 20 |
21 |
22 |
-------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/query.go.tmpl: -------------------------------------------------------------------------------- 1 | package [[ .fragmenta_resources ]] 2 | 3 | import ( 4 | "github.com/fragmenta/query" 5 | 6 | "github.com/fragmenta/fragmenta-app/src/lib/status" 7 | ) 8 | 9 | // Query returns a new query for [[ .fragmenta_resources ]] with a default order. 10 | func Query() *query.Query { 11 | return query.New(TableName, KeyName).Order(Order) 12 | } 13 | 14 | // Where returns a new query for [[ .fragmenta_resources ]] with the format and arguments supplied. 15 | func Where(format string, args ...interface{}) *query.Query { 16 | return Query().Where(format, args...) 17 | } 18 | 19 | // Published returns a query for all [[ .fragmenta_resources ]] with status >= published. 20 | func Published() *query.Query { 21 | return Query().Where("status>=?", status.Published) 22 | } 23 | -------------------------------------------------------------------------------- /src/users/actions/destroy.go: -------------------------------------------------------------------------------- 1 | package useractions 2 | 3 | import ( 4 | "github.com/fragmenta/auth/can" 5 | "github.com/fragmenta/router" 6 | 7 | "github.com/fragmenta/fragmenta-app/src/lib/auth" 8 | "github.com/fragmenta/fragmenta-app/src/users" 9 | ) 10 | 11 | // HandleDestroy responds to /users/n/destroy by deleting the user. 12 | func HandleDestroy(context router.Context) error { 13 | 14 | // Find the user 15 | user, err := users.Find(context.ParamInt("id")) 16 | if err != nil { 17 | return router.NotFoundError(err) 18 | } 19 | 20 | // Authorise destroy user 21 | err = can.Destroy(user, auth.CurrentUser(context)) 22 | if err != nil { 23 | return router.NotAuthorizedError(err) 24 | } 25 | 26 | // Destroy the user 27 | user.Destroy() 28 | 29 | // Redirect to users root 30 | return router.Redirect(context, user.IndexURL()) 31 | } 32 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | // TestServer tests running the server and using http client to GET / 10 | // this test will fail if there is no secrets file. 11 | func TestServer(t *testing.T) { 12 | // Setup our server from config 13 | s, err := SetupServer() 14 | if err != nil { 15 | t.Fatalf("server: error setting up %s\n", err) 16 | } 17 | 18 | // Start the server 19 | go s.Start() 20 | 21 | // Try hitting the server to see if it is working 22 | 23 | host := fmt.Sprintf("http://localhost%s/", s.PortString()) 24 | r, err := http.Get(host) 25 | if err != nil { 26 | t.Fatalf("server: error getting / %s", err) 27 | } 28 | 29 | if r.StatusCode != http.StatusOK { 30 | t.Fatalf("server: error getting / expected:%d got:%d", http.StatusOK, r.StatusCode) 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/views/index.html.got.tmpl: -------------------------------------------------------------------------------- 1 |
2 |

[[ .Fragmenta_Resources ]]

3 | 4 |
5 |
6 | Add [[ .Fragmenta_Resource ]] 7 | 8 |
9 |
10 | 11 |
12 | 13 | {{ $0 := . }} 14 | {{ template "[[ .fragmenta_resources ]]/views/row.html.got" empty }} 15 | {{ range $i,$m := .[[ .fragmenta_resources ]] }} 16 | {{ set $0 "i" $i }} 17 | {{ set $0 "[[ .fragmenta_resource ]]" $m }} 18 | {{ template "[[ .fragmenta_resources ]]/views/row.html.got" $0 }} 19 | {{ end }} 20 |
21 |
22 |
-------------------------------------------------------------------------------- /src/users/actions/show.go: -------------------------------------------------------------------------------- 1 | package useractions 2 | 3 | import ( 4 | "github.com/fragmenta/auth/can" 5 | "github.com/fragmenta/router" 6 | "github.com/fragmenta/view" 7 | 8 | "github.com/fragmenta/fragmenta-app/src/lib/auth" 9 | "github.com/fragmenta/fragmenta-app/src/users" 10 | ) 11 | 12 | // HandleShow displays a single user. 13 | func HandleShow(context router.Context) error { 14 | 15 | // Find the user 16 | user, err := users.Find(context.ParamInt("id")) 17 | if err != nil { 18 | return router.NotFoundError(err) 19 | } 20 | 21 | // Authorise access 22 | err = can.Show(user, auth.CurrentUser(context)) 23 | if err != nil { 24 | return router.NotAuthorizedError(err) 25 | } 26 | 27 | // Render the template 28 | w := context.Writer() 29 | // Set cache control headers 30 | w.Header().Set("Cache-Control", "no-cache, public") 31 | w.Header().Set("Etag", user.CacheKey()) 32 | view := view.New(context) 33 | view.AddKey("user", user) 34 | return view.Render() 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/mail/email.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // Email represents an email to be sent. 8 | type Email struct { 9 | Recipients []string 10 | ReplyTo string 11 | Subject string 12 | Body string 13 | Template string 14 | Layout string 15 | } 16 | 17 | // New returns a new email with the default tenplates and the given recipient. 18 | func New(r string) *Email { 19 | e := &Email{ 20 | Layout: "lib/mail/views/layout.html.got", 21 | Template: "lib/mail/views/template.html.got", 22 | } 23 | e.Recipients = append(e.Recipients, r) 24 | return e 25 | } 26 | 27 | // String returns a formatted string representation for debug. 28 | func (e *Email) String() string { 29 | return fmt.Sprintf("email to:%v from:%s subject:%s\n\n%s", e.Recipients, e.ReplyTo, e.Subject, e.Body) 30 | } 31 | 32 | // Invalid returns true if this email is not ready to send. 33 | func (e *Email) Invalid() bool { 34 | return (e.ReplyTo == "" || e.Subject == "" || e.Body == "") 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/actions/show.go.tmpl: -------------------------------------------------------------------------------- 1 | package [[ .fragmenta_resource ]]actions 2 | 3 | import ( 4 | "github.com/fragmenta/auth/can" 5 | "github.com/fragmenta/router" 6 | "github.com/fragmenta/view" 7 | 8 | "github.com/fragmenta/fragmenta-app/src/lib/auth" 9 | "github.com/fragmenta/fragmenta-app/src/[[ .fragmenta_resources ]]" 10 | ) 11 | 12 | // HandleShow displays a single [[ .fragmenta_resource ]]. 13 | func HandleShow(context router.Context) error { 14 | 15 | // Find the [[ .fragmenta_resource ]] 16 | [[ .fragmenta_resource ]], err := [[ .fragmenta_resources ]].Find(context.ParamInt("id")) 17 | if err != nil { 18 | return router.NotFoundError(err) 19 | } 20 | 21 | // Authorise access 22 | err = can.Show([[ .fragmenta_resource ]], auth.CurrentUser(context)) 23 | if err != nil { 24 | return router.NotAuthorizedError(err) 25 | } 26 | 27 | // Render the template 28 | view := view.New(context) 29 | view.AddKey("[[ .fragmenta_resource ]]", [[ .fragmenta_resource ]]) 30 | return view.Render() 31 | } 32 | -------------------------------------------------------------------------------- /src/app/auth.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/fragmenta/auth" 5 | "github.com/fragmenta/auth/can" 6 | 7 | "github.com/fragmenta/fragmenta-app/src/users" 8 | ) 9 | 10 | // SetupAuth sets up the auth pkg and authorisation for users 11 | func SetupAuth(c Config) { 12 | 13 | // Set up the auth package with our secrets from config 14 | auth.HMACKey = auth.HexToBytes(c.Config("hmac_key")) 15 | auth.SecretKey = auth.HexToBytes(c.Config("secret_key")) 16 | auth.SessionName = c.Config("session_name") 17 | 18 | // Enable https cookies on production server - everyone should be on https 19 | if c.Production() { 20 | auth.SecureCookies = true 21 | } 22 | 23 | // Set up our authorisation for user roles on resources using can pkg 24 | 25 | // Admins are allowed to manage all resources 26 | can.Authorise(users.Admin, can.ManageResource, can.Anything) 27 | 28 | // Editors may edit their user 29 | can.AuthoriseOwner(users.Editor, can.UpdateResource, users.TableName) 30 | // ... 31 | 32 | // Readers may edit their user 33 | can.AuthoriseOwner(users.Reader, can.UpdateResource, users.TableName) 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/actions/destroy.go.tmpl: -------------------------------------------------------------------------------- 1 | package [[ .fragmenta_resource ]]actions 2 | 3 | import ( 4 | "github.com/fragmenta/auth/can" 5 | "github.com/fragmenta/router" 6 | 7 | "github.com/fragmenta/fragmenta-app/src/lib/auth" 8 | "github.com/fragmenta/fragmenta-app/src/[[ .fragmenta_resources ]]" 9 | ) 10 | 11 | // HandleDestroy responds to /[[ .fragmenta_resources ]]/n/destroy by deleting the [[ .fragmenta_resource ]]. 12 | func HandleDestroy(context router.Context) error { 13 | 14 | // Find the [[ .fragmenta_resource ]] 15 | [[ .fragmenta_resource ]], err := [[ .fragmenta_resources ]].Find(context.ParamInt("id")) 16 | if err != nil { 17 | return router.NotFoundError(err) 18 | } 19 | 20 | // Authorise destroy [[ .fragmenta_resource ]] 21 | err = can.Destroy([[ .fragmenta_resource ]], auth.CurrentUser(context)) 22 | if err != nil { 23 | return router.NotAuthorizedError(err) 24 | } 25 | 26 | // Destroy the [[ .fragmenta_resource ]] 27 | [[ .fragmenta_resource ]].Destroy() 28 | 29 | // Redirect to [[ .fragmenta_resources ]] root 30 | return router.Redirect(context, [[ .fragmenta_resource ]].IndexURL()) 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Mechanism Design Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/lib/resource/urls.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // IndexURL returns the index url for this model - /table 8 | func (r *Base) IndexURL() string { 9 | return fmt.Sprintf("/%s", r.TableName) 10 | } 11 | 12 | // CreateURL returns the create url for this model /table/create 13 | func (r *Base) CreateURL() string { 14 | return fmt.Sprintf("/%s/create", r.TableName) 15 | } 16 | 17 | // UpdateURL returns the update url for this model /table/id/update 18 | func (r *Base) UpdateURL() string { 19 | return fmt.Sprintf("/%s/%d/update", r.TableName, r.ID) 20 | } 21 | 22 | // DestroyURL returns the destroy url for this model /table/id/destroy 23 | func (r *Base) DestroyURL() string { 24 | return fmt.Sprintf("/%s/%d/destroy", r.TableName, r.ID) 25 | } 26 | 27 | // ShowURL returns the show url for this model /table/id 28 | func (r *Base) ShowURL() string { 29 | return fmt.Sprintf("/%s/%d", r.TableName, r.ID) 30 | } 31 | 32 | // PublicURL returns the canonical url for showing this resource 33 | // usually this will differ in using the name as a slug 34 | func (r *Base) PublicURL() string { 35 | return fmt.Sprintf("/%s/%d", r.TableName, r.ID) 36 | } 37 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fragmenta/server" 7 | 8 | "github.com/fragmenta/fragmenta-app/src/app" 9 | ) 10 | 11 | // Main entrypoint for the server which performs bootstrap, setup 12 | // then runs the server. Most setup is delegated to the src/app pkg. 13 | func main() { 14 | 15 | // Bootstrap if required (no config file found). 16 | if app.RequiresBootStrap() { 17 | err := app.Bootstrap() 18 | if err != nil { 19 | fmt.Printf("Error bootstrapping server %s\n", err) 20 | return 21 | } 22 | } 23 | 24 | // Setup our server from config 25 | s, err := SetupServer() 26 | if err != nil { 27 | fmt.Printf("server: error setting up %s\n", err) 28 | return 29 | } 30 | 31 | // Start the server 32 | err = s.Start() 33 | if err != nil { 34 | s.Fatalf("server: error starting %s\n", err) 35 | } 36 | 37 | } 38 | 39 | // SetupServer reads the config and sets the server up by calling app.Setup(). 40 | func SetupServer() (*server.Server, error) { 41 | 42 | // Setup server 43 | s, err := server.New() 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | // Call the app to perform additional setup 49 | app.Setup(s) 50 | 51 | return s, nil 52 | } 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fragmenta-app 2 | A minimal website built with fragmenta, with only a home page and minimal styling. 3 | 4 | ## Gettting Started 5 | To create a copy of this app, run: 6 | 7 | fragmenta new $GOPATH/src/my/app/name app 8 | 9 | Then cd to your new app and run migrations: 10 | 11 | fragmenta migrate 12 | 13 | Then run the server: 14 | 15 | fragmenta 16 | 17 | 18 | 19 | ## App Structure 20 | 21 | #### server.go 22 | This is the main entrypoint for the application. The structure of other parts of the application is dictated by what you need from it. 23 | 24 | #### The src folder 25 | This is a suggested structure for an application, the structure used is entirely up to you, if you prefer you don't have to use a src folder. 26 | 27 | 28 | #### The src/app folder 29 | This contains general app files, resources like pages or users should go in a separate pkg. 30 | 31 | 32 | #### The src/lib folder 33 | 34 | lib is used to store utility packages which can be used by several parts of the app. Some examples of libraries are included, but unused in this example application. 35 | 36 | #### The src/lib/templates folder 37 | 38 | Templates for generating new resources are stored in here and used by fragmenta generate to generate a new resource package, containing assets, code and views. -------------------------------------------------------------------------------- /src/users/actions/index.go: -------------------------------------------------------------------------------- 1 | package useractions 2 | 3 | import ( 4 | "github.com/fragmenta/auth/can" 5 | "github.com/fragmenta/router" 6 | "github.com/fragmenta/view" 7 | 8 | "github.com/fragmenta/fragmenta-app/src/lib/auth" 9 | "github.com/fragmenta/fragmenta-app/src/users" 10 | ) 11 | 12 | // HandleIndex displays a list of users. 13 | func HandleIndex(context router.Context) error { 14 | 15 | // Authorise list user 16 | err := can.List(users.New(), auth.CurrentUser(context)) 17 | if err != nil { 18 | return router.NotAuthorizedError(err) 19 | } 20 | 21 | // Build a query 22 | q := users.Query() 23 | 24 | // Order by required order, or default to id asc 25 | switch context.Param("order") { 26 | 27 | case "1": 28 | q.Order("created desc") 29 | 30 | case "2": 31 | q.Order("updated desc") 32 | 33 | case "3": 34 | q.Order("name asc") 35 | 36 | default: 37 | q.Order("id asc") 38 | 39 | } 40 | 41 | // Filter if requested 42 | filter := context.Param("filter") 43 | if len(filter) > 0 { 44 | q.Where("name ILIKE ?", filter) 45 | } 46 | 47 | // Fetch the users 48 | results, err := users.FindAll(q) 49 | if err != nil { 50 | return router.InternalError(err) 51 | } 52 | 53 | // Render the template 54 | view := view.New(context) 55 | view.AddKey("filter", filter) 56 | view.AddKey("users", results) 57 | return view.Render() 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/actions/index.go.tmpl: -------------------------------------------------------------------------------- 1 | package [[ .fragmenta_resource ]]actions 2 | 3 | import ( 4 | "github.com/fragmenta/auth/can" 5 | "github.com/fragmenta/router" 6 | "github.com/fragmenta/view" 7 | 8 | "github.com/fragmenta/fragmenta-app/src/lib/auth" 9 | "github.com/fragmenta/fragmenta-app/src/[[ .fragmenta_resources ]]" 10 | ) 11 | 12 | // HandleIndex displays a list of [[ .fragmenta_resources ]]. 13 | func HandleIndex(context router.Context) error { 14 | 15 | // Authorise list [[ .fragmenta_resource ]] 16 | err := can.List([[ .fragmenta_resources ]].New(), auth.CurrentUser(context)) 17 | if err != nil { 18 | return router.NotAuthorizedError(err) 19 | } 20 | 21 | // Build a query 22 | q := [[ .fragmenta_resources ]].Query() 23 | 24 | // Order by required order, or default to id asc 25 | switch context.Param("order") { 26 | 27 | case "1": 28 | q.Order("created desc") 29 | 30 | case "2": 31 | q.Order("updated desc") 32 | 33 | case "3": 34 | q.Order("name asc") 35 | 36 | default: 37 | q.Order("id asc") 38 | 39 | } 40 | 41 | // Filter if requested 42 | filter := context.Param("filter") 43 | if len(filter) > 0 { 44 | q.Where("name ILIKE ?", filter) 45 | } 46 | 47 | // Fetch the [[ .fragmenta_resources ]] 48 | results, err := [[ .fragmenta_resources ]].FindAll(q) 49 | if err != nil { 50 | return router.InternalError(err) 51 | } 52 | 53 | // Render the template 54 | view := view.New(context) 55 | view.AddKey("filter", filter) 56 | view.AddKey("[[ .fragmenta_resources ]]", results) 57 | return view.Render() 58 | } 59 | -------------------------------------------------------------------------------- /src/app/routes.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/fragmenta/router" 5 | 6 | "github.com/fragmenta/fragmenta-app/src/users/actions" 7 | ) 8 | 9 | // SetupRoutes adds routes for this app to this router. 10 | func SetupRoutes(r *router.Router) { 11 | 12 | // Resource Routes 13 | r.Add("/users", useractions.HandleIndex) 14 | r.Add("/users/create", useractions.HandleCreateShow) 15 | r.Add("/users/create", useractions.HandleCreate).Post() 16 | r.Add("/users/{id:[0-9]+}/update", useractions.HandleUpdateShow) 17 | r.Add("/users/{id:[0-9]+}/update", useractions.HandleUpdate).Post() 18 | r.Add("/users/{id:[0-9]+}/destroy", useractions.HandleDestroy).Post() 19 | r.Add("/users/{id:[0-9]+}", useractions.HandleShow) 20 | r.Add("/users/login", useractions.HandleLoginShow) 21 | r.Add("/users/login", useractions.HandleLogin).Post() 22 | r.Add("/users/logout", useractions.HandleLogout).Post() 23 | r.Add("/users/password", useractions.HandlePasswordReset) 24 | r.Add("/users/password/reset", useractions.HandlePasswordResetShow) 25 | r.Add("/users/password/reset", useractions.HandlePasswordResetSend).Post() 26 | r.Add("/users/password/sent", useractions.HandlePasswordResetSentShow) 27 | 28 | // Set the default file handler 29 | r.FileHandler = fileHandler 30 | r.ErrorHandler = errHandler 31 | 32 | // Add a files route to handle static images under files 33 | // - nginx deals with this in production - perhaps only do this in dev? 34 | r.Add("/files/{path:.*}", fileHandler) 35 | r.Add("/favicon.ico", fileHandler) 36 | 37 | // Add the home page route 38 | r.Add("/", homeHandler) 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/users/actions/create.go: -------------------------------------------------------------------------------- 1 | package useractions 2 | 3 | import ( 4 | "github.com/fragmenta/auth/can" 5 | "github.com/fragmenta/router" 6 | "github.com/fragmenta/view" 7 | 8 | "github.com/fragmenta/fragmenta-app/src/lib/auth" 9 | "github.com/fragmenta/fragmenta-app/src/users" 10 | ) 11 | 12 | // HandleCreateShow serves the create form via GET for users. 13 | func HandleCreateShow(context router.Context) error { 14 | 15 | user := users.New() 16 | 17 | // Authorise 18 | err := can.Create(user, auth.CurrentUser(context)) 19 | if err != nil { 20 | return router.NotAuthorizedError(err) 21 | } 22 | 23 | // Render the template 24 | view := view.New(context) 25 | view.AddKey("user", user) 26 | return view.Render() 27 | } 28 | 29 | // HandleCreate handles the POST of the create form for users 30 | func HandleCreate(context router.Context) error { 31 | 32 | user := users.New() 33 | 34 | // Authorise 35 | err := can.Create(user, auth.CurrentUser(context)) 36 | if err != nil { 37 | return router.NotAuthorizedError(err) 38 | } 39 | 40 | // Setup context 41 | params, err := context.Params() 42 | if err != nil { 43 | return router.InternalError(err) 44 | } 45 | 46 | // Validate the params, removing any we don't accept 47 | userParams := user.ValidateParams(params.Map(), users.AllowedParams()) 48 | 49 | id, err := user.Create(userParams) 50 | if err != nil { 51 | return router.InternalError(err) 52 | } 53 | 54 | // Redirect to the new user 55 | user, err = users.Find(id) 56 | if err != nil { 57 | return router.InternalError(err) 58 | } 59 | 60 | return router.Redirect(context, user.IndexURL()) 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/editable/views/editable-toolbar.html.got: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/auth/authenticate.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/fragmenta/auth" 7 | "github.com/fragmenta/router" 8 | 9 | "github.com/fragmenta/fragmenta-app/src/users" 10 | ) 11 | 12 | // CurrentUserFilter can be added as a pre-action filter to set the current user on the context if required. 13 | func CurrentUserFilter(context router.Context) error { 14 | u := CurrentUser(context) 15 | context.Set("current_user", u) 16 | return nil 17 | } 18 | 19 | // CurrentUser returns the saved user (or an empty anon user) for the current session cookie 20 | func CurrentUser(context router.Context) *users.User { 21 | 22 | // First check if the user has already been set on context, if so return it 23 | if context.Get("current_user") != nil { 24 | return context.Get("current_user").(*users.User) 25 | } 26 | 27 | // Start with an anon user by default (role 0, id 0) 28 | user := &users.User{} 29 | 30 | // Build the session from the secure cookie, or create a new one 31 | session, err := auth.Session(context.Writer(), context.Request()) 32 | if err != nil { 33 | context.Logf("#error problem retrieving session") 34 | return user 35 | } 36 | 37 | // Fetch the current user record if we have one recorded in the session 38 | var id int64 39 | val := session.Get(auth.SessionUserKey) 40 | if len(val) > 0 { 41 | id, err = strconv.ParseInt(val, 10, 64) 42 | if err != nil { 43 | context.Logf("#error Error decoding session user key:%s\n", err) 44 | return user 45 | } 46 | } 47 | 48 | if id != 0 { 49 | u, err := users.Find(id) 50 | if err != nil { 51 | context.Logf("#info User not found from session id:%d\n", id) 52 | return user 53 | } 54 | user = u 55 | } 56 | 57 | return user 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/resource/query.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/fragmenta/query" 7 | ) 8 | 9 | // Query creates a new query relation referencing this specific resource by id. 10 | func (r *Base) Query() *query.Query { 11 | return query.New(r.Table(), r.PrimaryKey()).Where("id=?", r.ID) 12 | } 13 | 14 | // ValidateParams allows only those params by AllowedParams() 15 | // to perform more sophisticated validation override it. 16 | func (r *Base) ValidateParams(params map[string]string, allowed []string) map[string]string { 17 | 18 | for k := range params { 19 | paramAllowed := false 20 | for _, v := range allowed { 21 | if k == v { 22 | paramAllowed = true 23 | } 24 | } 25 | if !paramAllowed { 26 | delete(params, k) 27 | } 28 | } 29 | return params 30 | } 31 | 32 | // Create inserts a new database record and returns the id or an error 33 | func (r *Base) Create(params map[string]string) (int64, error) { 34 | 35 | // Make sure updated_at and created_at are set to the current time 36 | now := query.TimeString(time.Now().UTC()) 37 | params["created_at"] = now 38 | params["updated_at"] = now 39 | 40 | // Insert a record into the database 41 | id, err := query.New(r.Table(), r.PrimaryKey()).Insert(params) 42 | return id, err 43 | } 44 | 45 | // Update the database record for this resource with the given params. 46 | func (r *Base) Update(params map[string]string) error { 47 | 48 | // Make sure updated_at is set to the current time 49 | now := query.TimeString(time.Now().UTC()) 50 | params["updated_at"] = now 51 | 52 | return r.Query().Update(params) 53 | } 54 | 55 | // Destroy deletes this resource by removing the database record. 56 | func (r *Base) Destroy() error { 57 | return r.Query().Delete() 58 | } 59 | -------------------------------------------------------------------------------- /src/users/actions/update.go: -------------------------------------------------------------------------------- 1 | package useractions 2 | 3 | import ( 4 | "github.com/fragmenta/auth/can" 5 | "github.com/fragmenta/router" 6 | "github.com/fragmenta/view" 7 | 8 | "github.com/fragmenta/fragmenta-app/src/lib/auth" 9 | "github.com/fragmenta/fragmenta-app/src/users" 10 | ) 11 | 12 | // HandleUpdateShow renders the form to update a user. 13 | func HandleUpdateShow(context router.Context) error { 14 | 15 | // Find the user 16 | user, err := users.Find(context.ParamInt("id")) 17 | if err != nil { 18 | return router.NotFoundError(err) 19 | } 20 | 21 | // Authorise update user 22 | err = can.Update(user, auth.CurrentUser(context)) 23 | if err != nil { 24 | return router.NotAuthorizedError(err) 25 | } 26 | 27 | // Render the template 28 | view := view.New(context) 29 | view.AddKey("user", user) 30 | return view.Render() 31 | } 32 | 33 | // HandleUpdate handles the POST of the form to update a user 34 | func HandleUpdate(context router.Context) error { 35 | 36 | // Find the user 37 | user, err := users.Find(context.ParamInt("id")) 38 | if err != nil { 39 | return router.NotFoundError(err) 40 | } 41 | 42 | // Authorise update user 43 | err = can.Update(user, auth.CurrentUser(context)) 44 | if err != nil { 45 | return router.NotAuthorizedError(err) 46 | } 47 | 48 | // Update the user from params 49 | params, err := context.Params() 50 | if err != nil { 51 | return router.InternalError(err) 52 | } 53 | 54 | // Validate the params, removing any we don't accept 55 | userParams := user.ValidateParams(params.Map(), users.AllowedParams()) 56 | 57 | err = user.Update(userParams) 58 | if err != nil { 59 | return router.InternalError(err) 60 | } 61 | 62 | // Redirect to user 63 | return router.Redirect(context, user.ShowURL()) 64 | } 65 | -------------------------------------------------------------------------------- /src/lib/editable/views/editable-toolbar-full.html.got: -------------------------------------------------------------------------------- 1 | 28 |
-------------------------------------------------------------------------------- /src/lib/status/status.go: -------------------------------------------------------------------------------- 1 | package status 2 | 3 | import ( 4 | "github.com/fragmenta/query" 5 | "github.com/fragmenta/view/helpers" 6 | ) 7 | 8 | // Status values valid in the status field added with status.ResourceStatus. 9 | const ( 10 | None = 0 11 | Draft = 1 12 | Suspended = 50 13 | Published = 100 14 | ) 15 | 16 | // ResourceStatus adds a status field to resources. 17 | type ResourceStatus struct { 18 | Status int64 19 | } 20 | 21 | // WherePublished modifies the given query to select status greater than published. 22 | // Note this selects >= Published. 23 | func WherePublished(q *query.Query) *query.Query { 24 | return q.Where("status >= ?", Published) 25 | } 26 | 27 | // Options returns an array of statuses for a status select. 28 | func Options() []helpers.Option { 29 | var options []helpers.Option 30 | 31 | options = append(options, helpers.Option{Id: Draft, Name: "Draft"}) 32 | options = append(options, helpers.Option{Id: Suspended, Name: "Suspended"}) 33 | options = append(options, helpers.Option{Id: Published, Name: "Published"}) 34 | 35 | return options 36 | } 37 | 38 | // OptionsAll returns a list of options starting with a None option using the name passed in, 39 | // which is useful for filter menus filtering on status. 40 | func OptionsAll(name string) []helpers.Option { 41 | options := Options() 42 | return append(options, helpers.Option{Id: None, Name: name}) 43 | } 44 | 45 | // StatusOptions returns an array of statuses for a status select for this resource. 46 | func (r *ResourceStatus) StatusOptions() []helpers.Option { 47 | return Options() 48 | } 49 | 50 | // StatusDisplay returns a string representation of the model status. 51 | func (r *ResourceStatus) StatusDisplay() string { 52 | for _, o := range r.StatusOptions() { 53 | if o.Id == r.Status { 54 | return o.Name 55 | } 56 | } 57 | return "" 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/resource/validate.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Methods for validating params passed from the database row as interface{} types 8 | // perhaps this should be a sub-package for clarity? 9 | 10 | // ValidateFloat returns the float value of param or 0.0 11 | func ValidateFloat(param interface{}) float64 { 12 | var v float64 13 | if param != nil { 14 | switch param.(type) { 15 | case float64: 16 | v = param.(float64) 17 | case float32: 18 | v = float64(param.(float32)) 19 | case int: 20 | v = float64(param.(int)) 21 | case int64: 22 | v = float64(param.(int64)) 23 | } 24 | } 25 | return v 26 | } 27 | 28 | // ValidateBoolean returns the bool value of param or false 29 | func ValidateBoolean(param interface{}) bool { 30 | var v bool 31 | if param != nil { 32 | switch param.(type) { 33 | case bool: 34 | v = param.(bool) 35 | } 36 | } 37 | return v 38 | } 39 | 40 | // ValidateInt returns the int value of param or 0 41 | func ValidateInt(param interface{}) int64 { 42 | var v int64 43 | if param != nil { 44 | switch param.(type) { 45 | case int64: 46 | v = param.(int64) 47 | case float64: 48 | v = int64(param.(float64)) 49 | case int: 50 | v = int64(param.(int)) 51 | } 52 | } 53 | return v 54 | } 55 | 56 | // ValidateString returns the string value of param or "" 57 | func ValidateString(param interface{}) string { 58 | var v string 59 | if param != nil { 60 | switch param.(type) { 61 | case string: 62 | v = param.(string) 63 | } 64 | } 65 | return v 66 | } 67 | 68 | // ValidateTime returns the time value of param or the zero value of time.Time 69 | func ValidateTime(param interface{}) time.Time { 70 | var v time.Time 71 | if param != nil { 72 | switch param.(type) { 73 | case time.Time: 74 | v = param.(time.Time) 75 | } 76 | } 77 | return v 78 | } 79 | -------------------------------------------------------------------------------- /src/lib/auth/authenticate_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http/httptest" 5 | "testing" 6 | 7 | "github.com/fragmenta/auth" 8 | ) 9 | 10 | var ( 11 | testKey = "12353bce2bbc4efb90eff81c29dc982de9a0176b568db18a61b4f4732cadabbc" 12 | set = "foo" 13 | ) 14 | 15 | // TestAuthenticate tests storing a value in a cookie and retreiving it again. 16 | func TestAuthenticate(t *testing.T) { 17 | 18 | r := httptest.NewRequest("GET", "/", nil) 19 | w := httptest.NewRecorder() 20 | 21 | // Setup auth with some test values - could read these from config I guess 22 | auth.HMACKey = auth.HexToBytes(testKey) 23 | auth.SecretKey = auth.HexToBytes(testKey) 24 | auth.SessionName = "test_session" 25 | 26 | // Build the session from the secure cookie, or create a new one 27 | session, err := auth.Session(w, r) 28 | if err != nil { 29 | t.Fatalf("auth: failed to build session") 30 | } 31 | 32 | // Write value 33 | session.Set(auth.SessionUserKey, set) 34 | 35 | // Set the cookie on the recorder 36 | err = session.Save(w) 37 | if err != nil { 38 | t.Fatalf("auth: failed to save session") 39 | } 40 | 41 | session.Set(auth.SessionUserKey, "bar") 42 | 43 | // Try with a bogus key, should fail 44 | auth.SecretKey = auth.HexToBytes(testKey + "bogus") 45 | err = session.Load(r) 46 | if err == nil { 47 | t.Fatalf("auth: failed to detect invalid key") 48 | } 49 | 50 | // TODO: Copy the Cookie over to a new Request and build another session 51 | /* 52 | r = httptest.NewRequest("GET", "/", nil) 53 | r.Header.Set("Cookie", strings.Join(w.HeaderMap["Set-Cookie"], "")) 54 | session, err = auth.Session(w, r) 55 | if err != nil { 56 | t.Fatalf("auth: failed to build session") 57 | } 58 | 59 | got := session.Get(auth.SessionUserKey) 60 | if got != set { 61 | t.Fatalf("auth: failed to get cookie expected:%s got:%s", set, got) 62 | } 63 | */ 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/lib/mail/mail.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/fragmenta/view" 8 | ) 9 | 10 | // TODO - add more mail services, at present only sendgrid is supported 11 | // Usage: 12 | // email := mail.New(recipient) 13 | // email.Subject = "blah" 14 | // email.Body = blah 15 | // mail.Send(email,context) 16 | 17 | // Sender is the interface for our adapters for mail services. 18 | type Sender interface { 19 | Send(email *Email) error 20 | } 21 | 22 | // Context defines a simple list of string:value pairs for mail templates. 23 | type Context map[string]interface{} 24 | 25 | // Production should be set to true in production environment. 26 | var Production = false 27 | 28 | // Service is the mail adapter to send with and should be set on startup. 29 | var Service Sender 30 | 31 | // Send the email using our default adapter and optional context. 32 | func Send(email *Email, context Context) error { 33 | // If we have a template, render the email in that template 34 | if email.Body == "" && email.Template != "" { 35 | var err error 36 | email.Body, err = RenderTemplate(email, context) 37 | if err != nil { 38 | return err 39 | } 40 | } 41 | 42 | // If dev just log and return, don't send messages 43 | if !Production { 44 | fmt.Printf("#debug mail sent:%s\n", email) 45 | return nil 46 | } 47 | 48 | return Service.Send(email) 49 | } 50 | 51 | // RenderTemplate renders the email into its template with context. 52 | func RenderTemplate(email *Email, context Context) (string, error) { 53 | if email.Template == "" || context == nil { 54 | return "", errors.New("mail: missing template or context") 55 | } 56 | 57 | view := view.NewWithPath("", nil) 58 | view.Layout(email.Layout) 59 | view.Template(email.Template) 60 | view.Context(context) 61 | body, err := view.RenderToStringWithLayout() 62 | if err != nil { 63 | return "", err 64 | } 65 | 66 | return body, nil 67 | } 68 | -------------------------------------------------------------------------------- /src/app/assets/styles/admin.css: -------------------------------------------------------------------------------- 1 | /* FORMS */ 2 | article form { 3 | padding:1rem 0; 4 | } 5 | 6 | article form.nopadding { 7 | padding:0; 8 | } 9 | 10 | label { 11 | color:#555; 12 | font-weight:normal; 13 | } 14 | 15 | .inline-fields .field { 16 | float:left; 17 | margin-right:3rem; 18 | } 19 | 20 | .wide-fields .field { 21 | clear:both; 22 | } 23 | 24 | .wide-fields input { 25 | width:100%; 26 | } 27 | 28 | .resource-update-form { 29 | position:relative; 30 | } 31 | 32 | section.actions { 33 | clear:both; 34 | padding:0; 35 | text-align:center; 36 | } 37 | 38 | section.actions .button { 39 | margin-left:2rem; 40 | } 41 | 42 | 43 | @media(min-width:1000px){ 44 | 45 | section.actions { 46 | text-align:right; 47 | margin-top:-6.5rem; 48 | } 49 | 50 | section.actions .button { 51 | float:right; 52 | } 53 | 54 | } 55 | 56 | 57 | section.admin-bar-actions { 58 | position:absolute; 59 | top:0; 60 | right:0; 61 | margin:0; 62 | padding:0.4rem 1rem; 63 | } 64 | 65 | 66 | 67 | /* TABLES */ 68 | 69 | .data-table { 70 | width:100%; 71 | } 72 | 73 | .data-table tr td { 74 | padding:0.5rem 1rem; 75 | } 76 | 77 | .data-table tr.odd { 78 | background-color:#eaeaea; 79 | } 80 | 81 | .data-table tr.level_1.odd, .data-table tr.level_2.odd, .data-table tr.level_3.odd, .data-table tr.level_4.odd { 82 | background-color:#fafafa; 83 | } 84 | 85 | .data-table tr.level_1 td:first-child { 86 | padding-left:2rem; 87 | } 88 | 89 | .data-table tr.level_2 td:first-child { 90 | padding-left:3rem; 91 | } 92 | 93 | .data-table tr.level_3 td:first-child { 94 | padding-left:4rem; 95 | } 96 | .data-table tr.level_4 td:first-child { 97 | padding-left:5rem; 98 | } 99 | 100 | .filter-form { 101 | clear:both; 102 | } 103 | 104 | .filter-form input { 105 | float:right; 106 | } -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/actions/create.go.tmpl: -------------------------------------------------------------------------------- 1 | package [[ .fragmenta_resource ]]actions 2 | 3 | import ( 4 | "github.com/fragmenta/auth/can" 5 | "github.com/fragmenta/router" 6 | "github.com/fragmenta/view" 7 | 8 | "github.com/fragmenta/fragmenta-app/src/lib/auth" 9 | "github.com/fragmenta/fragmenta-app/src/[[ .fragmenta_resources ]]" 10 | ) 11 | 12 | // HandleCreateShow serves the create form via GET for [[ .fragmenta_resources ]]. 13 | func HandleCreateShow(context router.Context) error { 14 | 15 | [[ .fragmenta_resource ]] := [[ .fragmenta_resources ]].New() 16 | 17 | // Authorise 18 | err := can.Create([[ .fragmenta_resource ]], auth.CurrentUser(context)) 19 | if err != nil { 20 | return router.NotAuthorizedError(err) 21 | } 22 | 23 | // Render the template 24 | view := view.New(context) 25 | view.AddKey("[[ .fragmenta_resource ]]", [[ .fragmenta_resource ]]) 26 | return view.Render() 27 | } 28 | 29 | // HandleCreate handles the POST of the create form for [[ .fragmenta_resources ]] 30 | func HandleCreate(context router.Context) error { 31 | 32 | [[ .fragmenta_resource ]] := [[ .fragmenta_resources ]].New() 33 | 34 | // Authorise 35 | err := can.Create([[ .fragmenta_resource ]], auth.CurrentUser(context)) 36 | if err != nil { 37 | return router.NotAuthorizedError(err) 38 | } 39 | 40 | // Setup context 41 | params, err := context.Params() 42 | if err != nil { 43 | return router.InternalError(err) 44 | } 45 | 46 | // Validate the params, removing any we don't accept 47 | [[ .fragmenta_resource ]]Params := [[ .fragmenta_resource ]].ValidateParams(params.Map(), [[ .fragmenta_resources ]].AllowedParams()) 48 | 49 | id, err := [[ .fragmenta_resource ]].Create([[ .fragmenta_resource ]]Params) 50 | if err != nil { 51 | return router.InternalError(err) 52 | } 53 | 54 | // Redirect to the new [[ .fragmenta_resource ]] 55 | [[ .fragmenta_resource ]], err = [[ .fragmenta_resources ]].Find(id) 56 | if err != nil { 57 | return router.InternalError(err) 58 | } 59 | 60 | return router.Redirect(context, [[ .fragmenta_resource ]].IndexURL()) 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/mail/adapters/sendgrid/sendgrid.go: -------------------------------------------------------------------------------- 1 | package sendgrid 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sendgrid/sendgrid-go" 7 | "github.com/sendgrid/sendgrid-go/helpers/mail" 8 | 9 | m "github.com/fragmenta/fragmenta-app/src/lib/mail" 10 | ) 11 | 12 | // Service sends mail via sendgrid and conforms to mail.Service. 13 | type Service struct { 14 | from string 15 | secret string 16 | } 17 | 18 | // New returns a new sendgrid Service. 19 | func New(f string, s string) *Service { 20 | return &Service{ 21 | from: f, 22 | secret: s, 23 | } 24 | } 25 | 26 | // Send the given message to recipients, using the context to render it 27 | func (s *Service) Send(email *m.Email) error { 28 | 29 | if s.secret == "" { 30 | return errors.New("mail: invalid mail settings") 31 | } 32 | 33 | // Set the default from if required 34 | if email.ReplyTo == "" { 35 | email.ReplyTo = s.from 36 | } 37 | 38 | // Check if other fields are filled in on email 39 | if email.Invalid() { 40 | return errors.New("mail: attempt to send invalid email") 41 | } 42 | 43 | // Create a sendgrid message with the byzantine sendgrid API 44 | sendgridContent := mail.NewContent("text/html", email.Body) 45 | var sendgridRecipients []*mail.Email 46 | for _, r := range email.Recipients { 47 | // We could possibly split recipients on <> to get email (e.g. name) 48 | // for now we assume they are just an email address 49 | sendgridRecipients = append(sendgridRecipients, mail.NewEmail("", r)) 50 | } 51 | 52 | message := mail.NewV3Mail() 53 | message.Subject = email.Subject 54 | message.From = mail.NewEmail("", email.ReplyTo) 55 | if email.ReplyTo != "" { 56 | message.SetReplyTo(mail.NewEmail("", email.ReplyTo)) 57 | } 58 | p := mail.NewPersonalization() 59 | p.AddTos(sendgridRecipients...) 60 | message.AddPersonalizations(p) 61 | message.AddContent(sendgridContent) 62 | 63 | request := sendgrid.GetRequest(s.secret, "/v3/mail/send", "https://api.sendgrid.com") 64 | request.Method = "POST" 65 | request.Body = mail.GetRequestBody(message) 66 | _, err := sendgrid.API(request) 67 | return err 68 | } 69 | -------------------------------------------------------------------------------- /src/app/assets/scripts/app.js: -------------------------------------------------------------------------------- 1 | DOM.Ready(function(){ 2 | // Show/Hide elements with selector in attribute data-show 3 | ActivateShowlinks(); 4 | // Perform AJAX post on click on method=post|delete anchors 5 | ActivateMethodLinks(); 6 | // Submit forms of class .filter-form when filter fields change 7 | ActivateFilterFields(); 8 | }); 9 | 10 | // Perform AJAX post on click on method=post|delete anchors 11 | function ActivateMethodLinks() { 12 | DOM.On('a[method="post"], a[method="delete"]','click',function(e){ 13 | // Confirm action before delete 14 | if (this.getAttribute('method') == 'delete') { 15 | if (!confirm('Are you sure you want to delete this item, this action cannot be undone?')) { 16 | return false; 17 | } 18 | } 19 | 20 | // Perform a post to the specified url (href of link) 21 | var url = this.getAttribute('href'); 22 | var redirect = this.getAttribute('data-redirect'); 23 | 24 | DOM.Post(url,function(){ 25 | if (redirect.length > 0) { 26 | // If we have a redirect, redirect to it after the link is clicked 27 | window.location = redirect; 28 | } else { 29 | // If no redirect supplied, we just reload the current screen 30 | window.location.reload(); 31 | } 32 | },function(){ 33 | console.log("#error POST to"+url+"failed"); 34 | }); 35 | 36 | 37 | return false; 38 | }); 39 | } 40 | 41 | // Submit forms of class .filter-form when filter fields change 42 | function ActivateFilterFields() { 43 | DOM.On('.filter-form .field select, .filter-form .field input','change',function(e){ 44 | this.form.submit(); 45 | }); 46 | } 47 | 48 | // Show/Hide elements with selector in attribute href - do this with a hidden class name 49 | function ActivateShowlinks() { 50 | DOM.On('.show','click',function(e){ 51 | var selector = this.getAttribute('href'); 52 | DOM.Each(selector,function(el,i){ 53 | if (el.className != 'hidden') { 54 | el.className = 'hidden'; 55 | } else { 56 | el.className = el.className.replace(/hidden/gi,''); 57 | } 58 | }); 59 | 60 | return false; 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/app/app_test.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "testing" 9 | 10 | "github.com/fragmenta/auth/can" 11 | "github.com/fragmenta/router" 12 | "github.com/fragmenta/view" 13 | 14 | "github.com/fragmenta/fragmenta-app/src/lib/resource" 15 | "github.com/fragmenta/fragmenta-app/src/users" 16 | ) 17 | 18 | var config = &resource.MockConfig{} 19 | 20 | // TestRouter tests our routes are functioning correctly. 21 | func TestRouter(t *testing.T) { 22 | 23 | logger := log.New(os.Stderr, "test:", log.Lshortfile) 24 | 25 | // Set up the router with mock config 26 | router, err := router.New(logger, config) 27 | if err != nil { 28 | t.Fatalf("app: error creating router %s", err) 29 | } 30 | 31 | // Set up routes 32 | SetupRoutes(router) 33 | 34 | // Setup our view templates (required for home route) 35 | err = view.LoadTemplatesAtPaths([]string{".."}, view.Helpers) 36 | if err != nil { 37 | t.Fatalf("app: error reading templates %s", err) 38 | } 39 | 40 | // Test serving the route / which should always exist 41 | r := httptest.NewRequest("GET", "/", nil) 42 | w := httptest.NewRecorder() 43 | router.ServeHTTP(w, r) 44 | 45 | // Test code on response 46 | if w.Code != http.StatusOK { 47 | t.Fatalf("app: error code on / expected:%d got:%d", http.StatusOK, w.Code) 48 | } 49 | 50 | } 51 | 52 | // TestAuth tests our authentication is functioning after setup. 53 | func TestAuth(t *testing.T) { 54 | 55 | SetupAuth(config) 56 | 57 | user := &users.User{} 58 | 59 | // Test anon cannot access /users 60 | err := can.List(user, users.MockAnon()) 61 | if err == nil { 62 | t.Fatalf("app: authentication block failed for anon") 63 | } 64 | 65 | // Test anon cannot edit admin user 66 | err = can.Update(users.MockAdmin(), users.MockAnon()) 67 | if err == nil { 68 | t.Fatalf("app: authentication block failed for anon") 69 | } 70 | 71 | // Test admin can access /users 72 | err = can.List(user, users.MockAdmin()) 73 | if err != nil { 74 | t.Fatalf("app: authentication failed for admin") 75 | } 76 | 77 | // Test admin can admin user 78 | err = can.Manage(user, users.MockAdmin()) 79 | if err != nil { 80 | t.Fatalf("app: authentication failed for admin") 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/actions/update.go.tmpl: -------------------------------------------------------------------------------- 1 | package [[ .fragmenta_resource ]]actions 2 | 3 | import ( 4 | "github.com/fragmenta/auth/can" 5 | "github.com/fragmenta/router" 6 | "github.com/fragmenta/view" 7 | 8 | "github.com/fragmenta/fragmenta-app/src/lib/auth" 9 | "github.com/fragmenta/fragmenta-app/src/[[ .fragmenta_resources ]]" 10 | ) 11 | 12 | // HandleUpdateShow renders the form to update a [[ .fragmenta_resource ]]. 13 | func HandleUpdateShow(context router.Context) error { 14 | 15 | // Find the [[ .fragmenta_resource ]] 16 | [[ .fragmenta_resource ]], err := [[ .fragmenta_resources ]].Find(context.ParamInt("id")) 17 | if err != nil { 18 | return router.NotFoundError(err) 19 | } 20 | 21 | // Authorise update [[ .fragmenta_resource ]] 22 | err = can.Update([[ .fragmenta_resource ]], auth.CurrentUser(context)) 23 | if err != nil { 24 | return router.NotAuthorizedError(err) 25 | } 26 | 27 | // Render the template 28 | view := view.New(context) 29 | view.AddKey("[[ .fragmenta_resource ]]", [[ .fragmenta_resource ]]) 30 | return view.Render() 31 | } 32 | 33 | // HandleUpdate handles the POST of the form to update a [[ .fragmenta_resource ]] 34 | func HandleUpdate(context router.Context) error { 35 | 36 | // Find the [[ .fragmenta_resource ]] 37 | [[ .fragmenta_resource ]], err := [[ .fragmenta_resources ]].Find(context.ParamInt("id")) 38 | if err != nil { 39 | return router.NotFoundError(err) 40 | } 41 | 42 | // Authorise update [[ .fragmenta_resource ]] 43 | err = can.Update([[ .fragmenta_resource ]], auth.CurrentUser(context)) 44 | if err != nil { 45 | return router.NotAuthorizedError(err) 46 | } 47 | 48 | // Update the [[ .fragmenta_resource ]] from params 49 | params, err := context.Params() 50 | if err != nil { 51 | return router.InternalError(err) 52 | } 53 | 54 | // Validate the params, removing any we don't accept 55 | [[ .fragmenta_resource ]]Params := [[ .fragmenta_resource ]].ValidateParams(params.Map(), [[ .fragmenta_resources ]].AllowedParams()) 56 | 57 | err = [[ .fragmenta_resource ]].Update([[ .fragmenta_resource ]]Params) 58 | if err != nil { 59 | return router.InternalError(err) 60 | } 61 | 62 | // Redirect to [[ .fragmenta_resource ]] 63 | return router.Redirect(context, [[ .fragmenta_resource ]].ShowURL()) 64 | } 65 | -------------------------------------------------------------------------------- /public/assets/styles/normalize.min.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:0;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}button,html [type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{border-style:none;padding:0}button:-moz-focusring,[type="button"]:-moz-focusring,[type="reset"]:-moz-focusring,[type="submit"]:-moz-focusring{outline:1px dotted ButtonText}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type="checkbox"],[type="radio"]{box-sizing:border-box;padding:0}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}[type="search"]::-webkit-search-cancel-button,[type="search"]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}template{display:none}[hidden]{display:none} -------------------------------------------------------------------------------- /src/lib/resource/resource.go: -------------------------------------------------------------------------------- 1 | // Package resource provides some shared behaviour for resources, and basic CRUD and URL helpers. 2 | package resource 3 | 4 | import ( 5 | "crypto/sha256" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/fragmenta/auth" 10 | ) 11 | 12 | // Base defines shared fields and behaviour for resources. 13 | type Base struct { 14 | // ID is the default primary key of the resource. 15 | ID int64 16 | 17 | // CreatedAt stores the creation time of the resource. 18 | CreatedAt time.Time 19 | 20 | // UpdatedAt stores the last update time of the resource. 21 | UpdatedAt time.Time 22 | 23 | // TableName is used for database queries and urls. 24 | TableName string 25 | 26 | // KeyName is used for database queries as the primary key. 27 | KeyName string 28 | } 29 | 30 | // String returns a string representation of the resource 31 | func (r *Base) String() string { 32 | return fmt.Sprintf("%s/%d", r.TableName, r.ID) 33 | } 34 | 35 | // Queryable interface 36 | 37 | // Table returns the table name for this object 38 | func (r *Base) Table() string { 39 | return r.TableName 40 | } 41 | 42 | // PrimaryKey returns the id for primary key by default - used by query 43 | func (r *Base) PrimaryKey() string { 44 | return r.KeyName 45 | } 46 | 47 | // PrimaryKeyValue returns the unique id 48 | func (r *Base) PrimaryKeyValue() int64 { 49 | return r.ID 50 | } 51 | 52 | // Selectable interface 53 | 54 | // SelectName returns our name for select menus 55 | func (r *Base) SelectName() string { 56 | return fmt.Sprintf("%s-%d", r.TableName, r.ID) 57 | } 58 | 59 | // SelectValue returns our value for select options 60 | func (r *Base) SelectValue() string { 61 | return fmt.Sprintf("%d", r.ID) 62 | } 63 | 64 | // Cacheable interface 65 | 66 | // CacheKey generates a cache key for this resource 67 | // based on the TableName, ID and UpdatedAt 68 | func (r *Base) CacheKey() string { 69 | key := []byte(fmt.Sprintf("%s/%d/%s", r.TableName, r.ID, r.UpdatedAt)) 70 | hash := sha256.Sum256(key) 71 | return auth.BytesToHex(hash[:32]) 72 | } 73 | 74 | // can.Resource interface 75 | 76 | // OwnedBy returns true if the user id passed in owns this resource. 77 | func (r *Base) OwnedBy(uid int64) bool { 78 | return false 79 | } 80 | 81 | // ResourceID returns a key unique to this resource (we use table). 82 | func (r *Base) ResourceID() string { 83 | return r.TableName 84 | } 85 | -------------------------------------------------------------------------------- /src/lib/editable/assets/styles/editable.css: -------------------------------------------------------------------------------- 1 | /* Editable.js styling */ 2 | 3 | 4 | .toolbar { 5 | list-style: none; 6 | min-height: 4rem; 7 | padding: 0; 8 | margin: 0; 9 | 10 | border-top: 2px solid #ccc; 11 | border-bottom: 2px solid #ccc; 12 | } 13 | 14 | 15 | .toolbar li { 16 | padding: 0; 17 | margin: 0; 18 | float: left; 19 | position: relative; 20 | } 21 | 22 | 23 | .toolbar li a { 24 | margin: 0; 25 | width: 4rem; 26 | height: 4rem; 27 | line-height: 4rem; 28 | display: block; 29 | text-align: center; 30 | margin-top: -1px; 31 | overflow: hidden; 32 | top: 0; 33 | background-color: #fff; 34 | 35 | border-top: 1px solid #ccc; 36 | border-bottom: 1px solid #ccc; 37 | border-right:1px solid #eee; 38 | } 39 | 40 | 41 | .toolbar li.right { 42 | float: right; 43 | border-left: 1px solid #ccc; 44 | } 45 | 46 | 47 | .toolbar li:first-child { 48 | } 49 | 50 | 51 | .toolbar li a:hover { 52 | background-color: #dedede; 53 | } 54 | 55 | 56 | .toolbar li.clear { 57 | clear: both; 58 | } 59 | 60 | 61 | .toolbar .button-blockquote { 62 | font-size: 2em; 63 | min-width: 4rem; 64 | position: relative; 65 | top: 0.28em; 66 | } 67 | 68 | 69 | .toolbar .button-ol { 70 | font-size: 0.6em; 71 | line-height: 1.3em; 72 | top: 0.6rem; 73 | position: relative; 74 | display: block; 75 | } 76 | 77 | 78 | .toolbar .button-ul { 79 | font-size: 0.6em; 80 | line-height: 1.3em; 81 | top: 0.6rem; 82 | position: relative; 83 | display: block; 84 | } 85 | 86 | 87 | .toolbar .button-code { 88 | font-size: 0.7em; 89 | font-family: monospace; 90 | } 91 | 92 | 93 | .content-editable { 94 | border-bottom: 2px solid #ccc; 95 | margin-top: -0.3rem; 96 | margin-bottom: 2rem; 97 | padding-bottom: 1rem; 98 | z-index: 2; 99 | position: relative; 100 | } 101 | 102 | 103 | .content-editable:focus { 104 | outline: 0; 105 | } 106 | 107 | 108 | .padded-content-editable { 109 | padding: 1rem; 110 | background-color: #fff; 111 | } 112 | 113 | 114 | 115 | .content-textarea { 116 | width: 100%; 117 | min-height: 50em; 118 | background-color: #222; 119 | color: #ddd; 120 | font: 14px/1.5em 'Consolas', 'Monaco', 'Lucida Console', 'Liberation Mono', 'Mono', 'Courier New', monospace; 121 | padding: 2rem; 122 | border-radius: 0; 123 | } 124 | 125 | -------------------------------------------------------------------------------- /src/users/role.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/fragmenta/query" 5 | "github.com/fragmenta/view/helpers" 6 | 7 | "github.com/fragmenta/fragmenta-app/src/lib/resource" 8 | ) 9 | 10 | // This file contains functions related to authorisation and roles. 11 | 12 | // User roles 13 | const ( 14 | Anon = 0 15 | Editor = 10 16 | Reader = 20 17 | Admin = 100 18 | ) 19 | 20 | // RoleOptions returns an array of Role values for this model (embedders may override this and roledisplay to extend) 21 | func (u *User) RoleOptions() []helpers.Option { 22 | var options []helpers.Option 23 | 24 | options = append(options, helpers.Option{Id: Reader, Name: "Reader"}) 25 | options = append(options, helpers.Option{Id: Editor, Name: "Editor"}) 26 | options = append(options, helpers.Option{Id: Admin, Name: "Administrator"}) 27 | 28 | return options 29 | } 30 | 31 | // RoleDisplay returns the string representation of the Role status 32 | func (u *User) RoleDisplay() string { 33 | for _, o := range u.RoleOptions() { 34 | if o.Id == u.Role { 35 | return o.Name 36 | } 37 | } 38 | return "" 39 | } 40 | 41 | // Anon returns true if this user is not a logged in user. 42 | func (u *User) Anon() bool { 43 | return u.Role == Anon || u.ID == 0 44 | } 45 | 46 | // Admin returns true if this user is an Admin. 47 | func (u *User) Admin() bool { 48 | return u.Role == Admin 49 | } 50 | 51 | // Reader returns true if this user is an Reader. 52 | func (u *User) Reader() bool { 53 | return u.Role == Reader 54 | } 55 | 56 | // Admins returns a query which finds all admin users 57 | func Admins() *query.Query { 58 | return Query().Where("role=?", Admin).Order("name asc") 59 | } 60 | 61 | // Editors returns a query which finds all editor users 62 | func Editors() *query.Query { 63 | return Query().Where("role=?", Editor).Order("name asc") 64 | } 65 | 66 | // Readers returns a query which finds all reader users 67 | func Readers() *query.Query { 68 | return Query().Where("role=?", Reader).Order("name asc") 69 | } 70 | 71 | // can.User interface 72 | 73 | // RoleID returns the user role for auth. 74 | func (u *User) RoleID() int64 { 75 | return u.Role 76 | } 77 | 78 | // UserID returns the user id for auth. 79 | func (u *User) UserID() int64 { 80 | return u.ID 81 | } 82 | 83 | // MockAnon returns a mock user for testing with Role Anon. 84 | func MockAnon() *User { 85 | return &User{Role: Anon, Email: "anon@example.com"} 86 | } 87 | 88 | // MockAdmin returns a mock user for testing with Role Admin. 89 | func MockAdmin() *User { 90 | return &User{Role: Admin, Email: "admin@example.com", Base: resource.Base{ID: 1}} 91 | } 92 | -------------------------------------------------------------------------------- /src/users/actions/login.go: -------------------------------------------------------------------------------- 1 | package useractions 2 | 3 | import ( 4 | "fmt" 5 | 6 | authenticate "github.com/fragmenta/auth" 7 | "github.com/fragmenta/router" 8 | "github.com/fragmenta/view" 9 | 10 | "github.com/fragmenta/fragmenta-app/src/lib/auth" 11 | "github.com/fragmenta/fragmenta-app/src/users" 12 | ) 13 | 14 | // HandleLoginShow shows the page at /users/login 15 | func HandleLoginShow(context router.Context) error { 16 | 17 | // Check they're not logged in already. 18 | if !auth.CurrentUser(context).Anon() { 19 | return router.Redirect(context, "/?warn=already_logged_in") 20 | } 21 | 22 | // Show the login page, with login failure warnings. 23 | view := view.New(context) 24 | switch context.Param("error") { 25 | case "failed_email": 26 | view.AddKey("warning", "Sorry, we couldn't find a user with that email.") 27 | case "failed_password": 28 | view.AddKey("warning", "Sorry, the password was incorrect, please try again.") 29 | } 30 | return view.Render() 31 | } 32 | 33 | // HandleLogin responds to POST /users/login 34 | // by setting a cookie on the request with encrypted user data. 35 | func HandleLogin(context router.Context) error { 36 | 37 | // Check they're not logged in already if so redirect. 38 | if !auth.CurrentUser(context).Anon() { 39 | return router.Redirect(context, "/?warn=already_logged_in") 40 | } 41 | 42 | // Get the user details from the database 43 | params, err := context.Params() 44 | if err != nil { 45 | return router.NotFoundError(err) 46 | } 47 | 48 | // Fetch the first user 49 | user, err := users.FindFirst("email=?", params.Get("email")) 50 | if err != nil { 51 | context.Logf("#error Login failed for user no such user : %s %s", params.Get("email"), err) 52 | return router.Redirect(context, "/users/login?error=failed_email") 53 | } 54 | 55 | // Check password against the stored password 56 | err = authenticate.CheckPassword(params.Get("password"), user.PasswordHash) 57 | if err != nil { 58 | context.Logf("#error Login failed for user : %s %s", params.Get("email"), err) 59 | return router.Redirect(context, "/users/login?error=failed_password") 60 | } 61 | 62 | // Now save the user details in a secure cookie, so that we remember the next request 63 | session, err := authenticate.Session(context, context.Request()) 64 | if err != nil { 65 | context.Logf("#error problem retrieving session") 66 | } 67 | 68 | // Success, log it and set the cookie with user id 69 | context.Logf("#info Login success for user: %d %s", user.ID, user.Email) 70 | session.Set(authenticate.SessionUserKey, fmt.Sprintf("%d", user.ID)) 71 | session.Save(context) 72 | 73 | // Redirect - ideally here we'd redirect to their original request path 74 | return router.Redirect(context, "/") 75 | } 76 | -------------------------------------------------------------------------------- /src/app/handlers.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "path" 7 | "strings" 8 | 9 | "github.com/fragmenta/router" 10 | "github.com/fragmenta/view" 11 | ) 12 | 13 | // Serve static files (assets, images etc) 14 | func fileHandler(context router.Context) error { 15 | 16 | // First try serving assets 17 | err := serveAsset(context) 18 | if err == nil { 19 | return nil 20 | } 21 | 22 | // If assets fail, try to serve file in public 23 | return serveFile(context) 24 | } 25 | 26 | // serveFile serves a file from ./public if it exists 27 | func serveFile(context router.Context) error { 28 | 29 | // Try a local path in the public directory 30 | localPath := "./public" + path.Clean(context.Path()) 31 | s, err := os.Stat(localPath) 32 | if err != nil { 33 | // If file not found return 404 34 | if os.IsNotExist(err) { 35 | return router.NotFoundError(err) 36 | } 37 | 38 | // For other errors return not authorised 39 | return router.NotAuthorizedError(err) 40 | } 41 | 42 | // If not a file return immediately 43 | if s.IsDir() { 44 | return nil 45 | } 46 | 47 | // If the file exists and we can access it, serve it with cache control 48 | context.Writer().Header().Set("Cache-Control", "max-age:3456000, public") 49 | http.ServeFile(context, context.Request(), localPath) 50 | return nil 51 | } 52 | 53 | // serveAsset serves a file from ./public/assets usings appAssets 54 | func serveAsset(context router.Context) error { 55 | p := path.Clean(context.Path()) 56 | 57 | // It must be under /assets, or we don't serve 58 | if !strings.HasPrefix(p, "/assets/") { 59 | return router.NotFoundError(nil) 60 | } 61 | 62 | // Try to find an asset in our list 63 | f := appAssets.File(path.Base(p)) 64 | if f == nil { 65 | return router.NotFoundError(nil) 66 | } 67 | 68 | // Serve the local file, with cache control 69 | localPath := "./" + f.LocalPath() 70 | context.Writer().Header().Set("Cache-Control", "max-age:3456000, public") 71 | http.ServeFile(context, context.Request(), localPath) 72 | return nil 73 | } 74 | 75 | // errHandler renders an error using error templates if available 76 | func errHandler(context router.Context, e error) { 77 | 78 | // Cast the error to a status error if it is one, if not wrap it in a Status 500 error 79 | err := router.ToStatusError(e) 80 | context.Logf("#error %s\n", err) 81 | 82 | view := view.New(context) 83 | view.AddKey("title", err.Title) 84 | view.AddKey("message", err.Message) 85 | // In production, provide no detail for security reasons 86 | if !context.Production() { 87 | view.AddKey("status", err.Status) 88 | view.AddKey("file", err.FileLine()) 89 | view.AddKey("error", err.Err) 90 | } 91 | view.Template("app/views/error.html.got") 92 | context.Writer().WriteHeader(err.Status) 93 | view.Render() 94 | } 95 | -------------------------------------------------------------------------------- /src/users/users_test.go: -------------------------------------------------------------------------------- 1 | // Tests for the users package 2 | package users 3 | 4 | import ( 5 | "testing" 6 | 7 | "github.com/fragmenta/fragmenta-app/src/lib/resource" 8 | ) 9 | 10 | func TestSetup(t *testing.T) { 11 | err := resource.SetupTestDatabase(2) 12 | if err != nil { 13 | t.Fatalf("users: Setup db failed %s", err) 14 | } 15 | } 16 | 17 | // Test Create method 18 | func TestCreateUser(t *testing.T) { 19 | name := "'fué ';'\"" 20 | userParams := map[string]string{"name": name} 21 | id, err := New().Create(userParams) 22 | if err != nil { 23 | t.Fatalf("users: Create user failed :%s", err) 24 | } 25 | 26 | user, err := Find(id) 27 | if err != nil { 28 | t.Fatalf("users: Create user find failed") 29 | } 30 | 31 | if user.Name != name { 32 | t.Fatalf("users: Create user name failed expected:%s got:%s", name, user.Name) 33 | } 34 | 35 | } 36 | 37 | // Test Index (List) method 38 | func TestListUsers(t *testing.T) { 39 | 40 | // Get all users (we should have at least one) 41 | results, err := FindAll(Query()) 42 | if err != nil { 43 | t.Fatalf("users: List no user found :%s", err) 44 | } 45 | 46 | if len(results) < 1 { 47 | t.Fatalf("users: List no users found :%s", err) 48 | } 49 | 50 | } 51 | 52 | // Test Update method 53 | func TestUpdateUser(t *testing.T) { 54 | 55 | // Get the last user (created in TestCreateUser above) 56 | results, err := FindAll(Query()) 57 | if err != nil || len(results) == 0 { 58 | t.Fatalf("users: Destroy no user found :%s", err) 59 | } 60 | user := results[0] 61 | 62 | name := "bar" 63 | userParams := map[string]string{"name": name} 64 | err = user.Update(userParams) 65 | if err != nil { 66 | t.Fatalf("users: Update user failed :%s", err) 67 | } 68 | 69 | // Fetch the user again from db 70 | user, err = Find(user.ID) 71 | if err != nil { 72 | t.Fatalf("users: Update user fetch failed :%s", user.Name) 73 | } 74 | 75 | if user.Name != name { 76 | t.Fatalf("users: Update user failed :%s", user.Name) 77 | } 78 | 79 | } 80 | 81 | // Test Destroy method 82 | func TestDestroyUser(t *testing.T) { 83 | 84 | results, err := FindAll(Query()) 85 | if err != nil || len(results) == 0 { 86 | t.Fatalf("users: Destroy no user found :%s", err) 87 | } 88 | user := results[0] 89 | count := len(results) 90 | 91 | err = user.Destroy() 92 | if err != nil { 93 | t.Fatalf("users: Destroy user failed :%s", err) 94 | } 95 | 96 | // Check new length of users returned 97 | results, err = FindAll(Query()) 98 | if err != nil { 99 | t.Fatalf("users: Destroy error getting results :%s", err) 100 | } 101 | 102 | // length should be one less than previous 103 | if len(results) != count-1 { 104 | t.Fatalf("users: Destroy user count wrong :%d", len(results)) 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/fragmenta_resources.go.tmpl: -------------------------------------------------------------------------------- 1 | // Package [[ .fragmenta_resources ]] represents the [[ .fragmenta_resource ]] resource 2 | package [[ .fragmenta_resources ]] 3 | 4 | import ( 5 | "time" 6 | 7 | "github.com/fragmenta/query" 8 | 9 | "github.com/fragmenta/fragmenta-app/src/lib/resource" 10 | "github.com/fragmenta/fragmenta-app/src/lib/status" 11 | ) 12 | 13 | // [[ .Fragmenta_Resource ]] handles saving and retreiving [[ .fragmenta_resources ]] from the database 14 | type [[ .Fragmenta_Resource ]] struct { 15 | // resource.Base defines behaviour and fields shared between all resources 16 | resource.Base 17 | // status.ResourceStatus defines a status field and associated behaviour 18 | status.ResourceStatus 19 | 20 | [[ .fragmenta_fields ]] 21 | } 22 | 23 | const ( 24 | // TableName is the database table for this resource 25 | TableName = "[[ .fragmenta_resources ]]" 26 | // KeyName is the primary key value for this resource 27 | KeyName = "id" 28 | // Order defines the default sort order in sql for this resource 29 | Order = "name asc, id desc" 30 | ) 31 | 32 | // AllowedParams returns an array of allowed param keys for Update and Create. 33 | func AllowedParams() []string { 34 | return []string{"status", [[ .fragmenta_columns ]]} 35 | } 36 | 37 | // NewWithColumns creates a new [[ .fragmenta_resource ]] instance and fills it with data from the database cols provided. 38 | func NewWithColumns(cols map[string]interface{}) *[[ .Fragmenta_Resource ]] { 39 | 40 | [[ .fragmenta_resource ]] := New() 41 | [[ .fragmenta_resource ]].ID = resource.ValidateInt(cols["id"]) 42 | [[ .fragmenta_resource ]].CreatedAt = resource.ValidateTime(cols["created_at"]) 43 | [[ .fragmenta_resource ]].UpdatedAt = resource.ValidateTime(cols["updated_at"]) 44 | [[ .fragmenta_resource ]].Status = resource.ValidateInt(cols["status"]) 45 | [[ .fragmenta_new_fields ]] 46 | 47 | return [[ .fragmenta_resource ]] 48 | } 49 | 50 | // New creates and initialises a new [[ .fragmenta_resource ]] instance. 51 | func New() *[[ .Fragmenta_Resource ]] { 52 | [[ .fragmenta_resource ]] := &[[ .Fragmenta_Resource ]]{} 53 | [[ .fragmenta_resource ]].CreatedAt = time.Now() 54 | [[ .fragmenta_resource ]].UpdatedAt = time.Now() 55 | [[ .fragmenta_resource ]].TableName = TableName 56 | [[ .fragmenta_resource ]].KeyName = KeyName 57 | [[ .fragmenta_resource ]].Status = status.Draft 58 | return [[ .fragmenta_resource ]] 59 | } 60 | 61 | // Find fetches a single [[ .fragmenta_resource ]] record from the database by id. 62 | func Find(id int64) (*[[ .Fragmenta_Resource ]], error) { 63 | result, err := Query().Where("id=?", id).FirstResult() 64 | if err != nil { 65 | return nil, err 66 | } 67 | return NewWithColumns(result), nil 68 | } 69 | 70 | // FindAll fetches all [[ .fragmenta_resource ]] records matching this query from the database. 71 | func FindAll(q *query.Query) ([]*[[ .Fragmenta_Resource ]], error) { 72 | 73 | // Fetch query.Results from query 74 | results, err := q.Results() 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | // Return an array of [[ .fragmenta_resources ]] constructed from the results 80 | var [[ .fragmenta_resources ]] []*[[ .Fragmenta_Resource ]] 81 | for _, cols := range results { 82 | p := NewWithColumns(cols) 83 | [[ .fragmenta_resources ]] = append([[ .fragmenta_resources ]], p) 84 | } 85 | 86 | return [[ .fragmenta_resources ]], nil 87 | } 88 | -------------------------------------------------------------------------------- /src/users/users.go: -------------------------------------------------------------------------------- 1 | // Package users represents the user resource 2 | package users 3 | 4 | import ( 5 | "time" 6 | 7 | "github.com/fragmenta/query" 8 | 9 | "github.com/fragmenta/fragmenta-app/src/lib/resource" 10 | "github.com/fragmenta/fragmenta-app/src/lib/status" 11 | ) 12 | 13 | // User handles saving and retreiving users from the database 14 | type User struct { 15 | // resource.Base defines behaviour and fields shared between all resources 16 | resource.Base 17 | 18 | // status.ResourceStatus defines a status field and associated behaviour 19 | status.ResourceStatus 20 | 21 | // Authorisation 22 | Role int64 23 | 24 | // Authentication 25 | PasswordHash string 26 | PasswordResetToken string 27 | PasswordResetAt time.Time 28 | 29 | // User details 30 | Email string 31 | Name string 32 | } 33 | 34 | const ( 35 | // TableName is the database table for this resource 36 | TableName = "users" 37 | // KeyName is the primary key value for this resource 38 | KeyName = "id" 39 | // Order defines the default sort order in sql for this resource 40 | Order = "name asc, id desc" 41 | ) 42 | 43 | // AllowedParams returns an array of allowed param keys for Update and Create. 44 | func AllowedParams() []string { 45 | return []string{"status", "email", "name", "role"} 46 | } 47 | 48 | // NewWithColumns creates a new user instance and fills it with data from the database cols provided. 49 | func NewWithColumns(cols map[string]interface{}) *User { 50 | 51 | user := New() 52 | user.ID = resource.ValidateInt(cols["id"]) 53 | user.CreatedAt = resource.ValidateTime(cols["created_at"]) 54 | user.UpdatedAt = resource.ValidateTime(cols["updated_at"]) 55 | user.Status = resource.ValidateInt(cols["status"]) 56 | user.Email = resource.ValidateString(cols["email"]) 57 | user.Name = resource.ValidateString(cols["name"]) 58 | user.Role = resource.ValidateInt(cols["role"]) 59 | 60 | user.PasswordHash = resource.ValidateString(cols["password_hash"]) 61 | user.PasswordResetToken = resource.ValidateString(cols["password_reset_token"]) 62 | user.PasswordResetAt = resource.ValidateTime(cols["password_reset_at"]) 63 | 64 | return user 65 | } 66 | 67 | // New creates and initialises a new user instance. 68 | func New() *User { 69 | user := &User{} 70 | user.CreatedAt = time.Now() 71 | user.UpdatedAt = time.Now() 72 | user.TableName = TableName 73 | user.KeyName = KeyName 74 | user.Status = status.Draft 75 | return user 76 | } 77 | 78 | // FindFirst fetches a single user record from the database using 79 | // a where query with the format and args provided. 80 | func FindFirst(format string, args ...interface{}) (*User, error) { 81 | result, err := Query().Where(format, args...).FirstResult() 82 | if err != nil { 83 | return nil, err 84 | } 85 | return NewWithColumns(result), nil 86 | } 87 | 88 | // Find fetches a single user record from the database by id. 89 | func Find(id int64) (*User, error) { 90 | result, err := Query().Where("id=?", id).FirstResult() 91 | if err != nil { 92 | return nil, err 93 | } 94 | return NewWithColumns(result), nil 95 | } 96 | 97 | // FindAll fetches all user records matching this query from the database. 98 | func FindAll(q *query.Query) ([]*User, error) { 99 | 100 | // Fetch query.Results from query 101 | results, err := q.Results() 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | // Return an array of users constructed from the results 107 | var users []*User 108 | for _, cols := range results { 109 | p := NewWithColumns(cols) 110 | users = append(users, p) 111 | } 112 | 113 | return users, nil 114 | } 115 | -------------------------------------------------------------------------------- /src/lib/resource/resource_test.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | var r = Base{ID: 99, TableName: "images", KeyName: "id"} 8 | 9 | func TestValidate(t *testing.T) { 10 | 11 | // TEST NIL VALUES in db 12 | // most validation is to ensure nil db columns return zero values rather than panic 13 | 14 | // Test against nil bool 15 | if ValidateBoolean(nil) != false { 16 | t.Fatalf("Validate does not match expected:%v got:%v", false, ValidateBoolean(nil)) 17 | } 18 | 19 | // Test against nil float 20 | if ValidateFloat(nil) != 0 { 21 | t.Fatalf("Validate does not match expected:%v got:%v", 0, ValidateFloat(nil)) 22 | } 23 | 24 | // Test against nil int64 25 | if ValidateInt(nil) != 0 { 26 | t.Fatalf("Validate does not match expected:%v got:%v", 0, ValidateInt(nil)) 27 | } 28 | 29 | // Test against nil time 30 | if !ValidateTime(nil).IsZero() { 31 | t.Fatalf("Validate does not match expected:%v got:%v", "zero time", ValidateTime(nil)) 32 | } 33 | 34 | // Test against nil string 35 | if ValidateString(nil) != "" { 36 | t.Fatalf("Validate does not match expected:%v got:%v", "", ValidateString(nil)) 37 | } 38 | 39 | // TEST VALUES 40 | 41 | if ValidateBoolean(true) != true { // yes, I know! 42 | t.Fatalf("Validate does not match expected:%v got:%v", true, ValidateBoolean(true)) 43 | } 44 | 45 | // Test against range of ints as sanity check on casts 46 | ints := []int{99, 5, 0, 1, 1110011, -1200} 47 | for _, i := range ints { 48 | if ValidateInt(i) != int64(i) { 49 | t.Fatalf("Validate float does not match expected:%v got:%v", i, ValidateInt(i)) 50 | } 51 | } 52 | 53 | // Test against range of floats as sanity check 54 | floats := []float64{5.0, 0.0, 0.0001, -0.40} 55 | for _, f := range floats { 56 | if ValidateFloat(f) != f { 57 | t.Fatalf("Validate float does not match expected:%v got:%v", f, ValidateFloat(f)) 58 | } 59 | } 60 | // Check success of cast of int column values to floats 61 | for _, f := range ints { 62 | if ValidateFloat(f) != float64(f) { 63 | t.Fatalf("Validate float does not match expected:%v got:%v", f, ValidateFloat(f)) 64 | } 65 | } 66 | 67 | } 68 | 69 | // TestValidateParams tests we remove params correctly when not authorised 70 | // the default set is an empty set 71 | func TestValidateParams(t *testing.T) { 72 | allowed := []string{"name"} 73 | params := map[string]string{"name_asdfasdf_name": "asdf", "name": "foo", "bar": "baz"} 74 | params = r.ValidateParams(params, allowed) 75 | if len(params) > 1 { 76 | t.Fatalf("Validate params does not match expected:%v got:%v", "[]", params) 77 | } 78 | } 79 | 80 | // We should probably have an in-memory adapter for query which lets us test creation etc easily 81 | 82 | // TestURLs tests the url functions in urls.go 83 | func TestURLs(t *testing.T) { 84 | 85 | expected := "/images" 86 | if r.IndexURL() != expected { 87 | t.Fatalf("URL does not match expected:%s got:%s", expected, r.IndexURL()) 88 | } 89 | expected = "/images/create" 90 | if r.CreateURL() != expected { 91 | t.Fatalf("URL does not match expected:%s got:%s", expected, r.CreateURL()) 92 | } 93 | expected = "/images/99/update" 94 | if r.UpdateURL() != expected { 95 | t.Fatalf("URL does not match expected:%s got:%s", expected, r.UpdateURL()) 96 | } 97 | expected = "/images/99" 98 | if r.ShowURL() != expected { 99 | t.Fatalf("URL does not match expected:%s got:%s", expected, r.ShowURL()) 100 | } 101 | expected = "/images/99/destroy" 102 | if r.DestroyURL() != expected { 103 | t.Fatalf("URL does not match expected:%s got:%s", expected, r.DestroyURL()) 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/fragmenta_resources_test.go.tmpl: -------------------------------------------------------------------------------- 1 | // Tests for the [[ .fragmenta_resources ]] package 2 | package [[ .fragmenta_resources ]] 3 | 4 | import ( 5 | "testing" 6 | 7 | "github.com/fragmenta/fragmenta-app/src/lib/resource" 8 | ) 9 | 10 | func TestSetup(t *testing.T) { 11 | err := resource.SetupTestDatabase(2) 12 | if err != nil { 13 | t.Fatalf("[[ .fragmenta_resources ]]: Setup db failed %s", err) 14 | } 15 | } 16 | 17 | // Test Create method 18 | func TestCreate[[ .Fragmenta_Resource ]](t *testing.T) { 19 | name := "'fué ';'\"" 20 | [[ .fragmenta_resource ]]Params := map[string]string{"name": name} 21 | id, err := New().Create([[ .fragmenta_resource ]]Params) 22 | if err != nil { 23 | t.Fatalf("[[ .fragmenta_resources ]]: Create [[ .fragmenta_resource ]] failed :%s", err) 24 | } 25 | 26 | [[ .fragmenta_resource ]], err := Find(id) 27 | if err != nil { 28 | t.Fatalf("[[ .fragmenta_resources ]]: Create [[ .fragmenta_resource ]] find failed") 29 | } 30 | 31 | if [[ .fragmenta_resource ]].Name != name { 32 | t.Fatalf("[[ .fragmenta_resources ]]: Create [[ .fragmenta_resource ]] name failed expected:%s got:%s", name, [[ .fragmenta_resource ]].Name) 33 | } 34 | 35 | } 36 | 37 | // Test Index (List) method 38 | func TestList[[ .Fragmenta_Resources ]](t *testing.T) { 39 | 40 | // Get all [[ .fragmenta_resources ]] (we should have at least one) 41 | results, err := FindAll(Query()) 42 | if err != nil { 43 | t.Fatalf("[[ .fragmenta_resources ]]: List no [[ .fragmenta_resource ]] found :%s", err) 44 | } 45 | 46 | if len(results) < 1 { 47 | t.Fatalf("[[ .fragmenta_resources ]]: List no [[ .fragmenta_resources ]] found :%s", err) 48 | } 49 | 50 | } 51 | 52 | // Test Update method 53 | func TestUpdate[[ .Fragmenta_Resource ]](t *testing.T) { 54 | 55 | // Get the last [[ .fragmenta_resource ]] (created in TestCreate[[ .Fragmenta_Resource ]] above) 56 | results, err := FindAll(Query()) 57 | if err != nil || len(results) == 0 { 58 | t.Fatalf("[[ .fragmenta_resources ]]: Destroy no [[ .fragmenta_resource ]] found :%s", err) 59 | } 60 | [[ .fragmenta_resource ]] := results[0] 61 | 62 | name := "bar" 63 | [[ .fragmenta_resource ]]Params := map[string]string{"name": name} 64 | err = [[ .fragmenta_resource ]].Update([[ .fragmenta_resource ]]Params) 65 | if err != nil { 66 | t.Fatalf("[[ .fragmenta_resources ]]: Update [[ .fragmenta_resource ]] failed :%s", err) 67 | } 68 | 69 | // Fetch the [[ .fragmenta_resource ]] again from db 70 | [[ .fragmenta_resource ]], err = Find([[ .fragmenta_resource ]].ID) 71 | if err != nil { 72 | t.Fatalf("[[ .fragmenta_resources ]]: Update [[ .fragmenta_resource ]] fetch failed :%s", [[ .fragmenta_resource ]].Name) 73 | } 74 | 75 | if [[ .fragmenta_resource ]].Name != name { 76 | t.Fatalf("[[ .fragmenta_resources ]]: Update [[ .fragmenta_resource ]] failed :%s", [[ .fragmenta_resource ]].Name) 77 | } 78 | 79 | } 80 | 81 | // Test Destroy method 82 | func TestDestroy[[ .Fragmenta_Resource ]](t *testing.T) { 83 | 84 | results, err := FindAll(Query()) 85 | if err != nil || len(results) == 0 { 86 | t.Fatalf("[[ .fragmenta_resources ]]: Destroy no [[ .fragmenta_resource ]] found :%s", err) 87 | } 88 | [[ .fragmenta_resource ]] := results[0] 89 | count := len(results) 90 | 91 | err = [[ .fragmenta_resource ]].Destroy() 92 | if err != nil { 93 | t.Fatalf("[[ .fragmenta_resources ]]: Destroy [[ .fragmenta_resource ]] failed :%s", err) 94 | } 95 | 96 | // Check new length of [[ .fragmenta_resources ]] returned 97 | results, err = FindAll(Query()) 98 | if err != nil { 99 | t.Fatalf("[[ .fragmenta_resources ]]: Destroy error getting results :%s", err) 100 | } 101 | 102 | // length should be one less than previous 103 | if len(results) != count-1 { 104 | t.Fatalf("[[ .fragmenta_resources ]]: Destroy [[ .fragmenta_resource ]] count wrong :%d", len(results)) 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /src/app/assets/styles/app.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Lato 3 | src: url(/assets/fonts/lato.ttf); 4 | } 5 | 6 | body { 7 | font:1.6em/2 "Lato", "Roboto", "Helvetica Neue",Arial,sans-serif; 8 | } 9 | 10 | h1,h2,h3,h4,h5,h6 { 11 | letter-spacing:normal; 12 | color:#666; 13 | font-weight:300; 14 | font-style:normal; 15 | margin: 0.8rem 0 0.5rem 0; 16 | } 17 | 18 | h1,h2,h3 { 19 | font-family:"Futura","Trebuchet MS",Arial,sans-serif; 20 | font-style:normal; 21 | text-align:center; 22 | } 23 | 24 | h1 { 25 | text-transform:uppercase; 26 | color:#05afe7; 27 | } 28 | 29 | h2 { 30 | color:#555; 31 | } 32 | 33 | h2 b { 34 | color:#888; 35 | } 36 | 37 | h3 { 38 | 39 | } 40 | 41 | h4 { 42 | 43 | } 44 | 45 | h5 { 46 | font-size:1.4rem; 47 | text-transform:uppercase; 48 | letter-spacing:0.1rem; 49 | } 50 | 51 | 52 | a { 53 | text-decoration:none; 54 | color:#ea8c06; 55 | } 56 | 57 | a:hover { 58 | color:#ff9804; 59 | } 60 | 61 | p { 62 | margin-bottom:1rem; 63 | } 64 | 65 | code { 66 | white-space:normal; 67 | font-size:0.8em; 68 | } 69 | 70 | .hidden { 71 | display:none; 72 | } 73 | 74 | .clear { 75 | clear:both; 76 | } 77 | 78 | .left { 79 | text-align:left; 80 | } 81 | 82 | .right { 83 | text-align:right; 84 | } 85 | 86 | .center { 87 | text-align:center; 88 | } 89 | 90 | .button, button, input[type="submit"], input[type="reset"], input[type="button"] { 91 | color:#fff; 92 | background-color:#05afe7; 93 | border:none; 94 | font-size:1em; 95 | font-weight:300; 96 | height:3.8rem; 97 | line-height:3.8rem; 98 | padding:0rem 2rem; 99 | } 100 | 101 | .button:hover, button:hover, input[type="submit"]:hover, input[type="reset"]:hover, input[type="button"]:hover { 102 | color:#fff; 103 | text-shadow:0 0 0.4rem rgba(0,0,0,0.2) 104 | } 105 | 106 | .button.grey { 107 | background-color:#aaa; 108 | } 109 | 110 | .button.orange { 111 | background-color:#f49a18; 112 | } 113 | 114 | .button.small { 115 | height:3rem; 116 | line-height:3rem; 117 | font-size:0.9em; 118 | padding:0rem 1rem; 119 | margin:0; 120 | } 121 | 122 | .inline-buttons { 123 | text-align:center; 124 | } 125 | .inline-buttons .button { 126 | margin:1rem 1rem 2rem 1rem; 127 | } 128 | 129 | ul.list { 130 | list-style:disc; 131 | padding:0 0 0 2rem; 132 | color:#444; 133 | } 134 | 135 | 136 | ul.inline, nav ul { 137 | list-style:none; 138 | margin:0; 139 | padding:0; 140 | display:inline; 141 | } 142 | 143 | ul.inline li, nav ul li { 144 | display: inline-block; 145 | white-space: nowrap; 146 | padding:0 2rem; 147 | margin:0 0 0.2rem 0; 148 | } 149 | 150 | header a { 151 | line-height:4rem; 152 | color:#fff; 153 | } 154 | header a:hover { 155 | color:#fff; 156 | } 157 | 158 | header { 159 | margin:0 0 0 0; 160 | color:#fff; 161 | border-bottom:2px solid #f1e42a; 162 | background-color:#f49a18; 163 | } 164 | 165 | 166 | footer { 167 | clear:both; 168 | font-size:0.9em; 169 | text-align:center; 170 | padding:2rem 0 1rem 0; 171 | margin:4rem 0 0 0; 172 | border-top:1px solid #f1e42a; 173 | border-bottom:2px solid #f49a18; 174 | } 175 | 176 | nav { 177 | clear:both; 178 | font-weight:300; 179 | } 180 | 181 | nav.admin { 182 | background-color:#000; 183 | } 184 | 185 | article { 186 | clear:both; 187 | padding:0; 188 | min-height:50rem; 189 | } 190 | 191 | section { 192 | clear:both; 193 | display:block; 194 | padding:1rem 2rem; 195 | margin:1rem auto; 196 | } 197 | 198 | 199 | section.feature { 200 | background-color:#05afe7; 201 | color:#fff; 202 | width:100%; 203 | text-align:center; 204 | margin:0 0 2rem 0; 205 | padding:0 0 0.5rem 0; 206 | } 207 | 208 | section.feature a, section.feature h3, section.feature h4, section.feature h5 { 209 | color:#fff; 210 | } 211 | 212 | section.padded { 213 | padding:0 2rem; 214 | max-width:80em; 215 | } 216 | 217 | section.narrow { 218 | padding:0 2rem; 219 | max-width:50em; 220 | } 221 | -------------------------------------------------------------------------------- /src/lib/resource/tests.go: -------------------------------------------------------------------------------- 1 | package resource 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "path/filepath" 12 | 13 | "github.com/fragmenta/auth/can" 14 | "github.com/fragmenta/query" 15 | "github.com/fragmenta/router" 16 | "github.com/fragmenta/view" 17 | ) 18 | 19 | // This file contains some test helpers for resources. 20 | 21 | // basePath returns the path to the fragmenta root from a given test folder. 22 | func basePath(depth int) string { 23 | // Construct a path to root 24 | p := "" 25 | for i := 0; i < depth; i++ { 26 | p = filepath.Join(p, "..") 27 | } 28 | return p 29 | } 30 | 31 | // SetupAuthorisation sets up mock authorisation. 32 | func SetupAuthorisation() { 33 | // Set up some simple permissions for testing - 34 | // at present we just test on admins if testing other permissions 35 | // they'd need to be added here 36 | can.Authorise(100, can.ManageResource, can.Anything) 37 | } 38 | 39 | // SetupView sets up the view package for testing by loading templates. 40 | func SetupView(depth int) error { 41 | view.Production = false 42 | return view.LoadTemplatesAtPaths([]string{filepath.Join(basePath(depth), "src")}, view.Helpers) 43 | } 44 | 45 | // SetupTestDatabase sets up the database for all tests from the test config. 46 | func SetupTestDatabase(depth int) error { 47 | 48 | // Read config json 49 | path := filepath.Join(basePath(depth), "secrets", "fragmenta.json") 50 | file, err := ioutil.ReadFile(path) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | var data map[string]map[string]string 56 | err = json.Unmarshal(file, &data) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | config := data["test"] 62 | options := map[string]string{ 63 | "adapter": config["db_adapter"], 64 | "user": config["db_user"], 65 | "password": config["db_pass"], 66 | "db": config["db"], 67 | } 68 | 69 | // Ask query to open the database 70 | err = query.OpenDatabase(options) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | // For speed 76 | query.Exec("set synchronous_commit=off;") 77 | return nil 78 | } 79 | 80 | // MockConfig conforms to the config interface. 81 | type MockConfig struct { 82 | Data map[string]string 83 | } 84 | 85 | // Production returns false. 86 | func (c *MockConfig) Production() bool { 87 | return false 88 | } 89 | 90 | // Config returns the config value for key. 91 | func (c *MockConfig) Config(key string) string { 92 | return c.Data[key] 93 | } 94 | 95 | // Configuration returns the current config 96 | func (c *MockConfig) Configuration() map[string]string { 97 | return c.Data 98 | } 99 | 100 | // TestContextForRequest returns a context for testing handlers. 101 | func TestContextForRequest(w http.ResponseWriter, r *http.Request, pattern string) router.Context { 102 | route, err := router.NewRoute(pattern, nil) 103 | if err != nil { 104 | return nil 105 | } 106 | return router.NewContext(w, r, route, &MockConfig{}, log.New(os.Stderr, "test:", log.Lshortfile)) 107 | } 108 | 109 | // GetRequestContext returns a context for testing GET handlers with a path and current user. 110 | func GetRequestContext(path string, pattern string, u interface{}) (*httptest.ResponseRecorder, router.Context) { 111 | r := httptest.NewRequest("GET", path, nil) 112 | w := httptest.NewRecorder() 113 | c := TestContextForRequest(w, r, pattern) 114 | c.Set("current_user", u) // Set user for testing permissions 115 | return w, c 116 | } 117 | 118 | // PostRequestContext returns a context for testing POST handlers with a path, body and current user. 119 | func PostRequestContext(path string, pattern string, body io.Reader, u interface{}) (*httptest.ResponseRecorder, router.Context) { 120 | r := httptest.NewRequest("POST", path, body) 121 | r.Header.Add("Content-Type", "application/x-www-form-urlencoded") 122 | w := httptest.NewRecorder() 123 | c := TestContextForRequest(w, r, pattern) 124 | c.Set("current_user", u) // Set user for testing permissions 125 | return w, c 126 | } 127 | -------------------------------------------------------------------------------- /src/app/setup.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/fragmenta/assets" 7 | "github.com/fragmenta/query" 8 | "github.com/fragmenta/router" 9 | "github.com/fragmenta/server" 10 | "github.com/fragmenta/server/log" 11 | "github.com/fragmenta/view" 12 | 13 | "github.com/fragmenta/fragmenta-app/src/lib/mail" 14 | "github.com/fragmenta/fragmenta-app/src/lib/mail/adapters/sendgrid" 15 | ) 16 | 17 | // Config is used to pass settings to setup functions. 18 | type Config interface { 19 | Production() bool 20 | Configuration() map[string]string 21 | Config(string) string 22 | } 23 | 24 | // appAssets holds a reference to our assets for use in asset setup used in the handlers. 25 | var appAssets *assets.Collection 26 | 27 | // Setup sets up our application. 28 | func Setup(server *server.Server) { 29 | 30 | // Setup log 31 | server.Logger = log.New(server.Config("log"), server.Production()) 32 | 33 | // Set up our mail adapter 34 | SetupMail(server) 35 | 36 | // Set up our assets 37 | SetupAssets(server) 38 | 39 | // Setup our view templates 40 | SetupView(server) 41 | 42 | // Setup our database 43 | SetupDatabase(server) 44 | 45 | // Set up auth pkg and authorisation for access 46 | SetupAuth(server) 47 | 48 | // Create a new router 49 | router, err := router.New(server.Logger, server) 50 | if err != nil { 51 | server.Fatalf("Error creating router %s", err) 52 | } 53 | 54 | // Setup our router and handlers 55 | SetupRoutes(router) 56 | 57 | // Inform user of imminent server setup 58 | server.Logf("#info Starting server in %s mode on port %d", server.Mode(), server.Port()) 59 | 60 | } 61 | 62 | // SetupMail sets us up to send mail via sendgrid (requires key). 63 | func SetupMail(server *server.Server) { 64 | mail.Production = server.Production() 65 | mail.Service = sendgrid.New(server.Config("mail_from"), server.Config("mail_secret")) 66 | } 67 | 68 | // SetupAssets compiles or copies our assets from src into the public assets folder. 69 | func SetupAssets(server *server.Server) { 70 | defer server.Timef("#info Finished loading assets in %s", time.Now()) 71 | 72 | // Compilation of assets is done on deploy 73 | // We just load them here 74 | assetsCompiled := server.ConfigBool("assets_compiled") 75 | appAssets = assets.New(assetsCompiled) 76 | 77 | // Load asset details from json file on each run 78 | err := appAssets.Load() 79 | if err != nil { 80 | // Compile assets for the first time 81 | server.Logf("#info Compiling assets") 82 | err := appAssets.Compile("src", "public") 83 | if err != nil { 84 | server.Fatalf("#error compiling assets %s", err) 85 | } 86 | } 87 | 88 | // Set up helpers which are aware of fingerprinted assets 89 | // These behave differently depending on the compile flag above 90 | // when compile is set to no, they use precompiled assets 91 | // otherwise they serve all files in a group separately 92 | view.Helpers["style"] = appAssets.StyleLink 93 | view.Helpers["script"] = appAssets.ScriptLink 94 | 95 | } 96 | 97 | // SetupView sets up the view package by loadind templates. 98 | func SetupView(server *server.Server) { 99 | defer server.Timef("#info Finished loading templates in %s", time.Now()) 100 | 101 | view.Production = server.Production() 102 | err := view.LoadTemplates() 103 | if err != nil { 104 | server.Fatalf("Error reading templates %s", err) 105 | } 106 | 107 | } 108 | 109 | // SetupDatabase sets up the db with query given our server config. 110 | func SetupDatabase(server *server.Server) { 111 | defer server.Timef("#info Finished opening in %s database %s for user %s", time.Now(), server.Config("db"), server.Config("db_user")) 112 | 113 | config := server.Configuration() 114 | options := map[string]string{ 115 | "adapter": config["db_adapter"], 116 | "user": config["db_user"], 117 | "password": config["db_pass"], 118 | "db": config["db"], 119 | } 120 | 121 | // Ask query to open the database 122 | err := query.OpenDatabase(options) 123 | 124 | if err != nil { 125 | server.Fatalf("Error reading database %s", err) 126 | } 127 | 128 | } 129 | -------------------------------------------------------------------------------- /src/users/actions/password.go: -------------------------------------------------------------------------------- 1 | package useractions 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/fragmenta/auth" 8 | "github.com/fragmenta/query" 9 | "github.com/fragmenta/router" 10 | "github.com/fragmenta/view" 11 | 12 | "github.com/fragmenta/fragmenta-app/src/lib/mail" 13 | "github.com/fragmenta/fragmenta-app/src/users" 14 | ) 15 | 16 | const ( 17 | // ResetLifetime is the maximum time reset tokens are valid for 18 | ResetLifetime = time.Hour 19 | ) 20 | 21 | // HandlePasswordResetShow responds to GET /users/password/reset 22 | // by showing the password reset page. 23 | func HandlePasswordResetShow(context router.Context) error { 24 | // No authorisation required, just show the view 25 | view := view.New(context) 26 | view.Template("users/views/password_reset.html.got") 27 | return view.Render() 28 | } 29 | 30 | // HandlePasswordResetSend responds to POST /users/password/reset 31 | // by sending a password reset email. 32 | func HandlePasswordResetSend(context router.Context) error { 33 | 34 | // No authorisation required 35 | 36 | // Find the user by email (if not found let them know) 37 | // Find the user by hex token in the db 38 | email := context.Param("email") 39 | user, err := users.FindFirst("email=?", email) 40 | if err != nil { 41 | return router.Redirect(context, "/users/password/reset?message=invalid_email") 42 | } 43 | 44 | // Generate a random token and url for the email 45 | token := auth.BytesToHex(auth.RandomToken(32)) 46 | 47 | // Update the user record with with this token 48 | userParams := map[string]string{ 49 | "password_reset_token": token, 50 | "password_reset_at": query.TimeString(time.Now().UTC()), 51 | } 52 | // Direct access to the user columns, bypassing validation 53 | user.Update(userParams) 54 | 55 | // Generate the url to use in our email 56 | url := fmt.Sprintf("%s/users/password?token=%s", context.Config("root_url"), token) 57 | 58 | // Send a password reset email out to this user 59 | emailContext := map[string]interface{}{ 60 | "url": url, 61 | "name": user.Name, 62 | } 63 | context.Logf("#info sending reset email:%s url:%s", user.Email, url) 64 | e := mail.New(user.Email) 65 | e.Subject = "Reset Password" 66 | e.Template = "users/views/password_reset_mail.html.got" 67 | err = mail.Send(e, emailContext) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | // Tell the user what we have done 73 | return router.Redirect(context, "/users/password/sent") 74 | } 75 | 76 | // HandlePasswordResetSentShow responds to GET /users/password/sent 77 | func HandlePasswordResetSentShow(context router.Context) error { 78 | view := view.New(context) 79 | view.Template("users/views/password_sent.html.got") 80 | return view.Render() 81 | } 82 | 83 | // HandlePasswordReset responds to POST /users/password?token=DEADFISH 84 | // by logging the user in, removing the token 85 | // and allowing them to set their password. 86 | func HandlePasswordReset(context router.Context) error { 87 | 88 | token := context.Param("token") 89 | if len(token) < 10 || len(token) > 64 { 90 | return router.InternalError(fmt.Errorf("Invalid reset token"), "Invalid Token") 91 | } 92 | 93 | // Find the user by hex token in the db 94 | user, err := users.FindFirst("password_reset_token=?", token) 95 | if err != nil { 96 | return router.InternalError(err) 97 | } 98 | 99 | // Make sure the reset at time is less expire time 100 | if time.Since(user.PasswordResetAt) > ResetLifetime { 101 | return router.InternalError(nil, "Token invalid", "Your password reset token has expired, please request another.") 102 | } 103 | 104 | // Remove the reset token from this user 105 | // using direct access, bypassing validation 106 | user.Update(map[string]string{"password_reset_token": ""}) 107 | 108 | // Log in the user and store in the session 109 | // Now save the user details in a secure cookie, so that we remember the next request 110 | // Build the session from the secure cookie, or create a new one 111 | session, err := auth.Session(context, context.Request()) 112 | if err != nil { 113 | return router.InternalError(err) 114 | } 115 | 116 | session.Set(auth.SessionUserKey, fmt.Sprintf("%d", user.ID)) 117 | session.Save(context.Writer()) 118 | context.Logf("#info Login success after reset for user: %d %s", user.ID, user.Email) 119 | 120 | // Redirect to the user update page so that they can change their password 121 | return router.Redirect(context, fmt.Sprintf("/users/%d/update", user.ID)) 122 | } 123 | -------------------------------------------------------------------------------- /public/assets/styles/app-58a0afbf47fbb0647dc34dff83732be2509c4c7e.min.css: -------------------------------------------------------------------------------- 1 | article form{padding:1rem 0}article form.nopadding{padding:0}label{color:#555;font-weight:normal}.inline-fields .field{float:left;margin-right:3rem}.wide-fields .field{clear:both}.wide-fields input{width:100%}.resource-update-form{position:relative}section.actions{clear:both;padding:0;text-align:center}section.actions .button{margin-left:2rem}@media(min-width:1000px){section.actions{text-align:right;margin-top:-6.5rem}section.actions .button{float:right}}section.admin-bar-actions{position:absolute;top:0;right:0;margin:0;padding:.4rem 1rem}.data-table{width:100%}.data-table tr td{padding:.5rem 1rem}.data-table tr.odd{background-color:#eaeaea}.data-table tr.level_1.odd,.data-table tr.level_2.odd,.data-table tr.level_3.odd,.data-table tr.level_4.odd{background-color:#fafafa}.data-table tr.level_1 td:first-child{padding-left:2rem}.data-table tr.level_2 td:first-child{padding-left:3rem}.data-table tr.level_3 td:first-child{padding-left:4rem}.data-table tr.level_4 td:first-child{padding-left:5rem}.filter-form{clear:both}.filter-form input{float:right}@font-face{font-family:Lato src:url(/assets/fonts/lato.ttf)}body{font:1.6em/2 "Lato","Roboto","Helvetica Neue",Arial,sans-serif}h1,h2,h3,h4,h5,h6{letter-spacing:normal;color:#666;font-weight:300;font-style:normal;margin:.8rem 0 .5rem 0}h1,h2,h3{font-family:"Futura","Trebuchet MS",Arial,sans-serif;font-style:normal;text-align:center}h1{text-transform:uppercase;color:#05afe7}h2{color:#555}h2 b{color:#888}h3{}h4{}h5{font-size:1.4rem;text-transform:uppercase;letter-spacing:.1rem}a{text-decoration:none;color:#ea8c06}a:hover{color:#ff9804}p{margin-bottom:1rem}code{white-space:normal;font-size:.8em}.hidden{display:none}.clear{clear:both}.left{text-align:left}.right{text-align:right}.center{text-align:center}.button,button,input[type="submit"],input[type="reset"],input[type="button"]{color:#fff;background-color:#05afe7;border:none;font-size:1em;font-weight:300;height:3.8rem;line-height:3.8rem;padding:0rem 2rem}.button:hover,button:hover,input[type="submit"]:hover,input[type="reset"]:hover,input[type="button"]:hover{color:#fff;text-shadow:0 0 .4rem rgba(0,0,0,0.2)}.button.grey{background-color:#aaa}.button.orange{background-color:#f49a18}.button.small{height:3rem;line-height:3rem;font-size:.9em;padding:0rem 1rem;margin:0}.inline-buttons{text-align:center}.inline-buttons .button{margin:1rem 1rem 2rem 1rem}ul.list{list-style:disc;padding:0 0 0 2rem;color:#444}ul.inline,nav ul{list-style:none;margin:0;padding:0;display:inline}ul.inline li,nav ul li{display:inline-block;white-space:nowrap;padding:0 2rem;margin:0 0 .2rem 0}header a{line-height:4rem;color:#fff}header a:hover{color:#fff}header{margin:0;color:#fff;border-bottom:2px solid #f1e42a;background-color:#f49a18}footer{clear:both;font-size:.9em;text-align:center;padding:2rem 0 1rem 0;margin:4rem 0 0 0;border-top:1px solid #f1e42a;border-bottom:2px solid #f49a18}nav{clear:both;font-weight:300}nav.admin{background-color:#000}article{clear:both;padding:0;min-height:50rem}section{clear:both;display:block;padding:1rem 2rem;margin:1rem auto}section.feature{background-color:#05afe7;color:#fff;width:100%;text-align:center;margin:0 0 2rem 0;padding:0 0 .5rem 0}section.feature a,section.feature h3,section.feature h4,section.feature h5{color:#fff}section.padded{padding:0 2rem;max-width:80em}section.narrow{padding:0 2rem;max-width:50em}.toolbar{list-style:none;min-height:4rem;padding:0;margin:0;border-top:2px solid #ccc;border-bottom:2px solid #ccc}.toolbar li{padding:0;margin:0;float:left;position:relative}.toolbar li a{margin:0;width:4rem;height:4rem;line-height:4rem;display:block;text-align:center;margin-top:-1px;overflow:hidden;top:0;background-color:#fff;border-top:1px solid #ccc;border-bottom:1px solid #ccc;border-right:1px solid #eee}.toolbar li.right{float:right;border-left:1px solid #ccc}.toolbar li:first-child{}.toolbar li a:hover{background-color:#dedede}.toolbar li.clear{clear:both}.toolbar .button-blockquote{font-size:2em;min-width:4rem;position:relative;top:.28em}.toolbar .button-ol{font-size:.6em;line-height:1.3em;top:.6rem;position:relative;display:block}.toolbar .button-ul{font-size:.6em;line-height:1.3em;top:.6rem;position:relative;display:block}.toolbar .button-code{font-size:.7em;font-family:monospace}.content-editable{border-bottom:2px solid #ccc;margin-top:-0.3rem;margin-bottom:2rem;padding-bottom:1rem;z-index:2;position:relative}.content-editable:focus{outline:0}.padded-content-editable{padding:1rem;background-color:#fff}.content-textarea{width:100%;min-height:50em;background-color:#222;color:#ddd;font:14px/1.5em 'Consolas','Monaco','Lucida Console','Liberation Mono','Mono','Courier New',monospace;padding:2rem;border-radius:0} -------------------------------------------------------------------------------- /public/assets/scripts/app-d0d2a631d0b5b2e52b6def992031e9fd1309a66d.min.js: -------------------------------------------------------------------------------- 1 | var DOM=(function(){return{Ready:function(f){if(document.readyState!='loading'){f();}else{document.addEventListener('DOMContentLoaded',f);}},Nearest:function(el,s){while(el!==undefined&&el!==null){var nearest=el.querySelectorAll(s);if(nearest.length>0){return nearest;} 2 | el=el.parentNode;} 3 | return[];},Attribute:function(el,a){if(el.getAttribute(a)===null){return''} 4 | return el.getAttribute(a)},HasClass:function(el,c){var regexp=new RegExp("\\b"+c+"\\b",'gi');return regexp.test(el.className);},AddClass:function(s,c){if(typeof s==="string"){DOM.Each(s,function(el,i){if(!DOM.HasClass(el,c)){el.className=el.className+' '+c;}});}else{if(!DOM.HasClass(s,c)){s.className=s.className+' '+c;}}},RemoveClass:function(s,c){var regexp=new RegExp("\\b"+c+"\\b",'gi');if(typeof s==="string"){DOM.Each(s,function(el,i){el.className=el.className.replace(regexp,'')});}else{s.className=s.className.replace(regexp,'')}},Format:function(f){for(var i=1;i=200&&request.status<400){fs(request);}else{fe();}};request.setRequestHeader('Content-Type','application/x-www-form-urlencoded; charset=UTF-8');request.send(d);},Get:function(u,fs,fe){var request=new XMLHttpRequest();request.open('GET',u,true);request.onload=function(){if(request.status>=200&&request.status<400){fs(request);}else{fe();}};request.onerror=fe;request.send();}};}());DOM.Ready(function(){ActivateShowlinks();ActivateMethodLinks();ActivateFilterFields();});function ActivateMethodLinks(){DOM.On('a[method="post"], a[method="delete"]','click',function(e){if(this.getAttribute('method')=='delete'){if(!confirm('Are you sure you want to delete this item, this action cannot be undone?')){return false;}} 6 | var url=this.getAttribute('href');var redirect=this.getAttribute('data-redirect');DOM.Post(url,function(){if(redirect.length>0){window.location=redirect;}else{window.location.reload();}},function(){console.log("#error POST to"+url+"failed");});return false;});} 7 | function ActivateFilterFields(){DOM.On('.filter-form .field select, .filter-form .field input','change',function(e){this.form.submit();});} 8 | function ActivateShowlinks(){DOM.On('.show','click',function(e){var selector=this.getAttribute('href');DOM.Each(selector,function(el,i){if(el.className!='hidden'){el.className='hidden';}else{el.className=el.className.replace(/hidden/gi,'');}});return false;});} 9 | DOM.Ready(function(){Editable.Activate('.content-editable-toolbar');});var Editable=(function(){return{Activate:function(s){if(!DOM.Exists(s)){return;} 10 | DOM.Each('.content-editable-toolbar',function(toolbar){toolbar.buttons=toolbar.querySelectorAll('a');var dataEditable=toolbar.getAttribute('data-editable');toolbar.editable=DOM.First(DOM.Format("#{0}-editable",dataEditable));toolbar.textarea=DOM.First(DOM.Format("#{0}-textarea",dataEditable));if(toolbar.editable===undefined){toolbar.editable=DOM.Nearest(toolbar,'.content-editable')[0];} 11 | if(toolbar.textarea===undefined){toolbar.textarea=DOM.Nearest(toolbar,'.content-textarea')[0];} 12 | toolbar.textarea.style.display='none';toolbar.textarea.form.addEventListener('submit',function(e){Editable.updateContent(toolbar.editable,toolbar.textarea,false);return false;});toolbar.editable.addEventListener('input',function(e){Editable.cleanHTMLElements(this);});DOM.ForEach(toolbar.buttons,function(el,i){el.addEventListener('click',function(e){var cmd=this.id;var insert="";switch(cmd){case"showCode":Editable.updateContent(toolbar.editable,toolbar.textarea,true);break;case"createLink":insert=prompt("Supply the web URL to link to");if(insert.indexOf('http')!==0){insert="http://"+insert;} 13 | break;case"formatblock":insert=this.getAttribute('data-format');break;default:break;} 14 | if(cmd.length>0){document.execCommand(cmd,false,insert);} 15 | var sel=Editable.getSelectionParentElement();if(sel!==null){Editable.cleanAlign(cmd,sel);Editable.cleanHTMLElements(sel);sel.removeAttribute('style');} 16 | return false;});});});},cleanAlign:function(cmd,el){switch(cmd){case"justifyCenter":if(sel.hasClass('align-center')){sel.removeClass('align-center');}else{sel.addClass('align-center');} 17 | sel.removeClass('align-left').removeClass('align-right');sel.removeAttr('style');break;case"justifyLeft":if(sel.hasClass('align-left')){sel.removeClass('align-left');}else{sel.addClass('align-left');} 18 | sel.removeClass('align-center').removeClass('align-right');sel.removeAttribute('style');break;case"justifyRight":if(sel.hasClass('align-right')){sel.removeClass('align-right');}else{sel.addClass('align-right');} 19 | sel.removeClass('align-center').removeClass('align-left');sel.removeAttribute('style');break;}},cleanHTML:function(html){html=html.replace(/<\/?span>/gi,'');html=html.replace(/<\/?font [^>]*>/gi,'');html=html.replace(/ <\/p>/gi,'\n');html=html.replace(/
<\/li>/gi,'<\/li>');html=html.replace(//gi,'');html=html.replace(/ class\=\"MsoNormal\"/gi,'');html=html.replace(/

<\/o:p><\/p>/gi,'');html=html.replace(/><(li|ul|ol|p|h\d|\/ul|\/ol)>/gi,'>\n<$1>');return html;},cleanHTMLElements:function(el){DOM.ForEach(el.querySelectorAll('p, div, b, i, h1, h2, h3, h4, h5, h6'),function(e){e.removeAttribute('style');});DOM.ForEach(el.querySelectorAll('span'),function(e){e.removeAttribute('style');e.removeAttribute('lang');});DOM.ForEach(el.querySelectorAll('font'),function(e){e.removeAttribute('color');});},updateContent:function(editable,textarea,toggle){var html='';if(textarea.style.display!=='none'){html=textarea.value;editable.innerHTML=html;if(toggle){editable.style.display='';textarea.style.display='none';}}else{html=editable.innerHTML;html=Editable.cleanHTML(html);textarea.value=html;if(toggle){editable.style.display='none';textarea.style.display='';}}},getSelectionParentElement:function(){var p=null,sel;if(window.getSelection){sel=window.getSelection();if(sel.rangeCount){p=sel.getRangeAt(0).commonAncestorContainer;if(p.nodeType!=1){p=p.parentNode;}}}else if((sel=document.selection)&&sel.type!="Control"){p=sel.createRange().parentElement();} 20 | return p;}};}()); -------------------------------------------------------------------------------- /src/app/assets/scripts/adom.js: -------------------------------------------------------------------------------- 1 | // Package DOM provides functions to replace the use of jquery in 1.4KB of js 2 | // See http://youmightnotneedjquery.com/ for more if required 3 | var DOM = (function() { 4 | return { 5 | // Apply a function on document ready 6 | Ready: function(f) { 7 | if (document.readyState != 'loading') { 8 | f(); 9 | } else { 10 | document.addEventListener('DOMContentLoaded', f); 11 | } 12 | }, 13 | 14 | // Return a NodeList of nearest elements matching selector, 15 | // checking children, siblings or parents of el 16 | Nearest: function(el, s) { 17 | 18 | // Start with this element, then walk up the tree till 19 | // we find a child which matches selector or we run out of elements 20 | while (el !== undefined && el !== null) { 21 | var nearest = el.querySelectorAll(s); 22 | if (nearest.length > 0) { 23 | return nearest; 24 | } 25 | el = el.parentNode; 26 | } 27 | 28 | return []; // return empty array 29 | }, 30 | 31 | // FIXME - perhaps adjust all to operate on either selector or an element? 32 | 33 | // Attribute returns either an attribute value or an empty string (if null) 34 | Attribute: function(el, a) { 35 | if (el.getAttribute(a) === null) { 36 | return '' 37 | } 38 | return el.getAttribute(a) 39 | }, 40 | 41 | // HasClass returns true if this element has this className 42 | HasClass: function(el, c) { 43 | var regexp = new RegExp("\\b" + c + "\\b", 'gi'); 44 | return regexp.test(el.className); 45 | }, 46 | 47 | // AddClass Adds the given className from el.className 48 | AddClass: function(s, c) { 49 | if (typeof s === "string") { 50 | DOM.Each(s, function(el, i) { 51 | if (!DOM.HasClass(el, c)) { 52 | el.className = el.className + ' ' + c; 53 | } 54 | }); 55 | } else { 56 | if (!DOM.HasClass(s, c)) { 57 | s.className = s.className + ' ' + c; 58 | } 59 | } 60 | }, 61 | 62 | // RemoveClass removes the given className from el.className 63 | RemoveClass: function(s, c) { 64 | var regexp = new RegExp("\\b" + c + "\\b", 'gi'); 65 | if (typeof s === "string") { 66 | DOM.Each(s, function(el, i) { 67 | el.className = el.className.replace(regexp, '') 68 | }); 69 | } else { 70 | s.className = s.className.replace(regexp, '') 71 | } 72 | }, 73 | 74 | // Format returns the format string with the indexed arguments substituted 75 | // Formats are of the form - "{0} {1}" which uses variables 0 and 1 respectively 76 | Format: function(f) { 77 | for (var i = 1; i < arguments.length; i++) { 78 | var regexp = new RegExp('\\{' + (i - 1) + '\\}', 'gi'); 79 | f = f.replace(regexp, arguments[i]); 80 | } 81 | return f; 82 | }, 83 | 84 | 85 | // Apply a function to elements of an array 86 | ForEach: function(a, f) { 87 | Array.prototype.forEach.call(a, f); 88 | }, 89 | 90 | 91 | // Return true if any element match selector 92 | Exists: function(s) { 93 | return (document.querySelector(s) !== null); 94 | }, 95 | 96 | // Return a NodeList of elements matching selector 97 | All: function(s) { 98 | return document.querySelectorAll(s); 99 | }, 100 | 101 | 102 | // Return the first in the NodeList of elements matching selector - may return nil 103 | First: function(s) { 104 | return DOM.All(s)[0]; 105 | }, 106 | 107 | // Apply a function to elements matching selector, return true to break 108 | Each: function(s, f) { 109 | var a = DOM.All(s); 110 | for (i = 0; i < a.length; ++i) { 111 | f(a[i], i); 112 | } 113 | }, 114 | 115 | 116 | // Hidden returns true if this element is hidden 117 | Hidden: function(s) { 118 | if (typeof s === "string") { 119 | return (DOM.First(s).style.display == 'none'); 120 | } else { 121 | return s.style.display == 'none'; 122 | } 123 | 124 | }, 125 | 126 | // Hide elements matching selector 127 | Hide: function(s) { 128 | if (typeof s === "string") { 129 | DOM.Each(s, function(el, i) { 130 | el.style.display = 'none'; 131 | }); 132 | } else { 133 | s.style.display = 'none'; 134 | } 135 | }, 136 | 137 | // Show elements matching selector 138 | Show: function(s) { 139 | if (typeof s === "string") { 140 | DOM.Each(s, function(el, i) { 141 | el.style.display = ''; 142 | }); 143 | } else { 144 | s.style.display = ''; 145 | } 146 | }, 147 | 148 | // Toggle the Shown or Hidden value of elements matching selector 149 | ShowHide: function(s) { 150 | if (typeof s === "string") { 151 | DOM.Each(s, function(el, i) { 152 | if (el.style.display != 'none') { 153 | el.style.display = 'none'; 154 | } else { 155 | el.style.display = ''; 156 | } 157 | }); 158 | } else { 159 | if (s.style.display != 'none') { 160 | s.style.display = 'none'; 161 | } else { 162 | s.style.display = ''; 163 | } 164 | } 165 | }, 166 | 167 | // Attach event handlers to all matches for a selector 168 | On: function(s, b, f) { 169 | DOM.Each(s, function(el, i) { 170 | el.addEventListener(b, f); 171 | }); 172 | }, 173 | 174 | 175 | // Ajax - Send to url u the data d, call fs for success, ff for failures 176 | Post: function(u, d, fs, fe) { 177 | var request = new XMLHttpRequest(); 178 | request.open('POST', u, true); 179 | request.onerror = fe; 180 | request.onload = function() { 181 | if (request.status >= 200 && request.status < 400) { 182 | fs(request); 183 | } else { 184 | fe(); 185 | } 186 | }; 187 | request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8'); 188 | request.send(d); 189 | }, 190 | 191 | // Ajax - Get the data from url u, call fs for success, ff for failures 192 | Get: function(u, fs, fe) { 193 | var request = new XMLHttpRequest(); 194 | request.open('GET', u, true); 195 | request.onload = function() { 196 | if (request.status >= 200 && request.status < 400) { 197 | fs(request); 198 | } else { 199 | fe(); 200 | } 201 | }; 202 | request.onerror = fe; 203 | request.send(); 204 | } 205 | 206 | }; 207 | 208 | }()); -------------------------------------------------------------------------------- /src/lib/editable/assets/scripts/editable.js: -------------------------------------------------------------------------------- 1 | // Package Editable provides an active toolbar for content-editable textareas 2 | // Version 1.0 3 | 4 | // TODO - show formatting selected on toolbar when selection changes in our contenteditable 5 | // TODO - perhaps intercept return key to be sure we get a para, and to be sure we insert newline within code sections, not new code tags + br or similar 6 | // TODO - Clean out more HTML cruft from programs like word/textedit 7 | 8 | // On document ready, scan for and activate toolbars associated with contenteditable 9 | DOM.Ready(function(){ 10 | // Activate editable content 11 | Editable.Activate('.content-editable-toolbar'); 12 | }); 13 | 14 | var Editable = (function() { 15 | return { 16 | // Activate editable elements with selector s 17 | Activate:function(s) { 18 | if (!DOM.Exists(s)) { 19 | return; 20 | } 21 | 22 | DOM.Each('.content-editable-toolbar',function(toolbar){ 23 | // Store associated elements for access later 24 | toolbar.buttons = toolbar.querySelectorAll('a'); 25 | var dataEditable = toolbar.getAttribute('data-editable'); 26 | toolbar.editable = DOM.First(DOM.Format("#{0}-editable",dataEditable)); 27 | toolbar.textarea = DOM.First(DOM.Format("#{0}-textarea",dataEditable)); 28 | 29 | if (toolbar.editable === undefined) { 30 | toolbar.editable = DOM.Nearest(toolbar,'.content-editable')[0]; 31 | } 32 | if (toolbar.textarea === undefined) { 33 | toolbar.textarea = DOM.Nearest(toolbar,'.content-textarea')[0]; 34 | } 35 | 36 | // Set textarea to hidden initially 37 | toolbar.textarea.style.display = 'none'; 38 | 39 | // Listen to a form submit, and call updateContent to make sure 40 | // our textarea in the form is up to date with the latest content 41 | toolbar.textarea.form.addEventListener('submit', function(e) { 42 | Editable.updateContent(toolbar.editable,toolbar.textarea,false); 43 | return false; 44 | }); 45 | 46 | // Intercept paste on editable and remove complex html before it is pasted in 47 | toolbar.editable.addEventListener('input', function(e) { 48 | Editable.cleanHTMLElements(this); 49 | }); 50 | 51 | 52 | // Listen to button clicks within the toolbar 53 | DOM.ForEach(toolbar.buttons,function(el,i){ 54 | el.addEventListener('click', function(e) { 55 | var cmd = this.id; 56 | var insert = ""; 57 | 58 | switch (cmd){ 59 | case "showCode": 60 | Editable.updateContent(toolbar.editable,toolbar.textarea,true); 61 | break; 62 | case "createLink": 63 | insert = prompt("Supply the web URL to link to"); 64 | // Prefix url with http:// if no scheme supplied 65 | if (insert.indexOf('http') !== 0) { 66 | insert = "http://" + insert; 67 | } 68 | break; 69 | case "formatblock": 70 | insert = this.getAttribute('data-format'); 71 | break; 72 | default: 73 | break; 74 | } 75 | 76 | if (cmd.length > 0) { 77 | document.execCommand(cmd,false,insert); 78 | } 79 | 80 | // Find and remove evil html created by browsers 81 | var sel = Editable.getSelectionParentElement(); 82 | 83 | if (sel !== null) { 84 | // Clean align stuff 85 | Editable.cleanAlign(cmd,sel); 86 | Editable.cleanHTMLElements(sel); 87 | sel.removeAttribute('style'); 88 | } 89 | 90 | 91 | return false; 92 | }); 93 | }); 94 | }); 95 | },// End activate 96 | 97 | // cleanAlign 98 | cleanAlign:function(cmd,el) { 99 | 100 | switch (cmd){ 101 | case "justifyCenter": 102 | 103 | if (sel.hasClass('align-center')) { 104 | sel.removeClass('align-center'); 105 | } else { 106 | sel.addClass('align-center'); 107 | } 108 | 109 | sel.removeClass('align-left').removeClass('align-right'); 110 | sel.removeAttr('style'); 111 | break; 112 | case "justifyLeft": 113 | if (sel.hasClass('align-left')) { 114 | sel.removeClass('align-left'); 115 | } else { 116 | sel.addClass('align-left'); 117 | } 118 | 119 | sel.removeClass('align-center').removeClass('align-right'); 120 | sel.removeAttribute('style'); 121 | 122 | 123 | break; 124 | case "justifyRight": 125 | 126 | if (sel.hasClass('align-right')) { 127 | sel.removeClass('align-right'); 128 | } else { 129 | sel.addClass('align-right'); 130 | } 131 | 132 | sel.removeClass('align-center').removeClass('align-left'); 133 | sel.removeAttribute('style'); 134 | break; 135 | } 136 | }, 137 | 138 | 139 | 140 | // CleanHTML is used to clean the html content from the contenteditable before it is assigned to the textarea 141 | cleanHTML:function(html) { 142 | html = html.replace(/<\/?span>/gi,'');// Remove all empty span tags 143 | html = html.replace(/<\/?font [^>]*>/gi,'');// Remove ALL font tags 144 | html = html.replace(/ <\/p>/gi,'\n');// pretty format but remove empty paras 146 | html = html.replace(/
<\/li>/gi,'<\/li>'); 147 | 148 | // Remove comments and other MS cruft 149 | html = html.replace(//gi,''); 150 | html = html.replace(/ class\=\"MsoNormal\"/gi,''); 151 | html = html.replace(/

<\/o:p><\/p>/gi,''); 152 | 153 | // Pretty printing elements which follow on from one another 154 | html = html.replace(/><(li|ul|ol|p|h\d|\/ul|\/ol)>/gi,'>\n<$1>'); 155 | 156 | return html; 157 | }, 158 | 159 | // cleanHTMLElements removes certain attributes which are usually full of junk (style, color etc) 160 | cleanHTMLElements:function(el) { 161 | // Browsers tend to use style attributes to add all sorts of awful stuff to the html 162 | // No inline styles allowed 163 | DOM.ForEach(el.querySelectorAll('p, div, b, i, h1, h2, h3, h4, h5, h6'),function(e){e.removeAttribute('style');}); 164 | DOM.ForEach(el.querySelectorAll('span'),function(e){e.removeAttribute('style');e.removeAttribute('lang');}); 165 | DOM.ForEach(el.querySelectorAll('font'),function(e){e.removeAttribute('color');}); 166 | }, 167 | 168 | // If textarea visible update the content editable with new html 169 | // else update the textarea with new content editable html 170 | updateContent:function(editable,textarea,toggle) { 171 | var html = ''; 172 | if (textarea.style.display !== 'none') { 173 | html = textarea.value; 174 | editable.innerHTML = html; 175 | if (toggle){ 176 | editable.style.display = ''; 177 | textarea.style.display = 'none'; 178 | } 179 | } else { 180 | html = editable.innerHTML; 181 | // Cleanup the html by removing plain spans 182 | html = Editable.cleanHTML(html); 183 | textarea.value = html; 184 | if (toggle){ 185 | editable.style.display = 'none'; 186 | textarea.style.display = ''; 187 | } 188 | } 189 | 190 | }, 191 | 192 | // The closest parent element which encloses the entire text selection 193 | getSelectionParentElement:function() { 194 | var p = null, sel; 195 | if (window.getSelection) { 196 | sel = window.getSelection(); 197 | if (sel.rangeCount) { 198 | p = sel.getRangeAt(0).commonAncestorContainer; 199 | if (p.nodeType != 1) { 200 | p = p.parentNode; 201 | } 202 | } 203 | } else if ( (sel = document.selection) && sel.type != "Control") { 204 | p = sel.createRange().parentElement(); 205 | } 206 | return p; 207 | } 208 | 209 | }; 210 | }()); 211 | 212 | 213 | -------------------------------------------------------------------------------- /src/lib/templates/fragmenta_resources/actions/actions_test.go.tmpl: -------------------------------------------------------------------------------- 1 | package [[ .fragmenta_resource ]]actions 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/fragmenta/query" 11 | 12 | "github.com/fragmenta/fragmenta-app/src/lib/resource" 13 | "github.com/fragmenta/fragmenta-app/src/[[ .fragmenta_resources ]]" 14 | "github.com/fragmenta/fragmenta-app/src/users" 15 | ) 16 | 17 | // names is used to test setting and getting the first string field of the [[ .fragmenta_resource ]]. 18 | var names = []string{"foo", "bar"} 19 | 20 | // TestSetup performs setup for integration tests 21 | // using the test database, real views, and mock authorisation 22 | // If we can run this once for global tests it might be more efficient? 23 | func TestSetup(t *testing.T) { 24 | err := resource.SetupTestDatabase(3) 25 | if err != nil { 26 | t.Fatalf("[[ .fragmenta_resources ]]: Setup db failed %s", err) 27 | } 28 | 29 | // Set up mock auth 30 | resource.SetupAuthorisation() 31 | 32 | // Load templates for rendering 33 | resource.SetupView(3) 34 | 35 | // Delete all [[ .fragmenta_resources ]] to ensure we get consistent results? 36 | query.ExecSQL("delete from [[ .fragmenta_resources ]];") 37 | query.ExecSQL("ALTER SEQUENCE [[ .fragmenta_resources ]]_id_seq RESTART WITH 1;") 38 | } 39 | 40 | // Test GET /[[ .fragmenta_resources ]]/create 41 | func TestShowCreate[[ .Fragmenta_Resource ]](t *testing.T) { 42 | 43 | // Create request context 44 | w, c := resource.GetRequestContext("/[[ .fragmenta_resources ]]/create", "/[[ .fragmenta_resources ]]/create", users.MockAdmin()) 45 | 46 | // Run the handler 47 | err := HandleCreateShow(c) 48 | 49 | // Test the error response 50 | if err != nil || w.Code != http.StatusOK { 51 | t.Fatalf("[[ .fragmenta_resource ]]actions: error handling HandleCreateShow %s", err) 52 | } 53 | 54 | // Test the body for a known pattern 55 | pattern := "resource-update-form" 56 | if !strings.Contains(w.Body.String(), pattern) { 57 | t.Fatalf("[[ .fragmenta_resource ]]actions: unexpected response for HandleCreateShow expected:%s got:%s", pattern, w.Body.String()) 58 | } 59 | 60 | } 61 | 62 | // Test POST /[[ .fragmenta_resources ]]/create 63 | func TestCreate[[ .Fragmenta_Resource ]](t *testing.T) { 64 | form := url.Values{} 65 | form.Add("name", names[0]) 66 | body := strings.NewReader(form.Encode()) 67 | 68 | // Create request context 69 | w, c := resource.PostRequestContext("/[[ .fragmenta_resources ]]/create", "/[[ .fragmenta_resources ]]/create", body, users.MockAdmin()) 70 | 71 | // Run the handler to update the [[ .fragmenta_resource ]] 72 | err := HandleCreate(c) 73 | if err != nil { 74 | t.Fatalf("[[ .fragmenta_resource ]]actions: error handling HandleCreate %s", err) 75 | } 76 | 77 | // Test we get a redirect after update (to the [[ .fragmenta_resource ]] concerned) 78 | if w.Code != http.StatusFound { 79 | t.Fatalf("[[ .fragmenta_resource ]]actions: unexpected response code for HandleCreate expected:%d got:%d", http.StatusFound, w.Code) 80 | } 81 | 82 | // Check the [[ .fragmenta_resource ]] name is in now value names[1] 83 | [[ .fragmenta_resource ]], err := [[ .fragmenta_resources ]].Find(1) 84 | if err != nil { 85 | t.Fatalf("[[ .fragmenta_resource ]]actions: error finding created [[ .fragmenta_resource ]] %s", err) 86 | } 87 | if [[ .fragmenta_resource ]].ID != 1 || [[ .fragmenta_resource ]].Name != names[0] { 88 | t.Fatalf("[[ .fragmenta_resource ]]actions: error with created [[ .fragmenta_resource ]] values: %v", [[ .fragmenta_resource ]]) 89 | } 90 | } 91 | 92 | // Test GET /[[ .fragmenta_resources ]] 93 | func TestList[[ .Fragmenta_Resources ]](t *testing.T) { 94 | 95 | // Create request context 96 | w, c := resource.GetRequestContext("/[[ .fragmenta_resources ]]", "/[[ .fragmenta_resources ]]", users.MockAdmin()) 97 | 98 | // Run the handler 99 | err := HandleIndex(c) 100 | 101 | // Test the error response 102 | if err != nil || w.Code != http.StatusOK { 103 | t.Fatalf("[[ .fragmenta_resource ]]actions: error handling HandleIndex %s", err) 104 | } 105 | 106 | // Test the body for a known pattern 107 | pattern := "data-table-head" 108 | if !strings.Contains(w.Body.String(), pattern) { 109 | t.Fatalf("[[ .fragmenta_resource ]]actions: unexpected response for HandleIndex expected:%s got:%s", pattern, w.Body.String()) 110 | } 111 | 112 | } 113 | 114 | // Test of GET /[[ .fragmenta_resources ]]/1 115 | func TestShow[[ .Fragmenta_Resource ]](t *testing.T) { 116 | 117 | // Create request context 118 | w, c := resource.GetRequestContext("/[[ .fragmenta_resources ]]/1", "/[[ .fragmenta_resources ]]/{id:[0-9]+}", users.MockAdmin()) 119 | 120 | // Run the handler 121 | err := HandleShow(c) 122 | 123 | // Test the error response 124 | if err != nil || w.Code != http.StatusOK { 125 | t.Fatalf("[[ .fragmenta_resource ]]actions: error handling HandleShow %s", err) 126 | } 127 | 128 | // Test the body for a known pattern 129 | pattern := fmt.Sprintf("

%s

", names[0]) 130 | if !strings.Contains(w.Body.String(), pattern) { 131 | t.Fatalf("[[ .fragmenta_resource ]]actions: unexpected response for HandleShow expected:%s got:%s", pattern, w.Body.String()) 132 | } 133 | } 134 | 135 | // Test GET /[[ .fragmenta_resources ]]/123/update 136 | func TestShowUpdate[[ .Fragmenta_Resource ]](t *testing.T) { 137 | 138 | // Create request context 139 | w, c := resource.GetRequestContext("/[[ .fragmenta_resources ]]/1/update", "/[[ .fragmenta_resources ]]/{id:[0-9]+}/update", users.MockAdmin()) 140 | 141 | // Run the handler 142 | err := HandleUpdateShow(c) 143 | 144 | // Test the error response 145 | if err != nil || w.Code != http.StatusOK { 146 | t.Fatalf("[[ .fragmenta_resource ]]actions: error handling HandleCreateShow %s", err) 147 | } 148 | 149 | // Test the body for a known pattern 150 | pattern := "resource-update-form" 151 | if !strings.Contains(w.Body.String(), pattern) { 152 | t.Fatalf("[[ .fragmenta_resource ]]actions: unexpected response for HandleCreateShow expected:%s got:%s", pattern, w.Body.String()) 153 | } 154 | 155 | } 156 | 157 | // Test POST /[[ .fragmenta_resources ]]/123/update 158 | func TestUpdate[[ .Fragmenta_Resource ]](t *testing.T) { 159 | form := url.Values{} 160 | form.Add("name", names[1]) 161 | body := strings.NewReader(form.Encode()) 162 | 163 | // Create request context 164 | w, c := resource.PostRequestContext("/[[ .fragmenta_resources ]]/1/update", "/[[ .fragmenta_resources ]]/{id:[0-9]+}/update", body, users.MockAdmin()) 165 | 166 | // Run the handler to update the [[ .fragmenta_resource ]] 167 | err := HandleUpdate(c) 168 | if err != nil { 169 | t.Fatalf("[[ .fragmenta_resource ]]actions: error handling HandleUpdate[[ .Fragmenta_Resource ]] %s", err) 170 | } 171 | 172 | // Test we get a redirect after update (to the [[ .fragmenta_resource ]] concerned) 173 | if w.Code != http.StatusFound { 174 | t.Fatalf("[[ .fragmenta_resource ]]actions: unexpected response code for HandleUpdate[[ .Fragmenta_Resource ]] expected:%d got:%d", http.StatusFound, w.Code) 175 | } 176 | 177 | // Check the [[ .fragmenta_resource ]] name is in now value names[1] 178 | [[ .fragmenta_resource ]], err := [[ .fragmenta_resources ]].Find(1) 179 | if err != nil { 180 | t.Fatalf("[[ .fragmenta_resource ]]actions: error finding updated [[ .fragmenta_resource ]] %s", err) 181 | } 182 | if [[ .fragmenta_resource ]].ID != 1 || [[ .fragmenta_resource ]].Name != names[1] { 183 | t.Fatalf("[[ .fragmenta_resource ]]actions: error with updated [[ .fragmenta_resource ]] values: %v", [[ .fragmenta_resource ]]) 184 | } 185 | 186 | } 187 | 188 | // Test of POST /[[ .fragmenta_resources ]]/123/destroy 189 | func TestDelete[[ .Fragmenta_Resource ]](t *testing.T) { 190 | 191 | body := strings.NewReader(``) 192 | 193 | // Test permissions - anon users can't destroy [[ .fragmenta_resources ]] 194 | 195 | // Create request context 196 | _, c := resource.PostRequestContext("/[[ .fragmenta_resources ]]/2/destroy", "/[[ .fragmenta_resources ]]/{id:[0-9]+}/destroy", body, users.MockAnon()) 197 | 198 | // Run the handler to test failure as anon 199 | err := HandleDestroy(c) 200 | if err == nil { // failure expected 201 | t.Fatalf("[[ .fragmenta_resource ]]actions: unexpected response for HandleDestroy as anon, expected failure") 202 | } 203 | 204 | // Now test deleting the [[ .fragmenta_resource ]] created above as admin 205 | // Create request context 206 | w, c := resource.PostRequestContext("/[[ .fragmenta_resources ]]/1/destroy", "/[[ .fragmenta_resources ]]/{id:[0-9]+}/destroy", body, users.MockAdmin()) 207 | 208 | // Run the handler 209 | err = HandleDestroy(c) 210 | 211 | // Test the error response is 302 StatusFound 212 | if err != nil { 213 | t.Fatalf("[[ .fragmenta_resource ]]actions: error handling HandleDestroy %s", err) 214 | } 215 | 216 | // Test we get a redirect after delete 217 | if w.Code != http.StatusFound { 218 | t.Fatalf("[[ .fragmenta_resource ]]actions: unexpected response code for HandleDestroy expected:%d got:%d", http.StatusFound, w.Code) 219 | } 220 | 221 | } 222 | -------------------------------------------------------------------------------- /src/app/bootstrap.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "os" 12 | "os/exec" 13 | "path" 14 | "path/filepath" 15 | "sort" 16 | "strings" 17 | "time" 18 | 19 | "github.com/fragmenta/query" 20 | ) 21 | 22 | // TODO: This should probably go into a bootstrap package within fragmenta? 23 | 24 | const ( 25 | fragmentaVersion = "1.2" 26 | 27 | permissions = 0744 28 | createDatabaseMigrationName = "Create-Database" 29 | createTablesMigrationName = "Create-Tables" 30 | ) 31 | 32 | var ( 33 | // ConfigDevelopment holds the development config from fragmenta.json 34 | ConfigDevelopment map[string]string 35 | 36 | // ConfigProduction holds development config from fragmenta.json 37 | ConfigProduction map[string]string 38 | 39 | // ConfigTest holds the app test config from fragmenta.json 40 | ConfigTest map[string]string 41 | ) 42 | 43 | // Bootstrap generates missing config files, sql migrations, and runs the first migrations 44 | // For this we need to know what to call the app, but we default to fragmenta-cms for now 45 | // we could use our current folder name? 46 | func Bootstrap() error { 47 | // We assume we're being run from root of project path 48 | projectPath, err := os.Getwd() 49 | if err != nil { 50 | return err 51 | } 52 | 53 | fmt.Printf("\nBootstrapping server...\n") 54 | 55 | err = generateConfig(projectPath) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | err = generateCreateSQL(projectPath) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | // Run the migrations without the fragmenta tool being present 66 | err = runMigrations(projectPath) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | return nil 72 | } 73 | 74 | // RequiresBootStrap returns true if the app requires bootstrapping 75 | func RequiresBootStrap() bool { 76 | if !fileExists(configPath()) { 77 | return true 78 | } 79 | return false 80 | } 81 | 82 | func configPath() string { 83 | return "secrets/fragmenta.json" 84 | } 85 | 86 | func projectPathRelative(projectPath string) string { 87 | goSrc := os.Getenv("GOPATH") + "/src/" 88 | return strings.Replace(projectPath, goSrc, "", 1) 89 | } 90 | 91 | func generateConfig(projectPath string) error { 92 | configPath := configPath() 93 | prefix := path.Base(projectPath) 94 | prefix = strings.Replace(prefix, "-", "_", -1) 95 | log.Printf("Generating new config at %s", configPath) 96 | 97 | ConfigProduction = map[string]string{} 98 | ConfigDevelopment = map[string]string{} 99 | ConfigTest = map[string]string{ 100 | "port": "3000", 101 | "log": "log/test.log", 102 | "db_adapter": "postgres", 103 | "db": prefix + "_test", 104 | "db_user": prefix + "_server", 105 | "db_pass": randomKey(8), 106 | "assets_compiled": "no", 107 | "path": projectPathRelative(projectPath), 108 | "hmac_key": randomKey(32), 109 | "secret_key": randomKey(32), 110 | "session_name": prefix, 111 | } 112 | 113 | for k, v := range ConfigTest { 114 | ConfigDevelopment[k] = v 115 | ConfigProduction[k] = v 116 | } 117 | ConfigDevelopment["db"] = prefix + "_development" 118 | ConfigDevelopment["log"] = "log/development.log" 119 | ConfigDevelopment["hmac_key"] = randomKey(32) 120 | ConfigDevelopment["secret_key"] = randomKey(32) 121 | 122 | ConfigProduction["db"] = prefix + "_production" 123 | ConfigProduction["log"] = "log/production.log" 124 | ConfigProduction["port"] = "80" //FIXME set up for https with port 443 125 | ConfigProduction["assets_compiled"] = "yes" 126 | ConfigProduction["hmac_key"] = randomKey(32) 127 | ConfigProduction["secret_key"] = randomKey(32) 128 | 129 | configs := map[string]map[string]string{ 130 | "production": ConfigProduction, 131 | "development": ConfigDevelopment, 132 | "test": ConfigTest, 133 | } 134 | 135 | configJSON, err := json.MarshalIndent(configs, "", "\t") 136 | if err != nil { 137 | log.Printf("Error parsing config %s %v", configPath, err) 138 | return err 139 | } 140 | 141 | // Write the config json file 142 | err = ioutil.WriteFile(configPath, configJSON, permissions) 143 | if err != nil { 144 | log.Printf("Error writing config %s %v", configPath, err) 145 | return err 146 | } 147 | 148 | return nil 149 | } 150 | 151 | // generateCreateSQL generates an SQL migration file to create the database user and database referred to in config 152 | func generateCreateSQL(projectPath string) error { 153 | 154 | // Set up a Create-Database migration, which comes first 155 | name := path.Base(projectPath) 156 | d := ConfigDevelopment["db"] 157 | u := ConfigDevelopment["db_user"] 158 | p := ConfigDevelopment["db_pass"] 159 | sql := fmt.Sprintf("/* Setup database for %s */\nCREATE USER \"%s\" WITH PASSWORD '%s';\nCREATE DATABASE \"%s\" WITH OWNER \"%s\";", name, u, p, d, u) 160 | 161 | // Generate a migration to create db with today's date 162 | file := migrationPath(projectPath, createDatabaseMigrationName) 163 | err := ioutil.WriteFile(file, []byte(sql), 0744) 164 | if err != nil { 165 | return err 166 | } 167 | 168 | // If we have a Create-Tables file, copy it out to a new migration with today's date 169 | createTablesPath := path.Join(projectPath, "db", "migrate", createTablesMigrationName+".sql.tmpl") 170 | if fileExists(createTablesPath) { 171 | sql, err := ioutil.ReadFile(createTablesPath) 172 | if err != nil { 173 | return err 174 | } 175 | 176 | // Now vivify the template, for now we just replace one key 177 | sqlString := strings.Replace(string(sql), "[[.fragmenta_db_user]]", u, -1) 178 | 179 | file = migrationPath(projectPath, createTablesMigrationName) 180 | err = ioutil.WriteFile(file, []byte(sqlString), 0744) 181 | if err != nil { 182 | return err 183 | } 184 | // Remove the old file 185 | os.Remove(createTablesPath) 186 | 187 | } else { 188 | fmt.Printf("NO TABLES %s", createTablesPath) 189 | } 190 | 191 | return nil 192 | } 193 | 194 | // runMigrations at projectPath 195 | func runMigrations(projectPath string) error { 196 | var migrations []string 197 | var migrationCount int 198 | 199 | config := ConfigDevelopment 200 | 201 | // Get a list of migration files 202 | files, err := filepath.Glob("./db/migrate/*.sql") 203 | if err != nil { 204 | return err 205 | } 206 | 207 | // Sort the list alphabetically 208 | sort.Strings(files) 209 | 210 | for _, file := range files { 211 | filename := path.Base(file) 212 | 213 | log.Printf("Running migration %s", filename) 214 | 215 | args := []string{"-d", config["db"], "-f", file} 216 | if strings.Contains(filename, createDatabaseMigrationName) { 217 | args = []string{"-f", file} 218 | log.Printf("Running database creation migration: %s", file) 219 | } 220 | 221 | // Execute this sql file against the database 222 | result, err := runCommand("psql", args...) 223 | if err != nil || strings.Contains(string(result), "ERROR") { 224 | if err == nil { 225 | err = fmt.Errorf("\n%s", string(result)) 226 | } 227 | 228 | // If at any point we fail, log it and break 229 | log.Printf("ERROR loading sql migration:%s\n", err) 230 | log.Printf("All further migrations cancelled\n\n") 231 | return err 232 | } 233 | 234 | migrationCount++ 235 | migrations = append(migrations, filename) 236 | log.Printf("Completed migration %s\n%s\n%s", filename, string(result), "-") 237 | 238 | } 239 | 240 | if migrationCount > 0 { 241 | writeMetadata(config, migrations) 242 | log.Printf("Migrations complete up to migration %v on db %s\n\n", migrations, config["db"]) 243 | } 244 | 245 | return nil 246 | } 247 | 248 | // Oh, we need to write the full list of migrations, not just one migration version 249 | 250 | // Update the database with a line recording what we have done 251 | func writeMetadata(config map[string]string, migrations []string) { 252 | // Try opening the db (db may not exist at this stage) 253 | err := openDatabase(config) 254 | if err != nil { 255 | log.Printf("Database ERROR %s", err) 256 | } 257 | defer query.CloseDatabase() 258 | 259 | for _, m := range migrations { 260 | sql := "Insert into fragmenta_metadata(updated_at,fragmenta_version,migration_version,status) VALUES(NOW(),$1,$2,100);" 261 | result, err := query.ExecSQL(sql, fragmentaVersion, m) 262 | if err != nil { 263 | log.Printf("Database ERROR %s %s", err, result) 264 | } 265 | } 266 | 267 | } 268 | 269 | // Open our database 270 | func openDatabase(config map[string]string) error { 271 | // Open the database 272 | options := map[string]string{ 273 | "adapter": config["db_adapter"], 274 | "user": config["db_user"], 275 | "password": config["db_pass"], 276 | "db": config["db"], 277 | // "debug" : "true", 278 | } 279 | 280 | err := query.OpenDatabase(options) 281 | if err != nil { 282 | return err 283 | } 284 | 285 | log.Printf("%s\n", "-") 286 | log.Printf("Opened database at %s for user %s", config["db"], config["db_user"]) 287 | return nil 288 | } 289 | 290 | // Generate a suitable path for a migration from the current date/time down to nanosecond 291 | func migrationPath(path string, name string) string { 292 | now := time.Now() 293 | layout := "2006-01-02-150405" 294 | return fmt.Sprintf("%s/db/migrate/%s-%s.sql", path, now.Format(layout), name) 295 | } 296 | 297 | // Generate a random 32 byte key encoded in base64 298 | func randomKey(l int64) string { 299 | k := make([]byte, l) 300 | if _, err := io.ReadFull(rand.Reader, k); err != nil { 301 | return "" 302 | } 303 | return hex.EncodeToString(k) 304 | } 305 | 306 | // fileExists returns true if this file exists 307 | func fileExists(p string) bool { 308 | _, err := os.Stat(p) 309 | if err != nil && os.IsNotExist(err) { 310 | return false 311 | } 312 | 313 | return true 314 | } 315 | 316 | // runCommand runs a command with exec.Command 317 | func runCommand(command string, args ...string) ([]byte, error) { 318 | 319 | cmd := exec.Command(command, args...) 320 | output, err := cmd.CombinedOutput() 321 | if err != nil { 322 | return output, err 323 | } 324 | 325 | return output, nil 326 | } 327 | -------------------------------------------------------------------------------- /src/users/actions/actions_test.go: -------------------------------------------------------------------------------- 1 | package useractions 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/fragmenta/auth" 11 | "github.com/fragmenta/query" 12 | 13 | "github.com/fragmenta/fragmenta-app/src/lib/resource" 14 | "github.com/fragmenta/fragmenta-app/src/users" 15 | ) 16 | 17 | // names is used to test setting and getting the first string field of the user. 18 | var names = []string{"foo", "bar"} 19 | 20 | // TestSetup performs setup for integration tests 21 | // using the test database, real views, and mock authorisation 22 | // If we can run this once for global tests it might be more efficient? 23 | func TestSetup(t *testing.T) { 24 | err := resource.SetupTestDatabase(3) 25 | if err != nil { 26 | t.Fatalf("users: Setup db failed %s", err) 27 | } 28 | 29 | // Set up mock auth 30 | resource.SetupAuthorisation() 31 | 32 | // Load templates for rendering 33 | resource.SetupView(3) 34 | 35 | // Delete all users to ensure we get consistent results? 36 | query.ExecSQL("delete from users;") 37 | query.ExecSQL("ALTER SEQUENCE users_id_seq RESTART WITH 1;") 38 | 39 | // Insert a test user for checking logins 40 | query.ExecSQL("INSERT INTO users VALUES(1,NOW(),NOW(),'example@example.com','test',0,10,'$2a$10$2IUzpI/yH0Xc.qs9Z5UUL.3f9bqi0ThvbKs6Q91UOlyCEGY8hdBw6');") 41 | 42 | } 43 | 44 | // Test GET /users/create 45 | func TestShowCreateUser(t *testing.T) { 46 | 47 | // Create request context 48 | w, c := resource.GetRequestContext("/users/create", "/users/create", users.MockAdmin()) 49 | 50 | // Run the handler 51 | err := HandleCreateShow(c) 52 | 53 | // Test the error response 54 | if err != nil || w.Code != http.StatusOK { 55 | t.Fatalf("useractions: error handling HandleCreateShow %s", err) 56 | } 57 | 58 | // Test the body for a known pattern 59 | pattern := "resource-update-form" 60 | if !strings.Contains(w.Body.String(), pattern) { 61 | t.Fatalf("useractions: unexpected response for HandleCreateShow expected:%s got:%s", pattern, w.Body.String()) 62 | } 63 | 64 | } 65 | 66 | // Test POST /users/create 67 | func TestCreateUser(t *testing.T) { 68 | form := url.Values{} 69 | form.Add("name", names[0]) 70 | body := strings.NewReader(form.Encode()) 71 | 72 | // Create request context 73 | w, c := resource.PostRequestContext("/users/create", "/users/create", body, users.MockAdmin()) 74 | 75 | // Run the handler to update the user 76 | err := HandleCreate(c) 77 | if err != nil { 78 | t.Fatalf("useractions: error handling HandleCreate %s", err) 79 | } 80 | 81 | // Test we get a redirect after update (to the user concerned) 82 | if w.Code != http.StatusFound { 83 | t.Fatalf("useractions: unexpected response code for HandleCreate expected:%d got:%d", http.StatusFound, w.Code) 84 | } 85 | 86 | // Check the user name is in now value names[1] 87 | user, err := users.Find(1) 88 | if err != nil { 89 | t.Fatalf("useractions: error finding created user %s", err) 90 | } 91 | if user.ID != 1 || user.Name != names[0] { 92 | t.Fatalf("useractions: error with created user values: %v", user) 93 | } 94 | } 95 | 96 | // Test GET /users 97 | func TestListUsers(t *testing.T) { 98 | 99 | // Create request context 100 | w, c := resource.GetRequestContext("/users", "/users", users.MockAdmin()) 101 | 102 | // Run the handler 103 | err := HandleIndex(c) 104 | 105 | // Test the error response 106 | if err != nil || w.Code != http.StatusOK { 107 | t.Fatalf("useractions: error handling HandleIndex %s", err) 108 | } 109 | 110 | // Test the body for a known pattern 111 | pattern := "data-table-head" 112 | if !strings.Contains(w.Body.String(), pattern) { 113 | t.Fatalf("useractions: unexpected response for HandleIndex expected:%s got:%s", pattern, w.Body.String()) 114 | } 115 | 116 | } 117 | 118 | // Test of GET /users/1 119 | func TestShowUser(t *testing.T) { 120 | 121 | // Create request context 122 | w, c := resource.GetRequestContext("/users/1", "/users/{id:[0-9]+}", users.MockAdmin()) 123 | 124 | // Run the handler 125 | err := HandleShow(c) 126 | 127 | // Test the error response 128 | if err != nil || w.Code != http.StatusOK { 129 | t.Fatalf("useractions: error handling HandleShow %s", err) 130 | } 131 | 132 | // Test the body for a known pattern 133 | pattern := fmt.Sprintf("

%s

", names[0]) 134 | if !strings.Contains(w.Body.String(), pattern) { 135 | t.Fatalf("useractions: unexpected response for HandleShow expected:%s got:%s", pattern, w.Body.String()) 136 | } 137 | } 138 | 139 | // Test GET /users/123/update 140 | func TestShowUpdateUser(t *testing.T) { 141 | 142 | // Create request context 143 | w, c := resource.GetRequestContext("/users/1/update", "/users/{id:[0-9]+}/update", users.MockAdmin()) 144 | 145 | // Run the handler 146 | err := HandleUpdateShow(c) 147 | 148 | // Test the error response 149 | if err != nil || w.Code != http.StatusOK { 150 | t.Fatalf("useractions: error handling HandleCreateShow %s", err) 151 | } 152 | 153 | // Test the body for a known pattern 154 | pattern := "resource-update-form" 155 | if !strings.Contains(w.Body.String(), pattern) { 156 | t.Fatalf("useractions: unexpected response for HandleCreateShow expected:%s got:%s", pattern, w.Body.String()) 157 | } 158 | 159 | } 160 | 161 | // Test POST /users/123/update 162 | func TestUpdateUser(t *testing.T) { 163 | form := url.Values{} 164 | form.Add("name", names[1]) 165 | body := strings.NewReader(form.Encode()) 166 | 167 | // Create request context 168 | w, c := resource.PostRequestContext("/users/1/update", "/users/{id:[0-9]+}/update", body, users.MockAdmin()) 169 | 170 | // Run the handler to update the user 171 | err := HandleUpdate(c) 172 | if err != nil { 173 | t.Fatalf("useractions: error handling HandleUpdateUser %s", err) 174 | } 175 | 176 | // Test we get a redirect after update (to the user concerned) 177 | if w.Code != http.StatusFound { 178 | t.Fatalf("useractions: unexpected response code for HandleUpdateUser expected:%d got:%d", http.StatusFound, w.Code) 179 | } 180 | 181 | // Check the user name is in now value names[1] 182 | user, err := users.Find(1) 183 | if err != nil { 184 | t.Fatalf("useractions: error finding updated user %s", err) 185 | } 186 | if user.ID != 1 || user.Name != names[1] { 187 | t.Fatalf("useractions: error with updated user values: %v", user) 188 | } 189 | 190 | } 191 | 192 | // Test of POST /users/123/destroy 193 | func TestDeleteUser(t *testing.T) { 194 | 195 | body := strings.NewReader(``) 196 | 197 | // Test permissions - anon users can't destroy users 198 | 199 | // Create request context 200 | _, c := resource.PostRequestContext("/users/2/destroy", "/users/{id:[0-9]+}/destroy", body, users.MockAnon()) 201 | 202 | // Run the handler to test failure as anon 203 | err := HandleDestroy(c) 204 | if err == nil { // failure expected 205 | t.Fatalf("useractions: unexpected response for HandleDestroy as anon, expected failure") 206 | } 207 | 208 | // Now test deleting the user created above as admin 209 | // Create request context 210 | w, c := resource.PostRequestContext("/users/1/destroy", "/users/{id:[0-9]+}/destroy", body, users.MockAdmin()) 211 | 212 | // Run the handler 213 | err = HandleDestroy(c) 214 | 215 | // Test the error response is 302 StatusFound 216 | if err != nil { 217 | t.Fatalf("useractions: error handling HandleDestroy %s", err) 218 | } 219 | 220 | // Test we get a redirect after delete 221 | if w.Code != http.StatusFound { 222 | t.Fatalf("useractions: unexpected response code for HandleDestroy expected:%d got:%d", http.StatusFound, w.Code) 223 | } 224 | 225 | } 226 | 227 | // Test GET /users/login 228 | func TestShowLogin(t *testing.T) { 229 | 230 | // Create request context with admin user 231 | w, c := resource.GetRequestContext("/users/login", "/users/login", users.MockAdmin()) 232 | 233 | // Run the handler 234 | err := HandleLoginShow(c) 235 | 236 | // Check for redirect as they are considered logged in 237 | if err != nil || w.Code != http.StatusFound { 238 | t.Fatalf("useractions: error handling HandleLoginShow %s", err) 239 | } 240 | 241 | // Create request context with anon user 242 | w, c = resource.GetRequestContext("/users/login", "/users/login", users.MockAnon()) 243 | 244 | // Run the handler 245 | err = HandleLoginShow(c) 246 | 247 | // Test the error response 248 | if err != nil || w.Code != http.StatusOK { 249 | t.Fatalf("useractions: error handling HandleLoginShow %s", err) 250 | } 251 | 252 | // Test the body for a known pattern 253 | pattern := "password" 254 | if !strings.Contains(w.Body.String(), pattern) { 255 | t.Fatalf("useractions: unexpected response for HandleLoginShow expected:%s got:%s", pattern, w.Body.String()) 256 | } 257 | 258 | } 259 | 260 | // Test POST /users/login 261 | func TestLogin(t *testing.T) { 262 | 263 | // These need to match entries in the test db for this to work 264 | form := url.Values{} 265 | form.Add("email", "example@example.com") 266 | form.Add("password", "Hunter2") 267 | body := strings.NewReader(form.Encode()) 268 | 269 | // Test posting to the login link, we expect success as setup inserts this user 270 | w, c := resource.PostRequestContext("/users/1/destroy", "/users/{id:[0-9]+}/destroy", body, users.MockAnon()) 271 | 272 | // Run the handler 273 | err := HandleLogin(c) 274 | if err != nil || w.Code != http.StatusFound { 275 | t.Fatalf("useractions: error on HandleLogin %s", err) 276 | } 277 | 278 | } 279 | 280 | // Test POST /users/logout 281 | func TestLogout(t *testing.T) { 282 | 283 | // Test posting to logout link to log the user out 284 | w, c := resource.PostRequestContext("/users/1/destroy", "/users/{id:[0-9]+}/destroy", nil, users.MockAnon()) 285 | 286 | // Store something in the session 287 | session, err := auth.Session(w, c.Request()) 288 | if err != nil { 289 | t.Fatalf("#error problem retrieving session") 290 | } 291 | 292 | // Set the cookie with user ID 293 | session.Set(auth.SessionUserKey, fmt.Sprintf("%d", 99)) 294 | session.Save(w) 295 | 296 | // Run the handler 297 | err = HandleLogout(c) 298 | if err != nil { 299 | t.Fatalf("useractions: error on HandleLogout %s", err) 300 | } 301 | 302 | // Check we've set an empty session on this outgoing writer 303 | if !strings.Contains(string(w.Header().Get("Set-Cookie")), auth.SessionName+"=;") { 304 | t.Fatalf("useractions: error on HandleLogout - session not cleared") 305 | } 306 | 307 | // TODO - to better test this we should have an integration test with a server 308 | 309 | } 310 | --------------------------------------------------------------------------------