Page MenuHomePhorge

No OneTemporary

Size
23 KB
Referenced Files
None
Subscribers
None
diff --git a/README.md b/README.md
index 28ed902..7eced3d 100644
--- a/README.md
+++ b/README.md
@@ -1,181 +1,192 @@
# Majic
**Majic** provides a robust integration of [libmagic](http://man7.org/linux/man-pages/man3/libmagic.3.html) for Elixir.
With this library, you can start an one-off process to run a single check, or run the process as a daemon if you expect to run
many checks.
It is a friendly fork of [gen_magic](https://github.com/evadne/gen_magic) featuring a (arguably) more robust C-code
using erl_interface, built in pooling, unified/clean API, and an optional Plug.
This package is regulary tested on multiple platforms (Debian, macOS, Fedora, Alpine, FreeBSD) to ensure it'll work fine
in any environment.
## Installation
The package can be installed by adding `majic` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:majic, "~> 1.0"}
]
end
```
You must also have [libmagic](http://man7.org/linux/man-pages/man3/libmagic.3.html) installed locally with headers, alongside common compilation tools (i.e. build-essential). These can be acquired by apt-get, yum, brew, etc.
Compilation of the underlying C program is automatic and handled by [elixir_make](https://github.com/elixir-lang/elixir_make).
## Usage
Depending on the use case, you may utilise a single (one-off) Majic process without reusing it as a daemon, or utilise a connection pool (such as Poolboy) in your application to run multiple persistent Majic processes.
To use Majic directly, you can use `Majic.Once.perform/1`:
```elixir
iex(1)> Majic.perform(".", once: true)
{:ok,
%Majic.Result{
content: "directory",
encoding: "binary",
mime_type: "inode/directory"
}}
```
To use the Majic server as a daemon, you can start it first, keep a reference, then feed messages to it as you require:
```elixir
{:ok, pid} = Majic.Server.start_link([])
{:ok, result} = Majic.perform(path, server: pid)
```
See `Majic.Server.start_link/1` and `t:Majic.Server.option/0` for more information on startup parameters.
See `Majic.Result` for details on the result provided.
## Configuration
When using `Majic.Server.start_link/1` to start a persistent server, or `Majic.Helpers.perform_once/2` to run an ad-hoc request, you can override specific options to suit your use case.
| Name | Default | Description |
| - | - | - |
| `:startup_timeout` | 1000 | Number of milliseconds to wait for client startup |
| `:process_timeout` | 30000 | Number of milliseconds to process each request |
| `:recycle_threshold` | 10 | Number of cycles before the C process is replaced |
| `:database_patterns` | `[:default]` | Databases to load |
See `t:Majic.Server.option/0` for details.
__Note__ `:recycle_thresold` is only useful if you are using a libmagic `<5.29`, where it was susceptible to memleaks
([details](https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=840754)]). In future versions of `majic` this option could
be ignored.
### Reloading / Altering databases
If you want `majic` to reload its database(s), run `Majic.Server.reload(ref)`.
If you want to add or remove databases to a running server, you would have to run `Majic.Server.reload(ref, databases)`
where databases being the same argument as `database_patterns` on start. `Majic` does not support adding/removing
databases at runtime without a port reload.
### Use Cases
#### Ad-Hoc Requests
For ad-hoc requests, you can use the helper method `Majic.Once.perform_once/2`:
```elixir
iex(1)> Majic.perform(Path.join(File.cwd!(), "Makefile"), once: true)
{:ok,
%Majic.Result{
content: "makefile script, ASCII text",
encoding: "us-ascii",
mime_type: "text/x-makefile"
}}
```
#### Supervised Requests
The Server should be run under a supervisor which provides resiliency.
Here we run it under a supervisor in an application:
```elixir
children =
[
# ...
{Majic.Server, [name: YourApp.Majic]}
]
opts = [strategy: :one_for_one, name: YourApp.Supervisor]
Supervisor.start_link(children, opts)
```
Now we can ask it to inspect a file:
```elixir
iex(2)> Majic.perform(Path.expand("~/.bash_history"), server: YourApp.Majic)
{:ok, %Majic.Result{mime_type: "text/plain", encoding: "us-ascii", content: "ASCII text"}}
```
Note that in this case we have opted to use a named process.
#### Pool
For concurrency *and* resiliency, you may start the `Majic.Pool`. By default, it will start a `Majic.Server`
worker per online scheduler:
You can add a pool in your application supervisor by adding it as a child:
```elixir
children =
[
# ...
{Majic.Pool, [name: YourApp.MajicPool, pool_size: 2]}
]
opts = [strategy: :one_for_one, name: YourApp.Supervisor]
Supervisor.start_link(children, opts)
```
And then you can use it with `Majic.perform/2` with `pool: YourApp.MajicPool` option:
```elixir
iex(1)> Majic.perform(Path.expand("~/.bash_history"), pool: YourApp.MajicPool)
{:ok, %Majic.Result{mime_type: "text/plain", encoding: "us-ascii", content: "ASCII text"}}
```
+#### Fixing extensions
+
+You may also want to fix the user-provided filename according to its detected MIME type. To do this, you can use `Majic.Extension.fix/3`:
+
+```elixir
+iex(1)> {:ok, result} = Majic.perform("cat.jpeg", once: true)
+{:ok, %Majic.Result{mime_type: "image/webp", ...}}
+iex(1)> Majic.Extension.fix("cat.jpeg", result)
+"cat.webp"
+```
#### Use with Plug.Upload
If you use Plug or Phoenix, you may want to automatically verify the content type of every `Plug.Upload`. The
`Majic.Plug` is there for this.
Enable it by using `plug Majic.Plug, pool: YourApp.MajicPool` in your pipeline or controller. Then, every `Plug.Upload`
-in `conn.params` is now verified. The filename is also altered with an extension matching its content-type.
+in `conn.params` and `conn.body_params` is now verified. The filename is also altered with an extension matching its
+content-type, using `Majic.Extension`.
## Notes
### Soak Test
Run an endless cycle to prove that the program is resilient:
```bash
find /usr/share/ -name *png | xargs mix run test/soak.exs
find . -name *ex | xargs mix run test/soak.exs
```
## Acknowledgements
During design and prototype development of this library, the Author has drawn inspiration from the following individuals, and therefore
thanks all contributors for their generosity:
- [Evadne Wu](https://github.com/evadne)
- Original [gen_magic](https://github.com/evadne/gen_magic) author.
- [James Every](https://github.com/devstopfix)
- Enhanced Elixir Wrapper (based on GenServer)
- Initial Hex packaging (v.0.22)
- Soak Testing
- Matthias and Ced for helping the author with C oddities
- [Hecate](https://github.com/Kleidukos) for laughing at aforementionned oddities
- majic for giving inspiration for the lib name (magic, majic, get it? hahaha..)
diff --git a/lib/majic/extension.ex b/lib/majic/extension.ex
new file mode 100644
index 0000000..fd2f269
--- /dev/null
+++ b/lib/majic/extension.ex
@@ -0,0 +1,89 @@
+defmodule Majic.Extension do
+ @moduledoc """
+ Helper module to fix extensions. Uses [MIME](https://hexdocs.pm/mime/MIME.html).
+ """
+
+ @typedoc """
+ If an extension is defined for a given MIME type, append it to the previous extension.
+
+ If no extension could be found for the MIME type, and `subtype_as_extension: false`, the returned filename will have no extension.
+ """
+ @type option_append :: {:append, false | true}
+
+ @typedoc "If no extension is defined for a given MIME type, use the subtype as its extension."
+ @type option_subtype_as_extension :: {:subtype_as_extension, false | true}
+
+ @spec fix(Path.t(), Majic.Result.t() | String.t(), [
+ option_append() | option_subtype_as_extension()
+ ]) :: Path.t()
+ @doc """
+ Fix `name`'s extension according to `result_or_mime_type`.
+
+ ```elixir
+ iex(1)> {:ok, result} = Majic.perform("cat.jpeg", once: true)
+ {:ok, %Majic.Result{mime_type: "image/webp", ...}}
+ iex(1)> Majic.Extension.fix("cat.jpeg", result)
+ "cat.webp"
+ ```
+
+ The `append: true` option will append the correct extension to the user-provided one, if there's an extension for the
+ type:
+
+ ```
+ iex(1)> Majic.Extension.fix("cat.jpeg", result, append: true)
+ "cat.jpeg.webp"
+ iex(2)> Majic.Extension.fix("Makefile.txt", "text/x-makefile", append: true)
+ "Makefile"
+ ```
+
+ The `subtype_as_extension: true` option will use the subtype part of the MIME type as an extension for the ones that
+ don't have any:
+
+ ```elixir
+ iex(1)> Majic.Extension.fix("Makefile.txt", "text/x-makefile", subtype_as_extension: true)
+ "Makefile.x-makefile"
+ iex(1)> Majic.Extension.fix("Makefile.txt", "text/x-makefile", subtype_as_extension: true, append: true)
+ "Makefile.txt.x-makefile"
+ ```
+ """
+ def fix(name, result_or_mime_type, options \\ [])
+
+ def fix(name, %Majic.Result{mime_type: mime_type}, options) do
+ do_fix(name, mime_type, options)
+ end
+
+ def fix(name, mime_type, options) do
+ do_fix(name, mime_type, options)
+ end
+
+ defp do_fix(name, mime_type, options) do
+ append? = Keyword.get(options, :append, false)
+ subtype? = Keyword.get(options, :subtype_as_extension, false)
+ exts = MIME.extensions(mime_type) ++ subtype_extension(subtype?, mime_type)
+ old_ext = String.downcase(Path.extname(name))
+ basename = Path.basename(name, old_ext)
+ "." <> old = old_ext
+
+ if old in exts do
+ Enum.join([basename, old])
+ else
+ ext = List.first(exts)
+
+ ext_list =
+ cond do
+ ext && append? -> [old, ext]
+ !ext -> []
+ ext -> [ext]
+ end
+
+ Enum.join([basename] ++ ext_list, ".")
+ end
+ end
+
+ defp subtype_extension(true, type) do
+ [_type, sub] = String.split(type, "/", parts: 2)
+ [sub]
+ end
+
+ defp subtype_extension(_, _), do: []
+end
diff --git a/lib/majic/plug.ex b/lib/majic/plug.ex
index 311d20b..99d78cc 100644
--- a/lib/majic/plug.ex
+++ b/lib/majic/plug.ex
@@ -1,194 +1,142 @@
if Code.ensure_loaded?(Plug) do
defmodule Majic.PlugError do
defexception [:message]
end
defmodule Majic.Plug do
@moduledoc """
A `Plug` to automatically set the `content_type` of every `Plug.Upload`.
One of the required option of `pool`, `server` or `once` must be set.
Additional options:
- * `fix_extension`, default true: rewrite the user provided `filename` with a valid extension for the detected content type
- * `append_extension`, default false: append the valid extension to the previous filename, without removing the user provided extension
+ * `fix_extension`, default true: enable use of `Majic.Extension`,
+ * options for `Majic.Extension`.
- To use a gen_magic pool:
+ To use a majic pool:
```
plug Majic.Plug, pool: MyApp.MajicPool
```
- To use a single gen_magic server:
+ To use a single majic server:
```
plug Majic.Plug, server: MyApp.MajicServer
```
- To start a gen_magic process at each file (not recommended):
+ To start a majic process at each file (not recommended):
```
plug Majic.Plug, once: true
```
"""
@behaviour Plug
@impl Plug
def init(opts) do
cond do
Keyword.has_key?(opts, :pool) -> true
Keyword.has_key?(opts, :server) -> true
Keyword.has_key?(opts, :once) -> true
true -> raise(Majic.PlugError, "No server/pool/once option defined")
end
opts
|> Keyword.put_new(:fix_extension, true)
- |> Keyword.put_new(:append_extension, false)
+ |> Keyword.put_new(:append, false)
+ |> Keyword.put_new(:subtype_as_extension, false)
end
@impl Plug
def call(conn, opts) do
collected = collect_uploads([], conn.body_params, [])
Enum.reduce(collected, conn, fn {param_path, upload}, conn ->
{array_index, param_path} =
case param_path do
[index, :array | path] ->
{index, path}
path ->
{nil, path}
end
param_path = Enum.reverse(param_path)
upload =
case Majic.perform(upload.path, opts) do
{:ok, magic} -> fix_upload(upload, magic, opts)
{:error, error} -> raise(Majic.PlugError, "Failed to majic: #{inspect(error)}")
end
conn
|> put_in_if_exists(:params, param_path, upload, array_index)
|> put_in_if_exists(:body_params, param_path, upload, array_index)
end)
end
defp collect_uploads(path, params, acc) do
Enum.reduce(params, acc, fn value, acc -> collect_upload(path, value, acc) end)
end
# An upload!
defp collect_upload(path, {k, %{__struct__: Plug.Upload} = upload}, acc) do
[{[k | path], upload} | acc]
end
# Ignore structs.
defp collect_upload(_path, {_, %{__struct__: _}}, acc) do
acc
end
# Nested map.
defp collect_upload(path, {k, v}, acc) when is_map(v) do
collect_uploads([k | path], v, acc)
end
defp collect_upload(path, {k, v}, acc) when is_list(v) do
Enum.reduce(Enum.with_index(v), acc, fn {item, index}, acc ->
collect_upload([:array, k | path], {index, item}, acc)
end)
end
defp collect_upload(_path, _, acc) do
acc
end
defp fix_upload(upload, magic, opts) do
- %{upload | content_type: magic.mime_type}
- |> fix_extension(Keyword.get(opts, :fix_extension), opts)
- end
-
- defp fix_extension(upload, true, opts) do
- old_ext = String.downcase(Path.extname(upload.filename))
- extensions = MIME.extensions(upload.content_type)
- rewrite_extension(upload, old_ext, extensions, opts)
- end
-
- defp fix_extension(upload, _, _) do
- upload
- end
-
- defp rewrite_extension(upload, old, [ext | _] = exts, opts) do
- if old in exts do
- upload
- else
- basename = Path.basename(upload.filename, old)
-
- %{
- upload
- | filename:
- rewrite_or_append_extension(
- basename,
- old,
- ext,
- Keyword.get(opts, :append_extension)
- )
- }
- end
- end
-
- # No extension for type.
- defp rewrite_extension(upload, old, [], opts) do
- %{
- upload
- | filename:
- rewrite_or_append_extension(
- Path.basename(upload.filename, old),
- old,
- nil,
- Keyword.get(opts, :append_extension)
- )
- }
- end
-
- # Append, no extension for type: keep old extension
- defp rewrite_or_append_extension(basename, "." <> old, nil, true) do
- basename <> "." <> old
- end
-
- # No extension for type: only keep basename
- defp rewrite_or_append_extension(basename, _, nil, _) do
- basename
- end
-
- # Append
- defp rewrite_or_append_extension(basename, "." <> old, ext, true) do
- Enum.join([basename, old, ext], ".")
- end
-
- # Rewrite
- defp rewrite_or_append_extension(basename, _, ext, _) do
- basename <> "." <> ext
+ filename =
+ if Keyword.get(opts, :fix_extension) do
+ IO.puts("FIXING EXTENSION #{inspect opts}")
+ ext_opts = [
+ append: Keyword.get(opts, :append, false),
+ subtype_as_extension: Keyword.get(opts, :subtype_as_extension, false)
+ ]
+
+ Majic.Extension.fix(upload.filename, magic, ext_opts)
+ end
+
+ %{upload | content_type: magic.mime_type, filename: filename || upload.filename}
end
# put value at path in conn.
defp put_in_if_exists(conn, key, path, value, nil) do
if get_in(Map.get(conn, key), path) do
Map.put(conn, key, put_in(Map.get(conn, key), path, value))
else
conn
end
end
# change value at index in list at path in conn.
defp put_in_if_exists(conn, key, path, value, index) do
if array = get_in(Map.get(conn, key), path) do
array = List.replace_at(array, index, value)
Map.put(conn, key, put_in(Map.get(conn, key), path, array))
else
conn
end
end
end
end
diff --git a/mix.exs b/mix.exs
index 2e0a321..f516fd3 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,73 +1,73 @@
defmodule Majic.MixProject do
use Mix.Project
if :erlang.system_info(:otp_release) < '21' do
raise "Majic requires Erlang/OTP 21 or newer"
end
def project do
[
app: :majic,
version: "1.0.0",
elixir: "~> 1.7",
elixirc_paths: elixirc_paths(Mix.env()),
elixirc_options: [warnings_as_errors: warnings_as_errors(Mix.env())],
start_permanent: Mix.env() == :prod,
compilers: [:elixir_make] ++ Mix.compilers(),
package: package(),
deps: deps(),
dialyzer: dialyzer(),
name: "Majic",
description: "File introspection with libmagic",
source_url: "https://github.com/hrefhref/majic",
docs: docs()
]
end
def application do
[extra_applications: [:logger]]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
defp dialyzer do
[
plt_add_apps: [:mix, :iex, :ex_unit, :plug, :mime],
flags: ~w(error_handling no_opaque race_conditions underspecs unmatched_returns)a,
ignore_warnings: "dialyzer-ignore-warnings.exs",
list_unused_filters: true
]
end
defp deps do
[
{:nimble_pool, "~> 0.1"},
+ {:mime, "~> 1.0"},
{:plug, "~> 1.0", optional: true},
- {:mime, "~> 1.0", optional: true},
{:credo, "~> 1.4", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.0.0-rc.6", only: :dev, runtime: false},
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
{:elixir_make, "~> 0.4", runtime: false}
]
end
defp package do
[
files: ~w(lib/gen_magic/* src/*.c Makefile),
licenses: ["Apache 2.0"],
links: %{"GitHub" => "https://github.com/hrefhref/majic"},
source_url: "https://github.com/hrefhref/majic"
]
end
defp docs do
[
main: "readme",
extras: ["README.md", "CHANGELOG.md"]
]
end
defp warnings_as_errors(:dev), do: false
defp warnings_as_errors(_), do: true
end
diff --git a/test/majic/extension_test.exs b/test/majic/extension_test.exs
new file mode 100644
index 0000000..2e375a1
--- /dev/null
+++ b/test/majic/extension_test.exs
@@ -0,0 +1,33 @@
+defmodule Majic.ExtensionTest do
+ use ExUnit.Case
+
+ alias Majic.Extension
+
+ test "it fixes extensions" do
+ assert "Makefile" == Extension.fix("Makefile.txt", "text/x-makefile")
+ assert "cat.webp" == Extension.fix("cat.jpeg", "image/webp")
+ end
+
+ test "it appends extensions" do
+ assert "Makefile" == Extension.fix("Makefile.txt", "text/x-makefile", append: true)
+ assert "cat.jpeg.webp" == Extension.fix("cat.jpeg", "image/webp", append: true)
+ end
+
+ test "it uses subtype as extension" do
+ assert "Makefile.x-makefile" ==
+ Extension.fix("Makefile.txt", "text/x-makefile", subtype_as_extension: true)
+
+ assert "cat.webp" == Extension.fix("cat.jpeg", "image/webp", subtype_as_extension: true)
+ end
+
+ test "it appends and use subtype" do
+ assert "Makefile.txt.x-makefile" ==
+ Extension.fix("Makefile.txt", "text/x-makefile",
+ subtype_as_extension: true,
+ append: true
+ )
+
+ assert "cat.jpeg.webp" ==
+ Extension.fix("cat.jpeg", "image/webp", subtype_as_extension: true, append: true)
+ end
+end
diff --git a/test/majic/plug_test.exs b/test/majic/plug_test.exs
index 192cd3c..fed945e 100644
--- a/test/majic/plug_test.exs
+++ b/test/majic/plug_test.exs
@@ -1,105 +1,111 @@
defmodule Majic.PlugTest do
use ExUnit.Case, async: true
use Plug.Test
defmodule TestRouter do
use Plug.Router
plug(:match)
plug(:dispatch)
plug(Plug.Parsers,
parsers: [:urlencoded, :multipart],
pass: ["*/*"]
)
# plug Majic.Plug, once: true
post "/" do
send_resp(conn, 200, "Ok")
end
end
setup_all do
Application.ensure_all_started(:plug)
:ok
end
@router_opts TestRouter.init([])
test "convert uploads" do
multipart = """
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"form[makefile]\"; filename*=\"utf-8''mymakefile.txt\"\r
Content-Type: text/plain\r
\r
#{File.read!("Makefile")}\r
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"form[make][file]\"; filename*=\"utf-8''mymakefile.txt\"\r
Content-Type: text/plain\r
\r
#{File.read!("Makefile")}\r
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"cat\"; filename*=\"utf-8''cute-cat.jpg\"\r
Content-Type: image/jpg\r
\r
#{File.read!("test/fixtures/cat.webp")}\r
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"cats[]\"; filename*=\"utf-8''first-cute-cat.jpg\"\r
Content-Type: image/jpg\r
\r
#{File.read!("test/fixtures/cat.webp")}\r
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"cats[]\"\r
\r
hello i am annoying
\r
------w58EW1cEpjzydSCq\r
Content-Disposition: form-data; name=\"cats[]\"; filename*=\"utf-8''second-cute-cat.jpg\"\r
Content-Type: image/jpg\r
\r
#{File.read!("test/fixtures/cat.webp")}\r
+ ------w58EW1cEpjzydSCq\r
+ Content-Disposition: form-data; name=\"cats[][inception][cat]\"; filename*=\"utf-8''third-cute-cat.jpg\"\r
+ Content-Type: image/jpg\r
+ \r
+ #{File.read!("test/fixtures/cat.webp")}\r
------w58EW1cEpjzydSCq--\r
"""
orig_conn =
conn(:post, "/", multipart)
|> put_req_header("content-type", "multipart/mixed; boundary=----w58EW1cEpjzydSCq")
|> TestRouter.call(@router_opts)
plug = Majic.Plug.init(once: true)
plug_no_ext = Majic.Plug.init(once: true, fix_extension: false)
- plug_append_ext = Majic.Plug.init(once: true, fix_extension: true, append_extension: true)
+ plug_append_ext = Majic.Plug.init(once: true, fix_extension: true, append: true)
conn = Majic.Plug.call(orig_conn, plug)
conn_no_ext = Majic.Plug.call(orig_conn, plug_no_ext)
conn_append_ext = Majic.Plug.call(orig_conn, plug_append_ext)
assert conn.state == :sent
assert conn.status == 200
assert get_in(conn.body_params, ["form", "makefile"]) ==
get_in(conn.params, ["form", "makefile"])
assert get_in(conn.params, ["form", "makefile"]).content_type == "text/x-makefile"
assert get_in(conn.params, ["form", "makefile"]).filename == "mymakefile"
assert get_in(conn_no_ext.params, ["form", "makefile"]).filename == "mymakefile.txt"
- assert get_in(conn_append_ext.params, ["form", "makefile"]).filename == "mymakefile.txt"
+ assert get_in(conn_append_ext.params, ["form", "makefile"]).filename == "mymakefile"
assert get_in(conn.body_params, ["form", "make", "file"]) ==
get_in(conn.params, ["form", "make", "file"])
assert get_in(conn.params, ["form", "make", "file"]).content_type == "text/x-makefile"
assert get_in(conn.body_params, ["cat"]) == get_in(conn.params, ["cat"])
assert get_in(conn.params, ["cat"]).content_type == "image/webp"
assert get_in(conn.params, ["cat"]).filename == "cute-cat.webp"
assert get_in(conn_no_ext.params, ["cat"]).filename == "cute-cat.jpg"
assert get_in(conn_append_ext.params, ["cat"]).filename == "cute-cat.jpg.webp"
assert Enum.all?(conn.params["cats"], fn
- %Plug.Upload{} = upload -> upload.content_type == "image/webp"
- _ -> true
- end)
+ %Plug.Upload{} = upload -> upload.content_type == "image/webp"
+ %{"inception" => %{"cat" => upload}} -> upload.content_type == "image/webp"
+ _ -> true
+ end)
end
end

File Metadata

Mime Type
text/x-diff
Expires
Mon, Nov 25, 12:42 AM (1 d, 6 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
39524
Default Alt Text
(23 KB)

Event Timeline