Initial commit

There's still a whole bunch of untouched default Lucky files, and the
database migrations and models are not complete yet.

So far the following models and migrations should match the current
schema:

* User
* Role
* Post
* TagNamespace
* Tag
* PostTag
This commit is contained in:
Les De Ridder 2020-03-07 05:39:00 +01:00
commit aab65e17b3
153 changed files with 10219 additions and 0 deletions

1
.crystal-version Normal file
View File

@ -0,0 +1 @@
0.32.1

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
/docs/
/lib/
/bin/
/.shards/
*.dwarf
start_server
*.dwarf
*.local.cr
.env
/tmp
/public/js
/public/css
/public/mix-manifest.json
/node_modules
yarn-error.log
/.vagrant/

22
.travis.yml Normal file
View File

@ -0,0 +1,22 @@
language: crystal
addons:
chrome: stable
services:
- postgresql
before_install:
# Setup chromedriver for LuckyFlow
- sudo apt-get install chromium-chromedriver
# Setup assets
- yarn install
- yarn prod
script:
- crystal spec
# Uncomment the next line if you'd like Travis to check code formatting
# - crystal tool format spec src --check
cache:
yarn: true
directories:
- bin/lucky
- lib
- .shards

2
Procfile Normal file
View File

@ -0,0 +1,2 @@
web: ./app
release: lucky db.migrate

2
Procfile.dev Normal file
View File

@ -0,0 +1,2 @@
web: lucky watch --reload-browser
assets: yarn watch

14
README.md Normal file
View File

