Merge branch 'master' of gitlab.com:mythic-insight/legendary

This commit is contained in:
Robert Prehn 2021-05-14 16:29:41 -05:00
commit 3458632a6a
46 changed files with 1151 additions and 169 deletions

View file

@ -1,6 +1,4 @@
stages:
- application_dependencies
- asset_dependencies
- test
- deploy_tags
- deploy
@ -15,99 +13,41 @@ variables:
# fetch & clean the repo rather than completely cloning (faster)
GIT_STRATEGY: fetch
# Dependency stages-- fetch these first so that we can cache them and reuse them
# across jobs and pipelines. Note that elixir deps need to go first, because
# we need the phoenix and phoenix_html hex packages to install their JS.
fetch_application_dependencies:
stage: application_dependencies
image: "elixir:1.10"
cache:
key:
files:
- mix.lock
paths:
- _build/
- deps/
script:
- mix local.hex --force
- mix local.rebar --force
- mix deps.get
- mix deps.compile
# Make results available to other jobs
artifacts:
paths:
- deps/phoenix
- deps/phoenix_html
exclude:
- deps/
fetch_asset_dependencies:
stage: asset_dependencies
image: "node:15.0"
needs:
- fetch_application_dependencies
only:
- master
cache:
key:
files:
- apps/app/assets/package-lock.json
paths:
- apps/app/assets/node_modules/
script:
- cd apps/app/assets/ && npm install
# Make results available to other jobs
artifacts:
paths:
- apps/app/assets/node_modules
exclude:
- apps/app/assets/node_modules
# Test stage. Runs various tests and speculatively builds docker image in
# parallel, in case the build passes.
test:
stage: test
needs:
- fetch_application_dependencies
image: "elixir:1.10"
services:
- name: postgres:12
cache:
key:
files:
- mix.lock
paths:
- _build/
- deps/
services:
- name: postgres:12
script: script/cibuild
build_image_for_commit:
stage: test
needs:
- fetch_asset_dependencies
- fetch_application_dependencies
image: "docker:20.10"
only:
- master
services:
- name: docker:20.10-dind
cache:
key:
files:
- mix.lock
paths:
- _build/
- deps/
services:
- name: docker:20.10-dind
script:
- docker login "https://${CI_REGISTRY}" -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD
- docker pull $CI_REGISTRY_IMAGE:latest || true
# This enables fast parallel builds
- export DOCKER_BUILDKIT=1
- docker build --cache-from $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA --build-arg BUILDKIT_INLINE_CACHE=1 .
- mkdir -p _build/prod
- docker build --cache-from $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
# Push the commit SHA tagged version to registry. We will later choose to tag that as stable
# if everything passes.
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# This copies the elixir build artifacts for deps and app so that we can cache them
# Pull out the built _build/prod directory so we can cache it!
- docker cp `docker create $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA`:/root/app/_build/prod _build
# If tests pass, tag the commit and update package versions

View file

@ -26,32 +26,36 @@ ADD ./apps/admin/mix.exs /root/app/apps/admin/
ADD ./apps/app/mix.exs /root/app/apps/app/
ADD ./apps/content/mix.exs /root/app/apps/content/
ADD ./apps/core/mix.exs /root/app/apps/core/
ADD ./deps /root/app/deps
ADD ./_build /root/app/_build
ADD ./_build/${MIX_ENV}/ /root/app/_build/${MIX_ENV}/
ADD ./deps/ /root/app/deps/
ADD ./script/ /root/app/script/
RUN script/restore-timestamps
RUN mix deps.get
RUN mix deps.compile
ADD ./apps /root/app/apps
# Leave off here so that we can built assets and compile the elixir app in parallel
FROM node:15.0
# Build assets in a node container
ADD ./apps/app/assets/ /root/app/apps/app/assets/
WORKDIR /root/app/apps/app/assets/
ADD ./apps/app/assets/node_modules /root/app/apps/app/assets/node_modules
COPY --from=0 /root/app/ /root/app/
RUN npm install
RUN npm run deploy
FROM elixir1
ADD ./apps /root/app/apps
# Resume compilation of the elixir app
ADD ./script /root/app/script
RUN MAKE=cmake mix compile
# Copy in the built assets & fingerprint them
COPY --from=1 /root/app/apps/app/priv/static/ /root/app/apps/app/priv/static
RUN mix phx.digest
RUN script/restore-timestamps
CMD ["mix", "phx.server"]

View file

@ -46,7 +46,7 @@ defmodule Legendary.Admin.MixProject do
{:ecto_sql, "~> 3.4"},
{:excoveralls, "~> 0.10", only: [:dev, :test]},
{:kaffy, path: "kaffy"},
{:phoenix, "~> 1.5.3"},
{:phoenix, "~> 1.5.8"},
{:phoenix_ecto, "~> 4.0"},
{:phoenix_html, "~> 2.11"},
{:phoenix_live_reload, "~> 1.2", only: :dev},

View file

@ -31,7 +31,7 @@
</li>
<%= if has_role?(@conn, :admin) do %>
<li class="mr-3">
<a class="inline-block py-2 px-4 text-white no-underline" href="/admin">Legendary.Admin</a>
<a class="inline-block py-2 px-4 text-white no-underline" href="/admin">Admin</a>
</li>
<% end %>
<%= if Pow.Plug.current_user(@conn) do %>

View file

@ -1,39 +1,15 @@
defmodule AppWeb.LayoutView do
use AppWeb, :view
def title(view_module, template, assigns) do
delegate_with_default(view_module, :title, [view_module, template, assigns], Legendary.I18n.t!("en", "site.title"))
end
def title(_, _, _) do
Legendary.I18n.t!("en", "site.title")
end
def excerpt(view_module, template, assigns) do
delegate_with_default(view_module, :excerpt, [view_module, template, assigns], Legendary.I18n.t!("en", "site.excerpt"))
end
def excerpt(_, _, _) do
Legendary.I18n.t!("en", "site.excerpt")
end
def feed_tag(conn, view_module, view_template, assigns) do
delegate_with_default(view_module, :feed_tag, [conn, view_module, view_template, assigns], nil)
end
defp delegate_with_default(nil, _, _, default), do: default
defp delegate_with_default(view_module, function_name, args, default) do
sibling_layout = sibling_layout_view(view_module)
if function_exported?(sibling_layout, function_name, args |> Enum.count()) do
apply(sibling_layout, function_name, args)
else
default
end
end
defp sibling_layout_view(view_module) do
view_module
|> parent_module()
|> Module.concat("LayoutView")
end
defp parent_module(mod) do
[_|tail] = Module.split(mod) |> Enum.reverse()
tail
|> Enum.reverse()
|> Module.concat()
end
def feed_tag(_, _, _, _) do
nil
end
end

View file

@ -44,7 +44,8 @@ defmodule App.MixProject do
{:core, in_umbrella: true},
{:ecto_sql, "~> 3.4"},
{:excoveralls, "~> 0.10", only: [:dev, :test]},
{:phoenix, "~> 1.5.3"},
{:oban, "~> 2.1"},
{:phoenix, "~> 1.5.8"},
{:phoenix_ecto, "~> 4.0"},
{:phoenix_html, "~> 2.11"},
{:phoenix_live_reload, "~> 1.2", only: :dev},

View file

