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:
commit
aab65e17b3
|
@ -0,0 +1 @@
|
|||
0.32.1
|
|
@ -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/
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
web: lucky watch --reload-browser
|
||||
assets: yarn watch
|
|
@ -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).
|
|
@ -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
|
|
@ -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
|
||||
};
|
|
@ -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
|
|
@ -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?
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
Lucky::ErrorHandler.configure do |settings|
|
||||
settings.show_debug_output = !Lucky::Env.production?
|
||||
end
|
|
@ -0,0 +1,3 @@
|
|||
Lucky::HTMLPage.configure do |settings|
|
||||
settings.render_component_comments = !Lucky::Env.production?
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
host: 127.0.0.1
|
||||
port: 5000
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
|
@ -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
|
||||
|
|
@ -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,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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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,0 +1,3 @@
|
|||
Spec.before_each do
|
||||
AppDatabase.truncate
|
||||
end
|
|
@ -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 }
|
|
@ -0,0 +1,3 @@
|
|||
Spec.before_each do
|
||||
Carbon::DevAdapter.reset
|
||||
end
|
|
@ -0,0 +1,2 @@
|
|||
Db::Create.new(quiet: true).call
|
||||
Db::Migrate.new(quiet: true).call
|
|
@ -0,0 +1,10 @@
|
|||
app_server = AppServer.new
|
||||
|
||||
spawn do
|
||||
app_server.listen
|
||||
end
|
||||
|
||||
Spec.after_suite do
|
||||
LuckyFlow.shutdown
|
||||
app_server.close
|
||||
end
|
|
@ -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,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,0 +1,6 @@
|
|||
class UserBox < Avram::Box
|
||||
def initialize
|
||||
email "#{sequence("test-email")}@example.com"
|
||||
encrypted_password Authentic.generate_encrypted_password("password")
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
# Add methods that all or most Flows need to share
|
||||
class BaseFlow < LuckyFlow
|
||||
end
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
class Api::Me::Show < ApiAction
|
||||
get "/api/me" do
|
||||
json UserSerializer.new(current_user)
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
class Me::Show < BrowserAction
|
||||
get "/me" do
|
||||
html ShowPage
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
module Auth::PasswordResets::Base
|
||||
macro included
|
||||
include Auth::RedirectSignedInUsers
|
||||
include Auth::PasswordResets::FindUser
|
||||
include Auth::PasswordResets::RequireToken
|
||||
end
|
||||
end
|
|
@ -0,0 +1,5 @@
|
|||
module Auth::PasswordResets::FindUser
|
||||
private def user : User
|
||||
UserQuery.find(user_id)
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
class PasswordResetRequests::New < BrowserAction
|
||||
include Auth::RedirectSignedInUsers
|
||||
|
||||
route do
|
||||
html NewPage, operation: RequestPasswordReset.new
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
class SignIns::New < BrowserAction
|
||||
include Auth::RedirectSignedInUsers
|
||||
|
||||
get "/sign_in" do
|
||||
html NewPage, operation: SignInUser.new
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
class SignUps::New < BrowserAction
|
||||
include Auth::RedirectSignedInUsers
|
||||
|
||||
get "/sign_up" do
|
||||
html NewPage, operation: SignUpUser.new
|
||||
end
|
||||
end
|
|
@ -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"
|
|
@ -0,0 +1,2 @@
|
|||
class AppDatabase < Avram::Database
|
||||
end
|
|
@ -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,0 +1,2 @@
|
|||
abstract class BaseComponent < Lucky::BaseComponent
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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,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
|
|
@ -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
|
|
@ -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
Loading…
Reference in New Issue