@ -0,0 +1,14 @@
# luckybooru
This is a project written using [Lucky](https://luckyframework.org). Enjoy!
### Setting up the project
1. [Install required dependencies](https://luckyframework.org/guides/getting-started/installing#install-required-dependencies)
1. Update database settings in `config/database.cr`
1. Run `script/setup`
1. Run `lucky dev` to start the app
### Learning Lucky
Lucky uses the [Crystal](https://crystal-lang.org) programming language. You can learn about Lucky from the [Lucky Guides](https://luckyframework.org/guides/getting-started/why-lucky).

44
Vagrantfile vendored Normal file
View File

@ -0,0 +1,44 @@
Vagrant.configure("2") do |config|
config.vm.box = "generic/arch"
config.vm.network "forwarded_port", guest: 3001, host: 8080
config.vm.network "forwarded_port", guest: 5432, host: 54320
config.vm.synced_folder ".", "/home/vagrant/luckybooru"
config.vm.provision "shell", inline: <<-SHELL
pacman -Syu --noconfirm
pacman -S --needed --noconfirm neovim fish base-devel
chsh -s /usr/bin/fish
chsh vagrant -s /usr/bin/fish
pacman -S --needed --noconfirm crystal shards yarn nodejs
SHELL
config.vm.provision "shell", inline: "#!/usr/bin/fish\n" + <<-SHELL
pacman -S --needed --noconfirm postgresql
if not find /var/lib/postgres/data -mindepth 1 | read > /dev/null
su - postgres -c "initdb --locale en_US.UTF-8 -D '/var/lib/postgres/data'"
end
systemctl enable --now postgresql
SHELL
config.vm.provision "shell", privileged: false, inline: "#!/usr/bin/fish\n" + <<-SHELL
function aur_install
git clone https://aur.archlinux.org/$argv; and cd $argv; and makepkg -is --noconfirm --needed; and cd ..; and rm -rf $argv
end
aur_install lucky-git
aur_install overmind-bin
echo 'set -x OVERMIND_SOCKET /home/vagrant/.overmind' > ~/.config/fish/config.fish
SHELL
config.vm.provision "shell", privileged: false, inline: "#!/usr/bin/fish\n" + <<-SHELL
cd luckybooru
./script/setup
SHELL
end

26
bs-config.js Normal file
View File

@ -0,0 +1,26 @@
/*
| Browser-sync config file
|
| For up-to-date information about the options:
| http://www.browsersync.io/docs/options/
|
*/
module.exports = {
snippetOptions: {
rule: {
match: /<\/head>/i,
fn: function (snippet, match) {
return snippet + match;
}
}
},
files: ["public/css/**/*.css", "public/js/**/*.js"],
watchEvents: ["change"],
open: false,
browser: "default",
ghostMode: false,
ui: false,
online: false,
logConnections: false
};

10
config/authentic.cr Normal file
View File

@ -0,0 +1,10 @@
require "./server"
Authentic.configure do |settings|
settings.secret_key = Lucky::Server.settings.secret_key_base
unless Lucky::Env.production?
fastest_encryption_possible = 4
settings.encryption_cost = fastest_encryption_possible
end
end

4
config/colors.cr Normal file
View File

@ -0,0 +1,4 @@
# This enables the color output when in development or test
# Check out the Colorize docs for more information
# https://crystal-lang.org/api/Colorize.html
Colorize.enabled = Lucky::Env.development? || Lucky::Env.test?

16
config/cookies.cr Normal file
View File

@ -0,0 +1,16 @@
require "./server"
Lucky::Session.configure do |settings|
settings.key = "_luckybooru_session"
end
Lucky::CookieJar.configure do |settings|
settings.on_set = ->(cookie : HTTP::Cookie) {
# If ForceSSLHandler is enabled, only send cookies over HTTPS
cookie.secure(Lucky::ForceSSLHandler.settings.enabled)
# You can set other defaults for cookies here. For example:
#
# cookie.expires(1.year.from_now).domain("mydomain.com")
}
end

32
config/database.cr Normal file
View File

@ -0,0 +1,32 @@
database_name = "luckybooru_#{Lucky::Env.name}"
AppDatabase.configure do |settings|
if Lucky::Env.production?
settings.url = ENV.fetch("DATABASE_URL")
else
settings.url = ENV["DATABASE_URL"]? || Avram::PostgresURL.build(
database: database_name,
hostname: ENV["DB_HOST"]? || "localhost",
# Some common usernames are "postgres", "root", or your system username (run 'whoami')
username: ENV["DB_USERNAME"]? || "postgres",
# Some Postgres installations require no password. Use "" if that is the case.
password: ENV["DB_PASSWORD"]? || "postgres"
)
end
#DB.open(settings.url) do |db|
# db.exec "set cluster setting sql.defaults.default_int_size = 4"
# db.exec "set cluster setting sql.defaults.serial_normalization = sql_sequence"
#end
end
Avram.configure do |settings|
settings.database_to_migrate = AppDatabase
# In production, allow lazy loading (N+1).
# In development and test, raise an error if you forget to preload associations
settings.lazy_load_enabled = Lucky::Env.production?
# Uncomment the next line to log all SQL queries
# settings.query_log_level = ::Logger::Severity::DEBUG
end

22
config/email.cr Normal file
View File

@ -0,0 +1,22 @@
BaseEmail.configure do |settings|
if Lucky::Env.production?
# If you don't need to send emails, set the adapter to DevAdapter instead:
#
# settings.adapter = Carbon::DevAdapter.new
#
# If you do need emails, get a key from SendGrid and set an ENV variable
send_grid_key = send_grid_key_from_env
settings.adapter = Carbon::SendGridAdapter.new(api_key: send_grid_key)
else
settings.adapter = Carbon::DevAdapter.new
end
end
private def send_grid_key_from_env
ENV["SEND_GRID_KEY"]? || raise_missing_key_message
end
private def raise_missing_key_message
puts "Missing SEND_GRID_KEY. Set the SEND_GRID_KEY env variable to 'unused' if not sending emails, or set the SEND_GRID_KEY ENV var.".colorize.red
exit(1)
end

13
config/env.cr Normal file
View File

@ -0,0 +1,13 @@
module Lucky::Env
extend self
{% for env in [:development, :test, :production] %}
def {{ env.id }}?
name == {{ env.id.stringify }}
end
{% end %}
def name
ENV["LUCKY_ENV"]? || "development"
end
end

3
config/error_handler.cr Normal file
View File

@ -0,0 +1,3 @@
Lucky::ErrorHandler.configure do |settings|
settings.show_debug_output = !Lucky::Env.production?
end

3
config/html_page.cr Normal file
View File

@ -0,0 +1,3 @@
Lucky::HTMLPage.configure do |settings|
settings.render_component_comments = !Lucky::Env.production?
end

48
config/logger.cr Normal file
View File

@ -0,0 +1,48 @@
require "file_utils"
logger =
if Lucky::Env.test?
# Logs to `tmp/test.log` so you can see what's happening without having
# a bunch of log output in your spec results.
FileUtils.mkdir_p("tmp")
Dexter::Logger.new(
io: File.new("tmp/test.log", mode: "w"),
level: Logger::Severity::DEBUG,
log_formatter: Lucky::PrettyLogFormatter
)
elsif Lucky::Env.production?
# This sets the log formatter to JSON so you can parse the logs with
# services like Logentries or Logstash.
#
# If you want logs like in develpoment use `Lucky::PrettyLogFormatter`.
Dexter::Logger.new(
io: STDOUT,
level: Logger::Severity::INFO,
log_formatter: Dexter::Formatters::JsonLogFormatter
)
else
# For development, log everything to STDOUT with the pretty formatter.
Dexter::Logger.new(
io: STDOUT,
level: Logger::Severity::DEBUG,
log_formatter: Lucky::PrettyLogFormatter
)
end
Lucky.configure do |settings|
settings.logger = logger
end
Lucky::LogHandler.configure do |settings|
# Skip logging static assets in development
if Lucky::Env.development?
settings.skip_if = ->(context : HTTP::Server::Context) {
context.request.method.downcase == "get" &&
context.request.resource.starts_with?(/\/css\/|\/js\/|\/assets\/|\/favicon\.ico/)
}
end
end
Avram.configure do |settings|
settings.logger = logger
end

10
config/route_helper.cr Normal file
View File

@ -0,0 +1,10 @@
# This is used when generating URLs for your application
Lucky::RouteHelper.configure do |settings|
if Lucky::Env.production?
# Example: https://my_app.com
settings.base_uri = ENV.fetch("APP_DOMAIN")
else
# Set domain to the default host/port in development/test
settings.base_uri = "http://localhost:#{Lucky::ServerSettings.port}"
end
end

56
config/server.cr Normal file
View File

@ -0,0 +1,56 @@
# Here is where you configure the Lucky server
#
# Look at config/route_helper.cr if you want to change the domain used when
# generating links with `Action.url`.
Lucky::Server.configure do |settings|
if Lucky::Env.production?
settings.secret_key_base = secret_key_from_env
settings.host = "0.0.0.0"
settings.port = ENV["PORT"].to_i
settings.gzip_enabled = true
# By default certain content types will be gzipped.
# For a full list look in
# https://github.com/luckyframework/lucky/blob/master/src/lucky/server.cr
# To add additional extensions do something like this:
# config.gzip_content_types << "content/type"
else
settings.secret_key_base = "SvDuwNASl8MVLGYq/iugM335UPqck5EeSFp+hCqjwsw="
# Change host/port in config/watch.yml
# Alternatively, you can set the PORT env to set the port
settings.host = Lucky::ServerSettings.host
settings.port = Lucky::ServerSettings.port
end
# By default Lucky will serve static assets in development and production.
#
# However you could use a CDN when in production like this:
#
# Lucky::Server.configure do |settings|
# if Lucky::Env.production?
# settings.asset_host = "https://mycdnhost.com"
# else
# settings.asset_host = ""
# end
# end
settings.asset_host = "" # Lucky will serve assets
end
Lucky::ForceSSLHandler.configure do |settings|
# To force SSL in production, uncomment the lines below.
# This will cause http requests to be redirected to https:
#
# settings.enabled = Lucky::Env.production?
# settings.strict_transport_security = {max_age: 1.year, include_subdomains: true}
#
# Or, leave it disabled:
settings.enabled = false
end
private def secret_key_from_env
ENV["SECRET_KEY_BASE"]? || raise_missing_secret_key_in_production
end
private def raise_missing_secret_key_in_production
puts "Please set the SECRET_KEY_BASE environment variable. You can generate a secret key with 'lucky gen.secret_key'".colorize.red
exit(1)
end

2
config/watch.yml Normal file
View File

@ -0,0 +1,2 @@
host: 127.0.0.1
port: 5000

0
db/migrations/.keep Normal file
View File

View File

@ -0,0 +1,16 @@
class CreateUsers::V00000000000001 < Avram::Migrator::Migration::V1
def migrate
create table_for(User) do
primary_key id : Int64
add name : String, unique: true
add encrypted_password : String # NOTE: Should really be called 'password_hash'
add email : String?, unique: true
add created_at : Time
end
end
def rollback
drop table_for(User)
end
end

View File

@ -0,0 +1,22 @@
class CreateRoles::V00000000000002 < Avram::Migrator::Migration::V1
def migrate
create table_for(Role) do
primary_key id : Int64
add name : String, unique: true
add description : String?
end
alter table_for(User) do
add_belongs_to primary_role : Role, on_delete: :restrict
end
end
def rollback
alter table_for(User) do
remove_belongs_to :primary_role
end
drop table_for(Role)
end
end

View File

@ -0,0 +1,21 @@
class CreatePosts::V00000000000003 < Avram::Migrator::Migration::V1
def migrate
create table_for(Post) do
primary_key id : Int64
add file_url : String, unique: true
add title : String?, default: nil
add description : String?, default: nil
add_belongs_to creator : User?, on_delete: :restrict
add_timestamps
add score : Int64, default: 0
add views : Int64, default: 0 # NOTE: Should really be UInt64
add favourites : Int64, default: 0 # NOTE: Should really be UInt64
add file_size : Int64
end
end
def rollback
drop table_for(Post)
end
end

View File

@ -0,0 +1,14 @@
class CreateTagNamespaces::V00000000000004 < Avram::Migrator::Migration::V1
def migrate
create table_for(TagNamespace) do
primary_key id : Int64
add name : String
add description : String?, default: nil
end
end
def rollback
drop table_for(TagNamespace)
end
end

View File

@ -0,0 +1,16 @@
class CreateTags::V00000000000005 < Avram::Migrator::Migration::V1
def migrate
create table_for(Tag) do
primary_key id : Int64
add_belongs_to namespace : TagNamespace, on_delete: :restrict
add name : String
add description : String?
end
end
def rollback
drop table_for(Tag)
end
end

View File

@ -0,0 +1,14 @@
class CreatePostTags::V00000000000006 < Avram::Migrator::Migration::V1
def migrate
create table_for(PostTag) do
primary_key id : Int64 # NOTE: Should not have a primary key
add_belongs_to post : Post, on_delete: :restrict
add_belongs_to tag : Tag, on_delete: :restrict
end
end
def rollback
drop table_for(PostTag)
end
end

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"license": "UNLICENSED",
"private": true,
"dependencies": {
"@rails/ujs": "^6.0.0",
"normalize-scss": "^7.0.1",
"turbolinks": "^5.2.0"
},
"scripts": {
"heroku-postbuild": "yarn prod",
"dev": "yarn run webpack --progress --hide-modules --color --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "yarn run webpack --watch --hide-modules --color --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "NODE_ENV=production yarn run webpack --progress --hide-modules --color --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"devDependencies": {
"browser-sync": "^2.18.13",
"compression-webpack-plugin": "^3.0.0",
"laravel-mix": "^4.0.0",
"resolve-url-loader": "2.3.1",
"sass": "1.17.1",
"sass-loader": "7.*",
"vue-template-compiler": "^2.5.22"
}
}

View File

0
public/favicon.ico Normal file
View File

70
script/setup Executable file
View File

@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Exit if any subcommand fails
set -e
set -o pipefail
indent() {
while read LINE; do
echo " $LINE" || true
done
}
# Ensure postgres client tools are installed
check_postgres() {
if ! command -v createdb > /dev/null; then
printf 'Please install the postgres CLI tools, then try again.\n'
if [[ "$OSTYPE" == "darwin"* ]]; then
printf "If you're using Postgres.app, see https://postgresapp.com/documentation/cli-tools.html.\n"
fi
printf 'See https://www.postgresql.org/docs/current/tutorial-install.html for install instructions.\n'
exit 1
fi
}
if ! command -v yarn > /dev/null; then
printf 'Yarn is not installed.\n'
printf 'See https://yarnpkg.com/lang/en/docs/install/ for install instructions.\n'
exit 1
fi
printf "\n▸ Installing node dependencies\n"
yarn install --no-progress | indent
printf "\n▸ Compiling assets\n"
yarn dev | indent
printf "\n▸ Installing shards\n"
shards install | indent
printf "\n▸ Checking that a process runner is installed\n"
# Only if this isn't CI
if [ -z "$CI" ]; then
lucky ensure_process_runner_installed
fi
printf "✔ Done\n" | indent
if [ ! -f ".env" ]; then
printf "\n▸ No .env found. Creating one.\n"
touch .env
printf "✔ Done\n" | indent
fi
printf "\n▸ Checking that postgres is installed\n"
check_postgres | indent
printf "✔ Done\n" | indent
printf "\n▸ Creating the database\n"
lucky db.create | indent
printf "\n▸ Verifying postgres connection\n"
lucky db.verify_connection | indent
printf "\n▸ Migrating the database\n"
lucky db.migrate | indent
printf "\n▸ Seeding the database with required and sample records\n"
lucky db.create_required_seeds | indent
lucky db.create_sample_seeds | indent
printf "\n✔ All done. Run 'lucky dev' to start the app\n"

90
shard.lock Normal file
View File

@ -0,0 +1,90 @@
version: 1.0
shards:
authentic:
github: luckyframework/authentic
version: 0.5.2
avram:
github: luckyframework/avram
version: 0.12.4
bindata:
github: spider-gazelle/bindata
version: 1.2.0
blank:
github: kostya/blank
version: 0.1.0
carbon:
github: luckyframework/carbon
version: 0.1.1
cry:
github: paulcsmith/cry
version: 0.4.2
db:
github: crystal-lang/crystal-db
version: 0.8.0
dexter:
github: luckyframework/dexter
version: 0.1.3
dotenv:
github: gdotdesign/cr-dotenv
version: 0.6.0
exception_page:
github: crystal-loot/exception_page
version: 0.1.4
habitat:
github: luckyframework/habitat
version: 0.4.3
jwt:
github: crystal-community/jwt
version: 1.4.0
lucky:
github: luckyframework/lucky
version: 0.19.0
lucky_cli:
github: luckyframework/lucky_cli
version: 0.18.4
lucky_flow:
github: luckyframework/lucky_flow
version: 0.6.2
lucky_router:
github: luckyframework/lucky_router
version: 0.2.2
openssl_ext:
github: stakach/openssl_ext
version: 1.2.0
pg:
github: will/crystal-pg
version: 0.20.0
selenium:
github: ysbaddaden/selenium-webdriver-crystal
commit: bda2fd406c1a118251c5a2883f1e1f3af242116e
shell-table:
github: luckyframework/shell-table.cr
commit: 078a04ea58ead5203bb435a3b5fff448ddabaeea
teeplate:
github: luckyframework/teeplate
version: 0.8.0
wordsmith:
github: luckyframework/wordsmith
version: 0.2.0

31
shard.yml Normal file
View File

@ -0,0 +1,31 @@
name: luckybooru
version: 0.1.0
authors:
- Les De Ridder <les@lesderid.net>
targets:
luckybooru:
main: src/luckybooru.cr
crystal: 0.33.0
dependencies:
lucky:
github: luckyframework/lucky
version: ~> 0.19.0
authentic:
github: luckyframework/authentic
version: ~> 0.5.1
carbon:
github: luckyframework/carbon
version: ~> 0.1.1
dotenv:
github: gdotdesign/cr-dotenv
version: 0.6.0
lucky_flow:
github: luckyframework/lucky_flow
version: ~> 0.6.2
jwt:
github: crystal-community/jwt
version: ~> 1.4.0

0
spec/flows/.keep Normal file
View File

View File

@ -0,0 +1,31 @@
require "../spec_helper"
describe "Authentication flow" do
it "works" do
flow = AuthenticationFlow.new("test@example.com")
flow.sign_up "password"
flow.should_be_signed_in
flow.sign_out
flow.sign_in "wrong-password"
flow.should_have_password_error
flow.sign_in "password"
flow.should_be_signed_in
end
# This is to show you how to sign in as a user during tests.
# Use the `visit` method's `as` option in your tests to sign in as that user.
#
# Feel free to delete this once you have other tests using the 'as' option.
it "allows sign in through backdoor when testing" do
user = UserBox.create
flow = BaseFlow.new
flow.visit Me::Show, as: user
should_be_signed_in(flow)
end
end
private def should_be_signed_in(flow)
flow.el("@sign-out-button").should be_on_page
end

View File

@ -0,0 +1,18 @@
require "../spec_helper"
describe "Reset password flow" do
it "works" do
user = UserBox.create
flow = ResetPasswordFlow.new(user)
flow.request_password_reset
flow.should_have_sent_reset_email
flow.reset_password "new-password"
flow.should_be_signed_in
flow.sign_out
flow.sign_in "wrong-password"
flow.should_have_password_error
flow.sign_in "new-password"
flow.should_be_signed_in
end
end

View File

@ -0,0 +1,17 @@
require "../../../spec_helper"
describe Api::Me::Show do
it "returns the signed in user" do
user = UserBox.create
response = AppClient.auth(user).exec(Api::Me::Show)
response.should send_json(200, email: user.email)
end
it "fails if not authenticated" do
response = AppClient.exec(Api::Me::Show)
response.status_code.should eq(401)
end
end

View File

@ -0,0 +1,33 @@
require "../../../spec_helper"
describe Api::SignIns::Create do
it "returns a token" do
UserToken.stub_token("fake-token") do
user = UserBox.create
response = AppClient.exec(Api::SignIns::Create, user: valid_params(user))
response.should send_json(200, token: "fake-token")
end
end
it "returns an error if credentials are invalid" do
user = UserBox.create
invalid_params = valid_params(user).merge(password: "incorrect")
response = AppClient.exec(Api::SignIns::Create, user: invalid_params)
response.should send_json(
400,
param: "password",
details: "password is wrong"
)
end
end
private def valid_params(user : User)
{
email: user.email,
password: "password",
}
end

View File

@ -0,0 +1,34 @@
require "../../../spec_helper"
describe Api::SignUps::Create do
it "creates user on sign up" do
UserToken.stub_token("fake-token") do
response = AppClient.exec(Api::SignUps::Create, user: valid_params)
response.should send_json(200, token: "fake-token")
new_user = UserQuery.first
new_user.email.should eq(valid_params[:email])
end
end
it "returns error for invalid params" do
invalid_params = valid_params.merge(password_confirmation: "wrong")
response = AppClient.exec(Api::SignUps::Create, user: invalid_params)
UserQuery.new.select_count.should eq(0)
response.should send_json(
400,
param: "password_confirmation",
details: "password_confirmation must match"
)
end
end
private def valid_params
{
email: "test@email.com",
password: "password",
password_confirmation: "password",
}
end

0
spec/setup/.keep Normal file
View File

View File

@ -0,0 +1,3 @@
Spec.before_each do
AppDatabase.truncate
end

View File

@ -0,0 +1,5 @@
LuckyFlow.configure do |settings|
settings.stop_retrying_after = 200.milliseconds
settings.base_uri = Lucky::RouteHelper.settings.base_uri
end
Spec.before_each { LuckyFlow::Server::INSTANCE.reset }

View File

@ -0,0 +1,3 @@
Spec.before_each do
Carbon::DevAdapter.reset
end

View File

@ -0,0 +1,2 @@
Db::Create.new(quiet: true).call
Db::Migrate.new(quiet: true).call

View File

@ -0,0 +1,10 @@
app_server = AppServer.new
spawn do
app_server.listen
end
Spec.after_suite do
LuckyFlow.shutdown
app_server.close
end

22
spec/spec_helper.cr Normal file
View File

@ -0,0 +1,22 @@
ENV["LUCKY_ENV"] = "test"
ENV["PORT"] = "5001"
require "spec"
require "lucky_flow"
require "../src/app"
require "./support/flows/base_flow"
require "./support/**"
require "../db/migrations/**"
# Add/modify files in spec/setup to start/configure programs or run hooks
#
# By default there are scripts for setting up and cleaning the database,
# configuring LuckyFlow, starting the app server, etc.
require "./setup/**"
include Carbon::Expectations
include Lucky::RequestExpectations
include LuckyFlow::Expectations
Avram::Migrator::Runner.new.ensure_migrated!
Avram::SchemaEnforcer.ensure_correct_column_mappings!
Habitat.raise_if_missing_settings!

0
spec/support/.keep Normal file
View File

View File

@ -0,0 +1,10 @@
class AppClient < Lucky::BaseHTTPClient
def initialize
super
headers("Content-Type": "application/json")
end
def self.auth(user : User)
new.headers("Authorization": UserToken.generate(user))
end
end

0
spec/support/boxes/.keep Normal file
View File

View File

@ -0,0 +1,6 @@
class UserBox < Avram::Box
def initialize
email "#{sequence("test-email")}@example.com"
encrypted_password Authentic.generate_encrypted_password("password")
end
end

View File

@ -0,0 +1,40 @@
class AuthenticationFlow < BaseFlow
private getter email
def initialize(@email : String)
end
def sign_up(password)
visit SignUps::New
fill_form SignUpUser,
email: email,
password: password,
password_confirmation: password
click "@sign-up-button"
end
def sign_out
visit Me::Show
sign_out_button.click
end
def sign_in(password)
visit SignIns::New
fill_form SignInUser,
email: email,
password: password
click "@sign-in-button"
end
def should_be_signed_in
sign_out_button.should be_on_page
end
def should_have_password_error
el("body", text: "Password is wrong").should be_on_page
end
private def sign_out_button
el("@sign-out-button")
end
end

View File

@ -0,0 +1,3 @@
# Add methods that all or most Flows need to share
class BaseFlow < LuckyFlow
end

View File

@ -0,0 +1,42 @@
class ResetPasswordFlow < BaseFlow
private getter user, authentication_flow
delegate sign_in, sign_out, should_have_password_error, should_be_signed_in,
to: authentication_flow
delegate email, to: user
def initialize(@user : User)
@authentication_flow = AuthenticationFlow.new(user.email)
end
def request_password_reset
with_fake_token do
visit PasswordResetRequests::New
fill_form RequestPasswordReset,
email: email
click "@request-password-reset-button"
end
end
def should_have_sent_reset_email
with_fake_token do
user = UserQuery.new.email(email).first
PasswordResetRequestEmail.new(user).should be_delivered
end
end
def reset_password(password)
user = UserQuery.new.email(email).first
token = Authentic.generate_password_reset_token(user)
visit PasswordResets::New.with(user.id, token)
fill_form ResetPassword,
password: password,
password_confirmation: password
click "@update-password-button"
end
private def with_fake_token
PasswordResetRequestEmail.temp_config(stubbed_token: "fake") do
yield
end
end
end

View File

@ -0,0 +1,5 @@
class Api::Me::Show < ApiAction
get "/api/me" do
json UserSerializer.new(current_user)
end
end

View File

@ -0,0 +1,13 @@
class Api::SignIns::Create < ApiAction
include Api::Auth::SkipRequireAuthToken
route do
SignInUser.new(params).submit do |operation, user|
if user
json({token: UserToken.generate(user)})
else
raise Avram::InvalidOperationError.new(operation)
end
end
end
end

View File

@ -0,0 +1,8 @@
class Api::SignUps::Create < ApiAction
include Api::Auth::SkipRequireAuthToken
route do
user = SignUpUser.create!(params)
json({token: UserToken.generate(user)})
end
end

10
src/actions/api_action.cr Normal file
View File

@ -0,0 +1,10 @@
# Include modules and add methods that are for all API requests
abstract class ApiAction < Lucky::Action
accepted_formats [:json]
include Api::Auth::Helpers
# By default all actions require sign in.
# Add 'include Api::Auth::SkipRequireAuthToken' to your actions to allow all requests.
include Api::Auth::RequireAuthToken
end

View File

@ -0,0 +1,30 @@
abstract class BrowserAction < Lucky::Action
include Lucky::ProtectFromForgery
accepted_formats [:html, :json], default: :html
# This module provides current_user, sign_in, and sign_out methods
include Authentic::ActionHelpers(User)
# When testing you can skip normal sign in by using `visit` with the `as` param
#
# flow.visit Me::Show, as: UserBox.create
include Auth::TestBackdoor
# By default all actions that inherit 'BrowserAction' require sign in.
#
# You can remove the 'include Auth::RequireSignIn' below to allow anyone to
# access actions that inherit from 'BrowserAction' or you can
# 'include Auth::AllowGuests' in individual actions to skip sign in.
include Auth::RequireSignIn
# `expose` means that `current_user` will be passed to pages automatically.
#
# In default Lucky apps, the `MainLayout` declares it `needs current_user : User`
# so that any page that inherits from MainLayout can use the `current_user`
expose current_user
# This method tells Authentic how to find the current user
private def find_current_user(id) : User?
UserQuery.new.id(id).first?
end
end

View File

@ -0,0 +1,60 @@
class Errors::Show < Lucky::ErrorAction
DEFAULT_MESSAGE = "Something went wrong."
default_format :html
dont_report [Lucky::RouteNotFoundError]
def render(error : Lucky::RouteNotFoundError)
if html?
error_html "Sorry, we couldn't find that page.", status: 404
else
error_json "Not found", status: 404
end
end
# When the request is JSON and an InvalidOperationError is raised, show a
# helpful error with the param that is invalid, and what was wrong with it.
def render(error : Avram::InvalidOperationError)
if html?
error_html DEFAULT_MESSAGE, status: 500
else
error_json \
message: error.renderable_message,
details: error.renderable_details,
param: error.invalid_attribute_name,
status: 400
end
end
# Always keep this below other 'render' methods or it may override your
# custom 'render' methods.
def render(error : Lucky::RenderableError)
if html?
error_html DEFAULT_MESSAGE, status: error.renderable_status
else
error_json error.renderable_message, status: error.renderable_status
end
end
# If none of the 'render' methods return a response for the raised Exception,
# Lucky will use this method.
def default_render(error : Exception) : Lucky::Response
if html?
error_html DEFAULT_MESSAGE, status: 500
else
error_json DEFAULT_MESSAGE, status: 500
end
end
private def error_html(message : String, status : Int)
context.response.status_code = status
html Errors::ShowPage, message: message, status: status
end
private def error_json(message : String, status : Int, details = nil, param = nil)
json ErrorSerializer.new(message: message, details: details, param: param), status: status
end
private def report(error : Exception) : Nil
# Send to Rollbar, send an email, etc.
end
end

18
src/actions/home/index.cr Normal file
View File

@ -0,0 +1,18 @@
class Home::Index < BrowserAction
include Auth::AllowGuests
get "/" do
if current_user?
redirect Me::Show
else
# When you're ready change this line to:
#
# redirect SignIns::New
#
# Or maybe show signed out users a marketing page:
#
# html Marketing::IndexPage
redirect SignIns::New
end
end
end

5
src/actions/me/show.cr Normal file
View File

@ -0,0 +1,5 @@
class Me::Show < BrowserAction
get "/me" do
html ShowPage
end
end

0
src/actions/mixins/.keep Normal file
View File

View File

@ -0,0 +1,27 @@
module Api::Auth::Helpers
def current_user? : User?
auth_token.try do |value|
user_from_auth_token(value)
end
end
private def auth_token : String?
bearer_token || token_param
end
private def bearer_token : String?
context.request.headers["Authorization"]?
.try(&.gsub("Bearer", ""))
.try(&.strip)
end
private def token_param : String?
params.get?(:auth_token)
end
private def user_from_auth_token(token : String) : User?
UserToken.decode_user_id(token).try do |user_id|
UserQuery.new.id(user_id).first?
end
end
end

View File

@ -0,0 +1,34 @@
module Api::Auth::RequireAuthToken
macro included
before require_auth_token
end
private def require_auth_token
if current_user?
continue
else
json auth_error_json, 401
end
end
private def auth_error_json
ErrorSerializer.new(
message: "Not authenticated.",
details: auth_error_details
)
end
private def auth_error_details : String
if auth_token
"The provided authentication token was incorrect."
else
"An authentication token is required. Please include a token in an 'auth_token' param or 'Authorization' header."
end
end
# Tells the compiler that the current_user is not nil since we have checked
# that the user is signed in
private def current_user : User
current_user?.not_nil!
end
end

View File

@ -0,0 +1,10 @@
module Api::Auth::SkipRequireAuthToken
macro included
skip require_auth_token
end
# Since sign in is not required, current_user might be nil
def current_user : User?
current_user?
end
end

View File

@ -0,0 +1,10 @@
module Auth::AllowGuests
macro included
skip require_sign_in
end
# Since sign in is not required, current_user might be nil
def current_user : User?
current_user?
end
end

View File

@ -0,0 +1,19 @@
module Auth::RedirectSignedInUsers
macro included
include Auth::AllowGuests
before redirect_signed_in_users
end
private def redirect_signed_in_users
if current_user?
flash.success = "You are already signed in"
redirect to: Home::Index
else
continue
end
end
# current_user returns nil because signed in users are redirected.
def current_user
end
end

View File

@ -0,0 +1,21 @@
module Auth::RequireSignIn
macro included
before require_sign_in
end
private def require_sign_in
if current_user?
continue
else
Authentic.remember_requested_path(self)
flash.info = "Please sign in first"
redirect to: SignIns::New
end
end
# Tells the compiler that the current_user is not nil since we have checked
# that the user is signed in
private def current_user : User
current_user?.not_nil!
end
end

View File

@ -0,0 +1,13 @@
module Auth::TestBackdoor
macro included
before test_backdoor
end
private def test_backdoor
if Lucky::Env.test? && (user_id = params.get?(:backdoor_user_id))
user = UserQuery.find(user_id)
sign_in user
end
continue
end
end

View File

@ -0,0 +1,7 @@
module Auth::PasswordResets::Base
macro included
include Auth::RedirectSignedInUsers
include Auth::PasswordResets::FindUser
include Auth::PasswordResets::RequireToken
end
end

View File

@ -0,0 +1,5 @@
module Auth::PasswordResets::FindUser
private def user : User
UserQuery.find(user_id)
end
end

View File

@ -0,0 +1,17 @@
module Auth::PasswordResets::RequireToken
macro included
before require_valid_password_reset_token
end
abstract def token : String
abstract def user : User
private def require_valid_password_reset_token
if Authentic.valid_password_reset_token?(user, token)
continue
else
flash.failure = "The password reset link is incorrect or expired. Please try again."
redirect to: PasswordResetRequests::New
end
end
end

View File

@ -0,0 +1,5 @@
module Auth::PasswordResets::TokenFromSession
private def token : String
session.get?(:password_reset_token) || raise "Password reset token not found in session"
end
end

View File

@ -0,0 +1,15 @@
class PasswordResetRequests::Create < BrowserAction
include Auth::RedirectSignedInUsers
route do
RequestPasswordReset.new(params).submit do |operation, user|
if user
PasswordResetRequestEmail.new(user).deliver
flash.success = "You should receive an email on how to reset your password shortly"
redirect SignIns::New
else
html NewPage, operation: operation
end
end
end
end

View File

@ -0,0 +1,7 @@
class PasswordResetRequests::New < BrowserAction
include Auth::RedirectSignedInUsers
route do
html NewPage, operation: RequestPasswordReset.new
end
end

View File

@ -0,0 +1,17 @@
class PasswordResets::Create < BrowserAction
include Auth::PasswordResets::Base
include Auth::PasswordResets::TokenFromSession
post "/password_resets/:user_id" do
ResetPassword.update(user, params) do |operation, user|
if operation.saved?
session.delete(:password_reset_token)
sign_in user
flash.success = "Your password has been reset"
redirect to: Home::Index
else
html NewPage, operation: operation, user_id: user_id.to_i64
end
end
end
end

View File

@ -0,0 +1,8 @@
class PasswordResets::Edit < BrowserAction
include Auth::PasswordResets::Base
include Auth::PasswordResets::TokenFromSession
get "/password_resets/:user_id/edit" do
html NewPage, operation: ResetPassword.new, user_id: user_id.to_i64
end
end

View File

@ -0,0 +1,20 @@
class PasswordResets::New < BrowserAction
include Auth::PasswordResets::Base
param token : String
get "/password_resets/:user_id" do
redirect_to_edit_form_without_token_param
end
# This is to prevent password reset tokens from being scraped in the HTTP Referer header
# See more info here: https://github.com/thoughtbot/clearance/pull/707
private def redirect_to_edit_form_without_token_param
make_token_available_to_future_actions
redirect to: PasswordResets::Edit.with(user_id)
end
private def make_token_available_to_future_actions
session.set(:password_reset_token, token)
end
end

View File

@ -0,0 +1,16 @@
class SignIns::Create < BrowserAction
include Auth::RedirectSignedInUsers
route do
SignInUser.new(params).submit do |operation, authenticated_user|
if authenticated_user
sign_in(authenticated_user)
flash.success = "You're now signed in"
Authentic.redirect_to_originally_requested_path(self, fallback: Home::Index)
else
flash.failure = "Sign in failed"
html NewPage, operation: operation
end
end
end
end

View File

@ -0,0 +1,7 @@
class SignIns::Delete < BrowserAction
delete "/sign_out" do
sign_out
flash.info = "You have been signed out"
redirect to: SignIns::New
end
end

View File

@ -0,0 +1,7 @@
class SignIns::New < BrowserAction
include Auth::RedirectSignedInUsers
get "/sign_in" do
html NewPage, operation: SignInUser.new
end
end

View File

@ -0,0 +1,16 @@
class SignUps::Create < BrowserAction
include Auth::RedirectSignedInUsers
route do
SignUpUser.create(params) do |operation, user|
if user
flash.info = "Thanks for signing up"
sign_in(user)
redirect to: Home::Index
else
flash.info = "Couldn't sign you up"
html NewPage, operation: operation
end
end
end
end

View File

@ -0,0 +1,7 @@
class SignUps::New < BrowserAction
include Auth::RedirectSignedInUsers
get "/sign_up" do
html NewPage, operation: SignUpUser.new
end
end

26
src/app.cr Normal file
View File

@ -0,0 +1,26 @@
require "./shards"
# Load the asset manifest in public/mix-manifest.json
Lucky::AssetHelpers.load_manifest
require "./app_database"
require "./models/base_model"
require "./models/mixins/**"
require "./models/**"
require "./queries/mixins/**"
require "./queries/**"
require "./operations/mixins/**"
require "./operations/**"
require "./serializers/base_serializer"
require "./serializers/**"
require "./emails/base_email"
require "./emails/**"
require "./actions/mixins/**"
require "./actions/**"
require "./components/base_component"
require "./components/**"
require "./pages/**"
require "../config/env"
require "../config/**"
require "../db/migrations/**"
require "./app_server"

2
src/app_database.cr Normal file
View File

@ -0,0 +1,2 @@
class AppDatabase < Avram::Database
end

26
src/app_server.cr Normal file
View File

@ -0,0 +1,26 @@
class AppServer < Lucky::BaseAppServer
def middleware : Array(HTTP::Handler)
[
Lucky::ForceSSLHandler.new,
Lucky::HttpMethodOverrideHandler.new,
Lucky::LogHandler.new,
Lucky::SessionHandler.new,
Lucky::FlashHandler.new,
Lucky::ErrorHandler.new(action: Errors::Show),
Lucky::RouteHandler.new,
Lucky::StaticCompressionHandler.new("./public", file_ext: "gz", content_encoding: "gzip"),
Lucky::StaticFileHandler.new("./public", false),
Lucky::RouteNotFoundHandler.new,
] of HTTP::Handler
end
def protocol
"http"
end
def listen
# Learn about bind_tcp: https://tinyurl.com/bind-tcp-docs
server.bind_tcp(host, port, reuse_port: false)
server.listen
end
end

0
src/components/.keep Normal file
View File

View File

@ -0,0 +1,2 @@
abstract class BaseComponent < Lucky::BaseComponent
end

View File

@ -0,0 +1,47 @@
# This component is used to make it easier to render the same fields styles
# throughout your app
#
# ## Usage
#
# mount Shared::Field.new(operation.name) # Renders text input by default
# mount Shared::Field.new(operation.email), &.email_input(autofocus: "true")
# mount Shared::Field.new(operation.username), &.email_input(placeholder: "Username")
# mount Shared::Field.new(operation.name), &.text_input(append_class: "custom-input-class")
# mount Shared::Field.new(operation.nickname), &.text_input(replace_class: "compact-input")
#
# ## Customization
#
# You can customize this class so that fields render like you expect
# For example, you might wrap it in a div with a "field-wrapper" class.
#
# div class: "field-wrapper"
# label_for field
# yield field
# mount Shared::FieldErrors.new(field)
# end
#
# You may also want to have more more classes if you render fields
# differently in different parts of your app, e.g. `Shared::CompactField``
class Shared::Field(T) < BaseComponent
needs field : Avram::PermittedAttribute(T)
def render
label_for @field
# You can add more default options here. For example:
#
# with_defaults field: @field, class: "input"
#
# Will add the class "input" to the generated HTML.
with_defaults field: @field do |input_builder|
yield input_builder
end
mount Shared::FieldErrors.new(@field)
end
# Use a text_input by default
def render
render &.text_input
end
end

View File

@ -0,0 +1,13 @@
class Shared::FieldErrors(T) < BaseComponent
needs field : Avram::PermittedAttribute(T)
# Customize the markup and styles to match your application
def render
unless @field.valid?
div class: "error" do
label_text = Wordsmith::Inflector.humanize(@field.name.to_s)
text "#{label_text} #{@field.errors.first}"
end
end
end
end

View File

@ -0,0 +1,11 @@
class Shared::FlashMessages < BaseComponent
needs flash : Lucky::FlashStore
def render
@flash.each do |flash_type, flash_message|
div class: "flash-#{flash_type}", flow_id: "flash" do
text flash_message
end
end
end
end

View File

@ -0,0 +1,17 @@
class Shared::LayoutHead < BaseComponent
needs page_title : String
# This is used by the 'csrf_meta_tags' method
needs context : HTTP::Server::Context
def render
head do
utf8_charset
title "My App - #{@page_title}"
css_link asset("css/app.css"), data_turbolinks_track: "reload"
js_link asset("js/app.js"), defer: "true", data_turbolinks_track: "reload"
meta name: "turbolinks-cache-control", content: "no-cache"
csrf_meta_tags
responsive_meta_tag
end
end
end

66
src/css/app.scss Normal file
View File

@ -0,0 +1,66 @@
// Lucky generates 3 folders to help you organize your CSS:
//
// - static/css/variables # Files for colors, spacing, etc.
// - static/css/mixins # Put your mixin functions in files here
// - static/css/components # CSS for your components
//
// Remember to import your new CSS files or they won't be loaded:
//
// @import "./variables/colors" # Imports the file in static/css/variables/_colors.scss
//
// Note: importing with `~` tells webpack to look in the installed npm packages
// https://stackoverflow.com/questions/39535760/what-does-a-tilde-in-a-css-url-do
@import "~normalize-scss/sass/normalize/import-now";
// Add your own components and import them like this:
//
// @import "components/my_new_component";
// Default Lucky styles.
// Delete these when you're ready to bring in your own CSS.
body {
font-family: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue,
sans-serif;
margin: 0 auto;
max-width: 800px;
padding: 20px 40px;
}
label, input {
display: flex;
}
label {
font-weight: 500;
}
[type='color'],
[type='date'],
[type='datetime'],
[type='datetime-local'],
[type='email'],
[type='month'],
[type='number'],
[type='password'],
[type='search'],
[type='tel'],
[type='text'],
[type='time'],
[type='url'],
[type='week'],
input:not([type]),
textarea {
border-radius: 3px;
border: 1px solid #bbb;
margin: 7px 0 14px 0;
max-width: 400px;
padding: 8px 6px;
width: 100%;
}
[type='submit'] {
font-weight: 900;
margin: 9px 0;
padding: 6px 9px;
}

0
src/css/components/.keep Normal file
View File

0
src/css/mixins/.keep Normal file
View File

0
src/css/variables/.keep Normal file
View File

0
src/emails/.keep Normal file
View File

13
src/emails/base_email.cr Normal file
View File

@ -0,0 +1,13 @@
abstract class BaseEmail < Carbon::Email
# You can add defaults using the 'inherited' hook
#
# Example:
#
# macro inherited
# from default_from
# end
#
# def default_from
# Carbon::Address.new("support@app.com")
# end
end

View File

@ -0,0 +1,13 @@
class PasswordResetRequestEmail < BaseEmail
Habitat.create { setting stubbed_token : String? }
delegate stubbed_token, to: :settings
def initialize(@user : User)
@token = stubbed_token || Authentic.generate_password_reset_token(@user)
end
to @user
from "myapp@support.com" # or set a default in src/emails/base_email.cr
subject "Reset your password"
templates html, text
end

View File

@ -0,0 +1,3 @@
<h1>Please reset your password</h1>
<a href="<%= PasswordResets::New.url(@user.id, @token) %>">Reset password</a>

Some files were not shown because too many files have changed in this diff Show More