@ -2,10 +2,19 @@
describe "<%= schema.plural %>" do
alias <%= inspect schema.module %>
import <%= inspect context.module %>Fixtures
@valid_attrs <%= inspect schema.params.create %>
@update_attrs <%= inspect schema.params.update %>
@invalid_attrs <%= inspect for {key, _} <- schema.params.create, into: %{}, do: {key, nil} %>
def <%= schema.singular %>_fixture(attrs \\ %{}) do
{:ok, <%= schema.singular %>} =
attrs
|> Enum.into(@valid_attrs)
|> <%= inspect context.alias %>.create_<%= schema.singular %>()
<%= schema.singular %>
end
test "list_<%= schema.plural %>/0 returns all <%= schema.plural %>" do
<%= schema.singular %> = <%= schema.singular %>_fixture()
assert <%= inspect context.alias %>.list_<%= schema.plural %>() == [<%= schema.singular %>]
@ -17,9 +26,7 @@
end
test "create_<%= schema.singular %>/1 with valid data creates a <%= schema.singular %>" do
valid_attrs = <%= inspect schema.params.create %>
assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.create_<%= schema.singular %>(valid_attrs)<%= for {field, value} <- schema.params.create do %>
assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.create_<%= schema.singular %>(@valid_attrs)<%= for {field, value} <- schema.params.create do %>
assert <%= schema.singular %>.<%= field %> == <%= Mix.Phoenix.Schema.value(schema, field, value) %><% end %>
end
@ -29,9 +36,7 @@
test "update_<%= schema.singular %>/2 with valid data updates the <%= schema.singular %>" do
<%= schema.singular %> = <%= schema.singular %>_fixture()
update_attrs = <%= inspect schema.params.update %>
assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.update_<%= schema.singular %>(<%= schema.singular %>, update_attrs)<%= for {field, value} <- schema.params.update do %>
assert {:ok, %<%= inspect schema.alias %>{} = <%= schema.singular %>} = <%= inspect context.alias %>.update_<%= schema.singular %>(<%= schema.singular %>, @update_attrs)<%= for {field, value} <- schema.params.update do %>
assert <%= schema.singular %>.<%= field %> == <%= Mix.Phoenix.Schema.value(schema, field, value) %><% end %>
end

View file

@ -1,40 +1,45 @@
defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ControllerTest do
use <%= inspect context.web_module %>.ConnCase
import <%= inspect context.module %>Fixtures
alias <%= inspect context.module %>
@create_attrs <%= inspect schema.params.create %>
@update_attrs <%= inspect schema.params.update %>
@invalid_attrs <%= inspect for {key, _} <- schema.params.create, into: %{}, do: {key, nil} %>
def fixture(:<%= schema.singular %>) do
{:ok, <%= schema.singular %>} = <%= inspect context.alias %>.create_<%= schema.singular %>(@create_attrs)
<%= schema.singular %>
end
describe "index" do
test "lists all <%= schema.plural %>", %{conn: conn} do
conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :index))
assert html_response(conn, 200) =~ "Listing <%= schema.human_plural %>"
assert html_response(conn, 200) =~ "<%= schema.human_plural %>"
end
end
describe "new <%= schema.singular %>" do
test "renders form", %{conn: conn} do
conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :new))
assert html_response(conn, 200) =~ "New <%= schema.human_singular %>"
assert html_response(conn, 200) =~ "Save"
end
end
describe "create <%= schema.singular %>" do
test "redirects to show when data is valid", %{conn: conn} do
conn = post(conn, Routes.<%= schema.route_helper %>_path(conn, :create), <%= schema.singular %>: @create_attrs)
post_conn = post(conn, Routes.<%= schema.route_helper %>_path(conn, :create), <%= schema.singular %>: @create_attrs)
assert %{id: id} = redirected_params(conn)
assert redirected_to(conn) == Routes.<%= schema.route_helper %>_path(conn, :show, id)
assert %{id: id} = redirected_params(post_conn)
assert redirected_to(post_conn) == Routes.<%= schema.route_helper %>_path(post_conn, :show, id)
conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :show, id))
assert html_response(conn, 200) =~ "Show <%= schema.human_singular %>"
get_conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :show, id))
assert html_response(get_conn, 200) =~ "Edit"
end
test "renders errors when data is invalid", %{conn: conn} do
conn = post(conn, Routes.<%= schema.route_helper %>_path(conn, :create), <%= schema.singular %>: @invalid_attrs)
assert html_response(conn, 200) =~ "New <%= schema.human_singular %>"
assert html_response(conn, 200) =~ "Save"
end
end
@ -51,12 +56,12 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
setup [:create_<%= schema.singular %>]
test "redirects when data is valid", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
conn = put(conn, Routes.<%= schema.route_helper %>_path(conn, :update, <%= schema.singular %>), <%= schema.singular %>: @update_attrs)
assert redirected_to(conn) == Routes.<%= schema.route_helper %>_path(conn, :show, <%= schema.singular %>)
put_conn = put(conn, Routes.<%= schema.route_helper %>_path(conn, :update, <%= schema.singular %>), <%= schema.singular %>: @update_attrs)
assert redirected_to(put_conn) == Routes.<%= schema.route_helper %>_path(put_conn, :show, <%= schema.singular %>)
conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :show, <%= schema.singular %>))<%= if schema.string_attr do %>
assert html_response(conn, 200) =~ <%= inspect Mix.Phoenix.Schema.default_param(schema, :update) %><% else %>
assert html_response(conn, 200)<% end %>
get_conn = get(conn, Routes.<%= schema.route_helper %>_path(conn, :show, <%= schema.singular %>))<%= if schema.string_attr do %>
assert html_response(get_conn, 200) =~ <%= inspect Mix.Phoenix.Schema.default_param(schema, :update) %><% else %>
assert html_response(get_conn, 200)<% end %>
end
test "renders errors when data is invalid", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
@ -69,8 +74,8 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
setup [:create_<%= schema.singular %>]
test "deletes chosen <%= schema.singular %>", %{conn: conn, <%= schema.singular %>: <%= schema.singular %>} do
conn = delete(conn, Routes.<%= schema.route_helper %>_path(conn, :delete, <%= schema.singular %>))
assert redirected_to(conn) == Routes.<%= schema.route_helper %>_path(conn, :index)
delete_conn = delete(conn, Routes.<%= schema.route_helper %>_path(conn, :delete, <%= schema.singular %>))
assert redirected_to(delete_conn) == Routes.<%= schema.route_helper %>_path(delete_conn, :index)
assert_error_sent 404, fn ->
get(conn, Routes.<%= schema.route_helper %>_path(conn, :show, <%= schema.singular %>))
end
@ -78,7 +83,7 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
end
defp create_<%= schema.singular %>(_) do
<%= schema.singular %> = <%= schema.singular %>_fixture()
<%= schema.singular %> = fixture(:<%= schema.singular %>)
%{<%= schema.singular %>: <%= schema.singular %>}
end
end

View file

