Merge pull request #266 from asciinema/ex-upload

New upload endpoint implementation
remove-old-upload-endpoint
Marcin Kulik 7 years ago committed by GitHub
commit 8d80aeb9b1

@ -49,6 +49,10 @@ config :asciinema, Asciinema.PngGenerator.A2png,
bin_path: System.get_env("A2PNG_BIN_PATH") || "./a2png/a2png.sh",
pool_size: String.to_integer(System.get_env("A2PNG_POOL_SIZE") || "2")
config :asciinema, :redis_url, System.get_env("REDIS_URL") || "redis://redis:6379"
config :asciinema, :poster_generator, Asciinema.Asciicasts.PosterGenerator.Sidekiq
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env}.exs"

@ -21,3 +21,5 @@ config :asciinema, Asciinema.Repo,
config :asciinema, :file_store, Asciinema.FileStore.Local
config :asciinema, Asciinema.FileStore.Local, path: "uploads/test/"
config :asciinema, :poster_generator, Asciinema.Asciicasts.PosterGenerator.Noop

@ -24,7 +24,7 @@ server {
client_max_body_size 16m;
location ~ ^/(phoenix/|css/|js/|images/|fonts/|docs/?) {
location ~ ^/(phoenix/|css/|js/|images/|fonts/|docs/?|api/asciicasts) {
try_files /maintenance.html $uri/index.html $uri.html $uri @phoenix;
}

@ -6,6 +6,8 @@ defmodule Asciinema do
def start(_type, _args) do
import Supervisor.Spec
redis_url = Application.get_env(:asciinema, :redis_url)
# Define workers and child supervisors to be supervised
children = [
# Start the Ecto repository
@ -15,6 +17,7 @@ defmodule Asciinema do
# Start your own worker by calling: Asciinema.Worker.start_link(arg1, arg2, arg3)
# worker(Asciinema.Worker, [arg1, arg2, arg3]),
:poolboy.child_spec(:worker, Asciinema.PngGenerator.A2png.poolboy_config(), []),
worker(Redix, [redis_url, [name: :redix]])
]
# See http://elixir-lang.org/docs/stable/elixir/Supervisor.html

@ -1,30 +1,100 @@
defmodule Asciinema.Asciicasts do
import Ecto.Query, warn: false
alias Asciinema.{Repo, Asciicast, FileStore}
alias Asciinema.Asciicasts.PosterGenerator
def create_asciicast(user, %Plug.Upload{path: path, filename: filename} = upload) do
asciicast = %Asciicast{user_id: user.id, file: filename}
with {:ok, json} <- File.read(path),
{:ok, attrs} <- Poison.decode(json),
{:ok, attrs} <- extract_attrs(attrs),
changeset = Asciicast.create_changeset(asciicast, attrs),
{:ok, %Asciicast{} = asciicast} <- Repo.insert(changeset) do
put_file(asciicast, upload)
{:ok, asciicast}
def get_asciicast!(id) when is_integer(id) do
Repo.get!(Asciicast, id)
end
def get_asciicast!(thing) when is_binary(thing) do
q = if String.length(thing) == 25 do
from a in Asciicast, where: a.secret_token == ^thing
else
case Integer.parse(thing) do
{id, ""} ->
from a in Asciicast, where: a.private == false and a.id == ^id
_ ->
from a in Asciicast, where: a.id == -1 # TODO fixme
end
end
Repo.one!(q)
end
def create_asciicast(user, params, user_agent \\ nil)
def create_asciicast(user, %Plug.Upload{path: path, filename: filename} = upload, user_agent) do
asciicast = %Asciicast{user_id: user.id,
user_agent: user_agent,
file: filename,
private: user.asciicasts_private_by_default}
{_, result} = Repo.transaction(fn ->
with {:ok, json} <- File.read(path),
{:ok, attrs} <- Poison.decode(json),
{:ok, attrs} <- extract_attrs(attrs),
changeset = Asciicast.create_changeset(asciicast, attrs),
{:ok, %Asciicast{} = asciicast} <- Repo.insert(changeset) do
save_file(asciicast, :file, upload)
generate_poster(asciicast)
{:ok, asciicast}
else
{:error, :invalid} ->
{:error, :parse_error}
otherwise ->
otherwise
end
end)
result
end
defp extract_attrs(attrs) do
def create_asciicast(user, %{"meta" => attrs,
"stdout" => %Plug.Upload{filename: d_filename} = data,
"stdout_timing" => %Plug.Upload{filename: t_filename} = timing}, user_agent) do
attrs = Map.put(attrs, "version", 0)
asciicast = %Asciicast{user_id: user.id,
user_agent: unless(attrs["uname"], do: user_agent),
stdout_data: d_filename,
stdout_timing: t_filename,
private: user.asciicasts_private_by_default}
changeset = Asciicast.create_changeset(asciicast, attrs)
{_, result} = Repo.transaction(fn ->
with {:ok, %Asciicast{} = asciicast} <- Repo.insert(changeset) do
save_file(asciicast, :stdout_data, data)
save_file(asciicast, :stdout_timing, timing)
generate_poster(asciicast)
{:ok, asciicast}
else
otherwise -> otherwise
end
end)
result
end
defp extract_attrs(%{"version" => 1} = attrs) do
attrs = %{version: attrs["version"],
duration: attrs["duration"],
terminal_columns: attrs["width"],
terminal_lines: attrs["height"],
terminal_type: get_in(attrs, ["env", "TERM"]),
command: attrs["command"],
shell: get_in(attrs, ["env", "SHELL"]),
title: attrs["title"]}
{:ok, attrs}
end
defp extract_attrs(_attrs) do
{:error, :unknown_format}
end
defp put_file(asciicast, %{path: tmp_file_path, content_type: content_type}) do
file_store_path = Asciicast.json_store_path(asciicast)
defp save_file(asciicast, type, %{path: tmp_file_path, content_type: content_type}) do
file_store_path = Asciicast.file_store_path(asciicast, type)
:ok = FileStore.put_file(file_store_path, tmp_file_path, content_type)
end
defp generate_poster(asciicast) do
PosterGenerator.generate(asciicast)
end
end

@ -0,0 +1,10 @@
defmodule Asciinema.Asciicasts.PosterGenerator do
alias Asciinema.Asciicast
@doc "Generates poster for given asciicast"
@callback generate(asciicast :: %Asciicast{}) :: :ok | {:error, term}
def generate(asciicast) do
Application.get_env(:asciinema, :poster_generator).generate(asciicast)
end
end

@ -0,0 +1,5 @@
defmodule Asciinema.Asciicasts.PosterGenerator.Noop do
def generate(_asciicast) do
:ok
end
end

@ -0,0 +1,8 @@
defmodule Asciinema.Asciicasts.PosterGenerator.Sidekiq do
alias Asciinema.Asciicast
alias Asciinema.SidekiqClient
def generate(%Asciicast{id: id}) do
SidekiqClient.enqueue("AsciicastWorker", [id])
end
end

@ -11,4 +11,23 @@ defmodule Asciinema.Auth do
user = user_id && Repo.get(User, user_id)
Conn.assign(conn, :current_user, user)
end
def get_basic_auth(conn) do
with ["Basic " <> auth] <- Conn.get_req_header(conn, "authorization"),
auth = String.replace(auth, ~r/^%/, ""), # workaround for 1.3.0-1.4.0 client bug
{:ok, username_password} <- Base.decode64(auth),
[username, password] <- String.split(username_password, ":") do
{username, password}
else
_ -> nil
end
end
def put_basic_auth(conn, nil, nil) do
conn
end
def put_basic_auth(conn, username, password) do
auth = Base.encode64("#{username}:#{password}")
Conn.put_req_header(conn, "authorization", "Basic " <> auth)
end
end

@ -2,4 +2,12 @@ defmodule Crypto do
def md5(data) do
Base.encode16(:erlang.md5(data), case: :lower)
end
def random_token(length) do
length
|> :crypto.strong_rand_bytes
|> Base.url_encode64
|> String.replace(~r/[_=-]/, "")
|> binary_part(0, length)
end
end

@ -0,0 +1,14 @@
defmodule Asciinema.SidekiqClient do
def enqueue(class, args, queue \\ "default") do
job = %{queue: queue,
class: class,
args: args,
enqueued_at: Timex.now |> Timex.to_unix,
jid: Crypto.random_token(24),
retry: true}
payload = Poison.encode!(job)
{:ok, _} = Redix.command(:redix, ["LPUSH", "queue:#{queue}", payload])
:ok
end
end

@ -0,0 +1,58 @@
defmodule Asciinema.Users do
import Ecto.Query, warn: false
alias Asciinema.{Repo, User, ApiToken}
def authenticate(api_token) do
q = from u in User,
join: at in ApiToken,
on: at.user_id == u.id,
select: {u, at.revoked_at},
where: at.token == ^api_token
case Repo.one(q) do
nil ->
{:error, :token_not_found}
{%User{} = user, nil} ->
{:ok, user}
{%User{}, _} ->
{:error, :token_revoked}
end
end
def get_user_with_api_token(username, api_token) do
case authenticate(api_token) do
{:ok, %User{} = user} ->
user
{:error, :token_revoked} ->
nil
{:error, :token_not_found} ->
create_user_with_api_token(username, api_token)
end
end
def create_user_with_api_token(username, api_token) do
user_changeset = User.temporary_changeset(username)
{_, result} = Repo.transaction(fn ->
with {:ok, %User{} = user} <- Repo.insert(user_changeset),
api_token_changeset = ApiToken.create_changeset(user, api_token),
{:ok, %ApiToken{}} <- Repo.insert(api_token_changeset) do
user
else
_otherwise -> Repo.rollback(nil)
end
end)
result
end
def get_api_token!(token) do
Repo.get_by!(ApiToken, token: token)
end
def revoke_api_token!(api_token) do
api_token
|> ApiToken.revoke_changeset
|> Repo.update!
end
end

@ -33,6 +33,9 @@ defmodule Asciinema.Mixfile do
:poolboy,
:porcelain,
:postgrex,
:redix,
:timex,
:timex_ecto,
]]
end
@ -61,6 +64,9 @@ defmodule Asciinema.Mixfile do
{:poolboy, "~> 1.5"},
{:porcelain, "~> 2.0"},
{:postgrex, ">= 0.0.0"},
{:redix, ">= 0.6.1"},
{:timex, "~> 3.0"},
{:timex_ecto, "~> 3.0"},
]
end

@ -1,6 +1,7 @@
%{"briefly": {:hex, :briefly, "0.3.0", "16e6b76d2070ebc9cbd025fa85cf5dbaf52368c4bd896fb482b5a6b95a540c2f", [:mix], [], "hexpm"},
"bugsnag": {:hex, :bugsnag, "1.4.0", "fda8c3f550c93568b6e9ac615b1a9be0c1c4e06c7eb0ffb04a133dfaf1e01327", [:mix], [{:httpoison, "~> 0.9", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"certifi": {:hex, :certifi, "1.0.0", "1c787a85b1855ba354f0b8920392c19aa1d06b0ee1362f9141279620a5be2039", [:rebar3], [], "hexpm"},
"combine": {:hex, :combine, "0.9.6", "8d1034a127d4cbf6924c8a5010d3534d958085575fa4d9b878f200d79ac78335", [:mix], [], "hexpm"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
"cowboy": {:hex, :cowboy, "1.1.2", "61ac29ea970389a88eca5a65601460162d370a70018afe6f949a29dca91f3bb0", [:rebar3], [{:cowlib, "~> 1.0.2", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3.2", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
"cowlib": {:hex, :cowlib, "1.0.2", "9d769a1d062c9c3ac753096f868ca121e2730b9a377de23dec0f7e08b1df84ee", [:make], [], "hexpm"},
@ -31,4 +32,8 @@
"porcelain": {:hex, :porcelain, "2.0.3", "2d77b17d1f21fed875b8c5ecba72a01533db2013bd2e5e62c6d286c029150fdc", [:mix], [], "hexpm"},
"postgrex": {:hex, :postgrex, "0.11.2", "139755c1359d3c5c6d6e8b1ea72556d39e2746f61c6ddfb442813c91f53487e8", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.0-rc", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
"ranch": {:hex, :ranch, "1.3.2", "e4965a144dc9fbe70e5c077c65e73c57165416a901bd02ea899cfd95aa890986", [:rebar3], [], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"}}
"redix": {:hex, :redix, "0.6.1", "20986b0e02f02b13e6f53c79a1ae70aa83147488c408f40275ec261f5bb0a6d0", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"},
"timex": {:hex, :timex, "3.1.15", "94abaec8fef2436ced4d0e1b4ed50c8eaa5fb9138fc0699946ddee7abf5aaff2", [:mix], [{:combine, "~> 0.7", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
"timex_ecto": {:hex, :timex_ecto, "3.0.5", "3ec6c25e10d2c0020958e5df64d2b5e690e441faa2c2259da8bc6bd3d7f39256", [:mix], [{:ecto, "~> 2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:timex, "~> 3.0", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"},
"tzdata": {:hex, :tzdata, "0.5.12", "1c17b68692c6ba5b6ab15db3d64cc8baa0f182043d5ae9d4b6d35d70af76f67b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}}

@ -0,0 +1,17 @@
{
"version": 5,
"width": 96,
"height": 26,
"duration": 11.146430015564,
"command": "/bin/bash",
"title": "bashing :)",
"env": {
"TERM": "screen-256color",
"SHELL": "/bin/zsh"
},
"stdout": [
[1.234567, "foo bar"],
[5.678987, "baz qux"],
[3.456789, "żółć jaźń"]
]
}

@ -0,0 +1,111 @@
defmodule Asciinema.AsciicastsTest do
use Asciinema.DataCase
alias Asciinema.{Asciicasts, Asciicast}
describe "create_asciicast/3" do
test "json file, v0 format with uname" do
user = fixture(:user)
params = %{"meta" => %{"command" => "/bin/bash",
"duration" => 11.146430015564,
"shell" => "/bin/zsh",
"terminal_columns" => 96,
"terminal_lines" => 26,
"terminal_type" => "screen-256color",
"title" => "bashing :)",
"uname" => "Linux 3.9.9-302.fc19.x86_64 #1 SMP Sat Jul 6 13:41:07 UTC 2013 x86_64"},
"stdout" => fixture(:upload, %{path: "0.9.7/stdout",
content_type: "application/octet-stream"}),
"stdout_timing" => fixture(:upload, %{path: "0.9.7/stdout.time",
content_type: "application/octet-stream"})}
{:ok, asciicast} = Asciicasts.create_asciicast(user, params, "a/user/agent")
assert %Asciicast{version: 0,
file: nil,
stdout_data: "stdout",
stdout_timing: "stdout.time",
command: "/bin/bash",
duration: 11.146430015564,
shell: "/bin/zsh",
terminal_type: "screen-256color",
terminal_columns: 96,
terminal_lines: 26,
title: "bashing :)",
uname: "Linux 3.9.9-302.fc19.x86_64 #1 SMP Sat Jul 6 13:41:07 UTC 2013 x86_64",
user_agent: nil} = asciicast
end
test "json file, v0 format without uname" do
user = fixture(:user)
params = %{"meta" => %{"command" => "/bin/bash",
"duration" => 11.146430015564,
"shell" => "/bin/zsh",
"terminal_columns" => 96,
"terminal_lines" => 26,
"terminal_type" => "screen-256color",
"title" => "bashing :)"},
"stdout" => fixture(:upload, %{path: "0.9.8/stdout",
content_type: "application/octet-stream"}),
"stdout_timing" => fixture(:upload, %{path: "0.9.8/stdout.time",
content_type: "application/octet-stream"})}
{:ok, asciicast} = Asciicasts.create_asciicast(user, params, "a/user/agent")
assert %Asciicast{version: 0,
file: nil,
stdout_data: "stdout",
stdout_timing: "stdout.time",
command: "/bin/bash",
duration: 11.146430015564,
shell: "/bin/zsh",
terminal_type: "screen-256color",
terminal_columns: 96,
terminal_lines: 26,
title: "bashing :)",
uname: nil,
user_agent: "a/user/agent"} = asciicast
end
test "json file, v1 format" do
user = fixture(:user)
upload = fixture(:upload, %{path: "1/asciicast.json"})
{:ok, asciicast} = Asciicasts.create_asciicast(user, upload, "a/user/agent")
assert %Asciicast{version: 1,
file: "asciicast.json",
stdout_data: nil,
stdout_timing: nil,
command: "/bin/bash",
duration: 11.146430015564,
shell: "/bin/zsh",
terminal_type: "screen-256color",
terminal_columns: 96,
terminal_lines: 26,
title: "bashing :)",
uname: nil,
user_agent: "a/user/agent"} = asciicast
end
test "json file, v1 format (missing required data)" do
user = fixture(:user)
upload = fixture(:upload, %{path: "1/invalid.json"})
assert {:error, %Ecto.Changeset{}} = Asciicasts.create_asciicast(user, upload)
end
test "json file, unsupported version number" do
user = fixture(:user)
upload = fixture(:upload, %{path: "5/asciicast.json"})
assert {:error, :unknown_format} = Asciicasts.create_asciicast(user, upload)
end
test "non-json file" do
user = fixture(:user)
upload = fixture(:upload, %{path: "new-logo-bars.png"})
assert {:error, :parse_error} = Asciicasts.create_asciicast(user, upload)
end
end
end

@ -0,0 +1,116 @@
defmodule Asciinema.Api.AsciicastControllerTest do
use Asciinema.ConnCase
alias Asciinema.Users
setup %{conn: conn} = context do
token = Map.get(context, :token, "9da34ff4-9bf7-45d4-aa88-98c933b15a3f")
conn = if token do
put_req_header(conn, "authorization", "Basic " <> Base.encode64("test:" <> token))
else
conn
end
{:ok, conn: conn, token: token}
end
@asciicast_url ~r|^http://localhost:4001/a/[a-zA-Z0-9]{25}|
describe ".create" do
@tag token: nil
test "separate files (pre-v1 params), v0.9.7 client", %{conn: conn} do
asciicast = %{"meta" => fixture(:upload, %{path: "0.9.7/meta.json",
content_type: "application/json"}),
"stdout" => fixture(:upload, %{path: "0.9.7/stdout",
content_type: "application/octet-stream"}),
"stdout_timing" => fixture(:upload, %{path: "0.9.7/stdout.time",
content_type: "application/octet-stream"})}
conn = post conn, api_asciicast_path(conn, :create), %{"asciicast" => asciicast}
assert text_response(conn, 201) =~ @asciicast_url
assert List.first(get_resp_header(conn, "location")) =~ @asciicast_url
end
@tag token: nil
test "separate files (pre-v1 params), v0.9.8 client", %{conn: conn} do
asciicast = %{"meta" => fixture(:upload, %{path: "0.9.8/meta.json",
content_type: "application/json"}),
"stdout" => fixture(:upload, %{path: "0.9.8/stdout",
content_type: "application/octet-stream"}),
"stdout_timing" => fixture(:upload, %{path: "0.9.8/stdout.time",
content_type: "application/octet-stream"})}
conn = post conn, api_asciicast_path(conn, :create), %{"asciicast" => asciicast}
assert text_response(conn, 201) =~ @asciicast_url
assert List.first(get_resp_header(conn, "location")) =~ @asciicast_url
end
test "separate files (pre-v1 params), v0.9.9 client", %{conn: conn} do
asciicast = %{"meta" => fixture(:upload, %{path: "0.9.9/meta.json",
content_type: "application/json"}),
"stdout" => fixture(:upload, %{path: "0.9.9/stdout",
content_type: "application/octet-stream"}),
"stdout_timing" => fixture(:upload, %{path: "0.9.9/stdout.time",
content_type: "application/octet-stream"})}
conn = post conn, api_asciicast_path(conn, :create), %{"asciicast" => asciicast}
assert text_response(conn, 201) =~ @asciicast_url
assert List.first(get_resp_header(conn, "location")) =~ @asciicast_url
end
test "json file, v1 format", %{conn: conn} do
upload = fixture(:upload, %{path: "1/asciicast.json"})
conn = post conn, api_asciicast_path(conn, :create), %{"asciicast" => upload}
assert text_response(conn, 201) =~ @asciicast_url
assert List.first(get_resp_header(conn, "location")) =~ @asciicast_url
end
test "json file, v1 format (missing required data)", %{conn: conn} do
upload = fixture(:upload, %{path: "1/invalid.json"})
conn = post conn, api_asciicast_path(conn, :create), %{"asciicast" => upload}
assert %{"errors" => _} = json_response(conn, 422)
end
test "json file, unsupported version number", %{conn: conn} do
upload = fixture(:upload, %{path: "5/asciicast.json"})
conn = post conn, api_asciicast_path(conn, :create), %{"asciicast" => upload}
assert text_response(conn, 415) =~ ~r|not supported|
end
test "non-json file", %{conn: conn} do
upload = fixture(:upload, %{path: "new-logo-bars.png"})
conn = post conn, api_asciicast_path(conn, :create), %{"asciicast" => upload}
assert text_response(conn, 400) =~ ~r|valid asciicast|
end
test "existing user (API token)", %{conn: conn, token: token} do
Users.create_user_with_api_token("test", token)
upload = fixture(:upload, %{path: "1/asciicast.json"})
conn = post conn, api_asciicast_path(conn, :create), %{"asciicast" => upload}
assert text_response(conn, 201) =~ @asciicast_url
assert List.first(get_resp_header(conn, "location")) =~ @asciicast_url
end
@tag token: nil
test "no authentication", %{conn: conn} do
upload = fixture(:upload, %{path: "1/asciicast.json"})
conn = post conn, api_asciicast_path(conn, :create), %{"asciicast" => upload}
assert response(conn, 401)
end
test "authentication with revoked token", %{conn: conn, token: token} do
Users.get_user_with_api_token("test", token) # force registration of the token
token |> Users.get_api_token! |> Users.revoke_api_token!
upload = fixture(:upload, %{path: "1/asciicast.json"})
conn = post conn, api_asciicast_path(conn, :create), %{"asciicast" => upload}
assert response(conn, 401)
end
@tag token: "invalid-lol"
test "authentication with invalid token", %{conn: conn} do
upload = fixture(:upload, %{path: "1/asciicast.json"})
conn = post conn, api_asciicast_path(conn, :create), %{"asciicast" => upload}
assert response(conn, 401)
end
end
end

@ -0,0 +1,55 @@
defmodule Asciinema.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,
it cannot be async. For this reason, every test runs
inside a transaction which is reset at the beginning
of the test unless the test case is marked as async.
"""
use ExUnit.CaseTemplate
using do
quote do
alias Asciinema.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import Asciinema.DataCase
import Asciinema.Fixtures
end
end
setup tags do
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Asciinema.Repo)
unless tags[:async] do
Ecto.Adapters.SQL.Sandbox.mode(Asciinema.Repo, {:shared, self()})
end
:ok
end
@doc """
A helper that transform changeset errors to 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} ->
Enum.reduce(opts, message, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end
end

@ -1,19 +1,23 @@
defmodule Asciinema.Fixtures do
alias Asciinema.{Repo, Asciicasts, User}
def fixture(:upload) do
%Plug.Upload{path: "resources/welcome.json",
filename: "welcome.json",
def fixture(what, attrs \\ %{})
def fixture(:upload, attrs) do
path = Map.get(attrs, :path) || "1/asciicast.json"
filename = Path.basename(path)
%Plug.Upload{path: "spec/fixtures/#{path}",
filename: filename,
content_type: "application/json"}
end
def fixture(:user) do
def fixture(:user, _attrs) do
attrs = %{username: "test",
auth_token: "authy-auth-auth"}
Repo.insert!(User.changeset(%User{}, attrs))
end
def fixture(:asciicast) do
def fixture(:asciicast, _attrs) do
user = fixture(:user)
upload = fixture(:upload)
{:ok, asciicast} = Asciicasts.create_asciicast(user, upload)

@ -0,0 +1,85 @@
defmodule Asciinema.Api.AsciicastController do
use Asciinema.Web, :controller
import Asciinema.Auth, only: [get_basic_auth: 1, put_basic_auth: 3]
alias Asciinema.{Asciicasts, Users, User}
plug :parse_v0_params
plug :authenticate
def create(conn, %{"asciicast" => %Plug.Upload{} = upload}) do
do_create(conn, upload)
end
def create(conn, %{"asciicast" => %{"meta" => %{},
"stdout" => %Plug.Upload{},
"stdout_timing" => %Plug.Upload{}} = asciicast_params}) do
do_create(conn, asciicast_params)
end
defp do_create(conn, params) do
user = conn.assigns.current_user
user_agent = conn |> get_req_header("user-agent") |> List.first
case Asciicasts.create_asciicast(user, params, user_agent) do
{:ok, asciicast} ->
url = asciicast_url(conn, :show, asciicast)
conn
|> put_status(:created)
|> put_resp_header("location", url)
|> text(url)
{:error, :parse_error} ->
conn
|> put_status(:bad_request)
|> text("This doesn't look like a valid asciicast file")
{:error, :unknown_format} ->
conn
|> put_status(:unsupported_media_type)
|> text("Format not supported")
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> render("error.json", changeset: changeset)
end
end
defp parse_v0_params(%Plug.Conn{params: %{"asciicast" => %{"meta" => %Plug.Upload{path: meta_path}}}} = conn, _) do
with {:ok, json} <- File.read(meta_path),
{:ok, attrs} <- Poison.decode(json),
{:ok, meta} <- extract_v0_attrs(attrs) do
conn
|> put_param(["asciicast", "meta"], meta)
|> put_basic_auth(attrs["username"], attrs["user_token"])
else
{:error, :invalid} ->
send_resp(conn, 400, "")
end
end
defp parse_v0_params(conn, _), do: conn
defp put_param(%Plug.Conn{params: params} = conn, path, value) do
params = put_in(params, path, value)
%{conn | params: params}
end
defp extract_v0_attrs(attrs) do
attrs = Map.merge(
Map.take(attrs, ["command", "duration", "shell", "title", "uname"]),
%{"terminal_columns" => get_in(attrs, ["term", "columns"]),
"terminal_lines" => get_in(attrs, ["term", "lines"]),
"terminal_type" => get_in(attrs, ["term", "type"])}
)
{:ok, attrs}
end
defp authenticate(conn, _opts) do
with {username, api_token} <- get_basic_auth(conn),
%User{} = user <- Users.get_user_with_api_token(username, api_token) do
assign(conn, :current_user, user)
else
_otherwise ->
conn
|> send_resp(401, "Invalid or revoked recorder token")
|> halt()
end
end
end

@ -1,9 +1,9 @@
defmodule Asciinema.AsciicastAnimationController do
use Asciinema.Web, :controller
alias Asciinema.{Repo, Asciicast}
alias Asciinema.Asciicasts
def show(conn, %{"id" => id}) do
asciicast = Repo.one!(Asciicast.by_id_or_secret_token(id))
asciicast = Asciicasts.get_asciicast!(id)
conn
|> put_layout("simple.html")

@ -1,9 +1,9 @@
defmodule Asciinema.AsciicastFileController do
use Asciinema.Web, :controller
alias Asciinema.{Repo, Asciicast}
alias Asciinema.{Asciicasts, Asciicast}
def show(conn, %{"id" => id} = params) do
asciicast = Repo.one!(Asciicast.by_id_or_secret_token(id))
asciicast = Asciicasts.get_asciicast!(id)
path = Asciicast.json_store_path(asciicast)
filename = download_filename(asciicast, params)

@ -1,12 +1,12 @@
defmodule Asciinema.AsciicastImageController do
use Asciinema.Web, :controller
alias Asciinema.{Repo, Asciicast, PngGenerator}
alias Asciinema.{Asciicasts, Asciicast, PngGenerator}
alias Plug.MIME
@max_age 604800 # 7 days
def show(conn, %{"id" => id} = _params) do
asciicast = Repo.one!(Asciicast.by_id_or_secret_token(id))
asciicast = Asciicasts.get_asciicast!(id)
user = Repo.preload(asciicast, :user).user
png_params = Asciicast.png_params(asciicast, user)

@ -0,0 +1,28 @@
defmodule Asciinema.ApiToken do
use Asciinema.Web, :model
alias Asciinema.{ApiToken, User}
schema "api_tokens" do
field :token, :string
field :revoked_at, Timex.Ecto.DateTime
timestamps(inserted_at: :created_at)
belongs_to :user, Asciinema.User
end
@uuid4 ~r/\A[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\z/
def create_changeset(%User{id: user_id}, token) do
%ApiToken{user_id: user_id}
|> change(%{token: token})
|> validate_format(:token, @uuid4)
end
def revoke_changeset(%ApiToken{revoked_at: nil} = api_token) do
change(api_token, %{revoked_at: Timex.now()})
end
def revoke_changeset(%ApiToken{} = api_token) do
change(api_token)
end
end

@ -11,6 +11,7 @@ defmodule Asciinema.Asciicast do
field :file, :string
field :terminal_columns, :integer
field :terminal_lines, :integer
field :terminal_type, :string
field :stdout_data, :string
field :stdout_timing, :string
field :stdout_frames, :string
@ -20,55 +21,62 @@ defmodule Asciinema.Asciicast do
field :title, :string
field :theme_name, :string
field :snapshot_at, :float
field :command, :string
field :shell, :string
field :uname, :string
field :user_agent, :string
timestamps(inserted_at: :created_at)
belongs_to :user, User
end
def changeset(struct, attrs \\ %{}) do
struct
|> cast(attrs, [:title])
defimpl Phoenix.Param, for: Asciicast do
def to_param(%Asciicast{private: false, id: id}) do
Integer.to_string(id)
end
def to_param(%Asciicast{private: true, secret_token: secret_token}) do
secret_token
end
end
def create_changeset(struct, attrs) do
struct
|> changeset(attrs)
|> cast(attrs, [:user_id, :version, :file, :duration, :terminal_columns, :terminal_lines])
|> cast(attrs, [:version, :file, :duration, :terminal_columns, :terminal_lines, :terminal_type, :command, :shell, :title, :uname])
|> validate_required([:user_id, :private, :version, :duration, :terminal_columns, :terminal_lines])
|> generate_secret_token
|> validate_required([:user_id, :version, :duration, :terminal_columns, :terminal_lines, :secret_token])
end
defp generate_secret_token(changeset) do
put_change(changeset, :secret_token, random_token(25))
def update_changeset(struct, attrs) do
struct
|> cast(attrs, [:title, :theme_name, :snapshot_at])
end
defp random_token(length) do
length
|> :crypto.strong_rand_bytes
|> Base.url_encode64
|> String.replace(~r/[_=-]/, "")
|> binary_part(0, length)
defp generate_secret_token(changeset) do
put_change(changeset, :secret_token, Crypto.random_token(25))
end
def by_id_or_secret_token(thing) do
if String.length(thing) == 25 do
from a in __MODULE__, where: a.secret_token == ^thing
else
case Integer.parse(thing) do
{id, ""} ->
from a in __MODULE__, where: a.private == false and a.id == ^id
:error ->
from a in __MODULE__, where: a.id == -1 # TODO fixme
end
end
def json_store_path(%Asciicast{file: v} = asciicast) when is_binary(v) do
file_store_path(asciicast, :file)
end
def json_store_path(%Asciicast{stdout_frames: v} = asciicast) when is_binary(v) do
file_store_path(asciicast, :stdout_frames)
end
def json_store_path(%__MODULE__{id: id, file: file}) when is_binary(file) do
"asciicast/file/#{id}/#{file}"
def file_store_path(%Asciicast{id: id, file: fname}, :file) do
file_store_path(:file, id, fname)
end
def file_store_path(%Asciicast{id: id, stdout_frames: fname}, :stdout_frames) do
file_store_path(:stdout_frames, id, fname)
end
def file_store_path(%Asciicast{id: id, stdout_data: fname}, :stdout_data) do
file_store_path(:stdout_data, id, fname)
end
def file_store_path(%Asciicast{id: id, stdout_timing: fname}, :stdout_timing) do
file_store_path(:stdout_timing, id, fname)
end
def json_store_path(%__MODULE__{id: id, stdout_frames: stdout_frames}) when is_binary(stdout_frames) do
"asciicast/stdout_frames/#{id}/#{stdout_frames}"
def file_store_path(type, id, fname) when is_binary(fname) do
"asciicast/#{type}/#{id}/#{fname}"
end
def snapshot_at(%Asciicast{snapshot_at: snapshot_at, duration: duration}) do

@ -1,5 +1,6 @@
defmodule Asciinema.User do
use Asciinema.Web, :model
alias Asciinema.User
schema "users" do
field :username, :string
@ -20,7 +21,17 @@ defmodule Asciinema.User do
"""
def changeset(struct, params \\ %{}) do
struct
|> cast(params, [:email, :name, :username, :temporary_username, :auth_token, :theme_name, :asciicasts_private_by_default])
|> cast(params, [:email, :name, :username, :auth_token, :theme_name, :asciicasts_private_by_default])
|> validate_required([:auth_token])
end
def temporary_changeset(temporary_username) do
%User{}
|> change(%{temporary_username: temporary_username})
|> generate_auth_token
end
defp generate_auth_token(changeset) do
put_change(changeset, :auth_token, Crypto.random_token(20))
end
end

@ -52,6 +52,10 @@ defmodule Asciinema.Router do
get "/docs/:topic", DocController, :show
end
scope "/api", Asciinema.Api, as: :api do
post "/asciicasts", AsciicastController, :create
end
# Other scopes may use custom stacks.
# scope "/api", Asciinema do
# pipe_through :api

@ -0,0 +1,11 @@
defmodule Asciinema.Api.AsciicastView do
use Asciinema.Web, :view
def translate_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
end
def render("error.json", %{changeset: changeset}) do
%{errors: translate_errors(changeset)}
end
end
Loading…
Cancel
Save