@ -1,8 +1,6 @@
defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>ControllerTest do
use <%= inspect context.web_module %>.ConnCase
import <%= inspect context.module %>Fixtures
alias <%= inspect schema.module %>
@create_attrs %{

View file

@ -2,7 +2,6 @@ defmodule <%= inspect context.web_module %>.<%= inspect Module.concat(schema.web
use <%= inspect context.web_module %>.ConnCase
import Phoenix.LiveViewTest
import <%= inspect context.module %>Fixtures
@create_attrs <%= inspect schema.params.create %>
@update_attrs <%= inspect schema.params.update %>

View file

@ -1,3 +0,0 @@
defmodule App.PageViewTest do
use App.ConnCase, async: true
end

View file

View file

@ -1,5 +1,5 @@
defmodule AppWeb.ErrorViewTest do
use App.ConnCase, async: true
use AppWeb.ConnCase, async: true
# Bring render/3 and render_to_string/3 for testing custom views
import Phoenix.View

View file

@ -1,5 +1,5 @@
defmodule App.LayoutViewTest do
use App.ConnCase, async: true
use AppWeb.ConnCase, async: true
import AppWeb.LayoutView

View file

@ -0,0 +1,3 @@
defmodule App.PageViewTest do
use AppWeb.ConnCase, async: true
end

View file

@ -1,4 +1,4 @@
defmodule App.ConnCase do
defmodule AppWeb.ConnCase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a connection.
@ -11,7 +11,7 @@ defmodule App.ConnCase do
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use App.ConnCase, async: true`, although
by setting `use AppWeb.ConnCase, async: true`, although
this option is not recommended for other databases.
"""
@ -22,9 +22,9 @@ defmodule App.ConnCase do
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
import App.ConnCase
import AppWeb.ConnCase
alias App.Router.Helpers, as: Routes
alias AppWeb.Router.Helpers, as: Routes
# The default endpoint for testing
@endpoint AppWeb.Endpoint

View file

@ -0,0 +1,55 @@
defmodule App.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use App.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
alias App.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import App.DataCase
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(App.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(App.Repo, {:shared, self()})
end
:ok
end
@doc """
A helper that transforms changeset errors into a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end

View file

@ -51,7 +51,7 @@ defmodule Legendary.Content.MixProject do
{:meck, "~> 0.8.13", only: :test},
{:neotomex, "~> 0.1.7"},
{:oban, "~> 2.1"},
{:phoenix, "~> 1.5.3"},
{:phoenix, "~> 1.5.8"},
{:phoenix_ecto, "~> 4.0"},
{:phoenix_html, "~> 2.11"},
{:phoenix_html_sanitizer, "~> 1.0.0"},

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View file

@ -0,0 +1,11 @@
# Admin
The admin interface is generated through [Kaffy](https://aesmail.github.io/kaffy/).
You can find extensive documentation on the Kaffy site regarding how to use and
customize your admin interface.
Legendary specific notes:
- The configuration for Kaffy in your Legendary app is located in config/admin.exs.
- Many of the built-in schemas provide admin modules. You shouldn't generally
need to change these, but you may want to do so if you are changing built-in schema modules.

View file

@ -0,0 +1,71 @@
# Authentication and Authorization
Legendary provides a set of authentication and authorization features out of the
box.
# Authentication
Legendary comes with authentication powered by [Pow](https://powauth.com/) out
of the box. The default configuration:
- supports sign in and registration with an email and password
- allows password resets
- requires users to confirm their email address before logging in
- emails for email confirmation and password reset will be nicely styled using your app's
email styles
> Tip: in development mode, emails your app sends will be visible at http://localhost:4000/sent_emails.
Your Pow configuration can be customized in config/config.exs.
By default, users can be administrated in the admin interface.
# Roles and Authorization
Users have an array of roles. By default, a user has no roles, but they can have
as many as you need. Roles in Legendary are arbitrary strings that you tag a user
with to give them certain privileges.
For example, here's a typical admin user created by the `mix legendary.create_admin` command:
```elixir
%Legendary.Auth.User{
email: "legendary@example.com",
homepage_url: nil,
id: 1,
inserted_at: ~N[2021-02-25 22:14:40],
# This user has one role-- admin!
roles: ["admin"],
updated_at: ~N[2021-02-25 22:14:40]
}
```
`admin` happens to be a role that the framework cares about-- via the `mix legendary.create_admin` command and the `:require_admin` pipeline that protects
the admin interface. However, you can use any string you want as a role and check
for it in your code. For example, your app might give some users a `paid_customer`
role and use it to protect certain features. You don't have to declare that in advance with the framework.
In some cases, you may want "resourceful roles"-- a role that corresponds to a
specific resource record in your app. We suggest the following convention for those
role names: `:role_name/:resource_type/:id`. So that could be `owner/home/3` to
indicate the user is the owner of the Home with the id of 3. An authorized guest
to the same home might be `guest/home/3`.
You can check whether a user has a role by calling Legendary.Auth.Roles.has_role?/2:
```elixir
Legendary.Auth.Roles.has_role?(user, "admin")
```
And you can always access the `user.roles` field directly.
# Protected routes
## Signed-In Only Routes
You can require that a given route requires a user by piping through the `:require_auth` pipeline. See apps/app/lib/app_web/router.ex for examples.
## Admin Only Routes
You can lock down a route to the app to only admin users by using the `:require_admin` pipeline. For example, the /admin area of your app is protected
that way. See apps/app/lib/app_web/router.ex for examples.

View file

@ -0,0 +1,13 @@
# Background Jobs
Background jobs and periodic jobs in Legendary are powered by [Oban](https://github.com/sorentwo/oban). See the Oban documentation for extensive information
on using Oban in your application, including:
- queue configuration
- worker configuration
- unique job constraints
- periodic jobs
The framework itself uses Oban for recurring tasks such as generating sitemaps.
Your app's Oban configuration is available in config/config.exs.

View file

@ -0,0 +1,70 @@
# Content Management
Your app includes a basic content management system including a simple blog
(including optional user comments), dynamic pages, and static pages.
Pages and blog posts can be managed from the admin interface. Posts and pages
support content in [Markdown](https://www.markdownguide.org/).
# Blog Posts
Your app has a blog at /blog. Your can create and manage posts from "Content > Pages and Posts"
in the /admin area. You can write your post body in Markdown.
By default, posts have a few fields:
- Type: for a blog post, this will be "Blog Post"
- Slug: this is the url path of your post. For example, a post with slug "hello-world"
would be available at /hello-world.
- Title: the human-readable title of your post.
- Content: this is the body of your post as Markdown. The admin provides a nice
editor in case you don't know Markdown syntax yet or don't want to bother.
- Status: this is Publish if you want your post to be visible to everyone, or draft
if you aren't ready to share it with the world.
- Author: this will normally be you, but we do allow admins to ghost-write for other
users.
- Excerpt: A short summary of your blog post that may show up in search engine results.
- Sticky: sticky posts will always show up first on your blog. They are generally used
for important announcements and community rules.
- Comment status: "open" will allow comments on your post. "closed" hides comments and
does not allow new comments to be entered.
- Ping status: whether the post supports (pingbacks)[https://en.wikipedia.org/wiki/Pingback].
**Coming soon:** we don't currently show pingbacks anywhere or notify anyone when a
pingback is received, but we may in the future.
- Menu order: **Coming soon:** the relative order this blog post will show up in
dynamic menus. Menu management is currently in development. The lower this number,
the higher the post will appear in the menu.
# Dynamic Pages
Dynamic pages are very similar to blog posts. The only differences are that their
type is "Page" instead of "Blog Post" and they do not appear in your blog feed.
They are intended for simple pages that will be updated by your admins, but don't
make sense as a blog post-- for example, terms of service or FAQ pages.
The fields of dynamic pages are the same as blog posts.
# Static Pages
Legendary also supports static pages. Static pages are not editable from the admin.
However, they provide an easy way for developers on a Legendary app to create
and serve a content page without defining custom controllers and routes. This is
a good fit for pages that are more complex than what can be done with Markdown
in dynamic pages.
Static pages are eex templates located in apps/content/lib/content_web/templates/posts/static_pages/.
For example, the home page of your app is a static page called index.html.eex.
The filename, less the .html.eex part, serves as the slug. In other words, a
static page called pricing.html.eex would have the url path /pricing in your app.
> Note: if a static page and a dynamic page have the same slug, the dynamic page
> will "win." This allows you to provide a default version of the page as a fallback
> in code, while allowing admins to create an updated version of the same page.
# Comments
As mentioned above, blog posts can optionally have comments enabled. On these posts,
there will be a feed of comments as well as a comment form at the bottom of the page.
Comments can be managed by admins in the admin interface under "Content > Comments."

View file

@ -0,0 +1,55 @@
# DevOps Templates
Legendary includes a full set of DevOps templates designed to make it easy to
test, build, and deploy your app.
# Overview
The setup we provide is an opinionated setup based on years of experience building
Phoenix applications and deploying them at scale. It's meant to be efficient and
easy for small teams while scaling to big teams. It's meant to be lean enough
for low-traffic apps while scaling quite well to apps receiving thousands of
requests a second.
Here's the process overview:
1. You make commits using [conventional commit messages](https://www.conventionalcommits.org/).
2. The CI runs tests and builds a Docker image unique to your latest commit.
3. Should your tests pass, that Docker image is labeled with a [semantic version](https://semver.org/)
driven by your commit messages. We also tag that commit with that version so that
you can refer to it. You never need to manually tag Docker image or a commit, so
long as you follow the commit message convention.
4. You can deploy that Docker image to Kubernetes, or any other Docker-friendly hosting environment.
- CI generates a Kubernetes manifest pointing at that new docker image. You can
apply that manifest to your cluster manually, or use a tool like flux2 to
automate that.
- If you don't use Kubernetes, you can tell your Docker-ized host to pull the new image in the method provided by that host.
# CI Configuration
Legendary comes with GitLab CI settings which should work for you with minimal
setup. This config is located in .gitlab-ci.yml.
The CI script will automatically tag successful builds. To do this, you will
need to configure a [CI variable](https://docs.gitlab.com/ee/ci/variables/) named
`GITLAB_TOKEN`. This token should be a
[personal access token](https://gitlab.com/-/profile/personal_access_tokens) with
`read_repository, write_repository` permissions.
This CI configuration provides a few nice features:
- Parallel build steps. The tests run while the Docker image builds, so you don't
have to wait for one then the other.
- Fast Docker build configuration. We use Docker BuildKit and a heavily tuned Dockerfile to reduce builld times from 15+ minutes to ~3 minutes.
- Fast Elixir compile times. Out of the box, Elixir compilation can be quite
slow in CI. We employ a few tricks to reduce the compilation time by over 75%
over default CI configuration.
- Automated semantic versioning. So long as you use conventional commit messages,
we will automatically bump the version number appropriately.
# Kubernetes Manifests
We also automatically generate a Kubernetes manifest for your app on each successful build. The generate manifest is commited back to your repo at infrastructure/. You can use a tool like flux2 to automatically update the configuration in your Kubernetes cluster from there. Or you could manually apply
it whenever you choose.
The template used to generate the manifest is located in infrastructure_templates. Feel free to customize it if your application needs different Kubernetes config.

View file

@ -0,0 +1,80 @@
# Email
# Fluid Email Templates
We provide an email template based on
[Cerberus's Fluid Template](https://tedgoas.github.io/Cerberus/#fluid). This
is a template well-suited for transactional email that has been well-tested on
a wide variety of email clients. It should let you send nice looking email from
your app without having to think about it a lot.
# Branding / Theming
Of course, you might want to customize the style of your emails to match your app's
unique look or brand. The trick is that for emails to really work across a broad
set of common clients, they need to _inline their CSS_. We take care of this for
you.
You can customize the variables (colors, sizes, etc) in config/email_styles.exs
and we'll apply them to your emails.
# Mailer
Of course, you may want to send your own emails. We provide two modules to help:
- Legendary.CoreEmail: responsible for generating emails to your specifications
- Legendary.CoreMailer: responsible for sending emails per your configuration
Both are powered by [Bamboo](https://github.com/thoughtbot/bamboo) so you
can follow the Bamboo documentation to learn more about customizing and using
email in your app.
Here's an example:
```elixir
defmodule App.HelloEmail do
import Bamboo.Email
use Bamboo.Phoenix, view: AppWeb.EmailView
def send_hello_email(to) do
to_address
|> hello_email()
|> Legendary.CoreMailer.deliver_later()
end
def hello_email(to_address) do
Legendary.CoreEmail.base_email()
|> to(to_address)
|> render(:hello, to_address: to_address)
end
end
```
> Tip: in development mode, any email you send can be viewed at localhost:4000/sent_emails.
# Email Helpers
Fluid email templates don't do any good if the content of your HTML emails isn't also as fluid and well-tested. We provide email tag helpers so that you don't
have to hand-craft email-friendly HTML. See `Legendary.CoreWeb.EmailHelpers`.
For example, your hello.html.eex might look something like this:
```eex
<%= preview do %>
Have you heard of our awesome app?
<% end %>
<%= h1 do %>
Hello, <%= to_address %>
<% end %>
<%= p do %>
We hope you'll join us.
<% end %>
<%= styled_button href: "http://example.com/" do %>
Join us!
<% end %>
```
We'll handle generating all of the nested tags and inline CSS needed to make the
email look good.

View file

@ -0,0 +1,39 @@
# Strings File and I18n
It's a good idea to extract any human-readable strings in your application out
into a configuration file. The reason is two-fold:
1. It makes it easier for developers to update "[copy](https://en.wikipedia.org/wiki/Copy_(written))" in the application and even
allows non-developers on a team to make copy changes.
2. When your application supports multiple languages, it is easy for translators
to provide translations for all of your copy at once.
In Legendary, we provide a set of tools for doing this via [linguist](https://github.com/change/linguist).
- (English) strings are stored in config/i18n/en.yml.
- You can call `Legendary.I18n.t!/2` to get a string by its key. For example: `Legendary.I18n.t! "en", "site.title"` retrieves the english version of the
string labeled "title" under the section "site" on en.yml.
> Tip: if you use t! a lot (good job!), you can import it in your view module
> to save some typing like `import Legendary.I18n, only: [t!: 2]` and then use it like `<%= t! "en", "site.title" %>` in your templates.
Note that the first argument is a two-letter language code. In order to support
other languages, you can provide more yml files in config/i18n (example, config/i18n/fr.yml for French) and call t!/2 with that language code instead.
Linguist also supports templated translations. If you have a section in en.yml like this:
```yaml
app:
hello_message: Hello, %{name}!
```
then you could call `t!` substitutions like this:
```elixir
t! "en", "app.hello_message", name: "Legend"
```
to get the string "Hello, legend!"
**On the roadmap:** in the future, we intend to provide a mechanism for detecting
and managing each visitor's language and providing those strings if available.

View file

@ -0,0 +1,22 @@
# Tasks and Scripts
Legendary follows the [scripts to rule them all pattern](https://github.com/github/scripts-to-rule-them-all). This allows any developers familiar with the pattern,
either from other Legendary projects, or from other projects that use the pattern,
to immediately pick up a project and get it running.
Here's a summary of the scripts you'll mostly use:
1. **bootstrap** installs all the dependencies needed to run the project.
2. **update** is used to update dependencies.
3. **server** runs the server.
4. **console** runs the interactive console.
5. **test** runs the test suite.
When you run server, console, or test, the script will make sure all the right
dependencies are in place (by running bootstrap or update). This means you can
go straight to running script/server and it should just work.
We encourage you to customize these scripts to the needs of your project as it grows. A developer should only _ever_ have to run script/server to run the server,
and should not need to remember anything beyond that. script/bootstrap should always install everything you need to set up the project from scratch. If you
find yourself updating setup steps in your project's README.md, consider how you
might automate away that setup in your scripts.

View file

@ -23,6 +23,13 @@ development better.
## Up and Running
Since Legendary is both a template and a framework, you can simply clone the repo
to start using it. It's a fully functional Phoenix app as-is. To start a new project:
```sh
git clone https://gitlab.com/mythic-insight/legendary.git <project_name>
```
In order to start the server, run `script/server`. Any dependencies required
will be installed automatically using [brew](https://brew.sh/),
[asdf](https://asdf-vm.com/#/), and [hex](https://hex.pm/).
@ -31,27 +38,39 @@ Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
## Development
Your main app lives in apps/app/ and that is where you will do most of your
Check out [the tutorial](tutorial.md) to learn how to build your first app with
Legendary.
Your main app lives in apps/app/ and you will do most of your
development there. This is a normal Phoenix application and you can develop it
as such. Any resources which apply to developing Phoenix applications will apply
inside of the app. See the [Phoenix Guides](https://hexdocs.pm/phoenix/overview.html)
for a good starting resource in Phoenix development.
You should not generally need to change code in the other applications which
are part of the framework-- admin, content, core. We encourage you to avoid
changing those as much as possible, because doing so will make it more difficult
to upgrade Legendary to newer versions. However, they are available to you if
you find that there are no other ways to accomplish the changes you want to
accomplish. If you find yourself adding functionality to admin, content, or core
are part of the framework-- apps/admin, apps/content, apps/core. We encourage you
to avoid changing those as much as possible, because doing so will make it more
difficult to upgrade Legendary to newer versions. However, they are available to
you if you find that there are no other ways to accomplish the changes that you want.
If you find yourself adding functionality to admin, content, or core
that you feel would be beneficial to all Legendary apps, consider making a
code contribution back to the framework!
## CI Configuration
Legendary comes with gitlab CI settings which should work for you with minimal
Legendary comes with GitLab CI settings which should work for you with minimal
setup.
The CI script will automatically tag successful builds. To do this, you will
need to configure a [CI variable](-/settings/ci_cd) named `GITLAB_TOKEN`. This
token should be a [personal access token](/-/profile/personal_access_tokens) with
need to configure a [CI variable](https://docs.gitlab.com/ee/ci/variables/) named
`GITLAB_TOKEN`. This token should be a
[personal access token](https://gitlab.com/-/profile/personal_access_tokens) with
`read_repository, write_repository` permissions.
## DevOps
The preconfigured CI pipeline generates semantically versioned docker images that
you can deploy in your choice of dockerized hosting. We also provide a manifest
for Kubernetes that is automatically updated with each version (see infrastructure/
for the generated result and infrastructure_templates/ for the templates used to
generate the manifest).

View file

@ -0,0 +1,418 @@
# Tutorial
This tutorial will teach by example you to make a simple application with Legendary.
We'll make a simple home inventory app. Our app will let users keep track of their possessions with some key characteristics like name, location, and value. We'll have two types of users— normal users and admins. We'll have a few features:
Normal User Features
- Adding items to their inventory
- Viewing a list of their items
- Updating their items
- Deleting their items
Admin Features
- Adding, viewing, updating, and deleting anyone's items
- Adding product categories and locations
We'll assume you have some general familiarity with programming— that you know a little bit about how to use the shell, a code editor, and git.
Let's get started.
# Prerequisites
While not strictly necessary, we recommend a couple of tools to make installing Legendary's dependencies (such as Node, Erlang, and Elixir):
- brew
- asdf
If you use brew and asdf, installing Legendary will automatically install the correct versions of Node, Erlang, and Elixir. Otherwise, you can manually install:
- erlang 23.0.2 or later
- elixir 1.10.3-otp-23 or later
- nodejs 14.5.0 or later
# Making a New Application
The first step is to clone the Legendary application template to a new project directory. We'll call it home_inventory:
```bash
$ git clone git@gitlab.com:mythic-insight/legendary.git home_inventory
$ cd home_inventory
```
This directory contains the skeleton of your app, plus a copy of Legendary to support it.
Let's start our server to check things out:
```bash
$ script/server
```
The first time you run the server, it will automatically install everything your project needs to run. This will take some time. The next time you run the server, everything will already be installed, so the process will be much faster.
You should now be able to visit [http://localhost:4000/](http://localhost:4000/) and see a home page. Congrats!
![Home Page](assets/homepage.png)
# Tour of the Code
# Generating Our First Resource
A resource is a chunk of related code that represents something your application does. In our example application, for example, we'll have an Item resource that represents one thing in a user's home inventory.
A few tasks go into having Items and having our app do useful things with them:
- We have a group of related things that a user can do, for example, creating an item, updating it, retrieving it to view later, deleting it, or even viewing whole lists of items. This group is called a context module.
- We need to describe what an Item is like, and how Items are stored. This is called a schema.
- We need to accept requests from the browser, perform some operations, then return a response. This is done by a controller.
- We need to generate markup that the browser can understand. This is done with views and templates.
It would be really tedious to have to create all these things by hand. Luckily, we have generators that do the boring part for us. There are a few different included generators, but we'll use phx.gen.html which generates all the parts of a resource. Let's generate our Item resource:
```bash
$ cd apps/app
$ mix phx.gen.html Inventory Item items name:string description:text purchase_date:date value:decimal
```
The generate command has quite a few parts, so let's walk through them one by one.
- mix is the elixir utility for running command line tasks
- phx.gen.html is the name of the generator. phx is for Phoenix (our parent framework), gen is for generators. html is because we're making a resource reachable as an html page.
- Inventory is the context where we are adding our resource. Right now it will just include Item and the Item-related features, but later it could include multiple related resources.
- items is the name of the database table to use to store Items.
- name:string, description:text, etc is a series of fields that each Item will have. The first part is the name of the field (e.g. description) and the second part is the type of the field (e.g. text).
You'll see that the generator made a few files:
```bash
* creating lib/app_web/controllers/item_controller.ex
* creating lib/app_web/templates/item/edit.html.eex
* creating lib/app_web/templates/item/form.html.eex
* creating lib/app_web/templates/item/index.html.eex
* creating lib/app_web/templates/item/new.html.eex
* creating lib/app_web/templates/item/show.html.eex
* creating lib/app_web/views/item_view.ex
* creating test/app_web/controllers/item_controller_test.exs
* creating lib/app/inventory/item.ex
* creating priv/repo/migrations/20210409172913_create_items.exs
* creating lib/app/inventory.ex
* injecting lib/app/inventory.ex
* creating test/app/inventory_test.exs
* injecting test/app/inventory_test.exs
```
In order to start doing things with Items, we need to do two small additional things:
- Migrating our database
- Setting up a route for the Item resource
# Migrating Our Database
Database migrations are how the application knows to change your database as you add new features (e.g. adding or deleting tables or columns). To run our pending migration, do this:
```bash
$ mix ecto.migrate
```
# Setting Up A Route
A route is what defines the URL structure of your application. It tells your application which URLs should be mapped to which controller actions. Let's add a simple route for Items.
In apps/app/lib/app_web/router.ex, you'll find a part that looks like :
```bash
scope "/" do
pipe_through :browser
pow_routes()
pow_extension_routes()
end
```
We want to add
```bash
resources "/items", ItemController
```
within the scope. The finished code will look like this:
```bash
scope "/" do
pipe_through :browser
pow_routes()
pow_extension_routes()
resources "/items", AppWeb.ItemController
end
```
This will add some new URLs to our application:
- /items for viewing a list of items
- /items/:id for viewing a single item. :id stands in for a unique ID of the item
- It also maps the create, update, and delete actions for items.
Let's check it out by going to https://localhost:4000/items
![Items Index](assets/items-index.png)
You can see that we get an empty table of Items and a button that says "New Item." Let's see what happens when we click New Item. We get a form that allows us to create a new item, complete with all the fields that we set up.
![New Item](assets/item-new.png)
![Item Detail Page](assets/item-show.png)
If we save that, we're presented with a detail view for the Item. Note that it has all the things we filled in. If we click "Back", we go back to the table, but now we have an Item!
![Items Index After](assets/items-index-after.png)
If you click on the ... menu in the table row. Try editing the Item. Then try deleting the Item. Pretty cool, right?
Congrats, you've made your first resource!
# Admin Site
All Legendary projects come with a built in administration tool. Let's check it out. First, we need to make an admin user:
```bash
$ mix legendary.create_admin
```
Give your email address and a password. Then, go to [localhost:4000](http://localhost:4000) and log in. Click Admin in the menu.
You'll see something like this:
![Admin Index](assets/admin.png)
You can see that some of the built-in resources, such as Users, Posts, and Comments are already in the admin. But Item isn't there. How do we add it?
We need to add Item to config/admin.exs:
```elixir
config :admin, Legendary.Admin,
resources: [
# Default admin config
auth: [
name: "Auth",
resources: [
user: [schema: Legendary.Auth.User, admin: Legendary.Auth.UserAdmin],
]
],
content: [
name: "Content",
resources: [
post: [schema: Legendary.Content.Post, admin: Legendary.Content.PostAdmin, label: "Posts and Pages", id_column: :name],
comment: [schema: Legendary.Content.Comment, admin: Legendary.Content.CommentAdmin],
]
],
# our new code
inventory: [
name: "Inventory",
resources: [
item: [schema: App.Inventory.Item],
]
]
]
```
Restart your server. For most changes, you don't need to restart, however, configuration changes like this one require a restart.
You'll see that admin users can now create, update, and delete Items.
# Making Things More Interesting
Right now, we have one list of items shared between all users. What if we want to limit each user's list of items to only ones that they created. We can extend our code a bit to accomplish this.
First, let's create a column in our database to store the item owner:
```bash
mix ecto.gen.migration add_owner_id_to_items
```
And use it to add an owner_id column and index for the owner_id column:
```elixir
defmodule App.Repo.Migrations.AddOwnerIdToItems do
use Ecto.Migration
def change do
alter table("items") do
add :owner_id, :integer
end
create index("items", [:owner_id])
end
end
```
And run the migration:
```bash
mix ecto.migrate
```
In item.ex:
```elixir
schema "items" do
field :description, :string
field :name, :string
field :purchase_date, :date
field :value, :decimal
field :owner_id, :integer # new!
timestamps()
end
def changeset(item, attrs) do
item
# Add owner_id
|> cast(attrs, [:name, :description, :purchase_date, :value, :owner_id])
# Add owner_id
|> validate_required([:name, :description, :purchase_date, :value, :owner_id])
end
```
Now that we have the column, how do we use it? We need to take the id of the current user and pass it along when we create the item. The best place to do this is in the controller:
```elixir
def create(conn, %{"item" => item_params}) do
%{id: current_user_id} = Pow.Plug.current_user(conn)
case Inventory.create_item(Map.merge(item_params, %{"owner_id" => current_user_id})) do
{:ok, item} ->
conn
|> put_flash(:info, "Item created successfully.")
|> redirect(to: Routes.item_path(conn, :show, item))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
```
Now every item we create will have a defined owner.
We also need to filter the items shown based on the current user. In inventory.ex:
```elixir
def list_items(user_id) do
Item
|> where(owner_id: user_id)
|> Repo.all()
end
```
and items_controller.ex:
```elixir
def index(conn, _params) do
%{id: current_user_id} = Pow.Plug.current_user(conn)
items = Inventory.list_items(current_user_id)
render(conn, "index.html", items: items)
end
```
We also need to prevent any sneaky people from viewing, editing, or deleting an item that doesn't belong to them:
```elixir
plug :authorize when action in [:show, :edit, :update, :delete]
# ...
defp authorize(%{params: %{"id" => id}} = conn, _options) do
item = Inventory.get_item!(id)
%{id: current_user_id} = Pow.Plug.current_user(conn)
if item.owner_id != current_user_id do
conn
|> put_status(403)
|> text("Forbidden")
|> halt()
else
conn
end
end
```
This adds a plug that will be run before the show, edit, update, and delete actions. It loads the item in question, and checks it against the current user. If they don't match, we give an error message and stop the request. If they match, we continue on to the action.
There's one problem— if a user tries to visit /items and they aren't logged in, they receive an ugly error message instead of being helpfully redirected to sign in. It's a pretty bad experience. Let's fix that. In router.ex, change it so it looks like this:
```elixir
scope "/" do
pipe_through :browser
pow_routes()
pow_extension_routes()
end
scope "/" do
pipe_through :browser
pipe_through :require_auth
resources "/items", AppWeb.ItemController
end
```
`pipe_through :require_auth` tells the framework that those routes are only for signed in users! Give it a try. Notice that if you visit /items while you are logged out, you will be taken to the sign in page. And once you sign in, you'll be taken automatically back to items. Pretty cool!
# Testing
When you generate a new resource, Legendary will also generate a set of unit tests that correspond with it. But we just added new, untested functionality.
First, we need to make sure that our tests are run with a user. In item_controller_test.exs:
```elixir
# Add owner_id
@create_attrs %{description: "some description", name: "some name", purchase_date: ~D[2010-04-17], value: "120.5", owner_id: 123}
# Add owner_id
@update_attrs %{description: "some updated description", name: "some updated name", purchase_date: ~D[2011-05-18], value: "456.7", owner_id: 123}
@invalid_attrs %{description: nil, name: nil, purchase_date: nil, value: nil}
def fixture(:item) do
{:ok, item} = Inventory.create_item(@create_attrs)
item
end
setup %{conn: conn} do
user = %Legendary.Auth.User{
id: 123
}
conn =
conn
|> Pow.Plug.put_config(current_user_assigns_key: :current_user)
|> Pow.Plug.assign_current_user(user, [])
%{
conn: conn
}
end
```
And in inventory_test.exs, we need to tell our tests that valid items have an owner_id:
```elixir
@valid_attrs %{
description: "test item description",
name: "item description",
purchase_date: ~D[2021-04-22],
value: 200.00,
owner_id: 123
}
```
# What's Next?
Legendary apps are just Phoenix apps with some bells and whistles included by default. The more you learn about Phoenix development, the more you'll know about building your app with Legendary. Here are some resources that we recommend:
- The Official Phoenix Framework Guides: [https://hexdocs.pm/phoenix/overview.html](https://hexdocs.pm/phoenix/overview.html)
- Programming Phoenix: [https://pragprog.com/titles/phoenix14/programming-phoenix-1-4/](https://pragprog.com/titles/phoenix14/programming-phoenix-1-4/)
- Phoenix LiveView Course by The Pragmatic Studio: [https://pragmaticstudio.com/courses/phoenix-liveview](https://pragmaticstudio.com/courses/phoenix-liveview)
To learn more about Legendary specifically, [check out the guides.](https://hexdocs.pm/legendary_core/)

View file

@ -1,6 +1,6 @@
defmodule Legendary.CoreWeb.Helpers do
@moduledoc """
HTML helpers for our styled (Fomantic UI) forms.
HTML helpers for our styled (Tailwind) forms.
"""
use Phoenix.HTML

View file

@ -26,8 +26,11 @@ defmodule Legendary.Core.MixProject do
homepage_url: "https://legendaryframework.org/",
docs: [
main: "overview",
extra_section: "GUIDES",
extras: extras()
extra_section: "Getting Started",
extras: extras(),
groups_for_extras: groups_for_extras(),
groups_for_modules: groups_for_modules(),
assets: "guides/assets",
],
# Hex
@ -45,7 +48,70 @@ defmodule Legendary.Core.MixProject do
end
defp extras do
Path.wildcard("guides/**/*.md")
[
"guides/overview.md",
"guides/tutorial.md",
# "guides/tutorial.md": [filename: "tutorial", title: "Tutorial"],
"guides/features/admin.md",
"guides/features/auth.md",
"guides/features/background-jobs.md",
"guides/features/content-management.md",
"guides/features/devops-templates.md",
"guides/features/email.md",
"guides/features/i18n.md",
"guides/features/tasks-and-scripts.md",
]
end
defp groups_for_extras do
[
Guides: ~r{guides/[^\.]+.md},
]
end
defp groups_for_modules do
[
"Auth": [
Legendary.Auth,
Legendary.AuthWeb,
~r{Legendary\.Auth(Web)?\..+},
Legendary.CoreWeb.Router.PowExtensionRouter
],
"Email": [
Legendary.CoreEmail,
Legendary.CoreMailer,
Legendary.CoreWeb.EmailHelpers,
Legendary.CoreWeb.CoreEmailView,
],
"Internationalization": [
Legendary.I18n
],
"Mix Tasks": [
Legendary.Mix,
],
"View Helpers": [
Legendary.CoreWeb.ErrorHelpers,
Legendary.CoreWeb.Helpers,
],
"Core Other": [
Legendary.Core,
Legendary.Core.MapUtils,
Legendary.Core.Repo,
Legendary.Core.SharedDBConnectionPool,
Mix.Legendary,
],
"Web Other": [
Legendary.CoreWeb,
Legendary.CoreWeb.Endpoint,
Legendary.CoreWeb.ErrorView,
Legendary.CoreWeb.Gettext,
Legendary.CoreWeb.LayoutView,
Legendary.CoreWeb.Router,
Legendary.CoreWeb.Router.Helpers,
Legendary.CoreWeb.Telemetry,
Legendary.CoreWeb.UserSocket,
]
]
end
# Configuration for the OTP application.
@ -72,7 +138,7 @@ defmodule Legendary.Core.MixProject do
{:ex_cldr, "~> 2.13.0"},
{:ex_doc, "~> 0.24", only: :dev, runtime: false},
{:excoveralls, "~> 0.10", only: [:dev, :test]},
{:phoenix, "~> 1.5.3"},
{:phoenix, "~> 1.5.8"},
{:phoenix_ecto, "~> 4.1"},
{:ecto_sql, "~> 3.4"},
{:ex_prompt, "~> 0.1.5"},

View file

@ -12,13 +12,13 @@ config :kaffy,
config :admin, Legendary.Admin,
resources: [
auth: [
name: "Legendary.Auth",
name: "Auth",
resources: [
user: [schema: Legendary.Auth.User, admin: Legendary.Auth.UserAdmin],
]
],
content: [
name: "Legendary.Content",
name: "Content",
resources: [
post: [schema: Legendary.Content.Post, admin: Legendary.Content.PostAdmin, label: "Posts and Pages", id_column: :name],
comment: [schema: Legendary.Content.Comment, admin: Legendary.Content.CommentAdmin],

View file

@ -66,6 +66,13 @@ config :content,
{"0 * * * *", Legendary.Content.Sitemaps},
]
config :app,
Oban,
repo: App.Repo,
queues: [default: 10],
crontab: [
]
import_config "email_styles.exs"
import_config "admin.exs"

View file

@ -11,6 +11,7 @@ use Mix.Config
# before starting your production server.
secret_key_base = System.get_env("SECRET_KEY_BASE")
signing_salt = System.get_env("LIVE_VIEW_SIGNING_SALT")
[
{:admin, Legendary.Admin, false},
@ -35,7 +36,7 @@ secret_key_base = System.get_env("SECRET_KEY_BASE")
],
secret_key_base: secret_key_base,
pubsub_server: App.PubSub,
live_view: [signing_salt: "g5ltUbnQ"],
live_view: [signing_salt: signing_salt],
server: start_server
] ++ extra_opts
end)
@ -64,6 +65,20 @@ database_url = System.get_env("DATABASE_URL")
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
end)
config :core, Legendary.CoreMailer,
adapter: Bamboo.SMTPAdapter,
server: {:system, "SMTP_HOST"},
hostname: {:system, "HOSTNAME"},
port: 25,
username: {:system, "SMTP_USERNAME"},
password: {:system, "SMTP_PASSWORD"},
tls: :if_available,
allowed_tls_versions: [:"tlsv1", :"tlsv1.1", :"tlsv1.2"],
ssl: false,
retries: 1,
no_mx_lookups: false,
auth: :always
# ## Using releases (Elixir v1.9+)
#
# If you are doing OTP releases, you need to instruct Phoenix

View file

@ -34,6 +34,11 @@ spec:
secretKeyRef:
name: legendary-doc-site
key: secret-key-base
- name: LIVE_VIEW_SIGNING_SALT
valueFrom:
secretKeyRef:
name: legendary-doc-site
key: live-view-signing-salt
---
apiVersion: v1
kind: Service

View file

@ -24,6 +24,8 @@ spec:
ports:
- containerPort: 4000
env:
- name: HOSTNAME
value: legendaryframework.org
- name: DATABASE_URL
valueFrom:
secretKeyRef:
@ -34,6 +36,26 @@ spec:
secretKeyRef:
name: legendary-doc-site
key: secret-key-base
- name: LIVE_VIEW_SIGNING_SALT
valueFrom:
secretKeyRef:
name: legendary
key: live-view-signing-salt
- name: SMTP_HOST
valueFrom:
secretKeyRef:
name: legendary
key: smtp-host
- name: SMTP_USERNAME
valueFrom:
secretKeyRef:
name: legendary
key: smtp-username
- name: SMTP_HOST
valueFrom:
secretKeyRef:
name: legendary
key: smtp-password
---
apiVersion: v1
kind: Service

View file

@ -43,7 +43,7 @@
"makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
"meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"},
"mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mochiweb": {:hex, :mochiweb, "2.12.2", "80804ad342afa3d7f3524040d4eed66ce74b17a555de454ac85b07c479928e46", [:make, :rebar], [], "hexpm", "d3e681d4054b74a96cf2efcd09e94157ab83a5f55ddc4ce69f90b8144673bd7a"},
"mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"},
@ -61,7 +61,7 @@
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"},
"php_serializer": {:hex, :php_serializer, "0.9.2", "59c5fd6bd3096671fd89358fb8229341ac7423b50ad8d45a15213b02ea2edab2", [:mix], [], "hexpm", "34eb835a460944f7fc216773b363c02e7dcf8ac0390c9e9ccdbd92b31a7ca59a"},
"plug": {:hex, :plug, "1.11.1", "f2992bac66fdae679453c9e86134a4201f6f43a687d8ff1cd1b2862d53c80259", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "23524e4fefbb587c11f0833b3910bfb414bf2e2534d61928e920f54e3a1b881f"},
"plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"},
"plug_cowboy": {:hex, :plug_cowboy, "2.5.0", "51c998f788c4e68fc9f947a5eba8c215fbb1d63a520f7604134cab0270ea6513", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5b2c8925a5e2587446f33810a58c01e66b3c345652eeec809b76ba007acde71a"},
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
"postgrex": {:hex, :postgrex, "0.15.5", "aec40306a622d459b01bff890fa42f1430dac61593b122754144ad9033a2152f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ed90c81e1525f65a2ba2279dbcebf030d6d13328daa2f8088b9661eb9143af7f"},
"pow": {:hex, :pow, "1.0.20", "b99993811af5233681bfc521e81ca706d25a56f2be54bad6424db327ce840ab9", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.3.0 and < 1.6.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, ">= 2.0.0 and <= 3.0.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, ">= 1.5.0 and < 2.0.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b6bd271399ccb353abbdbdc316199fe7fd7ae36bbf47059d53e366831c34fc8"},
@ -71,7 +71,7 @@
"slugger": {:hex, :slugger, "0.3.0", "efc667ab99eee19a48913ccf3d038b1fb9f165fa4fbf093be898b8099e61b6ed", [:mix], [], "hexpm", "20d0ded0e712605d1eae6c5b4889581c3460d92623a930ddda91e0e609b5afba"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
"swarm": {:hex, :swarm, "3.4.0", "64f8b30055d74640d2186c66354b33b999438692a91be275bb89cdc7e401f448", [:mix], [{:gen_state_machine, "~> 2.0", [hex: :gen_state_machine, repo: "hexpm", optional: false]}, {:libring, "~> 1.0", [hex: :libring, repo: "hexpm", optional: false]}], "hexpm", "94884f84783fc1ba027aba8fe8a7dae4aad78c98e9f9c76667ec3471585c08c6"},
"telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"},
"telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"},
"telemetry_metrics": {:hex, :telemetry_metrics, "0.5.0", "1b796e74add83abf844e808564275dfb342bcc930b04c7577ab780e262b0d998", [:mix], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "31225e6ce7a37a421a0a96ec55244386aec1c190b22578bd245188a4a33298fd"},
"telemetry_poller": {:hex, :telemetry_poller, "0.5.1", "21071cc2e536810bac5628b935521ff3e28f0303e770951158c73eaaa01e962a", [:rebar3], [{:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4cab72069210bc6e7a080cec9afffad1b33370149ed5d379b81c7c5f0c663fd4"},
"timex": {:hex, :timex, "3.6.2", "845cdeb6119e2fef10751c0b247b6c59d86d78554c83f78db612e3290f819bc2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "26030b46199d02a590be61c2394b37ea25a3664c02fafbeca0b24c972025d47a"},

View file

@ -12,7 +12,7 @@ else
export KERL_CONFIGURE_OPTIONS="--disable-hipe --with-ssl=$(brew --prefix openssl)"
fi
if ! asdf -v &> /dev/null
if ! which asdf &> /dev/null
then
echo "WARNING: No asdf. Skipping erlang, elixir, node installation."
else
@ -27,4 +27,4 @@ else
asdf install
fi
mix local.hex --force
mix local.hex --force

View file

@ -6,8 +6,12 @@ set -e
mix local.hex --force
mix local.rebar --force
script/restore-timestamps
mix deps.get
mix ecto.create
mix ecto.migrate
mix test
script/restore-timestamps

82
script/restore-timestamps Executable file
View file

@ -0,0 +1,82 @@
#!/usr/bin/env elixir
defmodule TimestampRestorer do
@environment System.get_env("MIX_ENV", "dev")
@db_path "_build/#{@environment}/timestamp-database"
def sha_all(opts \\ []) do
timestamp_database = load_timestamp_database()
"**/*.{ex,exs,beam}"
|> Path.wildcard()
|> Enum.reduce(%{}, fn filename, acc ->
{sha, timestamp} = process(filename, timestamp_database, opts)
Map.put(acc, sha, timestamp)
end)
|> write_timestamp_database()
end
defp load_timestamp_database() do
if File.exists?(@db_path) do
@db_path
|> File.read!()
|> String.split("\n")
|> Enum.reduce(%{}, fn line, acc ->
[sha, timestamp_string] = String.split(line, ":")
{timestamp, ""} = Integer.parse(timestamp_string)
Map.put(acc, sha, timestamp)
end)
else
%{}
end
end
defp write_timestamp_database(database) do
File.mkdir_p!(Path.dirname(@db_path))
database
|> Enum.map(fn {key, value} -> "#{key}:#{value}" end)
|> Enum.join("\n")
|> (& File.write!(@db_path, &1)).()
end
defp process(filename, timestamp_database, opts) do
{verbose, _opts} = Keyword.pop(opts, :verbose, false)
sha = sha(filename)
{:ok, %{mtime: new_timestamp}} = File.lstat(filename, time: :posix)
case Map.get(timestamp_database, sha) do
nil ->
log("[NEW SHA ] #{filename}: #{new_timestamp}", verbose)
timestamp when timestamp < new_timestamp ->
log("[RESTORED ] #{filename}: #{timestamp}", verbose)
File.touch(filename, timestamp)
timestamp when timestamp >= new_timestamp ->
log("[UNCHANGED] #{filename}: #{timestamp}", verbose)
end
{sha, new_timestamp}
end
defp log(_message, false), do: :ok
defp log(message, true), do: :logger.debug(message)
defp sha(filename) do
hash_ref = :crypto.hash_init(:sha)
File.stream!(filename)
|> Enum.reduce(hash_ref, fn chunk, prev_ref->
new_ref = :crypto.hash_update(prev_ref, chunk)
new_ref
end)
|> :crypto.hash_final()
|> Base.encode16()
|> String.downcase()
end
end
{opts, _args, _errors} = OptionParser.parse(System.argv(), switches: [verbose: :boolean])
{time, _result} = :timer.tc(TimestampRestorer, :sha_all, [opts])
:logger.info("Restored timestamps in #{time / 1_000_000}s")