├── .formatter.exs
├── .gitignore
├── Dockerfile
├── Dockerfile.dev
├── README.md
├── assets
├── .babelrc
├── css
│ └── app.css
├── js
│ ├── app.js
│ ├── socket.js
│ └── term
│ │ ├── pane.js
│ │ ├── session.js
│ │ ├── term.css
│ │ ├── window.js
│ │ └── xterm_constants.ts
├── package-lock.json
├── package.json
├── static
│ ├── css
│ │ ├── bootstrap.min.css
│ │ └── main.css
│ ├── img
│ │ ├── 1_step.png
│ │ ├── 2_step.png
│ │ ├── 3_step.png
│ │ ├── arch.svg
│ │ ├── digitalocean.png
│ │ ├── fork-me-on-github-right-orange@2x.png
│ │ ├── github-btn.html
│ │ ├── glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2
│ │ ├── glyphicons-halflings-regular.89889688147bd7575d6327160d64e760.svg
│ │ ├── glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf
│ │ ├── glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot
│ │ ├── glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff
│ │ ├── icon.png
│ │ ├── logo.png
│ │ ├── mac_notr.png
│ │ ├── mac_tr.png
│ │ ├── rebel.png
│ │ ├── tmate.svg
│ │ ├── video_linux.png
│ │ ├── video_linux_first_frame.png
│ │ ├── video_mac.png
│ │ ├── video_macos.png
│ │ └── video_macos_first_frame.png
│ └── js
│ │ ├── bootstrap.min.js
│ │ ├── index.js
│ │ ├── jquery-2.0.2.min.js
│ │ └── main.js
└── webpack.config.js
├── config
├── config.exs
├── dev.exs
├── prod.exs
└── test.exs
├── lib
├── tmate.ex
├── tmate
│ ├── application.ex
│ ├── event_projections.ex
│ ├── event_projections
│ │ ├── session.ex
│ │ └── user.ex
│ ├── mailer.ex
│ ├── models
│ │ ├── client.ex
│ │ ├── event.ex
│ │ ├── session.ex
│ │ └── user.ex
│ ├── monitoring.ex
│ ├── monitoring_collector.ex
│ ├── repo.ex
│ ├── scheduler.ex
│ ├── session_cleaner.ex
│ ├── util
│ │ ├── ecto_helpers.ex
│ │ ├── json_api.ex
│ │ ├── plug_remote_ip.ex
│ │ └── plug_verify_auth_token.ex
│ └── ws_api.ex
├── tmate_web.ex
└── tmate_web
│ ├── channels
│ └── user_socket.ex
│ ├── controllers
│ ├── internal_api_controller.ex
│ ├── sign_up_controller.ex
│ └── terminal_controller.ex
│ ├── emails
│ └── email.ex
│ ├── endpoint.ex
│ ├── gettext.ex
│ ├── router.ex
│ ├── templates
│ ├── email
│ │ ├── api_key.html.eex
│ │ └── api_key.text.eex
│ ├── layout
│ │ ├── app.html.eex
│ │ └── email.html.eex
│ ├── sign_up
│ │ └── home.html.eex
│ └── terminal
│ │ └── show.html.eex
│ └── views
│ ├── email_view.ex
│ ├── error_helpers.ex
│ ├── error_view.ex
│ ├── layout_view.ex
│ ├── sign_up_view.ex
│ └── terminal_view.ex
├── mix.exs
├── mix.lock
├── priv
├── gettext
│ ├── en
│ │ └── LC_MESSAGES
│ │ │ └── errors.po
│ └── errors.pot
├── repo
│ ├── console.exs
│ ├── migrations
│ │ ├── .formatter.exs
│ │ ├── 20151010162127_initial.exs
│ │ ├── 20151221142603_key_size.exs
│ │ ├── 20160121023039_add_identity_metadata.exs
│ │ ├── 20160123063003_add_connection_fmt.exs
│ │ ├── 20160304084101_add_client_stats.exs
│ │ ├── 20160328175128_client_id_uuid.exs
│ │ ├── 20160406210826_github_users.exs
│ │ ├── 20190904041603_add_disconnect_at.exs
│ │ ├── 20191005234200_add_generation.exs
│ │ ├── 20191014044039_add_closed_at.exs
│ │ ├── 20191108161753_remove_identity_one.exs
│ │ ├── 20191108174232_remove_identity_three.exs
│ │ ├── 20191110232601_remove_github_id.exs
│ │ ├── 20191110232704_expand_token_size.exs
│ │ ├── 20191111025821_add_api_key.exs
│ │ └── 20200725202312_add_generation_session.exs
│ ├── seeds.exs
│ └── structure.sql
└── static
│ ├── css
│ ├── bootstrap.min.css
│ └── main.css
│ ├── img
│ ├── 1_step.png
│ ├── 2_step.png
│ ├── 3_step.png
│ ├── arch.svg
│ ├── digitalocean.png
│ ├── fork-me-on-github-right-orange@2x.png
│ ├── github-btn.html
│ ├── glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2
│ ├── glyphicons-halflings-regular.89889688147bd7575d6327160d64e760.svg
│ ├── glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf
│ ├── glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot
│ ├── glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff
│ ├── mac_notr.png
│ ├── mac_tr.png
│ ├── rebel.png
│ ├── video_linux.png
│ ├── video_linux_first_frame.png
│ ├── video_mac.png
│ ├── video_macos.png
│ └── video_macos_first_frame.png
│ └── js
│ ├── bootstrap.min.js
│ ├── index.js
│ ├── jquery-2.0.2.min.js
│ └── main.js
├── rel
├── config.exs
├── plugins
│ └── .gitignore
└── vm.args
└── test
├── support
├── channel_case.ex
├── conn_case.ex
├── data_case.ex
├── event_case.ex
└── factory.ex
├── test_helper.exs
└── tmate_web
├── controllers
├── internal_api_controller_test.exs
├── page_controller_test.exs
└── sign_up_controller_test.exs
├── event_projections
└── session_test.exs
└── views
├── error_view_test.exs
├── layout_view_test.exs
└── page_view_test.exs
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto, :phoenix],
3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
4 | subdirectories: ["priv/*/migrations"]
5 | ]
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | tmate-*.tar
24 |
25 | # If NPM crashes, it generates a log, let's ignore it too.
26 | npm-debug.log
27 |
28 | # The directory NPM downloads your dependencies sources to.
29 | /assets/node_modules/
30 |
31 | # Since we are building assets from assets/,
32 | # we ignore priv/static. You may want to comment
33 | # this depending on your deployment strategy.
34 | /priv/static/
35 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM elixir:1.9-alpine AS build
2 |
3 | RUN mix local.hex --force && mix local.rebar --force
4 | RUN apk --no-cache add git npm
5 |
6 | WORKDIR /build
7 |
8 | COPY mix.exs .
9 | COPY mix.lock .
10 |
11 | ENV MIX_ENV prod
12 |
13 | RUN mix deps.get
14 | RUN mix deps.compile
15 |
16 | COPY assets/package-lock.json assets/package-lock.json
17 | COPY assets/package.json assets/package.json
18 | RUN cd assets && npm install
19 |
20 | COPY assets assets
21 | RUN cd assets && npm run deploy
22 |
23 | COPY lib lib
24 | COPY test test
25 | COPY config config
26 | COPY priv/gettext priv/gettext
27 | COPY priv/repo priv/repo
28 | COPY rel rel
29 |
30 | ENV HOSTNAME "master-0"
31 | RUN mix do phx.digest, distillery.release --no-tar && \
32 | mkdir _build/lib-layer && \
33 | mv _build/prod/rel/tmate/lib/tmate* _build/lib-layer
34 |
35 | ### Minimal run-time image
36 | FROM alpine:3.9
37 |
38 | RUN apk --no-cache add ncurses-libs openssl ca-certificates bash
39 |
40 | RUN adduser -D app
41 |
42 | ENV MIX_ENV prod
43 |
44 | WORKDIR /opt/app
45 |
46 | # Copy release from build stage
47 | # We copy in two passes to benefit from docker layers
48 | # Note "COPY some_dir dst" will copy the content of some_dir into dst
49 | COPY --from=build /build/_build/prod/rel/* .
50 | COPY --from=build /build/_build/lib-layer lib/
51 |
52 | USER app
53 |
54 | RUN mkdir /tmp/app
55 | ENV RELEASE_MUTABLE_DIR /tmp/app
56 | ENV REPLACE_OS_VARS true
57 |
58 | # Start command
59 | CMD ["/opt/app/bin/tmate", "foreground"]
60 |
--------------------------------------------------------------------------------
/Dockerfile.dev:
--------------------------------------------------------------------------------
1 | FROM elixir:1.9-alpine
2 |
3 | RUN mix local.hex --force && mix local.rebar --force
4 | RUN apk --no-cache add git inotify-tools npm
5 |
6 | WORKDIR /src/tmate-master
7 |
8 | COPY mix.exs .
9 | COPY mix.lock .
10 |
11 | ARG MIX_ENV=dev
12 | ENV MIX_ENV ${MIX_ENV}
13 |
14 | RUN mix deps.get
15 | RUN mix deps.compile
16 |
17 | COPY assets/package-lock.json assets/package-lock.json
18 | COPY assets/package.json assets/package.json
19 | RUN cd assets && npm install
20 |
21 | COPY assets assets
22 | COPY config config
23 | COPY lib lib
24 | COPY priv/gettext priv/gettext
25 | COPY priv/repo priv/repo
26 |
27 | RUN mix compile
28 |
29 | CMD mkfifo console; sleep 1000d > console & cat console | \
30 | iex --name master@master \
31 | --cookie cookie \
32 | --erl '-kernel inet_dist_listen_min 20000' \
33 | --erl '-kernel inet_dist_listen_max 20000' \
34 | -S mix phx.server
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Tmate
2 |
3 | To start your Phoenix server:
4 |
5 | * Install dependencies with `mix deps.get`
6 | * Create and migrate your database with `mix ecto.setup`
7 | * Install Node.js dependencies with `cd assets && npm install`
8 | * Start Phoenix endpoint with `mix phx.server`
9 |
10 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
11 |
12 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
13 |
14 | ## Learn more
15 |
16 | * Official website: http://www.phoenixframework.org/
17 | * Guides: https://hexdocs.pm/phoenix/overview.html
18 | * Docs: https://hexdocs.pm/phoenix
19 | * Mailing list: http://groups.google.com/group/phoenix-talk
20 | * Source: https://github.com/phoenixframework/phoenix
21 |
--------------------------------------------------------------------------------
/assets/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env",
4 | "@babel/preset-react",
5 | "@babel/typescript"
6 | ],
7 | "plugins": [
8 | "@babel/plugin-proposal-class-properties",
9 | "@babel/plugin-proposal-object-rest-spread"
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | /* This file is for your main application css. */
2 |
3 | /* @import "./phoenix.css"; */
4 |
5 | /* All the css is in the assets/static/css folder */
6 |
--------------------------------------------------------------------------------
/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // We need to import the CSS so that webpack will load it.
2 | // The MiniCssExtractPlugin is used to separate it out into
3 | // its own CSS file.
4 | //import css from "../css/app.css"
5 |
6 | // webpack automatically bundles all modules in your
7 | // entry points. Those entry points can be configured
8 | // in "webpack.config.js".
9 | //
10 | // Import dependencies
11 | //
12 | //import "phoenix_html"
13 |
14 | // Import local files
15 | //
16 | // Local files can be imported directly using relative paths, for example:
17 | // import socket from "./socket"
18 |
19 | import Session from './term/session'
20 |
21 | import ReactDOM from 'react-dom';
22 | import React from 'react';
23 |
24 | function initTerminal(rootElement, token)
25 | {
26 | ReactDOM.render( , rootElement);
27 | }
28 |
29 | window.initTerminal = initTerminal;
30 |
--------------------------------------------------------------------------------
/assets/js/socket.js:
--------------------------------------------------------------------------------
1 | // NOTE: The contents of this file will only be executed if
2 | // you uncomment its entry in "assets/js/app.js".
3 |
4 | // To use Phoenix channels, the first step is to import Socket,
5 | // and connect at the socket path in "lib/web/endpoint.ex".
6 | //
7 | // Pass the token on params as below. Or remove it
8 | // from the params if you are not using authentication.
9 | import {Socket} from "phoenix"
10 |
11 | let socket = new Socket("/socket", {params: {token: window.userToken}})
12 |
13 | // When you connect, you'll often need to authenticate the client.
14 | // For example, imagine you have an authentication plug, `MyAuth`,
15 | // which authenticates the session and assigns a `:current_user`.
16 | // If the current user exists you can assign the user's token in
17 | // the connection for use in the layout.
18 | //
19 | // In your "lib/web/router.ex":
20 | //
21 | // pipeline :browser do
22 | // ...
23 | // plug MyAuth
24 | // plug :put_user_token
25 | // end
26 | //
27 | // defp put_user_token(conn, _) do
28 | // if current_user = conn.assigns[:current_user] do
29 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id)
30 | // assign(conn, :user_token, token)
31 | // else
32 | // conn
33 | // end
34 | // end
35 | //
36 | // Now you need to pass this token to JavaScript. You can do so
37 | // inside a script tag in "lib/web/templates/layout/app.html.eex":
38 | //
39 | //
40 | //
41 | // You will need to verify the user token in the "connect/3" function
42 | // in "lib/web/channels/user_socket.ex":
43 | //
44 | // def connect(%{"token" => token}, socket, _connect_info) do
45 | // # max_age: 1209600 is equivalent to two weeks in seconds
46 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
47 | // {:ok, user_id} ->
48 | // {:ok, assign(socket, :user, user_id)}
49 | // {:error, reason} ->
50 | // :error
51 | // end
52 | // end
53 | //
54 | // Finally, connect to the socket:
55 | socket.connect()
56 |
57 | // Now that you are connected, you can join channels with a topic:
58 | let channel = socket.channel("topic:subtopic", {})
59 | channel.join()
60 | .receive("ok", resp => { console.log("Joined successfully", resp) })
61 | .receive("error", resp => { console.log("Unable to join", resp) })
62 |
63 | export default socket
64 |
--------------------------------------------------------------------------------
/assets/js/term/pane.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import ReactDOM from 'react-dom';
3 | import { Terminal } from 'xterm';
4 | import 'xterm/css/xterm.css';
5 | import { Attributes, FgFlags, BgFlags } from "./xterm_constants.ts";
6 |
7 | /* tmux grid attrs */
8 | const GRID_ATTR_BRIGHT = 0x1
9 | const GRID_ATTR_DIM = 0x2
10 | const GRID_ATTR_UNDERSCORE = 0x4
11 | const GRID_ATTR_BLINK = 0x8
12 | const GRID_ATTR_REVERSE = 0x10
13 | const GRID_ATTR_HIDDEN = 0x20
14 | const GRID_ATTR_ITALICS = 0x40
15 | const GRID_ATTR_CHARSET = 0x80
16 |
17 | /* tmux modes */
18 | const MODE_CURSOR = 0x1
19 | const MODE_INSERT = 0x2
20 | const MODE_KCURSOR = 0x4
21 | const MODE_KKEYPAD = 0x8 /* set = application, clear = number */
22 | const MODE_WRAP = 0x10 /* whether lines wrap */
23 | const MODE_MOUSE_STANDARD = 0x20
24 | const MODE_MOUSE_BUTTON = 0x40
25 | const MODE_MOUSE_ANY = 0x80
26 | const MODE_MOUSE_UTF8 = 0x100
27 | const MODE_MOUSE_SGR = 0x200
28 | const MODE_BRACKETPASTE = 0x400
29 | const MODE_FOCUSON = 0x800
30 | const ALL_MOUSE_MODES = (MODE_MOUSE_STANDARD|MODE_MOUSE_BUTTON|MODE_MOUSE_ANY)
31 |
32 | export default class Pane extends React.Component {
33 | componentDidMount() {
34 | if (this.term === undefined) {
35 | let term = new Terminal({
36 | // screenKeys: true,
37 | cursorBlink: false,
38 | rows: this.props.rows,
39 | cols: this.props.cols,
40 | disableStdin: false, /* set with readonly mode */
41 | fontFamily: "DejaVu Sans Mono, Liberation Mono, monospace",
42 | fontSize: 12,
43 | // lineHeight: 14/12,
44 | macOptionIsMeta: true,
45 | LogLevel: 'debug'
46 | // useFocus: false,
47 | // tmate_pane: this
48 | });
49 |
50 |
51 | /* TODO onBinary */
52 | term.onData(data => {
53 | this.props.session.send_pty_keys(this.props.id, data)
54 | })
55 |
56 | window.term = term
57 | // term.debug = true
58 | // term.on('error', msg => console.log(`error: ${msg}`))
59 |
60 | term.open(ReactDOM.findDOMNode(this))
61 |
62 | const dims = term._core._renderService.dimensions;
63 | this.props.session.set_char_size(dims.actualCellWidth, dims.actualCellHeight);
64 |
65 | if (this.props.active)
66 | term.focus()
67 |
68 | this.term = term
69 |
70 | this.props.session.get_pane_event_buffer(this.props.id).set_handler({
71 | on_pty_data: this.on_pty_data.bind(this),
72 | on_bootstrap_grid: this.on_bootstrap_grid.bind(this),
73 | on_sync_copy_mode: this.on_sync_copy_mode.bind(this),
74 | })
75 | }
76 | }
77 |
78 | render() {
79 | return
80 | }
81 |
82 | on_focus() {
83 | if (this.props.window.props.active_pane_id !== this.props.id)
84 | this.props.session.focus_pane(this.props.id)
85 | }
86 |
87 | componentDidUpdate() {
88 | this.term.resize(this.props.cols, this.props.rows)
89 |
90 | if (this.props.active)
91 | this.term.focus()
92 |
93 | this.term._core.cursorHidden = !this.props.active;
94 | this.term.refresh(this.term.buffer.cursorY, this.term.buffer.cursorY);
95 | }
96 |
97 | componentWillUnmount() {
98 | this.props.session.on_umount_pane(this.props.id)
99 | if (this.term) {
100 | this.term.dispose()
101 | this.term = undefined
102 | }
103 | }
104 |
105 | on_bootstrap_grid(...args) {
106 | bootstrap_grid(this.term, ...args)
107 | }
108 |
109 | on_pty_data(data) {
110 | this.term.write(data)
111 | }
112 |
113 | on_sync_copy_mode(data) {
114 | const term = this.term
115 | const select = term._core._selectionService;
116 | const top_line_offset = term.buffer._buffer.lines.length - term.rows;
117 |
118 | if (data && data.length == 0) {
119 | term._core.buffer.ydisp = top_line_offset
120 | select.clearSelection();
121 | term.refresh(0, term.rows-1);
122 | return;
123 | }
124 |
125 | let [backing, oy, cx, cy, sel, status] = data;
126 |
127 | if (sel && sel.length > 0) {
128 | let [selx, sely, flags] = sel
129 | sely = term.rows - sely
130 | select._model.selectionStart = [cx+1, top_line_offset + cy-oy]
131 | select._model.selectionEnd = [selx, top_line_offset + sely-1]
132 | select.refresh();
133 | }
134 |
135 | let new_ydisp = Math.max(top_line_offset - oy, 0)
136 | if (term._core.buffer.ydisp != new_ydisp) {
137 | term._core.buffer.ydisp = new_ydisp;
138 | term.refresh(0, term.rows-1);
139 | }
140 | }
141 | }
142 |
143 | const bootstrap_grid = (term, cursor_pos, mode, grid_data) => {
144 | const term_attr = packed_attrs => {
145 | let fg = packed_attrs & 0xFF
146 | let bg = (packed_attrs >> 8) & 0xFF
147 | let attr = (packed_attrs >> 16) & 0xFF
148 | let flags = (packed_attrs >> 24) & 0xFF
149 |
150 | if (fg != 8)
151 | fg |= Attributes.CM_P256
152 | if (bg != 8)
153 | bg |= Attributes.CM_P256
154 |
155 | if (attr & GRID_ATTR_BRIGHT) fg |= FgFlags.BOLD
156 | if (attr & GRID_ATTR_UNDERSCORE) fg |= FgFlags.UNDERLINE
157 | if (attr & GRID_ATTR_BLINK) fg |= FgFlags.BLINK
158 | if (attr & GRID_ATTR_REVERSE) fg |= FgFlags.INVERSE
159 | if (attr & GRID_ATTR_HIDDEN) fg |= FgFlags.INVISIBLE
160 | if (attr & GRID_ATTR_DIM) bg |= BgFlags.DIM
161 | if (attr & GRID_ATTR_ITALICS) bg |= BgFlags.ITALIC
162 |
163 | return [fg, bg]
164 | }
165 |
166 | const [cursor_x, cursor_y] = cursor_pos
167 | const [cols, rows] = [term.rows, term.cols]
168 | const buffer = term.buffer._buffer;
169 |
170 | /*
171 | * This gets us an empty line. We should call
172 | * getBlankLine(DEFAULT_ATTR_DATA), but we don't have access
173 | * to the constant.
174 | */
175 | buffer.clear();
176 | buffer.fillViewportRows();
177 | const emptyLine = buffer.lines.get(0).clone();
178 |
179 | grid_data.forEach((line_data, i) => {
180 | const [chars, attrs] = line_data
181 |
182 | let line = emptyLine.clone();
183 |
184 | for (let j = 0; j < chars.length; j++) {
185 | let c = chars.charCodeAt(j)
186 | const width = 1; /* TODO */
187 | const [fg, bg] = term_attr(attrs[j])
188 | line.setCellFromCodePoint(j, c, width, fg, bg)
189 | }
190 |
191 | buffer.lines.push(line)
192 | });
193 |
194 | buffer.ydisp = grid_data.length
195 | buffer.ybase = buffer.ydisp
196 |
197 | buffer.x = cursor_x
198 | buffer.y = cursor_y
199 |
200 | const core = term._core
201 | // if (mode & MODE_CURSOR)
202 | // core.applicationCursor = true
203 | core.wraparoundMode = !!(mode & MODE_WRAP)
204 | if (mode & ALL_MOUSE_MODES)
205 | core._coreMouseService.activeProtocol = "VT200"
206 | core.sendFocus = !!(mode & MODE_FOCUSON)
207 | // if (mode & MODE_MOUSE_UTF8)
208 | // core.utfMouse = true
209 | // if (mode & MODE_MOUSE_SGR)
210 | // core.sgrMouse = true
211 | core.cursorHidden = !(mode & MODE_CURSOR);
212 |
213 | term.refresh(0, term.rows - 1);
214 | }
215 |
--------------------------------------------------------------------------------
/assets/js/term/session.js:
--------------------------------------------------------------------------------
1 | import "./term.css"
2 |
3 | import React from "react"
4 | import ReactDOM from 'react-dom';
5 | import $ from "jquery"
6 | import msgpack from "msgpack-js-browser"
7 |
8 | import Window from "./window"
9 |
10 | const TMATE_WS_DAEMON_OUT_MSG = 0
11 | const TMATE_WS_SNAPSHOT = 1
12 |
13 | const TMATE_WS_PANE_KEYS = 0
14 | const TMATE_WS_EXEC_CMD = 1
15 | const TMATE_WS_RESIZE = 2
16 |
17 | const TMATE_OUT_HEADER = 0
18 | const TMATE_OUT_SYNC_LAYOUT = 1
19 | const TMATE_OUT_PTY_DATA = 2
20 | const TMATE_OUT_EXEC_CMD_STR = 3
21 | const TMATE_OUT_FAILED_CMD = 4
22 | const TMATE_OUT_STATUS = 5
23 | const TMATE_OUT_SYNC_COPY_MODE = 6
24 | const TMATE_OUT_WRITE_COPY_MODE = 7
25 | const TMATE_OUT_FIN = 8
26 | const TMATE_OUT_READY = 9
27 | const TMATE_OUT_RECONNECT = 10
28 | const TMATE_OUT_SNAPSHOT = 11
29 | const TMATE_OUT_EXEC_CMD = 12
30 |
31 | const WS_CONNECTING = 0
32 | const WS_BOOTSTRAPPING = 1
33 | const WS_OPEN = 2
34 | const WS_CLOSED = 3
35 | const WS_RECONNECT = 4
36 |
37 | const WS_ERRORS = {
38 | 1005: "Session closed",
39 | 1006: "Connection failed",
40 | }
41 |
42 | class EventBuffer {
43 | constructor() {
44 | this.pending_events = []
45 | this.handlers = null
46 | }
47 |
48 | send(f, args) {
49 | if (this.handlers)
50 | this.handlers[f].apply(null, args)
51 | else
52 | this.pending_events.push([f, args])
53 | }
54 |
55 | set_handler(handlers) {
56 | this.handlers = handlers
57 | this.pending_events.forEach(([f, args]) => this.send(f, args))
58 | this.pending_events = null
59 | }
60 | }
61 |
62 | export default class Session extends React.Component {
63 | state = { ws_state: WS_CONNECTING }
64 |
65 | constructor() {
66 | super()
67 | this.char_size = {width: 7, height: 14};
68 | /* From the padding in term.css : .terminal */
69 | this.terminal_padding_size = {width: 1*2, height: 4*2};
70 | }
71 |
72 | componentDidMount() {
73 | this.connect()
74 |
75 | this.ws_handlers = new Map()
76 | this.ws_handlers.set(TMATE_WS_DAEMON_OUT_MSG, this.on_ws_daemon_out_msg)
77 | this.ws_handlers.set(TMATE_WS_SNAPSHOT, this.on_ws_snapshot)
78 |
79 | this.daemon_handlers = new Map()
80 | this.daemon_handlers.set(TMATE_OUT_SYNC_LAYOUT, this.on_sync_layout)
81 | this.daemon_handlers.set(TMATE_OUT_PTY_DATA, this.on_pty_data)
82 | this.daemon_handlers.set(TMATE_OUT_STATUS, this.on_status)
83 | this.daemon_handlers.set(TMATE_OUT_FIN, this.on_fin)
84 | this.daemon_handlers.set(TMATE_OUT_SYNC_COPY_MODE, this.on_sync_copy_mode)
85 |
86 | this.pane_events = new Map()
87 |
88 | this.handleResize = this.handleResize.bind(this);
89 | window.addEventListener('resize', this.handleResize);
90 | }
91 |
92 | componentWillUnmount() {
93 | window.removeEventListener('resize', this.handleResize);
94 | this.disconnect()
95 | }
96 |
97 | set_char_size(width, height) {
98 | if (width && height && width > 0 && height > 0) {
99 | const old_size = this.char_size;
100 | this.char_size = {width, height};
101 | if (old_size.width != width || old_size.height != height)
102 | this.forceUpdate();
103 | }
104 | }
105 |
106 | get_row_width(num_chars) {
107 | return this.char_size.width * num_chars
108 | }
109 |
110 | get_col_height(num_chars) {
111 | return this.char_size.height * num_chars
112 | }
113 |
114 | get_pane_event_buffer(pane_id) {
115 | let e = this.pane_events.get(pane_id)
116 | if (!e) {
117 | e = new EventBuffer()
118 | this.pane_events.set(pane_id, e)
119 | }
120 | return e
121 | }
122 |
123 | emit_pane_event(pane_id, f, ...args) {
124 | this.get_pane_event_buffer(pane_id).send(f, args)
125 | }
126 |
127 | on_umount_pane(pane_id) {
128 | this.pane_events.delete(pane_id)
129 | }
130 |
131 | connect() {
132 | this.setState({ws_state: WS_CONNECTING})
133 |
134 | $.get(`/api/t/${this.props.token}`)
135 | .fail((jqXHR, textStatus, error) => {
136 | // TODO retry when needed here as well.
137 | this.setState({ws_state: WS_CLOSED, close_reason: `Error: ${error}`})
138 | })
139 | .done(session => {
140 | if (session.closed_at) {
141 | this.setState({ws_state: WS_CLOSED, close_reason: "Session closed"})
142 | } else if (this.state.ws_state != WS_CLOSED) {
143 | this.setState({ws_url_fmt: session.ws_url_fmt, ssh_cmd_fmt: session.ssh_cmd_fmt})
144 | const ws_url = session.ws_url_fmt.replace("%s", this.props.token);
145 | this.ws = new WebSocket(ws_url)
146 | this.ws.binaryType = "arraybuffer"
147 | this.ws.onmessage = event => {
148 | this.on_socket_msg(this.deserialize_msg(event.data))
149 | }
150 | this.ws.onopen = event => {
151 | this.handleResize()
152 | this.setState({ws_state: WS_BOOTSTRAPPING})
153 | }
154 | this.ws.onclose = event => {
155 | this.reconnect()
156 | }
157 | this.ws.onerror = event => {
158 | this.reconnect()
159 | }
160 | }
161 | })
162 | }
163 |
164 | reconnect() {
165 | if (this.state.ws_state == WS_RECONNECT || this.state.ws_state == WS_CLOSED)
166 | return;
167 |
168 | setTimeout(this.connect.bind(this), 1000);
169 | this.setState({ws_state: WS_RECONNECT})
170 | }
171 |
172 | disconnect() {
173 | this.setState({ws_state: WS_CLOSED})
174 | if (this.ws) {
175 | this.ws.close()
176 | this.ws = undefined
177 | }
178 | }
179 |
180 | on_socket_msg(msg) {
181 | const [cmd, ...rest] = msg
182 | const h = this.ws_handlers.get(cmd)
183 | if (h) { h.apply(this, rest) }
184 | }
185 |
186 | on_ws_daemon_out_msg(msg) {
187 | const [cmd, ...rest] = msg
188 | const h = this.daemon_handlers.get(cmd)
189 | if (h) { h.apply(this, rest) }
190 | }
191 |
192 | on_ws_snapshot(panes) {
193 | for (const pane of panes) {
194 | const [pane_id, ...rest] = pane
195 | this.emit_pane_event(pane_id, "on_bootstrap_grid", ...rest)
196 | }
197 |
198 | this.setState({ws_state: WS_OPEN})
199 | }
200 |
201 | on_sync_layout(session_x, session_y, windows, active_window_id) {
202 | this.setState({
203 | windows: windows,
204 | size: [session_x, session_y],
205 | active_window_id: active_window_id
206 | })
207 | }
208 |
209 | on_pty_data(pane_id, data) {
210 | this.emit_pane_event(pane_id, "on_pty_data", data)
211 | }
212 |
213 | on_sync_copy_mode(pane_id, data) {
214 | this.emit_pane_event(pane_id, "on_sync_copy_mode", data)
215 | }
216 |
217 | render_message(msg) {
218 | return
219 |
{msg}
220 |
221 | }
222 |
223 | render_ws_connecting() {
224 | return this.render_message("Connecting...")
225 | }
226 |
227 | render_ws_bootstrapping() {
228 | return this.render_message("Initializing session...")
229 | }
230 |
231 | render_ws_closed() {
232 | const event = this.state.ws_close_event
233 | const close_reason = this.state.close_reason ||
234 | (event ? WS_ERRORS[this.state.ws_close_event.code] ||
235 | `Not connected: ${event.code} ${event.reason}` :
236 | "Not connected")
237 | return this.render_message(close_reason)
238 | }
239 |
240 | render_ws_reconnect() {
241 | return this.render_message("Reconnecting...")
242 | }
243 |
244 | render_ws_open() {
245 | let win_nav = null;
246 | if (this.state.windows.length > 1) {
247 | const nav_elems = this.state.windows.map(win => {
248 | const [id, title, panes, active_pane_id] = win
249 | const active = this.state.active_window_id === id
250 | return
251 |
252 | {id}
253 | {title}
254 |
255 | })
256 | win_nav =
257 | }
258 |
259 | const wins = this.state.windows.map(win => {
260 | const [id, title, panes, active_pane_id] = win
261 | const active = this.state.active_window_id === id
262 | const wrapper_style = active ? {} : {display: 'none'}
263 | return
264 |
266 |
267 | })
268 |
269 | const session_style = {width: this.get_row_width(this.state.size[0])}
270 | const session_div =
271 | {win_nav}
272 |
273 | {wins}
274 |
275 |
276 | return
277 | {session_div}
278 | {this.render_ssh_connection_string()}
279 |
280 | The HTML5 client is a work in progress. tmux key bindings don't work. There are known graphical bugs.
281 |
282 |
283 | }
284 |
285 | render_ssh_connection_string() {
286 | if (!this.state.ssh_cmd_fmt)
287 | return
288 |
289 | const ssh_cmd = this.state.ssh_cmd_fmt.replace("%s", this.props.token);
290 |
291 | return
292 | {ssh_cmd}
293 |
294 | }
295 |
296 | componentDidUpdate(prevProps, prevState) {
297 | this.handleResize()
298 | }
299 |
300 | handleResize(_event) {
301 | if (this.state.ws_state != WS_OPEN)
302 | return
303 |
304 | let max_width = window.innerWidth
305 | let max_height = window.innerHeight
306 |
307 | if (this.refs.top_win)
308 | max_height -= ReactDOM.findDOMNode(this.refs.top_win).getBoundingClientRect().top
309 |
310 | max_width -= this.terminal_padding_size.width
311 | max_height -= this.terminal_padding_size.height
312 |
313 | this.notify_client_size(Math.floor(max_width / this.char_size.width),
314 | Math.floor(max_height / this.char_size.height))
315 | }
316 |
317 | render() {
318 | switch (this.state.ws_state) {
319 | case WS_CONNECTING: return this.render_ws_connecting()
320 | case WS_BOOTSTRAPPING: return this.render_ws_bootstrapping()
321 | case WS_OPEN: return this.render_ws_open()
322 | case WS_CLOSED: return this.render_ws_closed()
323 | case WS_RECONNECT: return this.render_ws_reconnect()
324 | }
325 | }
326 |
327 | on_click_win_tab(win_id, event) {
328 | if (this.state.active_window_id !== win_id)
329 | this.focus_window(win_id)
330 | }
331 |
332 | send_pty_keys(pane_id, data) {
333 | this.send_msg([TMATE_WS_PANE_KEYS, pane_id, data])
334 | }
335 |
336 | notify_client_size(x, y) {
337 | if (this.last_x !== x || this.last_y !== y) {
338 | this.send_msg([TMATE_WS_RESIZE, [x,y]])
339 | this.last_x = x
340 | this.last_y = y
341 | }
342 | }
343 |
344 | focus_window(win_id) {
345 | this.send_msg([TMATE_WS_EXEC_CMD, `select-window -t ${win_id}`])
346 | }
347 |
348 | focus_pane(pane_id) {
349 | this.send_msg([TMATE_WS_EXEC_CMD, `select-pane -t %${pane_id}`])
350 | }
351 |
352 | send_msg(msg) {
353 | if (this.state.ws_state == WS_OPEN)
354 | this.ws.send(this.serialize_msg(msg));
355 | }
356 |
357 | on_status(msg) {
358 | // TODO
359 | // console.log(`Got status: ${msg}`)
360 | }
361 |
362 | on_fin() {
363 | this.setState({ws_state: WS_CLOSED, close_reason: "Session closed"})
364 | }
365 |
366 | serialize_msg(msg) {
367 | return msgpack.encode(msg)
368 | }
369 |
370 | deserialize_msg(msg) {
371 | return msgpack.decode(msg)
372 | }
373 | }
374 |
--------------------------------------------------------------------------------
/assets/js/term/term.css:
--------------------------------------------------------------------------------
1 | .session-status h3 {
2 | text-align: center;
3 | }
4 |
5 | .session span.win-id {
6 | color: #666;
7 | }
8 |
9 | .session {
10 | margin: 5px auto;
11 | }
12 |
13 | .session .nav-tabs {
14 | margin-bottom: 0;
15 | }
16 |
17 | .session .nav-tabs > li > a {
18 | padding: 3px 6px 3px 6px;
19 | }
20 |
21 | .window {
22 | position: relative;
23 | margin-bottom: 8px;
24 | }
25 |
26 | .pane_container {
27 | position: absolute;
28 | }
29 |
30 | .pane_container.active {
31 | box-shadow: rgba(255, 255, 255, 0.40) 0 0px 7px;
32 | }
33 |
34 | .terminal {
35 | overflow: hidden;
36 | border-radius: 3px;
37 | padding: 4px 1px; /* XXX change the session.js terminal_padding_size */
38 | box-shadow: 0 1px rgba(255, 255, 255, 0.1);
39 | }
40 |
41 | .ssh-cstr {
42 | margin-top: 1em;
43 | font-family: "DejaVu Sans Mono", "Liberation Mono", monospace;
44 | font-size: 12px;
45 | line-height: 14px;
46 | display: block;
47 | text-align: center;
48 | }
49 |
50 | .faq {
51 | margin-top: 1em;
52 | text-align: center;
53 | color: #666;
54 | font-size: 10px;
55 | }
56 |
--------------------------------------------------------------------------------
/assets/js/term/window.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Pane from "./pane"
3 |
4 | export default class Window extends React.Component {
5 | render() {
6 | const win_size = this.props.session.state.size
7 |
8 | const panes = this.props.panes.map(pane => {
9 | const [id, cols, rows, x, y] = pane
10 |
11 | const pane_style = {left: this.props.session.get_row_width(x),
12 | top: this.props.session.get_col_height(y)}
13 | const active = this.props.active && this.props.active_pane_id === id
14 | const class_name = active && this.props.panes.length > 1 ?
15 | "pane_container active" : "pane_container"
16 |
17 | return
21 | })
22 |
23 | const style = {width: this.props.session.get_row_width(win_size[0]) +
24 | this.props.session.terminal_padding_size.width,
25 | height: this.props.session.get_col_height(win_size[1]) +
26 | this.props.session.terminal_padding_size.height}
27 | return
{panes}
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/assets/js/term/xterm_constants.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright (c) 2019 The xterm.js authors. All rights reserved.
3 | * @license MIT
4 | */
5 |
6 | export const DEFAULT_COLOR = 256;
7 | export const DEFAULT_ATTR = (0 << 18) | (DEFAULT_COLOR << 9) | (256 << 0);
8 |
9 | export const CHAR_DATA_ATTR_INDEX = 0;
10 | export const CHAR_DATA_CHAR_INDEX = 1;
11 | export const CHAR_DATA_WIDTH_INDEX = 2;
12 | export const CHAR_DATA_CODE_INDEX = 3;
13 |
14 | /**
15 | * Null cell - a real empty cell (containing nothing).
16 | * Note that code should always be 0 for a null cell as
17 | * several test condition of the buffer line rely on this.
18 | */
19 | export const NULL_CELL_CHAR = '';
20 | export const NULL_CELL_WIDTH = 1;
21 | export const NULL_CELL_CODE = 0;
22 |
23 | /**
24 | * Whitespace cell.
25 | * This is meant as a replacement for empty cells when needed
26 | * during rendering lines to preserve correct aligment.
27 | */
28 | export const WHITESPACE_CELL_CHAR = ' ';
29 | export const WHITESPACE_CELL_WIDTH = 1;
30 | export const WHITESPACE_CELL_CODE = 32;
31 |
32 | /**
33 | * Bitmasks for accessing data in `content`.
34 | */
35 | export enum Content {
36 | /**
37 | * bit 1..21 codepoint, max allowed in UTF32 is 0x10FFFF (21 bits taken)
38 | * read: `codepoint = content & Content.codepointMask;`
39 | * write: `content |= codepoint & Content.codepointMask;`
40 | * shortcut if precondition `codepoint <= 0x10FFFF` is met:
41 | * `content |= codepoint;`
42 | */
43 | CODEPOINT_MASK = 0x1FFFFF,
44 |
45 | /**
46 | * bit 22 flag indication whether a cell contains combined content
47 | * read: `isCombined = content & Content.isCombined;`
48 | * set: `content |= Content.isCombined;`
49 | * clear: `content &= ~Content.isCombined;`
50 | */
51 | IS_COMBINED_MASK = 0x200000, // 1 << 21
52 |
53 | /**
54 | * bit 1..22 mask to check whether a cell contains any string data
55 | * we need to check for codepoint and isCombined bits to see
56 | * whether a cell contains anything
57 | * read: `isEmpty = !(content & Content.hasContent)`
58 | */
59 | HAS_CONTENT_MASK = 0x3FFFFF,
60 |
61 | /**
62 | * bit 23..24 wcwidth value of cell, takes 2 bits (ranges from 0..2)
63 | * read: `width = (content & Content.widthMask) >> Content.widthShift;`
64 | * `hasWidth = content & Content.widthMask;`
65 | * as long as wcwidth is highest value in `content`:
66 | * `width = content >> Content.widthShift;`
67 | * write: `content |= (width << Content.widthShift) & Content.widthMask;`
68 | * shortcut if precondition `0 <= width <= 3` is met:
69 | * `content |= width << Content.widthShift;`
70 | */
71 | WIDTH_MASK = 0xC00000, // 3 << 22
72 | WIDTH_SHIFT = 22
73 | }
74 |
75 | export enum Attributes {
76 | /**
77 | * bit 1..8 blue in RGB, color in P256 and P16
78 | */
79 | BLUE_MASK = 0xFF,
80 | BLUE_SHIFT = 0,
81 | PCOLOR_MASK = 0xFF,
82 | PCOLOR_SHIFT = 0,
83 |
84 | /**
85 | * bit 9..16 green in RGB
86 | */
87 | GREEN_MASK = 0xFF00,
88 | GREEN_SHIFT = 8,
89 |
90 | /**
91 | * bit 17..24 red in RGB
92 | */
93 | RED_MASK = 0xFF0000,
94 | RED_SHIFT = 16,
95 |
96 | /**
97 | * bit 25..26 color mode: DEFAULT (0) | P16 (1) | P256 (2) | RGB (3)
98 | */
99 | CM_MASK = 0x3000000,
100 | CM_DEFAULT = 0,
101 | CM_P16 = 0x1000000,
102 | CM_P256 = 0x2000000,
103 | CM_RGB = 0x3000000,
104 |
105 | /**
106 | * bit 1..24 RGB room
107 | */
108 | RGB_MASK = 0xFFFFFF
109 | }
110 |
111 | export enum FgFlags {
112 | /**
113 | * bit 27..31 (32th bit unused)
114 | */
115 | INVERSE = 0x4000000,
116 | BOLD = 0x8000000,
117 | UNDERLINE = 0x10000000,
118 | BLINK = 0x20000000,
119 | INVISIBLE = 0x40000000
120 | }
121 |
122 | export enum BgFlags {
123 | /**
124 | * bit 27..32 (upper 4 unused)
125 | */
126 | ITALIC = 0x4000000,
127 | DIM = 0x8000000
128 | }
129 |
--------------------------------------------------------------------------------
/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "repository": {},
3 | "license": "MIT",
4 | "scripts": {
5 | "deploy": "webpack --mode production",
6 | "watch": "webpack --mode development --watch"
7 | },
8 | "dependencies": {
9 | "jquery": "^3.4.1",
10 | "msgpack-js-browser": "git://github.com/creationix/msgpack-js-browser.git",
11 | "phoenix": "file:../deps/phoenix",
12 | "phoenix_html": "file:../deps/phoenix_html",
13 | "react": "^16.12.0",
14 | "react-dom": "^16.12.0",
15 | "xterm": "^4.2.0-vscode1"
16 | },
17 | "devDependencies": {
18 | "@babel/core": "^7.0.0",
19 | "@babel/plugin-proposal-class-properties": "^7.2.3",
20 | "@babel/plugin-proposal-object-rest-spread": "^7.2.0",
21 | "@babel/preset-env": "^7.0.0",
22 | "@babel/preset-react": "^7.0.0",
23 | "@babel/preset-typescript": "^7.7.4",
24 | "babel-loader": "^8.0.0",
25 | "copy-webpack-plugin": "^4.5.0",
26 | "css-loader": "^2.1.1",
27 | "mini-css-extract-plugin": "^0.4.0",
28 | "optimize-css-assets-webpack-plugin": "^5.0.1",
29 | "terser-webpack-plugin": "^1.1.0",
30 | "webpack": "4.4.0",
31 | "webpack-cli": "^3.3.2"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/assets/static/css/main.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: #151515;
3 | /* background-image: url('/img/rebel.png'); */
4 | color: #bbb;
5 | font-size: 16px;
6 | }
7 |
8 | ::-webkit-selection { color: black; background: rgba(178, 104, 24, 0.99); }
9 | ::-moz-selection { color: black; background: rgba(178, 104, 24, 0.99); }
10 | ::selection { color: black; background: rgba(178, 104, 24, 0.99); }
11 |
12 | h1, h2, h3, h4, h5, h6 {
13 | color: #bbb;
14 | }
15 |
16 | .nav-tabs {
17 | font-size: 15px;
18 | min-inline-size: max-content;
19 | }
20 |
21 | .nav-tabs > li > a {
22 | padding: 7px;
23 | }
24 |
25 |
26 | li, p {
27 | line-height: 1.4em;
28 | }
29 |
30 | label, input, button, select, textarea {
31 | font-size: 1em;
32 | line-height: 1.4em;
33 | }
34 |
35 |
36 | .hero-unit {
37 | margin-top: 50px;
38 | text-align: center;
39 | background-color: rgba(0, 0, 0, 0.3);
40 |
41 | -webkit-box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
42 | -moz-box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
43 | box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
44 | }
45 |
46 | .hero-unit h1 { color: #fff; text-shadow: 0px 0px 5px rgba(255, 255, 255, 0.4); }
47 | .hero-unit h2 { color: #ccc; }
48 | .hero-unit p { text-align: center; }
49 |
50 | hr {
51 | border-bottom: 1px solid #3b3b3b;
52 | border-top: 0;
53 | margin-top: 35px;
54 |
55 | margin-top: 1em;
56 | margin-bottom: 2em;
57 | }
58 |
59 | code, pre {
60 | font-size: 13px;
61 | font-family: "Bitstream Vera Sans Mono", "Courier New", Courier, monospace;
62 | line-height: 1.2em;
63 | overflow: auto;
64 | background-color: rgba(0, 0, 0, 0.1);
65 | color: #bbb;
66 | -webkit-box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
67 | -moz-box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
68 | box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
69 | border: 0;
70 | }
71 |
72 | .fig {
73 | -webkit-box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
74 | -moz-box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
75 | box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
76 |
77 | -webkit-border-radius: 5px;
78 | -moz-border-radius: 5px;
79 | border-radius: 5px;
80 |
81 | background-color: rgba(0, 0, 0, 0.1);
82 | padding: 10px;
83 | margin-bottom: 1em;
84 | }
85 |
86 | p.fig-label {
87 | text-align: center;
88 | span { font-weight: bold; }
89 | margin-bottom: 2em;
90 | }
91 |
92 | .video {
93 | margin-top: 10px;
94 | -webkit-box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px;
95 | -moz-box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px;
96 | box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px;
97 | }
98 |
99 | .video.linux {
100 | background-image: url('/img/video_linux_first_frame.png');
101 | height: 285px;
102 | width: 413px;
103 | margin-left: 30px;
104 | }
105 |
106 | .video.macos {
107 | background-image: url('/img/video_macos_first_frame.png');
108 | height: 285px;
109 | width: 409px;
110 | float: right;
111 | margin-right: 30px;
112 | }
113 |
114 | .steps {
115 | text-align: center;
116 | margin: 0 auto;
117 | margin-top: 20px;
118 | width: 720px;
119 | height: 50px;
120 | }
121 |
122 | .steps h3 {
123 | width: 240px;
124 | float: left;
125 | display: none;
126 | margin-top: 0;
127 | margin-bottom: -40px;
128 | height: 50px;
129 | }
130 |
131 | .nav-tabs {
132 | border-bottom: 1px solid #3b3b3b;
133 | }
134 |
135 | .nav-tabs > li {
136 | margin-bottom: -1px;
137 | }
138 |
139 | .nav-tabs > li > a:hover,
140 | .nav-tabs > li > a:focus {
141 | border-color: #3b3b3b;
142 | }
143 |
144 | .nav-tabs > .active > a,
145 | .nav-tabs > .active > a:hover,
146 | .nav-tabs > .active > a:focus {
147 | color: #aaa;
148 | cursor: default;
149 | background-color: rgba(0, 0, 0, 0.3);
150 | border: 1px solid #3b3b3b;
151 | border-bottom-color: transparent;
152 | }
153 |
154 | .nav > li > a:hover,
155 | .nav > li > a:focus {
156 | text-decoration: none;
157 | color: #0088cc;
158 | background-color: rgba(0, 0, 0, 0.3);
159 | }
160 |
161 | .footer {
162 | width: 100%;
163 | height: 140px;
164 | margin-top: 40px;
165 | padding-top: 30px;
166 | background-color: rgba(0, 0, 0, 0.5);
167 | }
168 | .footer .digitalocean p {
169 | text-align: center;
170 | }
171 |
172 | .social-buttons {
173 | margin-top: 10px;
174 | margin-bottom: -30px;
175 | }
176 |
177 | .social-buttons .twitter {
178 | display: inline;
179 | margin-top: 8px;
180 | margin-right: 17px;
181 | }
182 |
183 | .social-buttons .github {
184 | display: inline;
185 | }
186 |
187 | .alert-success {
188 | color: #fff;
189 | background-color: #00ac7c;
190 | border-color: #00ac7c;
191 | text-shadow: none;
192 | }
193 |
194 | .user-registration input {
195 | color: #333;
196 | background-color: #d6d6d6;
197 | }
198 |
199 | .user-registration .help-block {
200 | color: #d88;
201 | }
202 |
203 | .user-registration button {
204 | margin-top: 0.5em;
205 | }
206 |
207 | .warning {
208 | color: #d88;
209 | }
210 |
211 | .app-container p.loading {
212 | margin: auto;
213 | margin-top: 100px;
214 | width: 200px;
215 | text-align: center;
216 | }
217 |
--------------------------------------------------------------------------------
/assets/static/img/1_step.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/1_step.png
--------------------------------------------------------------------------------
/assets/static/img/2_step.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/2_step.png
--------------------------------------------------------------------------------
/assets/static/img/3_step.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/3_step.png
--------------------------------------------------------------------------------
/assets/static/img/digitalocean.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/digitalocean.png
--------------------------------------------------------------------------------
/assets/static/img/fork-me-on-github-right-orange@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/fork-me-on-github-right-orange@2x.png
--------------------------------------------------------------------------------
/assets/static/img/glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2
--------------------------------------------------------------------------------
/assets/static/img/glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf
--------------------------------------------------------------------------------
/assets/static/img/glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot
--------------------------------------------------------------------------------
/assets/static/img/glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff
--------------------------------------------------------------------------------
/assets/static/img/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/icon.png
--------------------------------------------------------------------------------
/assets/static/img/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/logo.png
--------------------------------------------------------------------------------
/assets/static/img/mac_notr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/mac_notr.png
--------------------------------------------------------------------------------
/assets/static/img/mac_tr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/mac_tr.png
--------------------------------------------------------------------------------
/assets/static/img/rebel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/rebel.png
--------------------------------------------------------------------------------
/assets/static/img/tmate.svg:
--------------------------------------------------------------------------------
1 |
2 |
20 |
22 |
23 |
25 | image/svg+xml
26 |
28 | logomark + wordmark
29 |
30 |
31 |
32 |
57 |
58 | logomark + wordmark
60 | Created with Sketch.
62 |
64 |
67 |
76 |
82 |
88 |
93 |
98 |
99 |
100 |
101 |
--------------------------------------------------------------------------------
/assets/static/img/video_linux.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/video_linux.png
--------------------------------------------------------------------------------
/assets/static/img/video_linux_first_frame.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/video_linux_first_frame.png
--------------------------------------------------------------------------------
/assets/static/img/video_mac.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/video_mac.png
--------------------------------------------------------------------------------
/assets/static/img/video_macos.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/video_macos.png
--------------------------------------------------------------------------------
/assets/static/img/video_macos_first_frame.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/assets/static/img/video_macos_first_frame.png
--------------------------------------------------------------------------------
/assets/static/js/main.js:
--------------------------------------------------------------------------------
1 | /*
2 | * If you are curious, here is how I generated the videos:
3 | * ffmpeg -an -f x11grab -r 30 -s 460x285 -i :0.0+1920,0 -pix_fmt yuv444p -vcodec libx264 -x264opts crf=0 out.mp4
4 | * mplayer -fps 3000 -vo png out.mp4
5 | * montage *.png -tile x14 -geometry '460x285' out.png
6 | * pngcrush out.png video.png
7 | *
8 | * Safari and Firefox don't like very wide pngs, so we chunk the frames in
9 | * rows. Sadly it makes the png bigger.
10 | */
11 |
12 | $(function() {
13 | var video_linux = $('.video.linux');
14 | var video_macos = $('.video.macos');
15 | var current_frame = 0;
16 | var timer;
17 |
18 | var nextFrameFor = function(video, rows, total_frames, offset) {
19 | var frame = current_frame - offset;
20 | if (frame < 0)
21 | return;
22 |
23 | var frames_per_row = Math.ceil(total_frames / rows);
24 | var x = video.width() * (frame % frames_per_row);
25 | var y = video.height() * Math.floor(frame / frames_per_row);
26 | var position = "-" + x + "px -" + y + "px";
27 | video.css('background-position', position);
28 | }
29 |
30 | var nextFrame = function() {
31 | if (current_frame == 90)
32 | $('.steps .launch').fadeIn(900);
33 | if (current_frame == 250)
34 | $('.steps .share').fadeIn(900);
35 | if (current_frame == 410)
36 | $('.steps .pair').fadeIn(900);
37 |
38 | nextFrameFor(video_linux, 12, 465, 0);
39 | nextFrameFor(video_macos, 6, 231, 234);
40 |
41 | current_frame += 1;
42 | if (current_frame >= 465)
43 | clearTimeout(timer);
44 | };
45 |
46 | var startPlayback = function() {
47 | timer = setInterval(nextFrame, 33);
48 | };
49 |
50 | $("
").load(function() {
51 | $("
").load(function() {
52 | video_linux.css('background-image', "url('/img/video_linux.png')");
53 | video_macos.css('background-image', "url('/img/video_macos.png')");
54 | setTimeout(startPlayback, 2000);
55 | });
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/assets/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const glob = require('glob');
3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
4 | const TerserPlugin = require('terser-webpack-plugin');
5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
6 | const CopyWebpackPlugin = require('copy-webpack-plugin');
7 |
8 | module.exports = (env, options) => ({
9 | optimization: {
10 | minimizer: [
11 | new TerserPlugin({ cache: true, parallel: true, sourceMap: false }),
12 | new OptimizeCSSAssetsPlugin({})
13 | ]
14 | },
15 | entry: {
16 | './js/app.js': glob.sync('./vendor/**/*.js').concat(['./js/app.js'])
17 | },
18 | output: {
19 | filename: 'app.js',
20 | path: path.resolve(__dirname, '../priv/static/js')
21 | },
22 | module: {
23 | rules: [
24 | {
25 | test: /\.(js|ts)$/,
26 | exclude: /node_modules/,
27 | use: {
28 | loader: 'babel-loader'
29 | }
30 | },
31 | {
32 | test: /\.css$/,
33 | use: [MiniCssExtractPlugin.loader, 'css-loader']
34 | }
35 | ]
36 | },
37 | plugins: [
38 | new MiniCssExtractPlugin({ filename: '../css/app.css' }),
39 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }])
40 | ]
41 | });
42 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | use Mix.Config
9 |
10 | config :tmate,
11 | ecto_repos: [Tmate.Repo]
12 |
13 | # Configures the endpoint
14 | config :tmate, TmateWeb.Endpoint,
15 | url: [host: "localhost"],
16 | secret_key_base: "DKaayGHtzQJFsdBK4HfamYXCdSd4aOLV7T5+XY+9XzIKuoUWYwMhJH+/U2N/7zkf",
17 | render_errors: [view: TmateWeb.ErrorView, accepts: ~w(html json)],
18 | pubsub: [name: Tmate.PubSub, adapter: Phoenix.PubSub.PG2]
19 |
20 | # Configures Elixir's Logger
21 | config :logger, :console,
22 | format: "$time $metadata[$level] $message\n",
23 | metadata: [:request_id]
24 |
25 | # Use Jason for JSON parsing in Phoenix
26 | config :phoenix, :json_library, Jason
27 |
28 |
29 | config :tmate, Tmate.Monitoring.Endpoint,
30 | enabled: true,
31 | cowboy_opts: [port: 9100]
32 |
33 | config :tmate, Tmate.MonitoringCollector,
34 | metrics_enabled: true
35 |
36 | config :prometheus, Tmate.PlugExporter,
37 | path: "/metrics",
38 | format: :auto,
39 | registry: :default,
40 | auth: false
41 |
42 | config :tmate, Tmate.Mailer,
43 | from: System.get_env("EMAIL_FROM", "tmate
")
44 |
45 | # Import environment specific config. This must remain at the bottom
46 | # of this file so it overrides the configuration defined above.
47 | import_config "#{Mix.env()}.exs"
48 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # Configure your database
4 | config :tmate, Tmate.Repo,
5 | username: "postgres",
6 | password: "postgres",
7 | database: "tmate_dev",
8 | hostname: "postgres",
9 | show_sensitive_data_on_connection_error: true,
10 | pool_size: 10
11 |
12 | # For development, we disable any cache and enable
13 | # debugging and code reloading.
14 | #
15 | # The watchers configuration can be used to run external
16 | # watchers to your application. For example, we use it
17 | # with webpack to recompile .js and .css sources.
18 | config :tmate, TmateWeb.Endpoint,
19 | http: [port: 4000],
20 | debug_errors: true,
21 | code_reloader: true,
22 | check_origin: false,
23 | watchers: [
24 | node: [
25 | "node_modules/webpack/bin/webpack.js",
26 | "--mode",
27 | "development",
28 | "--watch-stdin",
29 | cd: Path.expand("../assets", __DIR__)
30 | ]
31 | ]
32 |
33 | # Watch static and templates for browser reloading.
34 | config :tmate, TmateWeb.Endpoint,
35 | live_reload: [
36 | patterns: [
37 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
38 | ~r"priv/gettext/.*(po)$",
39 | ~r"lib/tmate_web/{live,views}/.*(ex)$",
40 | ~r"lib/tmate_web/templates/.*(eex)$"
41 | ]
42 | ]
43 |
44 | # Do not include metadata nor timestamps in development logs
45 | config :logger, :console, format: "[$level] $message\n"
46 |
47 | # Set a higher stacktrace during development. Avoid configuring such
48 | # in production as building large stacktraces may be expensive.
49 | config :phoenix, :stacktrace_depth, 20
50 |
51 | # Initialize plugs at runtime for faster development compilation
52 | config :phoenix, :plug_init_mode, :runtime
53 |
54 | config :tmate, :master,
55 | internal_api: [auth_token: "internal_api_auth_token"]
56 |
57 | config :tmate, Tmate.Scheduler,
58 | enabled: true,
59 | jobs: [
60 | # Every 5 minutes
61 | {"*/5 * * * *", {Tmate.SessionCleaner, :check_for_disconnected_sessions, []}},
62 | {"*/5 * * * *", {Tmate.SessionCleaner, :prune_sessions, []}},
63 | ]
64 |
65 | config :tmate, Tmate.Mailer,
66 | adapter: Bamboo.LocalAdapter
67 |
--------------------------------------------------------------------------------
/config/prod.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # For production, don't forget to configure the url host
4 | # to something meaningful, Phoenix uses this information
5 | # when generating URLs.
6 | #
7 | # Note we also include the path to a cache manifest
8 | # containing the digested version of static files. This
9 | # manifest is generated by the `mix phx.digest` task,
10 | # which you should run after static files are built and
11 | # before starting your production server.
12 | config :tmate, TmateWeb.Endpoint,
13 | http: [port: System.get_env("MASTER_HTTP_PORT", "4000") |> String.to_integer(),
14 | compress: true, protocol_options: [
15 | proxy_header: System.get_env("USE_PROXY_PROTOCOL") == "1"]],
16 | url: System.get_env("MASTER_BASE_URL", "") |> URI.parse() |> Map.to_list(),
17 | secret_key_base: System.get_env("SECRET_KEY_BASE"),
18 | cache_static_manifest: "priv/static/cache_manifest.json"
19 | # XXX If SSL options are needed. See tmate-websocket for example
20 | config :tmate, TmateWeb.Endpoint, server: true
21 |
22 | # Do not print debug messages in production
23 | config :logger, level: :info
24 |
25 | #database_url =
26 | # System.get_env("DATABASE_URL") ||
27 | # raise """
28 | # environment variable DATABASE_URL is missing.
29 | # For example: ecto://USER:PASS@HOST/DATABASE
30 | # """
31 | #
32 | #config :tmate, Tmate.Repo,
33 | # # ssl: true,
34 | # url: database_url,
35 | # pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
36 |
37 | pg = URI.parse(System.get_env("PG_URI", "pg://user:pass@host:5432/db"))
38 | config :tmate, Tmate.Repo,
39 | adapter: Ecto.Adapters.Postgres,
40 | timeout: 60_000,
41 | username: pg.userinfo |> String.split(":") |> Enum.at(0),
42 | password: pg.userinfo |> String.split(":") |> Enum.at(1),
43 | database: pg.path |> String.split("/") |> Enum.at(1),
44 | port: pg.port,
45 | hostname: pg.host,
46 | pool_size: System.get_env("PG_POOLSIZE", "20") |> String.to_integer(),
47 | ssl: System.get_env("PG_SSL_CA_CERT") != nil,
48 | ssl_opts: [cacertfile: System.get_env("PG_SSL_CA_CERT")],
49 | # x4 all queue_target and queue_target settings,
50 | # in the hope to reduce the number of DBConnection Errors
51 | queue_target: 200,
52 | queue_interval: 4000
53 |
54 |
55 | config :tmate, Tmate.Monitoring.Endpoint,
56 | port: System.get_env("MASTER_METRICS_PORT", "9100") |> String.to_integer()
57 |
58 | config :tmate, :master,
59 | internal_api: [auth_token: System.get_env("INTERNAL_API_AUTH_TOKEN")]
60 |
61 | config :tzdata, :autoupdate, :disabled
62 |
63 | # This requires a statefulset setup
64 | machine_index = System.get_env("HOSTNAME", "master-0")
65 | |> String.split("-") |> Enum.at(-1) |> String.to_integer()
66 |
67 | config :tmate, Tmate.MonitoringCollector,
68 | metrics_enabled: machine_index == 0
69 |
70 | config :tmate, Tmate.Scheduler,
71 | enabled: machine_index == 0,
72 | jobs: [
73 | # every minute
74 | {"*/1 * * * *", {Tmate.SessionCleaner, :check_for_disconnected_sessions, []}},
75 | {"*/1 * * * *", {Tmate.SessionCleaner, :prune_sessions, []}},
76 | ]
77 |
78 | email_adapter_opts = case (System.get_env("EMAIL_ADAPTER", "mailgun") |> String.downcase) do
79 | "smtp" -> [
80 | adapter: Bamboo.SMTPAdapter,
81 | server: System.get_env("SMTP_HOST"),
82 | port: System.get_env("SMTP_PORT"),
83 | hostname: System.get_env("SMTP_DOMAIN")]
84 | "mailgun" -> [
85 | adapter: Bamboo.MailgunAdapter,
86 | api_key: System.get_env("MAILGUN_API_KEY"),
87 | domain: System.get_env("MAILGUN_DOMAIN")]
88 | other -> raise "Unknown email handler: '#{other}'"
89 | end
90 |
91 | config :tmate, Tmate.Mailer,
92 | email_adapter_opts
93 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # Configure your database
4 | config :tmate, Tmate.Repo,
5 | username: "postgres",
6 | password: "postgres",
7 | database: "tmate_test",
8 | hostname: "localhost",
9 | pool: Ecto.Adapters.SQL.Sandbox
10 |
11 | # We don't run a server during test. If one is required,
12 | # you can enable the server option below.
13 | config :tmate, TmateWeb.Endpoint,
14 | http: [port: 4002],
15 | server: false
16 |
17 | # Print only warnings and errors during test
18 | config :logger, level: if System.get_env("DEBUG"), do: :debug, else: :warn
19 |
20 | config :phoenix, :stacktrace_depth, 20
21 |
22 | config :tmate, Tmate.Monitoring.Endpoint,
23 | enabled: false
24 |
25 | config :tmate, :master,
26 | internal_api: [auth_token: "internal_api_auth_token"]
27 |
28 | config :tmate, Tmate.Scheduler,
29 | enabled: false
30 |
31 | config :tmate, Tmate.Mailer,
32 | adapter: Bamboo.TestAdapter
33 |
--------------------------------------------------------------------------------
/lib/tmate.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate do
2 | @moduledoc """
3 | Tmate keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/lib/tmate/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | def start(_type, _args) do
9 | Tmate.Monitoring.setup()
10 |
11 | # List all child processes to be supervised
12 | children = [
13 | # Start the Ecto repository
14 | Tmate.Repo,
15 | # Start the endpoint when the application starts
16 | TmateWeb.Endpoint
17 | # Starts a worker by calling: Tmate.Worker.start_link(arg)
18 | # {Tmate.Worker, arg},
19 | ]
20 |
21 | {:ok, monitoring_options} = Application.fetch_env(:tmate, Tmate.Monitoring.Endpoint)
22 | {:ok, scheduler_options} = Application.fetch_env(:tmate, Tmate.Scheduler)
23 |
24 | children = cond do
25 | monitoring_options[:enabled] -> children ++ [
26 | Plug.Cowboy.child_spec(scheme: :http, plug: Tmate.Monitoring.Endpoint,
27 | options: monitoring_options[:cowboy_opts])
28 | ]
29 | true -> children
30 | end
31 |
32 | children = cond do
33 | scheduler_options[:enabled] -> children ++ [Tmate.Scheduler]
34 | true -> children
35 | end
36 |
37 | # See https://hexdocs.pm/elixir/Supervisor.html
38 | # for other strategies and supported options
39 | opts = [strategy: :one_for_one, name: Tmate.Supervisor]
40 | Supervisor.start_link(children, opts)
41 | end
42 |
43 | # Tell Phoenix to update the endpoint configuration
44 | # whenever the application is updated.
45 | def config_change(changed, _new, removed) do
46 | TmateWeb.Endpoint.config_change(changed, removed)
47 | :ok
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/lib/tmate/event_projections.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.EventProjections do
2 | require Logger
3 |
4 | @handlers [
5 | &__MODULE__.Session.handle_event/4,
6 | &__MODULE__.User.handle_event/4,
7 | ]
8 |
9 | def handle_event(event_type, id, timestamp, params) do
10 | @handlers |> Enum.each(fn handler ->
11 | invoke_handler(handler, event_type, id, timestamp, params)
12 | end)
13 | end
14 |
15 | defp invoke_handler(func, event_type, id, timestamp, params) do
16 | # TODO raise after invoking all handlers
17 | try do
18 | func.(event_type, id, timestamp, params)
19 | catch
20 | kind, reason ->
21 | evt = %{event_type: event_type, entity_id: id, timestamp: timestamp, params: params}
22 | Logger.error("Exception occured while handling event: #{inspect(evt)}")
23 | :erlang.raise(kind, reason, __STACKTRACE__)
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/tmate/event_projections/session.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.EventProjections.Session do
2 | require Logger
3 |
4 | alias Tmate.Session
5 | alias Tmate.Client
6 | alias Tmate.Repo
7 | alias Tmate.Util.EctoHelpers
8 |
9 | import Ecto.Query
10 |
11 | # handle_event() is run withing a Repo.transaction()
12 |
13 | # Events are ordered for a given generation.
14 | # This is useful when the tmate client reconnects on an other server.
15 | # Consider this timeline:
16 | # * client connects to server A
17 | # * server A sends the session_register event. (generation=1)
18 | # * client reconnects to server B
19 | # * server B sends the session_register,reconnected. (generation=2)
20 | # * server A sends a disconnection event. (generation=1)
21 | # We need to throw away the last disconnect event. Thankfully,
22 | # the events have generation numbers. Generations are incremented
23 | # upon reconnections.
24 | # If we receive an even with a younger generation that we have seen, we must
25 | # throw it away.
26 |
27 | defp when_generation_fresh(%Ecto.Changeset{
28 | changes: %{generation: event_generation},
29 | data: %Session{id: id, generation: session_generation}
30 | }=changeset, event, func) do
31 | effective_gen = fn gen -> if gen == nil, do: 1, else: gen end
32 |
33 | if effective_gen.(session_generation) <= effective_gen.(event_generation) do
34 | func.(changeset)
35 | else
36 | Logger.warn("Discarding event=#{event} for session id=#{id} " <>
37 | "session_generation=#{inspect(session_generation)}, " <>
38 | "event_generation=#{inspect(event_generation)}")
39 | end
40 | end
41 |
42 | # This matches when there's no generation changes. Meaning the generations stay
43 | # the same.
44 | defp when_generation_fresh(changeset, _event, func) do
45 | func.(changeset)
46 | end
47 |
48 | defp close_session_clients(session_id) do
49 | from(c in Client, where: c.session_id == ^session_id) |> Repo.delete_all()
50 | end
51 |
52 | def handle_event(:session_register=event, id, timestamp,
53 | %{generation: generation, ip_address: ip_address,
54 | ws_url_fmt: ws_url_fmt, ssh_cmd_fmt: ssh_cmd_fmt,
55 | stoken: stoken, stoken_ro: stoken_ro,
56 | reconnected: reconnected}) do
57 | if reconnected do
58 | Logger.info("Reconnected session id=#{id}")
59 | else
60 | Logger.info("New session id=#{id}")
61 | end
62 |
63 | session_params = %{id: id, host_last_ip: ip_address,
64 | ws_url_fmt: ws_url_fmt, ssh_cmd_fmt: ssh_cmd_fmt,
65 | stoken: stoken, stoken_ro: stoken_ro, created_at: timestamp,
66 | generation: generation, disconnected_at: nil, closed: false}
67 | Session.changeset(%Session{}, session_params)
68 | |> EctoHelpers.get_or_insert!
69 | |> Session.changeset(session_params)
70 | |> when_generation_fresh(event, fn changeset ->
71 | Repo.update(changeset)
72 | close_session_clients(id)
73 | end)
74 | end
75 |
76 | def handle_event(:session_close=event, id, timestamp, %{generation: generation}) do
77 | Logger.info("Closed session id=#{id}")
78 |
79 | if (session = Repo.get(Session, id)) do
80 | session
81 | |> Session.changeset(%{generation: generation, disconnected_at: timestamp, closed: true})
82 | |> when_generation_fresh(event, fn changeset ->
83 | Repo.update(changeset)
84 | close_session_clients(id)
85 | end)
86 | end
87 | end
88 |
89 | def handle_event(:session_disconnect=event, id, timestamp, %{generation: generation}) do
90 | Logger.info("Disconnected session id=#{id}")
91 |
92 | if (session = Repo.get(Session, id)) do
93 | session
94 | |> Session.changeset(%{generation: generation, disconnected_at: timestamp})
95 | |> when_generation_fresh(event, fn changeset ->
96 | Repo.update(changeset)
97 | close_session_clients(id)
98 | end)
99 | end
100 | end
101 |
102 | def handle_event(:session_join=event, sid, timestamp, %{generation: generation,
103 | id: cid, ip_address: ip_address, type: type, readonly: readonly}) do
104 | Logger.info("Client joined session sid=#{sid}, cid=#{cid}" <>
105 | ", type=#{type}, readonly=#{readonly}")
106 |
107 | if (session = Repo.get(Session, sid)) do
108 | session
109 | |> Session.changeset(%{generation: generation})
110 | |> when_generation_fresh(event, fn changeset ->
111 | Repo.update(changeset)
112 |
113 | client_params = %{id: cid, session_id: sid,
114 | ip_address: ip_address, joined_at: timestamp, readonly: readonly}
115 | Client.changeset(%Client{}, client_params)
116 | |> EctoHelpers.get_or_insert!
117 | end)
118 | end
119 | end
120 |
121 | def handle_event(:session_left=event, sid, _timestamp, %{generation: generation, id: cid}) do
122 | Logger.info("Client left session sid=#{sid}, cid=#{cid}")
123 |
124 | if (session = Repo.get(Session, sid)) do
125 | session
126 | |> Session.changeset(%{generation: generation})
127 | |> when_generation_fresh(event, fn changeset ->
128 | Repo.update(changeset)
129 |
130 | # The session_left event can be duplicated. So we allow the record to be absent.
131 | %Client{id: cid}
132 | |> Repo.delete(stale_error_field: :_stale_)
133 | end)
134 | end
135 | end
136 |
137 | def handle_event(_, _, _, _) do
138 | end
139 | end
140 |
--------------------------------------------------------------------------------
/lib/tmate/event_projections/user.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.EventProjections.User do
2 | alias Tmate.Util.EctoHelpers
3 | alias Tmate.User
4 | alias Tmate.Repo
5 | import Ecto.Changeset
6 | require Logger
7 |
8 | def handle_event(:user_create, user_id, timestamp, params) do
9 | %User{id: user_id}
10 | |> change(params)
11 | |> put_change(:created_at, timestamp)
12 | |> User.changeset()
13 | |> EctoHelpers.get_or_insert!
14 | end
15 |
16 | def handle_event(:expire_user, user_id, _timestamp, _params) do
17 | if (user = Repo.get(User, user_id)) do
18 | Logger.info("Expire #{User.repr(user)}")
19 | end
20 | %User{id: user_id} |> Repo.delete(stale_error_field: :_stale_)
21 | end
22 |
23 |
24 | def handle_event(:email_api_key, user_id, _timestamp, _params) do
25 | user = Repo.get!(User, user_id)
26 | Logger.info("Emailing api_key to #{User.repr(user)}")
27 | Tmate.Email.api_key_email(user)
28 | |> Tmate.Mailer.deliver_now()
29 | end
30 |
31 | def handle_event(:session_register, _sid, timestamp,
32 | %{stoken: stoken, stoken_ro: stoken_ro}) do
33 | get_username_from = fn token ->
34 | case String.split(token, "/") do
35 | [username, _session_name] -> username
36 | _ -> nil
37 | end
38 | end
39 |
40 | username = get_username_from.(stoken) || get_username_from.(stoken_ro)
41 | cond do
42 | username == nil -> nil
43 | user = Repo.get_by(User, username: username) ->
44 | user
45 | |> User.seen(timestamp)
46 | |> Repo.update()
47 | true ->
48 | Logger.warn("Username not found: #{username}")
49 | end
50 | end
51 |
52 | def handle_event(_, _, _, _) do
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/lib/tmate/mailer.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Mailer do
2 | use Bamboo.Mailer, otp_app: :tmate
3 | end
4 |
--------------------------------------------------------------------------------
/lib/tmate/models/client.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Client do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | @primary_key {:id, :binary_id, autogenerate: false}
6 |
7 | schema "clients" do
8 | belongs_to :session, Tmate.Session, type: :binary_id, references: :id
9 | field :ip_address, :string
10 | field :joined_at, :utc_datetime
11 | field :readonly, :boolean
12 | end
13 |
14 | def changeset(model, params \\ %{}) do
15 | model
16 | |> change(params)
17 | |> unique_constraint(:id, name: :clients_pkey)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/tmate/models/event.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Event do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | alias Tmate.Event
6 | import Ecto.Query
7 | alias Tmate.Repo
8 |
9 | require Logger
10 |
11 | schema "events" do
12 | field :type, :string
13 | field :entity_id, Ecto.UUID
14 | field :timestamp, :utc_datetime
15 | field :params, :map
16 | end
17 |
18 | def changeset(model, params \\ %{}) do
19 | model
20 | |> change(params)
21 | end
22 |
23 | def emit!(event_type, entity_id, timestamp, params) do
24 | timestamp = DateTime.truncate(timestamp, :second)
25 | event_params = %{type: Atom.to_string(event_type), entity_id: entity_id,
26 | timestamp: timestamp, params: params}
27 | Repo.transaction fn ->
28 | Event.changeset(%Event{}, event_params) |> Repo.insert!
29 | Tmate.EventProjections.handle_event(event_type, entity_id, timestamp, params)
30 | end
31 | end
32 |
33 | def emit!(event_type, entity_id, timestamp, generation, params) do
34 | # generation == 1 will be stored as nil (easier to migrate, and maybe it's
35 | # more efficient storage wise).
36 | generation = if generation == 1, do: nil, else: generation
37 |
38 | # Turns out, trying to order event by looking at the Event table was a
39 | # mistake: performance were terrible.
40 | # generations are only useful for sessions, and we'll deal with that in the
41 | # session handlers.
42 | emit!(event_type, entity_id, timestamp, Map.merge(params, %{generation: generation}))
43 | end
44 |
45 | def emit!(event_type, entity_id, params) do
46 | now = DateTime.utc_now
47 | emit!(event_type, entity_id, now, params)
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/lib/tmate/models/session.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Session do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | @primary_key {:id, :binary_id, autogenerate: false}
6 |
7 | schema "sessions" do
8 | field :host_last_ip, :string
9 | field :ws_url_fmt, :string
10 | field :ssh_cmd_fmt, :string
11 | field :stoken, :string
12 | field :stoken_ro, :string
13 | field :created_at, :utc_datetime
14 | field :disconnected_at, :utc_datetime
15 | field :closed, :boolean
16 | field :generation, :integer
17 | has_many :clients, Tmate.Client
18 | end
19 |
20 | def changeset(model, params \\ %{}) do
21 | model
22 | |> change(params)
23 | |> unique_constraint(:id, name: :sessions_pkey)
24 | end
25 |
26 | def wsapi_base_url(ws_url_fmt) do
27 | # e.g., wss://lon1.tmate.io:33/ws/session/%s
28 | case URI.parse(ws_url_fmt).authority do
29 | # dev mode: hardcoding, not great, but it's fine for now
30 | "localhost:4001" -> "http://session:4001/internal_api"
31 | host -> "https://#{host}/internal_api"
32 | end
33 | end
34 |
35 | def edge_srv_hostname(ssh_hostname) do
36 | # ssh -p2200 %s@ny3.tmate.io
37 | ssh_hostname
38 | |> String.split("@") |> Enum.at(1)
39 | |> String.split(".") |> Enum.at(0)
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/tmate/models/user.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.User do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 |
5 | import Ecto.Query
6 | alias Tmate.Repo
7 | alias Tmate.User
8 |
9 | require Logger
10 |
11 | @primary_key {:id, :binary_id, autogenerate: false}
12 |
13 | schema "users" do
14 | field :username, :string
15 | field :email, :string
16 | field :api_key, :string
17 | field :verified, :boolean
18 | field :created_at, :utc_datetime
19 | field :last_seen_at, :utc_datetime
20 | field :allow_mailing_list, :boolean
21 | end
22 |
23 | def get_by_api_key(api_key) do
24 | user = Repo.one(from u in User, where: u.api_key == ^api_key)
25 | if !user, do: :timer.sleep(:crypto.rand_uniform(50, 200))
26 | user
27 | end
28 |
29 | def repr(user) do
30 | "user=#{user.username} (#{user.email})"
31 | end
32 |
33 | def seen(user, timestamp) do
34 | Logger.info("#{User.repr(user)} seen")
35 |
36 | user
37 | |> change(%{verified: true, last_seen_at: timestamp})
38 | end
39 |
40 | def changeset(model, params \\ %{}) do
41 | model
42 | |> cast(params, [:username, :email, :allow_mailing_list])
43 | |> validate_required(:username)
44 | |> validate_length(:username, min: 1, max: 40)
45 | |> validate_format(:username, ~r/^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9]))*$/,
46 | message: "Username may only contain alphanumeric characters or single hyphens"
47 | <> ", and cannot begin or end with a hyphen.")
48 | |> validate_required(:email)
49 | |> validate_format(:email, ~r/.@.*\.../, message: "Check your email")
50 | |> gen_api_key_if_empty()
51 | |> unique_constraint(:id, name: :users_pkey)
52 | |> unique_constraint(:username)
53 | |> unique_constraint(:email)
54 | |> unique_constraint(:api_key)
55 | end
56 |
57 | defmodule ApiKeyUtil do
58 | @api_key_letters "abcdefghjklmnopqrstuvwxyz" <>
59 | "ABCDEFGHJKLMNOPQRSTUVWXYZ" <>
60 | "0123456789"
61 |
62 | defp generate_random_char(chars, num_chars) do
63 | case :crypto.strong_rand_bytes(1) do
64 | <> when rand_int < num_chars -> String.at(chars, rand_int)
65 | _ -> generate_random_char(chars, num_chars)
66 | end
67 | end
68 |
69 | defp generate_random_string(chars, length) do
70 | rand_char = fn -> generate_random_char(chars, String.length(chars)) end
71 | Stream.repeatedly(rand_char) |> Enum.take(length) |> Enum.join()
72 | end
73 |
74 | def gen_api_key() do
75 | "tmk-#{generate_random_string(@api_key_letters, 26)}"
76 | end
77 | end
78 |
79 | defp gen_api_key_if_empty(changeset) do
80 | case get_change(changeset, :api_key) do
81 | nil -> put_change(changeset, :api_key, ApiKeyUtil.gen_api_key())
82 | _ -> changeset
83 | end
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/lib/tmate/monitoring.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Monitoring do
2 | require Prometheus.Registry
3 |
4 | def setup do
5 | Tmate.Endpoint.PhoenixInstrumenter.setup()
6 | Tmate.PlugExporter.setup()
7 | Tmate.Repo.Instrumenter.setup2()
8 | end
9 | end
10 |
11 | defmodule Tmate.Endpoint.PhoenixInstrumenter do
12 | use Prometheus.PhoenixInstrumenter
13 | end
14 |
15 | defmodule Tmate.Repo.Instrumenter do
16 | use Prometheus.EctoInstrumenter
17 |
18 | def setup2() do
19 | setup()
20 | :ok = :telemetry.attach(
21 | "prometheus-ecto",
22 | [:tmate, :repo, :query],
23 | &__MODULE__.handle_event/4,
24 | %{}
25 | )
26 | end
27 | end
28 |
29 | # Tmate metrics are implemented in Tmate.MonitoringCollector
30 |
31 | ### Exporter
32 |
33 | defmodule Tmate.PlugExporter do
34 | use Prometheus.PlugExporter
35 | end
36 |
37 | defmodule Tmate.Monitoring.Endpoint do
38 | use Plug.Router
39 |
40 | plug Tmate.PlugExporter
41 |
42 | plug :match
43 | plug :dispatch
44 |
45 | match _ do
46 | send_resp(conn, 404, "Oops!")
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/tmate/monitoring_collector.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.MonitoringCollector do
2 | use Prometheus.Collector
3 |
4 | alias Tmate.Repo
5 | alias Tmate.Session
6 | alias Tmate.User
7 | import Ecto.Query
8 |
9 | # Collectors are automatically registered by the prometheus module.
10 |
11 | @bucket_names %{nil => "never", 0 => "long time",
12 | 1 => "past year", 2 => "past month", 3 => "past week", 4 => "past day"}
13 | @bucket_days [ 365, 30, 7, 1]
14 |
15 | def collect_mf(_registry, callback) do
16 | {:ok, options} = Application.fetch_env(:tmate, Tmate.MonitoringCollector)
17 | if options[:metrics_enabled], do: collect_mf_stub(callback)
18 | :ok
19 | end
20 |
21 | defp collect_mf_stub(callback) do
22 | per_host_key_fn = & Enum.map(&1, fn {ssh_cmd_fmt, count} ->
23 | {[host: Session.edge_srv_hostname(ssh_cmd_fmt)], count}
24 | end)
25 |
26 | callback.(create_keyed_gauge(
27 | :tmate_num_sessions, "Number of sessions",
28 | from(s in Session, where: is_nil(s.disconnected_at),
29 | group_by: s.ssh_cmd_fmt,
30 | select: {s.ssh_cmd_fmt, count(s.id)}),
31 | per_host_key_fn))
32 |
33 | callback.(create_keyed_gauge(
34 | :tmate_num_paired_sessions, "Number of paired sessions",
35 | from(s in Session, where: is_nil(s.disconnected_at),
36 | join: assoc(s, :clients),
37 | group_by: s.ssh_cmd_fmt,
38 | select: {s.ssh_cmd_fmt, count(s.id, :distinct)}),
39 | per_host_key_fn))
40 |
41 | callback.(create_keyed_gauge(
42 | :tmate_num_users, "Number of users seen",
43 | from(u in User,
44 | select: %{bucket: fragment("width_bucket(?, ARRAY[?,?,?,?])",
45 | u.last_seen_at,
46 | # Quite terrible, we should do the quote
47 | # unquote danse to deal with this (fragment
48 | # and ago are macros that are capricious).
49 | ago(^(@bucket_days |> Enum.at(0)), "day"),
50 | ago(^(@bucket_days |> Enum.at(1)), "day"),
51 | ago(^(@bucket_days |> Enum.at(2)), "day"),
52 | ago(^(@bucket_days |> Enum.at(3)), "day")),
53 | count: count()},
54 | group_by: 1),
55 | & Enum.map(&1, fn %{bucket: bucket_index, count: count} ->
56 | {[bucket: Map.get(@bucket_names, bucket_index)], count}
57 | end)))
58 | end
59 |
60 | defmodule KeyedGauge do
61 | def collect_metrics(_name, {query, key_fn}) do
62 | Repo.all(query)
63 | |> key_fn.()
64 | |> Prometheus.Model.gauge_metrics()
65 | end
66 | end
67 |
68 | defp create_keyed_gauge(name, help, query, key_fn) do
69 | Prometheus.Model.create_mf(name, help, :gauge, __MODULE__.KeyedGauge, {query, key_fn})
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/tmate/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo do
2 | use Ecto.Repo,
3 | otp_app: :tmate,
4 | adapter: Ecto.Adapters.Postgres
5 | end
6 |
--------------------------------------------------------------------------------
/lib/tmate/scheduler.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Scheduler do
2 | use Quantum.Scheduler,
3 | otp_app: :tmate
4 | end
5 |
--------------------------------------------------------------------------------
/lib/tmate/session_cleaner.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.SessionCleaner do
2 | require Logger
3 |
4 | alias Tmate.Repo
5 | alias Tmate.Session
6 | alias Tmate.Event
7 | import Ecto.Query
8 |
9 | def prune_sessions() do
10 | prune_sessions({1, "week"})
11 | end
12 |
13 | def prune_sessions({timeout_value, timeout_unit}) do
14 | Logger.info("Pruning dead sessions older than #{timeout_value} #{timeout_unit}")
15 |
16 | {n_pruned, sids} = from(s in Session,
17 | where: s.disconnected_at < ago(^timeout_value, ^timeout_unit),
18 | select: s.id)
19 | |> Repo.delete_all
20 |
21 | if n_pruned != 0 do
22 | Logger.info("Pruned #{n_pruned} dead sessions: #{sids}")
23 | end
24 |
25 | :ok
26 | end
27 |
28 | def check_for_disconnected_sessions(wsapi_module \\ Tmate.WsApi) do
29 | Logger.info("Checking for disconnected sessions")
30 | from(s in Session, where: is_nil(s.disconnected_at),
31 | select: {s.id, s.generation, s.ws_url_fmt})
32 | |> Repo.all
33 | |> Enum.group_by(fn {_id, _generation, ws_url_fmt} -> ws_url_fmt end,
34 | fn {id, generation, _ws_url_fmt} -> {id, generation} end)
35 | |> Enum.each(fn {ws_url_fmt, sessions} ->
36 | sid_generations = sessions |> Map.new
37 | base_url = Session.wsapi_base_url(ws_url_fmt)
38 | check_for_disconnected_sessions(wsapi_module, base_url, sid_generations)
39 | end)
40 |
41 | :ok
42 | end
43 |
44 | defp check_for_disconnected_sessions(wsapi_module, base_url, sid_generations) do
45 | # When a websocket serves goes down, it does not necessarily notify disconnections.
46 | # We'll emit these missings events here.
47 |
48 | # 1) we get the stale entries
49 | case sid_generations
50 | |> Map.keys
51 | |> wsapi_module.get_stale_sessions(base_url) do
52 | {:ok, stale_ids} ->
53 | stale_ids
54 | |> Enum.map(& {&1, sid_generations[&1]})
55 | |> Enum.each(fn {sid, generation} ->
56 | # 2) emit the events for the stale entries
57 | Logger.warn("Stale session id=#{sid}")
58 | Event.emit!(:session_disconnect, sid, DateTime.utc_now, generation, %{})
59 | end)
60 | {:error, _} ->
61 | nil # error is already logged
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/lib/tmate/util/ecto_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Util.EctoHelpers do
2 | alias Tmate.Repo
3 | import Ecto.Query
4 |
5 | def get_or_insert(changeset, key, retry) when is_atom(key) do
6 | get_or_insert(changeset, [key], retry)
7 | end
8 |
9 | def get_or_insert(changeset, query_keys, retry) do
10 | params = Enum.map(query_keys, fn key ->
11 | {_, value} = Ecto.Changeset.fetch_field(changeset, key)
12 | {key, value}
13 | end) |> Enum.into(%{})
14 |
15 | case Repo.get_by(changeset.data.__struct__, params) do
16 | nil ->
17 | case {Repo.insert(changeset), retry} do
18 | {{:ok, instance}, _} ->
19 | {:ok, :insert, instance}
20 | {{:error, %{constraints: [%{field: _, type: :unique}]}}, true} ->
21 | # TODO write test case
22 | get_or_insert(changeset, query_keys, false)
23 | {{:error, changeset}, _} ->
24 | {:error, changeset}
25 | end
26 | instance -> {:ok, :get, instance}
27 | end
28 | end
29 |
30 | def get_or_insert(changeset, query_keys) do
31 | get_or_insert(changeset, query_keys, true)
32 | end
33 |
34 | def get_or_insert(changeset) do
35 | get_or_insert(changeset, Keyword.keys(Ecto.primary_key(changeset.data)))
36 | end
37 |
38 | def get_or_insert!(changeset, query_keys) do
39 | case get_or_insert(changeset, query_keys) do
40 | {:ok, _, instance} -> instance
41 | {:error, changeset} ->
42 | raise Ecto.InvalidChangesetError, action: :insert, changeset: changeset
43 | end
44 | end
45 |
46 | def get_or_insert!(changeset) do
47 | get_or_insert!(changeset, Keyword.keys(Ecto.primary_key(changeset.data)))
48 | end
49 |
50 | def last(model) do
51 | Repo.one(from s in model, order_by: [desc: :id], limit: 1)
52 | end
53 |
54 | def validate_changeset(changeset) do
55 | # Little sad that we don't get all constraints validations done, but it's a
56 | # hassle to do otherwise.
57 | case Repo.transaction(fn ->
58 | result = changeset |> Repo.insert
59 | Repo.rollback({:insert_result, result})
60 | end) do
61 | {:error, {:insert_result, {:ok, _}}} -> :ok
62 | {:error, {:insert_result, {:error, changeset}}} -> {:error, changeset}
63 | end
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/lib/tmate/util/json_api.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Util.JsonApi do
2 | defmacro __using__(opts) do
3 | quote do
4 | import Tmate.Util.JsonApi
5 | use HTTPoison.Base
6 | alias HTTPoison.Request
7 | alias HTTPoison.Response
8 | alias HTTPoison.Error
9 | require Logger
10 |
11 | @opts unquote(opts[:fn_opts])
12 |
13 | defp opts() do
14 | if is_function(@opts), do: @opts.(), else: @opts
15 | end
16 |
17 | def process_url(url) do
18 | base_url = opts()[:base_url]
19 | if base_url, do: base_url <> url, else: url
20 | end
21 |
22 | def process_request_headers(headers) do
23 | auth_token = opts()[:auth_token]
24 | auth_headers = if auth_token, do: [{"Authorization", "Bearer " <> auth_token}], else: []
25 | json_headers = [{"Content-Type", "application/json"}, {"Accept", "application/json"}]
26 | headers ++ auth_headers ++ json_headers
27 | end
28 |
29 | def process_request_body(""), do: ""
30 | def process_request_body(body) do
31 | Jason.encode!(body)
32 | end
33 |
34 | def process_response(%Response{headers: headers, body: body} = response) do
35 | content_type_hdr = Enum.find(headers, fn {name, _} -> name == "content-type" end)
36 | body = case content_type_hdr do
37 | {_, "application/json" <> _} -> Jason.decode!(body)
38 | _ -> body
39 | end
40 |
41 | %{response | body: body}
42 | end
43 |
44 | defp simplify_response({:ok, %Response{status_code: 200, body: body}}, _) do
45 | {:ok, body}
46 | end
47 |
48 | defp simplify_response({:ok, %Response{status_code: status_code}},
49 | %Request{url: url, method: method}) do
50 | Logger.error("API error: #{method} #{url} [#{status_code}]")
51 | {:error, status_code}
52 | end
53 |
54 | defp simplify_response({:error, %Error{reason: reason}},
55 | %Request{url: url, method: method}) do
56 | Logger.error("API error: #{method} #{url} [#{reason}]")
57 | {:error, reason}
58 | end
59 |
60 | defp debug_response({:ok, %Response{status_code: status_code, body: resp_body}} = response,
61 | %Request{url: url, body: req_body, params: params, method: method}) do
62 | Logger.debug("API Request: #{inspect(method)} #{inspect(url)} #{inspect(params)} #{inspect(req_body)}")
63 | Logger.debug("API Response: #{inspect(resp_body)} #{inspect(status_code)}")
64 | response
65 | end
66 | defp debug_response(resp, _req), do: resp
67 |
68 | def request(request) do
69 | super(request)
70 | |> debug_response(request)
71 | |> simplify_response(request)
72 | end
73 |
74 | def request!(method, url, body \\ "", headers \\ [], options \\ []) do
75 | case request(method, url, body, headers, options) do
76 | {:ok, body} -> body
77 | {:error, reason} -> raise Error, reason: reason
78 | end
79 | end
80 | end
81 | end
82 |
83 | def with_atom_keys(obj) do
84 | Map.new(obj, fn {k, v} ->
85 | v = if is_map(v), do: with_atom_keys(v), else: v
86 | {String.to_atom(k), v}
87 | end)
88 | end
89 |
90 | def as_atom(obj, key) do
91 | value = Map.get(obj, key)
92 | value = if value, do: String.to_atom(value), else: value
93 | Map.put(obj, key, value)
94 | end
95 |
96 | def as_timestamp(obj, key) do
97 | value = Map.get(obj, key)
98 |
99 | value = if value do
100 | {:ok, timestamp, 0} = DateTime.from_iso8601(value)
101 | timestamp
102 | else
103 | value
104 | end
105 |
106 | Map.put(obj, key, value)
107 | end
108 | end
109 |
--------------------------------------------------------------------------------
/lib/tmate/util/plug_remote_ip.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Util.PlugRemoteIp do
2 | require Logger
3 | @behaviour Plug
4 |
5 | # We use the PROXY protocol, but if we were to use headers,
6 | # we could use https://github.com/ajvondrak/remote_ip
7 |
8 | def init(opts) do
9 | opts
10 | end
11 |
12 | def call(conn, _opts) do
13 | conn
14 | |> set_proxied_remote_ip()
15 | |> log_remote_ip()
16 | end
17 |
18 | defp set_proxied_remote_ip(%{adapter: {_connection,
19 | %{proxy_header: %{src_address: src_address}
20 | =_proxy_header}=_req}}=conn) do
21 | %{conn | remote_ip: src_address}
22 | end
23 |
24 | defp set_proxied_remote_ip(conn) do
25 | conn
26 | end
27 |
28 | defp log_remote_ip(%{remote_ip: remote_ip}=conn) do
29 | ip = :inet_parse.ntoa(remote_ip) |> to_string
30 | Logger.metadata(remote_ip: ip)
31 | conn
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/tmate/util/plug_verify_auth_token.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Util.PlugVerifyAuthToken do
2 | @behaviour Plug
3 |
4 | defmodule Error.Unauthorized do
5 | defexception message: "Unauthorized", plug_status: 401
6 | end
7 |
8 | def init(opts) do
9 | opts
10 | end
11 |
12 | def call(conn, opts) do
13 | opts = if opts[:fn_opts], do: opts[:fn_opts].(), else: opts
14 | verify_auth_token!(conn, opts)
15 | conn
16 | end
17 |
18 | defp verify_auth_token1(%{req_headers: req_headers}, opts) do
19 | auth_header = Enum.find(req_headers, fn {name, _} -> name == "authorization" end)
20 | case auth_header do
21 | {_, "Bearer " <> token} -> Plug.Crypto.secure_compare(token, opts[:auth_token])
22 | _ -> false
23 | end
24 | end
25 |
26 | # old format
27 | defp verify_auth_token2(%{body_params: %{"userdata" => token}}, opts) do
28 | Plug.Crypto.secure_compare(token, opts[:auth_token])
29 | end
30 | defp verify_auth_token2(_conn, _opts), do: false
31 |
32 | defp verify_auth_token!(conn, opts) do
33 | if (!verify_auth_token1(conn, opts) && !verify_auth_token2(conn, opts)) do
34 | raise Error.Unauthorized
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/tmate/ws_api.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.WsApi do
2 | def internal_api_opts do
3 | # XXX We can't pass the auth token directly, it is not
4 | # necessarily defined at compile time.
5 | Application.fetch_env!(:tmate, :master)[:internal_api]
6 | end
7 | use Tmate.Util.JsonApi, fn_opts: &__MODULE__.internal_api_opts/0
8 |
9 | def get_stale_sessions(session_ids, base_url) do
10 | case post(base_url <> "/get_stale_sessions", %{session_ids: session_ids}) do
11 | {:ok, body} -> {:ok, body["stale_ids"]}
12 | {:error, reason} -> {:error, reason}
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/tmate_web.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, views, channels and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use TmateWeb, :controller
9 | use TmateWeb, :view
10 |
11 | The definitions below will be executed for every view,
12 | controller, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define any helper function in modules
17 | and import those modules here.
18 | """
19 |
20 | def controller do
21 | quote do
22 | use Phoenix.Controller, namespace: TmateWeb
23 |
24 | import Plug.Conn
25 | import TmateWeb.Gettext
26 | alias TmateWeb.Router.Helpers, as: Routes
27 | end
28 | end
29 |
30 | def view do
31 | quote do
32 | use Phoenix.View,
33 | root: "lib/tmate_web/templates",
34 | namespace: TmateWeb
35 |
36 | # Import convenience functions from controllers
37 | import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1]
38 |
39 | # Use all HTML functionality (forms, tags, etc)
40 | use Phoenix.HTML
41 |
42 | import TmateWeb.ErrorHelpers
43 | import TmateWeb.Gettext
44 | alias TmateWeb.Router.Helpers, as: Routes
45 | end
46 | end
47 |
48 | def router do
49 | quote do
50 | use Phoenix.Router
51 | import Plug.Conn
52 | import Phoenix.Controller
53 | end
54 | end
55 |
56 | def channel do
57 | quote do
58 | use Phoenix.Channel
59 | import TmateWeb.Gettext
60 | end
61 | end
62 |
63 | @doc """
64 | When used, dispatch to the appropriate controller/view/etc.
65 | """
66 | defmacro __using__(which) when is_atom(which) do
67 | apply(__MODULE__, which, [])
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/lib/tmate_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | # channel "room:*", TmateWeb.RoomChannel
6 |
7 | # Socket params are passed from the client and can
8 | # be used to verify and authenticate a user. After
9 | # verification, you can put default assigns into
10 | # the socket that will be set for all channels, ie
11 | #
12 | # {:ok, assign(socket, :user_id, verified_user_id)}
13 | #
14 | # To deny connection, return `:error`.
15 | #
16 | # See `Phoenix.Token` documentation for examples in
17 | # performing token verification on connect.
18 | def connect(_params, socket, _connect_info) do
19 | {:ok, socket}
20 | end
21 |
22 | # Socket id's are topics that allow you to identify all sockets for a given user:
23 | #
24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}"
25 | #
26 | # Would allow you to broadcast a "disconnect" event and terminate
27 | # all active sockets and channels for a given user:
28 | #
29 | # TmateWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
30 | #
31 | # Returning `nil` makes this socket anonymous.
32 | def id(_socket), do: nil
33 | end
34 |
--------------------------------------------------------------------------------
/lib/tmate_web/controllers/internal_api_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.InternalApiController do
2 | use TmateWeb, :controller
3 | alias Tmate.Session
4 | alias Tmate.User
5 | require Logger
6 | import Ecto.Query
7 | alias Tmate.Repo
8 |
9 | alias Tmate.Util.JsonApi
10 |
11 | # We come here authenticated
12 |
13 | def webhook(conn, event_payload) do
14 | # Note: the incoming data is trusted, it's okay to convert to atom.
15 | event_payload =
16 | event_payload
17 | |> JsonApi.with_atom_keys()
18 | |> JsonApi.as_atom(:type)
19 | |> JsonApi.as_timestamp(:timestamp)
20 |
21 | %{type: type, entity_id: entity_id,
22 | timestamp: timestamp, generation: generation,
23 | params: params} = event_payload
24 |
25 | Tmate.Event.emit!(type, entity_id, timestamp, generation, params)
26 |
27 | conn
28 | |> json(%{})
29 | end
30 |
31 | def get_session(conn, %{"token" => token}) do
32 | session = Repo.one(from s in Session,
33 | where: s.stoken == ^token or s.stoken_ro == ^token,
34 | select: %{id: s.id,
35 | ssh_cmd_fmt: s.ssh_cmd_fmt,
36 | created_at: s.created_at,
37 | disconnected_at: s.disconnected_at,
38 | closed: s.closed},
39 | order_by: [desc: s.created_at],
40 | limit: 1)
41 | if session do
42 | conn
43 | |> json(session)
44 | else
45 | conn
46 | |> put_status(404)
47 | |> json(%{error: "not found"})
48 | end
49 | end
50 |
51 | def get_named_session_prefix(conn, %{"api_key" => api_key}) do
52 | user = User.get_by_api_key(api_key)
53 | if user do
54 | prefix = "#{user.username}/"
55 | result = %{prefix: prefix}
56 | conn
57 | |> json(result)
58 | else
59 | conn
60 | |> put_status(404)
61 | |> json(%{error: "api key not found"})
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/lib/tmate_web/controllers/sign_up_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.SignUpController do
2 | use TmateWeb, :controller
3 |
4 | alias Tmate.Util.EctoHelpers
5 | alias Tmate.User
6 | alias Tmate.Repo
7 |
8 | require Logger
9 |
10 | def new(conn, _params) do
11 | flash_msg = get_flash(conn, :registration)
12 |
13 | changeset = User.changeset(%User{}, %{allow_mailing_list: true})
14 | conn
15 | |> clear_flash()
16 | |> render("home.html", changeset: changeset, flash_info: flash_msg)
17 | end
18 |
19 | def create(conn, %{"user"=> %{"email"=> email}=user_params}) do
20 | # When a user try to sign up with an email corresponding to an existing
21 | # verified account, we'll send the credentials that we already have.
22 | # Otherwise, we proceed to creating the account.
23 | case Repo.get_by(User, email: email) do
24 | %{id: user_id, verified: true} ->
25 | Tmate.Event.emit!(:email_api_key, user_id, %{again: true})
26 |
27 | conn
28 | |> put_flash(:registration, "Your API key has been sent to #{email}"
29 | <> ". If you need a new API key, please contact us at support@tmate.io")
30 | |> redirect(to: "#{Routes.sign_up_path(conn, :new)}#api_key")
31 | %{id: user_id, verified: false} ->
32 | Tmate.Event.emit!(:expire_user, user_id, %{})
33 | create_stub(conn, user_params)
34 | _ ->
35 | create_stub(conn, user_params)
36 | end
37 | end
38 |
39 | defp create_stub(conn, %{"email" => email, "username" => username}=user_params) do
40 | case Repo.get_by(User, username: username) do
41 | %{id: user_id, verified: false} ->
42 | Tmate.Event.emit!(:expire_user, user_id, %{})
43 | _ -> nil
44 | end
45 |
46 | user_id = UUID.uuid1()
47 | changeset = User.changeset(%User{id: user_id}, user_params)
48 |
49 | # Note that validate_changeset() will test the uniqueness validations
50 | case EctoHelpers.validate_changeset(changeset) do
51 | {:error, changeset} ->
52 | Logger.warn("signup invalid: #{inspect(changeset)}")
53 | conn
54 | |> put_status(400)
55 | |> render("home.html", changeset: changeset, flash_info: nil)
56 | :ok ->
57 | Tmate.Event.emit!(:user_create, user_id, changeset.changes)
58 | Tmate.Event.emit!(:email_api_key, user_id, %{})
59 | conn
60 | |> put_flash(:registration, "Your API key has been sent to #{email}")
61 | |> redirect(to: "#{Routes.sign_up_path(conn, :new)}#api_key")
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/lib/tmate_web/controllers/terminal_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.TerminalController do
2 | use TmateWeb, :controller
3 |
4 | alias Tmate.Repo
5 | alias Tmate.Session
6 | import Ecto.Query
7 |
8 | def show(conn, %{"token" => token_path}) do
9 | token = Enum.join(token_path, "/") # tokens can have /, and comes in as an array
10 | conn
11 | |> delete_resp_header("x-frame-options") # so it's embeddable in iframes.
12 | |> render("show.html", token: token)
13 | end
14 |
15 | def show_json(conn, %{"token" => token_path}) do
16 | token = Enum.join(token_path, "/") # tokens can have /, and comes in as an array
17 |
18 | session = Repo.one(from s in Session, where: s.stoken == ^token or s.stoken_ro == ^token,
19 | select: %{ws_url_fmt: s.ws_url_fmt,
20 | ssh_cmd_fmt: s.ssh_cmd_fmt,
21 | created_at: s.created_at,
22 | disconnected_at: s.disconnected_at,
23 | closed: s.closed},
24 | order_by: [desc: s.created_at],
25 | limit: 1)
26 | if session do
27 | # Compat for the UI
28 | closed_at = if session.closed, do: session.disconnected_at, else: nil
29 | session = Map.merge(session, %{closed_at: closed_at})
30 |
31 | conn
32 | |> json(session)
33 | else
34 | :timer.sleep(:crypto.rand_uniform(50, 200))
35 | conn
36 | |> put_status(404)
37 | |> json(%{error: "not found"})
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/tmate_web/emails/email.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Email do
2 | use Bamboo.Phoenix, view: TmateWeb.EmailView
3 |
4 | def api_key_email(%{email: email, username: username, api_key: api_key}) do
5 | base_email()
6 | |> to(email)
7 | |> subject("Your tmate API key")
8 | |> assign(:api_key, api_key)
9 | |> assign(:username, username)
10 | |> render(:api_key)
11 | end
12 |
13 | defp base_email do
14 | new_email()
15 | |> from(Application.fetch_env!(:tmate, Tmate.Mailer)[:from])
16 | |> put_html_layout({TmateWeb.LayoutView, "email.html"})
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/tmate_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :tmate
3 |
4 | socket "/socket", TmateWeb.UserSocket,
5 | websocket: true,
6 | longpoll: false
7 |
8 | # Serve at "/" the static files from "priv/static" directory.
9 | #
10 | # You should set gzip to true if you are running phx.digest
11 | # when deploying your static files in production.
12 | plug Plug.Static,
13 | at: "/",
14 | from: :tmate,
15 | gzip: true,
16 | only: ~w(css fonts img js favicon.ico robots.txt)
17 |
18 | # Code reloading can be explicitly enabled under the
19 | # :code_reloader configuration of your endpoint.
20 | if code_reloading? do
21 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
22 | plug Phoenix.LiveReloader
23 | plug Phoenix.CodeReloader
24 | end
25 |
26 | plug Tmate.Util.PlugRemoteIp
27 | # plug Plug.RequestId
28 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
29 | # plug Plug.Logger
30 |
31 | plug Plug.Parsers,
32 | parsers: [:urlencoded, :multipart, :json],
33 | pass: ["*/*"],
34 | json_decoder: Phoenix.json_library()
35 |
36 | plug Plug.MethodOverride
37 | plug Plug.Head
38 |
39 | # The session will be stored in the cookie and signed,
40 | # this means its contents can be read but not tampered with.
41 | # Set :encryption_salt if you would also like to encrypt it.
42 | plug Plug.Session,
43 | store: :cookie,
44 | key: "_tmate_key",
45 | signing_salt: "PlqZqmWt",
46 | encryption_salt: "vIeLihup"
47 |
48 | plug TmateWeb.Router
49 | end
50 |
--------------------------------------------------------------------------------
/lib/tmate_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import TmateWeb.Gettext
9 |
10 | # Simple translation
11 | gettext("Here is the string to translate")
12 |
13 | # Plural translation
14 | ngettext("Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3)
17 |
18 | # Domain-based translation
19 | dgettext("errors", "Here is the error message to translate")
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :tmate
24 | end
25 |
--------------------------------------------------------------------------------
/lib/tmate_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.Router do
2 | use TmateWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html"]
6 | plug :fetch_session
7 | plug :fetch_flash
8 | plug :protect_from_forgery
9 | plug :put_secure_browser_headers
10 | end
11 |
12 | pipeline :api do
13 | plug :accepts, ["json"]
14 | plug :fetch_session
15 | plug :put_secure_browser_headers
16 | end
17 |
18 | pipeline :internal_api do
19 | plug :accepts, ["json"]
20 |
21 | def internal_api_opts do
22 | # XXX We can't pass the auth token directly, it is not
23 | # necessarily defined at compile time.
24 | Application.fetch_env!(:tmate, :master)[:internal_api]
25 | end
26 | plug Tmate.Util.PlugVerifyAuthToken, fn_opts: &__MODULE__.internal_api_opts/0
27 | end
28 |
29 | scope "/api", TmateWeb do
30 | pipe_through :api
31 |
32 | get "/t/*token", TerminalController, :show_json
33 | end
34 |
35 | scope "/internal_api", TmateWeb do
36 | pipe_through :internal_api
37 | post "/webhook", InternalApiController, :webhook
38 | get "/session", InternalApiController, :get_session
39 | get "/named_session_prefix", InternalApiController, :get_named_session_prefix
40 | end
41 |
42 | scope "/", TmateWeb do
43 | pipe_through :browser
44 |
45 | get "/t/*token", TerminalController, :show
46 |
47 | get "/", SignUpController, :new
48 | post "/", SignUpController, :create
49 | end
50 |
51 | if Mix.env == :dev do
52 | # If using Phoenix
53 | forward "/sent_emails", Bamboo.SentEmailViewerPlug
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/tmate_web/templates/email/api_key.html.eex:
--------------------------------------------------------------------------------
1 | Dear tmate user,
2 |
3 | Your API key is: <%= @api_key %>
4 |
5 | You can use it to name sessions as such:
6 |
7 |
8 | From the CLI:
9 |
10 | tmate -k <%= @api_key %> -n testname
11 |
12 |
13 |
14 | Or from the ~/.tmate.conf
file:
15 |
16 | set tmate-api-key "<%= @api_key %>"
17 | set tmate-session-name "testname"
18 |
19 |
20 | It is also useful to put the API key in the tmate configuration file,
21 | and specify the session name on the CLI.
22 |
23 | Note that tmate version should be at least 2.4.0 .
24 | Check tmate version by running: tmate -V
25 |
26 |
27 | Access control must be considered when using named sessions, see https://tmate.io/#access_control .
28 |
29 |
30 | Good day,
31 | Jackie the robot
32 |
--------------------------------------------------------------------------------
/lib/tmate_web/templates/email/api_key.text.eex:
--------------------------------------------------------------------------------
1 | Dear tmate user,
2 |
3 | Your API key is: <%= @api_key %>
4 |
5 | You can use it to name sessions as such:
6 |
7 | From the CLI:
8 | tmate -k <%= @api_key %> -n testname
9 |
10 | Or from the ~/.tmate.conf file:
11 | set tmate-api-key "<%= @api_key %>"
12 | set tmate-session-name "testname"
13 |
14 | It is also useful to put the API key in the tmate configuration file,
15 | and specify the session name on the CLI.
16 |
17 | Note that tmate version should be at least 2.4.0.
18 | Check tmate version by running: tmate -V
19 |
20 | /!\ Access control must be considered when using named sessions,
21 | see https://tmate.io/#access_control
22 |
23 | Good day,
24 | Jackie the robot
25 |
--------------------------------------------------------------------------------
/lib/tmate_web/templates/layout/app.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | <%= @view_module.title(@view_template, assigns) %>
14 |
15 |
16 |
17 | <%= render @view_module, @view_template, assigns %>
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/lib/tmate_web/templates/layout/email.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
420 |
421 |
422 |
423 |
424 |
425 |
426 |
427 |
428 |
429 |
430 |
431 |
432 |
433 |
434 | <%= render @view_module, @view_template, assigns %>
435 |
436 |
437 |
438 |
439 |
440 |
441 |
442 |
443 |
444 |
445 |
446 |
447 |
448 |
449 |
--------------------------------------------------------------------------------
/lib/tmate_web/templates/terminal/show.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 | Loading...
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/lib/tmate_web/views/email_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.EmailView do
2 | use TmateWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/lib/tmate_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.ErrorHelpers do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | use Phoenix.HTML
7 |
8 | @doc """
9 | Generates tag for inlined form input errors.
10 | """
11 | def error_tag(form, field) do
12 | Enum.map(Keyword.get_values(form.errors, field), fn error ->
13 | content_tag(:span, translate_error(error), class: "help-block")
14 | end)
15 | end
16 |
17 | @doc """
18 | Translates an error message using gettext.
19 | """
20 | def translate_error({msg, opts}) do
21 | # When using gettext, we typically pass the strings we want
22 | # to translate as a static argument:
23 | #
24 | # # Translate "is invalid" in the "errors" domain
25 | # dgettext("errors", "is invalid")
26 | #
27 | # # Translate the number of files with plural rules
28 | # dngettext("errors", "1 file", "%{count} files", count)
29 | #
30 | # Because the error messages we show in our forms and APIs
31 | # are defined inside Ecto, we need to translate them dynamically.
32 | # This requires us to call the Gettext module passing our gettext
33 | # backend as first argument.
34 | #
35 | # Note we use the "errors" domain, which means translations
36 | # should be written to the errors.po file. The :count option is
37 | # set by Ecto and indicates we should also apply plural rules.
38 | if count = opts[:count] do
39 | Gettext.dngettext(TmateWeb.Gettext, "errors", msg, msg, count, opts)
40 | else
41 | Gettext.dgettext(TmateWeb.Gettext, "errors", msg, opts)
42 | end
43 | end
44 | end
45 |
46 | # old changesetview
47 | # defmodule Tmate.ChangesetView do
48 | # use TmateWeb, :view
49 |
50 | # def render("error.json", %{changeset: changeset}) do
51 | # validation_errors = Enum.reduce(changeset.errors, %{}, fn {field, error}, errors ->
52 | # Map.merge(errors, %{field => render_changeset_error(error)})
53 | # end)
54 |
55 | # if Enum.empty?(validation_errors) do
56 | # raise "No validation errors for #{inspect(changeset)}"
57 | # end
58 |
59 | # %{validation_errors: validation_errors}
60 | # end
61 |
62 | # def render_changeset_error({message, values}) do
63 | # Enum.reduce values, message, fn {k, v}, acc ->
64 | # String.replace(acc, "%{#{k}}", to_string(v))
65 | # end
66 | # end
67 |
68 | # def render_changeset_error(message) do
69 | # message
70 | # end
71 | # end
72 |
--------------------------------------------------------------------------------
/lib/tmate_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.ErrorView do
2 | use TmateWeb, :view
3 |
4 | # If you want to customize a particular status code
5 | # for a certain format, you may uncomment below.
6 | # def render("500.html", _assigns) do
7 | # "Internal Server Error"
8 | # end
9 |
10 | # By default, Phoenix returns the status message from
11 | # the template name. For example, "404.html" becomes
12 | # "Not Found".
13 | def template_not_found(template, _assigns) do
14 | Phoenix.Controller.status_message_from_template(template)
15 | end
16 | end
17 |
18 |
19 | # old error view
20 | # defmodule Tmate.ErrorView do
21 | # use TmateWeb, :view
22 |
23 | # def render("404.html", _assigns) do
24 | # "Page not found"
25 | # end
26 |
27 | # def render("500.html", _assigns) do
28 | # "Server internal error"
29 | # end
30 |
31 | # def render("500.json", _assigns) do
32 | # %{error: "Server internal error"}
33 | # end
34 |
35 | # # In case no render clause matches or no
36 | # # template is found, let's render it as 500
37 | # def template_not_found(_template, assigns) do
38 | # render "500.html", assigns
39 | # end
40 | # end
41 |
--------------------------------------------------------------------------------
/lib/tmate_web/views/layout_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.LayoutView do
2 | use TmateWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/lib/tmate_web/views/sign_up_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.SignUpView do
2 | use TmateWeb, :view
3 |
4 | def title("home.html", _assigns) do
5 | "tmate • Instant terminal sharing"
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/lib/tmate_web/views/terminal_view.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.TerminalView do
2 | use TmateWeb, :view
3 |
4 | def title("show.html", %{token: token}) do
5 | "tmate • #{token}"
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :tmate,
7 | version: "0.1.1",
8 | elixir: "~> 1.9",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | compilers: [:phoenix, :gettext] ++ Mix.compilers(),
11 | start_permanent: Mix.env() == :prod,
12 | aliases: aliases(),
13 | deps: deps()
14 | ]
15 | end
16 |
17 | # Configuration for the OTP application.
18 | #
19 | # Type `mix help compile.app` for more information.
20 | def application do
21 | [
22 | mod: {Tmate.Application, []},
23 | extra_applications: [:logger, :runtime_tools]
24 | ]
25 | end
26 |
27 | # Specifies which paths to compile per environment.
28 | defp elixirc_paths(:test), do: ["lib", "test/support"]
29 | defp elixirc_paths(_), do: ["lib"]
30 |
31 | # Specifies your project dependencies.
32 | #
33 | # Type `mix help deps` for examples and options.
34 | defp deps do
35 | [
36 | {:phoenix, "~> 1.4.11"},
37 | {:phoenix_pubsub, "~> 1.1"},
38 | {:phoenix_ecto, "~> 4.0"},
39 | {:ecto_sql, "~> 3.1"},
40 | {:postgrex, ">= 0.0.0"},
41 | {:phoenix_html, "~> 2.11"},
42 | {:phoenix_live_reload, "~> 1.2", only: :dev},
43 | {:gettext, "~> 0.11"},
44 | {:jason, "~> 1.0"},
45 | {:cowboy, "~> 2.0"},
46 | {:plug_cowboy, "~> 2.0"},
47 | {:uuid, "~> 1.1" },
48 | {:ex_machina, ">= 0.0.0", only: :test},
49 | {:distillery, "~> 2.0"},
50 | {:prometheus_plugs, "~> 1.1.1"},
51 | {:prometheus_phoenix, "~> 1.3.0"},
52 | {:prometheus_ecto, "~> 1.4.1"},
53 | {:quantum, "~> 2.3"},
54 | {:timex, "~> 3.0"},
55 | {:httpoison, ">= 0.0.0"},
56 | {:bamboo, "~> 1.3"},
57 | {:bamboo_smtp, "~> 3.0.0"},
58 | ]
59 | end
60 |
61 | # Aliases are shortcuts or tasks specific to the current project.
62 | # For example, to create, migrate and run the seeds file at once:
63 | #
64 | # $ mix ecto.setup
65 | #
66 | # See the documentation for `Mix` for more info on aliases.
67 | defp aliases do
68 | [
69 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
70 | "ecto.reset": ["ecto.drop", "ecto.setup"],
71 | test: ["ecto.create --quiet", "ecto.migrate", "test"]
72 | ]
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "accept": {:hex, :accept, "0.3.5", "b33b127abca7cc948bbe6caa4c263369abf1347cfa9d8e699c6d214660f10cd1", [:rebar3], [], "hexpm"},
3 | "artificery": {:hex, :artificery, "0.4.2", "3ded6e29e13113af52811c72f414d1e88f711410cac1b619ab3a2666bbd7efd4", [:mix], [], "hexpm"},
4 | "bamboo": {:hex, :bamboo, "1.3.0", "9ab7c054f1c3435464efcba939396c29c5e1b28f73c34e1f169e0881297a3141", [:mix], [{:hackney, ">= 1.13.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
5 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
6 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"},
7 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
8 | "cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
9 | "cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm"},
10 | "crontab": {:hex, :crontab, "1.1.8", "2ce0e74777dfcadb28a1debbea707e58b879e6aa0ffbf9c9bb540887bce43617", [:mix], [{:ecto, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
11 | "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
12 | "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm"},
13 | "distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"},
14 | "ecto": {:hex, :ecto, "3.2.5", "76c864b77948a479e18e69cc1d0f0f4ee7cced1148ffe6a093ff91eba644f0b5", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
15 | "ecto_sql": {:hex, :ecto_sql, "3.2.1", "4eed4100cbb2abcff10c46660d6613693807bf64f1b865f414daccf762d3758d", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.2.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
16 | "ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm"},
17 | "file_system": {:hex, :file_system, "0.2.7", "e6f7f155970975789f26e77b8b8d8ab084c59844d8ecfaf58cbda31c494d14aa", [:mix], [], "hexpm"},
18 | "gen_stage": {:hex, :gen_stage, "0.14.3", "d0c66f1c87faa301c1a85a809a3ee9097a4264b2edf7644bf5c123237ef732bf", [:mix], [], "hexpm"},
19 | "gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"},
20 | "gettext": {:hex, :gettext, "0.17.1", "8baab33482df4907b3eae22f719da492cee3981a26e649b9c2be1c0192616962", [:mix], [], "hexpm"},
21 | "hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
22 | "httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
23 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
24 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
25 | "libring": {:hex, :libring, "1.4.0", "41246ba2f3fbc76b3971f6bce83119dfec1eee17e977a48d8a9cfaaf58c2a8d6", [:mix], [], "hexpm"},
26 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
27 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
28 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
29 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
30 | "phoenix": {:hex, :phoenix, "1.4.11", "d112c862f6959f98e6e915c3b76c7a87ca3efd075850c8daa7c3c7a609014b0d", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
31 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
32 | "phoenix_html": {:hex, :phoenix_html, "2.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
33 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.1", "274a4b07c4adbdd7785d45a8b0bb57634d0b4f45b18d2c508b26c0344bd59b8f", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"},
34 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"},
35 | "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},
36 | "plug_cowboy": {:hex, :plug_cowboy, "2.1.0", "b75768153c3a8a9e8039d4b25bb9b14efbc58e9c4a6e6a270abff1cd30cbe320", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
37 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"},
38 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"},
39 | "postgrex": {:hex, :postgrex, "0.15.1", "23ce3417de70f4c0e9e7419ad85bdabcc6860a6925fe2c6f3b1b5b1e8e47bf2f", [: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"},
40 | "prometheus": {:hex, :prometheus, "4.4.1", "1e96073b3ed7788053768fea779cbc896ddc3bdd9ba60687f2ad50b252ac87d6", [:mix, :rebar3], [], "hexpm"},
41 | "prometheus_ecto": {:hex, :prometheus_ecto, "1.4.3", "3dd4da1812b8e0dbee81ea58bb3b62ed7588f2eae0c9e97e434c46807ff82311", [:mix], [{:ecto, "~> 2.0 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"},
42 | "prometheus_ex": {:hex, :prometheus_ex, "3.0.5", "fa58cfd983487fc5ead331e9a3e0aa622c67232b3ec71710ced122c4c453a02f", [:mix], [{:prometheus, "~> 4.0", [hex: :prometheus, repo: "hexpm", optional: false]}], "hexpm"},
43 | "prometheus_phoenix": {:hex, :prometheus_phoenix, "1.3.0", "c4b527e0b3a9ef1af26bdcfbfad3998f37795b9185d475ca610fe4388fdd3bb5", [:mix], [{:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.3 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}], "hexpm"},
44 | "prometheus_plugs": {:hex, :prometheus_plugs, "1.1.5", "25933d48f8af3a5941dd7b621c889749894d8a1082a6ff7c67cc99dec26377c5", [:mix], [{:accept, "~> 0.1", [hex: :accept, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:prometheus_ex, "~> 1.1 or ~> 2.0 or ~> 3.0", [hex: :prometheus_ex, repo: "hexpm", optional: false]}, {:prometheus_process_collector, "~> 1.1", [hex: :prometheus_process_collector, repo: "hexpm", optional: true]}], "hexpm"},
45 | "quantum": {:hex, :quantum, "2.3.4", "72a0e8855e2adc101459eac8454787cb74ab4169de6ca50f670e72142d4960e9", [:mix], [{:calendar, "~> 0.17", [hex: :calendar, repo: "hexpm", optional: true]}, {:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.12", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:swarm, "~> 3.3", [hex: :swarm, repo: "hexpm", optional: false]}, {:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: true]}], "hexpm"},
46 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
47 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
48 | "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"},
49 | "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"},
50 | "timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [: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"},
51 | "tzdata": {:hex, :tzdata, "1.0.2", "6c4242c93332b8590a7979eaf5e11e77d971e579805c44931207e32aa6ad3db1", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
52 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"},
53 | "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm"},
54 | }
55 |
--------------------------------------------------------------------------------
/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | ## From Ecto.Changeset.validate_length/3
53 | msgid "should be %{count} character(s)"
54 | msgid_plural "should be %{count} character(s)"
55 | msgstr[0] ""
56 | msgstr[1] ""
57 |
58 | msgid "should have %{count} item(s)"
59 | msgid_plural "should have %{count} item(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be at least %{count} character(s)"
64 | msgid_plural "should be at least %{count} character(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at most %{count} character(s)"
74 | msgid_plural "should be at most %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should have at most %{count} item(s)"
79 | msgid_plural "should have at most %{count} item(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | ## From Ecto.Changeset.validate_number/3
84 | msgid "must be less than %{number}"
85 | msgstr ""
86 |
87 | msgid "must be greater than %{number}"
88 | msgstr ""
89 |
90 | msgid "must be less than or equal to %{number}"
91 | msgstr ""
92 |
93 | msgid "must be greater than or equal to %{number}"
94 | msgstr ""
95 |
96 | msgid "must be equal to %{number}"
97 | msgstr ""
98 |
--------------------------------------------------------------------------------
/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here has no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 | ## From Ecto.Changeset.cast/4
12 | msgid "can't be blank"
13 | msgstr ""
14 |
15 | ## From Ecto.Changeset.unique_constraint/3
16 | msgid "has already been taken"
17 | msgstr ""
18 |
19 | ## From Ecto.Changeset.put_change/3
20 | msgid "is invalid"
21 | msgstr ""
22 |
23 | ## From Ecto.Changeset.validate_acceptance/3
24 | msgid "must be accepted"
25 | msgstr ""
26 |
27 | ## From Ecto.Changeset.validate_format/3
28 | msgid "has invalid format"
29 | msgstr ""
30 |
31 | ## From Ecto.Changeset.validate_subset/3
32 | msgid "has an invalid entry"
33 | msgstr ""
34 |
35 | ## From Ecto.Changeset.validate_exclusion/3
36 | msgid "is reserved"
37 | msgstr ""
38 |
39 | ## From Ecto.Changeset.validate_confirmation/3
40 | msgid "does not match confirmation"
41 | msgstr ""
42 |
43 | ## From Ecto.Changeset.no_assoc_constraint/3
44 | msgid "is still associated with this entry"
45 | msgstr ""
46 |
47 | msgid "are still associated with this entry"
48 | msgstr ""
49 |
50 | ## From Ecto.Changeset.validate_length/3
51 | msgid "should be %{count} character(s)"
52 | msgid_plural "should be %{count} character(s)"
53 | msgstr[0] ""
54 | msgstr[1] ""
55 |
56 | msgid "should have %{count} item(s)"
57 | msgid_plural "should have %{count} item(s)"
58 | msgstr[0] ""
59 | msgstr[1] ""
60 |
61 | msgid "should be at least %{count} character(s)"
62 | msgid_plural "should be at least %{count} character(s)"
63 | msgstr[0] ""
64 | msgstr[1] ""
65 |
66 | msgid "should have at least %{count} item(s)"
67 | msgid_plural "should have at least %{count} item(s)"
68 | msgstr[0] ""
69 | msgstr[1] ""
70 |
71 | msgid "should be at most %{count} character(s)"
72 | msgid_plural "should be at most %{count} character(s)"
73 | msgstr[0] ""
74 | msgstr[1] ""
75 |
76 | msgid "should have at most %{count} item(s)"
77 | msgid_plural "should have at most %{count} item(s)"
78 | msgstr[0] ""
79 | msgstr[1] ""
80 |
81 | ## From Ecto.Changeset.validate_number/3
82 | msgid "must be less than %{number}"
83 | msgstr ""
84 |
85 | msgid "must be greater than %{number}"
86 | msgstr ""
87 |
88 | msgid "must be less than or equal to %{number}"
89 | msgstr ""
90 |
91 | msgid "must be greater than or equal to %{number}"
92 | msgstr ""
93 |
94 | msgid "must be equal to %{number}"
95 | msgstr ""
96 |
--------------------------------------------------------------------------------
/priv/repo/console.exs:
--------------------------------------------------------------------------------
1 | # run: import_file("priv/repo/console.exs")
2 | # run: import_file("lib/tmate-0.1.1/priv/repo/console.exs")
3 |
4 | alias Tmate.Repo
5 | alias Tmate.Client
6 | alias Tmate.Session
7 | alias Tmate.Event
8 | alias Tmate.User
9 | alias Tmate.Identity
10 | import Ecto.Query
11 |
--------------------------------------------------------------------------------
/priv/repo/migrations/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto_sql],
3 | inputs: ["*.exs"]
4 | ]
5 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20151010162127_initial.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.Initial do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:events) do
6 | add :type, :string, null: false
7 | add :entity_id, :uuid
8 | add :timestamp, :utc_datetime, null: false
9 | add :params, :map, null: false
10 | end
11 | create index(:events, [:type])
12 | create index(:events, [:entity_id])
13 |
14 | create table(:identities) do
15 | add :type, :string, null: false
16 | add :key, :string, size: 1024, null: false
17 | end
18 | create index(:identities, [:type, :key], [unique: true])
19 |
20 | create table(:sessions, primary_key: false) do
21 | add :id, :uuid, primary_key: true
22 | add :host_identity_id, references(:identities, type: :integer), null: false
23 | add :host_last_ip, :string, null: false
24 | add :ws_base_url, :string, null: false
25 | add :stoken, :string, size: 30, null: false
26 | add :stoken_ro, :string, size: 30, null: false
27 | add :created_at, :utc_datetime, null: false
28 | add :closed_at, :utc_datetime
29 | end
30 | create index(:sessions, [:host_identity_id])
31 | create index(:sessions, [:stoken])
32 | create index(:sessions, [:stoken_ro])
33 |
34 | create table(:clients, primary_key: false) do
35 | add :session_id, references(:sessions, type: :uuid, on_delete: :delete_all), null: false
36 | add :identity_id, references(:identities, type: :integer), null: false
37 | add :client_id, :integer, null: false
38 | add :ip_address, :string, null: false
39 | add :joined_at, :utc_datetime, null: false
40 | add :readonly, :boolean, null: false
41 | end
42 | create index(:clients, [:session_id, :client_id], [unique: true])
43 | create index(:clients, [:session_id])
44 | create index(:clients, [:client_id])
45 |
46 | create table(:users, primary_key: false) do
47 | add :id, :uuid, primary_key: true
48 | add :email, :string, null: false
49 | add :name, :string, null: false
50 | add :nickname, :string, null: false
51 | add :github_login, :string
52 | add :github_access_token, :string
53 | end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20151221142603_key_size.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.KeySize do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:identities) do
6 | modify :key, :string, size: 4096, null: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20160121023039_add_identity_metadata.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.AddMetadataIdentity do
2 | use Ecto.Migration
3 |
4 | # alias Tmate.Repo
5 | # alias Tmate.Identity
6 |
7 | def change do
8 | alter table(:identities) do
9 | add :metadata, :map
10 | end
11 |
12 | flush()
13 |
14 | # Repo.all(Identity |> Identity.type("ssh"))
15 | # |> Enum.each(fn identity ->
16 | # identity |> migrate_ssh_key() |> Repo.update!()
17 | # end)
18 |
19 | alter table(:identities) do
20 | modify :key, :string, size: 64, null: false
21 | end
22 | end
23 |
24 | # defp migrate_ssh_key(identity) do
25 | # params = case {identity.type, identity.key} do
26 | # {"ssh", "SHA256:" <> _} -> %{}
27 | # {"ssh", _key} -> %{key: Identity.key_hash(identity.key), metadata: %{pubkey: identity.key}}
28 | # _ -> %{}
29 | # end
30 | # Identity.changeset(identity, params)
31 | # end
32 | end
33 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20160123063003_add_connection_fmt.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.AddConnectionFmt do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:sessions) do
6 | remove :ws_base_url
7 | add :ws_url_fmt, :string
8 | add :ssh_cmd_fmt, :string
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20160304084101_add_client_stats.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.AddClientStats do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:clients) do
6 | add :left_at, :utc_datetime
7 | add :latency_stats, :map
8 | end
9 |
10 | alter table(:sessions) do
11 | add :host_latency_stats, :map
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20160328175128_client_id_uuid.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.ClientIdUuid do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:clients) do
6 | add :id, :uuid
7 | end
8 |
9 | flush()
10 | Ecto.Adapters.SQL.query(Tmate.Repo, "update clients set id = md5(random()::text || clock_timestamp()::text)::uuid", [])
11 |
12 | drop index(:clients, [:session_id, :client_id], [unique: true])
13 | drop index(:clients, [:client_id])
14 |
15 | alter table(:clients) do
16 | modify :id, :uuid, primary_key: true
17 | remove :client_id
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20160406210826_github_users.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.GithubUsers do
2 | use Ecto.Migration
3 |
4 | def change do
5 | rename table(:users), :nickname, to: :username
6 | alter table(:users) do
7 | remove :github_access_token
8 | remove :github_login
9 | remove :name
10 | add :github_id, :integer
11 | end
12 | create index(:users, [:username], unique: true)
13 | create index(:users, [:email], unique: true)
14 | create index(:users, [:github_id], unique: true, where: "github_id is not null")
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20190904041603_add_disconnect_at.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.AddDisconnectAt do
2 | use Ecto.Migration
3 |
4 | def change do
5 | # Simplify things a bit.
6 |
7 | Ecto.Adapters.SQL.query(Tmate.Repo, "delete from sessions where closed_at is not null", [])
8 | Ecto.Adapters.SQL.query(Tmate.Repo, "delete from clients where left_at is not null", [])
9 | flush()
10 |
11 | alter table(:sessions) do
12 | add :disconnected_at, :utc_datetime
13 | remove :closed_at
14 | end
15 |
16 | alter table(:clients) do
17 | remove :left_at
18 | remove :latency_stats
19 | end
20 |
21 | alter table(:sessions) do
22 | remove :host_latency_stats
23 | end
24 |
25 | # Assume the worst for existing sessions.
26 | flush()
27 | Ecto.Adapters.SQL.query(Tmate.Repo, "update sessions set disconnected_at = clock_timestamp()", [])
28 |
29 | # Note: Sessions that are disconnected for too long should be pruned
30 | # We could have some sort of timer
31 | create index(:sessions, [:disconnected_at])
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20191005234200_add_generation.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.AddGeneration do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:events) do
6 | add :generation, :integer
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20191014044039_add_closed_at.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.AddClosedAt do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:sessions) do
6 | add :closed, :boolean, default: false
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20191108161753_remove_identity_one.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.RemoveIdentityOne do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:sessions) do
6 | modify :host_identity_id, :integer, null: true
7 | end
8 |
9 | drop constraint(:sessions, "sessions_host_identity_id_fkey")
10 |
11 | alter table(:clients) do
12 | modify :identity_id, :integer, null: true
13 | end
14 |
15 | drop constraint(:clients, "clients_identity_id_fkey")
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20191108174232_remove_identity_three.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.RemoveIdentityThree do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:sessions) do
6 | remove :host_identity_id
7 | end
8 |
9 | alter table(:clients) do
10 | remove :identity_id
11 | end
12 |
13 | drop table(:identities)
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20191110232601_remove_github_id.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.RemoveGithubId do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | remove :github_id
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20191110232704_expand_token_size.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.ExpandTokenSize do
2 | use Ecto.Migration
3 |
4 | def change do
5 | drop index(:sessions, [:stoken])
6 | drop index(:sessions, [:stoken_ro])
7 |
8 | flush()
9 |
10 | alter table(:sessions) do
11 | modify :stoken, :string, size: 255
12 | modify :stoken_ro, :string, size: 255
13 | end
14 |
15 | create index(:sessions, [:stoken])
16 | create index(:sessions, [:stoken_ro])
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20191111025821_add_api_key.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.AddApiKey do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:users) do
6 | add :api_key, :string
7 | add :verified, :boolean, default: false
8 | add :allow_mailing_list, :boolean, default: false
9 | add :created_at, :utc_datetime
10 | add :last_seen_at, :utc_datetime
11 | end
12 |
13 | create index(:users, [:api_key], [unique: true])
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20200725202312_add_generation_session.exs:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Repo.Migrations.AddGenerationSession do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:sessions) do
6 | add :generation, :integer
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | # Script for populating the database. You can run it as:
2 | #
3 | # mix run priv/repo/seeds.exs
4 | #
5 | # Inside the script, you can read and write to any of your
6 | # repositories directly:
7 | #
8 | # Tmate.Repo.insert!(%Tmate.SomeSchema{})
9 | #
10 | # We recommend using the bang functions (`insert!`, `update!`
11 | # and so on) as they will fail if something goes wrong.
12 |
--------------------------------------------------------------------------------
/priv/repo/structure.sql:
--------------------------------------------------------------------------------
1 | --
2 | -- PostgreSQL database dump
3 | --
4 |
5 | -- Dumped from database version 11.4
6 | -- Dumped by pg_dump version 11.4
7 |
8 | SET statement_timeout = 0;
9 | SET lock_timeout = 0;
10 | SET idle_in_transaction_session_timeout = 0;
11 | SET client_encoding = 'UTF8';
12 | SET standard_conforming_strings = on;
13 | SELECT pg_catalog.set_config('search_path', '', false);
14 | SET check_function_bodies = false;
15 | SET xmloption = content;
16 | SET client_min_messages = warning;
17 | SET row_security = off;
18 |
19 | SET default_tablespace = '';
20 |
21 | SET default_with_oids = false;
22 |
23 | --
24 | -- Name: clients; Type: TABLE; Schema: public; Owner: -
25 | --
26 |
27 | CREATE TABLE public.clients (
28 | session_id uuid NOT NULL,
29 | ip_address character varying(255) NOT NULL,
30 | joined_at timestamp(0) without time zone NOT NULL,
31 | readonly boolean NOT NULL,
32 | id uuid NOT NULL
33 | );
34 |
35 |
36 | --
37 | -- Name: events; Type: TABLE; Schema: public; Owner: -
38 | --
39 |
40 | CREATE TABLE public.events (
41 | id bigint NOT NULL,
42 | type character varying(255) NOT NULL,
43 | entity_id uuid,
44 | "timestamp" timestamp(0) without time zone NOT NULL,
45 | params jsonb NOT NULL,
46 | generation integer
47 | );
48 |
49 |
50 | --
51 | -- Name: events_id_seq; Type: SEQUENCE; Schema: public; Owner: -
52 | --
53 |
54 | CREATE SEQUENCE public.events_id_seq
55 | START WITH 1
56 | INCREMENT BY 1
57 | NO MINVALUE
58 | NO MAXVALUE
59 | CACHE 1;
60 |
61 |
62 | --
63 | -- Name: events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
64 | --
65 |
66 | ALTER SEQUENCE public.events_id_seq OWNED BY public.events.id;
67 |
68 |
69 | --
70 | -- Name: schema_migrations; Type: TABLE; Schema: public; Owner: -
71 | --
72 |
73 | CREATE TABLE public.schema_migrations (
74 | version bigint NOT NULL,
75 | inserted_at timestamp(0) without time zone
76 | );
77 |
78 |
79 | --
80 | -- Name: sessions; Type: TABLE; Schema: public; Owner: -
81 | --
82 |
83 | CREATE TABLE public.sessions (
84 | id uuid NOT NULL,
85 | host_last_ip character varying(255) NOT NULL,
86 | stoken character varying(255) NOT NULL,
87 | stoken_ro character varying(255) NOT NULL,
88 | created_at timestamp(0) without time zone NOT NULL,
89 | ws_url_fmt character varying(255),
90 | ssh_cmd_fmt character varying(255),
91 | disconnected_at timestamp(0) without time zone,
92 | closed boolean DEFAULT false
93 | );
94 |
95 |
96 | --
97 | -- Name: users; Type: TABLE; Schema: public; Owner: -
98 | --
99 |
100 | CREATE TABLE public.users (
101 | id uuid NOT NULL,
102 | email character varying(255) NOT NULL,
103 | username character varying(255) NOT NULL,
104 | api_key character varying(255),
105 | verified boolean DEFAULT false,
106 | allow_mailing_list boolean DEFAULT false,
107 | created_at timestamp(0) without time zone,
108 | last_seen_at timestamp(0) without time zone
109 | );
110 |
111 |
112 | --
113 | -- Name: events id; Type: DEFAULT; Schema: public; Owner: -
114 | --
115 |
116 | ALTER TABLE ONLY public.events ALTER COLUMN id SET DEFAULT nextval('public.events_id_seq'::regclass);
117 |
118 |
119 | --
120 | -- Name: clients clients_pkey; Type: CONSTRAINT; Schema: public; Owner: -
121 | --
122 |
123 | ALTER TABLE ONLY public.clients
124 | ADD CONSTRAINT clients_pkey PRIMARY KEY (id);
125 |
126 |
127 | --
128 | -- Name: events events_pkey; Type: CONSTRAINT; Schema: public; Owner: -
129 | --
130 |
131 | ALTER TABLE ONLY public.events
132 | ADD CONSTRAINT events_pkey PRIMARY KEY (id);
133 |
134 |
135 | --
136 | -- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
137 | --
138 |
139 | ALTER TABLE ONLY public.schema_migrations
140 | ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
141 |
142 |
143 | --
144 | -- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: -
145 | --
146 |
147 | ALTER TABLE ONLY public.sessions
148 | ADD CONSTRAINT sessions_pkey PRIMARY KEY (id);
149 |
150 |
151 | --
152 | -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: -
153 | --
154 |
155 | ALTER TABLE ONLY public.users
156 | ADD CONSTRAINT users_pkey PRIMARY KEY (id);
157 |
158 |
159 | --
160 | -- Name: clients_session_id_index; Type: INDEX; Schema: public; Owner: -
161 | --
162 |
163 | CREATE INDEX clients_session_id_index ON public.clients USING btree (session_id);
164 |
165 |
166 | --
167 | -- Name: events_entity_id_index; Type: INDEX; Schema: public; Owner: -
168 | --
169 |
170 | CREATE INDEX events_entity_id_index ON public.events USING btree (entity_id);
171 |
172 |
173 | --
174 | -- Name: events_type_index; Type: INDEX; Schema: public; Owner: -
175 | --
176 |
177 | CREATE INDEX events_type_index ON public.events USING btree (type);
178 |
179 |
180 | --
181 | -- Name: sessions_disconnected_at_index; Type: INDEX; Schema: public; Owner: -
182 | --
183 |
184 | CREATE INDEX sessions_disconnected_at_index ON public.sessions USING btree (disconnected_at);
185 |
186 |
187 | --
188 | -- Name: sessions_stoken_index; Type: INDEX; Schema: public; Owner: -
189 | --
190 |
191 | CREATE INDEX sessions_stoken_index ON public.sessions USING btree (stoken);
192 |
193 |
194 | --
195 | -- Name: sessions_stoken_ro_index; Type: INDEX; Schema: public; Owner: -
196 | --
197 |
198 | CREATE INDEX sessions_stoken_ro_index ON public.sessions USING btree (stoken_ro);
199 |
200 |
201 | --
202 | -- Name: users_api_key_index; Type: INDEX; Schema: public; Owner: -
203 | --
204 |
205 | CREATE UNIQUE INDEX users_api_key_index ON public.users USING btree (api_key);
206 |
207 |
208 | --
209 | -- Name: users_email_index; Type: INDEX; Schema: public; Owner: -
210 | --
211 |
212 | CREATE UNIQUE INDEX users_email_index ON public.users USING btree (email);
213 |
214 |
215 | --
216 | -- Name: users_username_index; Type: INDEX; Schema: public; Owner: -
217 | --
218 |
219 | CREATE UNIQUE INDEX users_username_index ON public.users USING btree (username);
220 |
221 |
222 | --
223 | -- Name: clients clients_session_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
224 | --
225 |
226 | ALTER TABLE ONLY public.clients
227 | ADD CONSTRAINT clients_session_id_fkey FOREIGN KEY (session_id) REFERENCES public.sessions(id) ON DELETE CASCADE;
228 |
229 |
230 | --
231 | -- PostgreSQL database dump complete
232 | --
233 |
234 | INSERT INTO public."schema_migrations" (version) VALUES (20151010162127), (20151221142603), (20160121023039), (20160123063003), (20160304084101), (20160328175128), (20160406210826), (20190904041603), (20191005234200), (20191014044039), (20191108161753), (20191108174232), (20191110232601), (20191110232704), (20191111025821);
235 |
236 |
--------------------------------------------------------------------------------
/priv/static/css/main.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: #151515;
3 | /* background-image: url('/img/rebel.png'); */
4 | color: #bbb;
5 | font-size: 16px;
6 | }
7 |
8 | ::-webkit-selection { color: black; background: rgba(178, 104, 24, 0.99); }
9 | ::-moz-selection { color: black; background: rgba(178, 104, 24, 0.99); }
10 | ::selection { color: black; background: rgba(178, 104, 24, 0.99); }
11 |
12 | h1, h2, h3, h4, h5, h6 {
13 | color: #bbb;
14 | }
15 |
16 | .nav-tabs {
17 | font-size: 15px;
18 | min-inline-size: max-content;
19 | }
20 |
21 | .nav-tabs > li > a {
22 | padding: 7px;
23 | }
24 |
25 |
26 | li, p {
27 | line-height: 1.4em;
28 | }
29 |
30 | label, input, button, select, textarea {
31 | font-size: 1em;
32 | line-height: 1.4em;
33 | }
34 |
35 |
36 | .hero-unit {
37 | margin-top: 50px;
38 | text-align: center;
39 | background-color: rgba(0, 0, 0, 0.3);
40 |
41 | -webkit-box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
42 | -moz-box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
43 | box-shadow: rgba(255, 255, 255, 0.1) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
44 | }
45 |
46 | .hero-unit h1 { color: #fff; text-shadow: 0px 0px 5px rgba(255, 255, 255, 0.4); }
47 | .hero-unit h2 { color: #ccc; }
48 | .hero-unit p { text-align: center; }
49 |
50 | hr {
51 | border-bottom: 1px solid #3b3b3b;
52 | border-top: 0;
53 | margin-top: 35px;
54 |
55 | margin-top: 1em;
56 | margin-bottom: 2em;
57 | }
58 |
59 | code, pre {
60 | font-size: 13px;
61 | font-family: "Bitstream Vera Sans Mono", "Courier New", Courier, monospace;
62 | line-height: 1.2em;
63 | overflow: auto;
64 | background-color: rgba(0, 0, 0, 0.1);
65 | color: #bbb;
66 | -webkit-box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
67 | -moz-box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
68 | box-shadow: rgba(255, 255, 255, 0.06) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
69 | border: 0;
70 | }
71 |
72 | .fig {
73 | -webkit-box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
74 | -moz-box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
75 | box-shadow: rgba(255, 255, 255, 0.05) 0 1px 0, rgba(0, 0, 0, 0.8) 0 1px 7px 0px inset;
76 |
77 | -webkit-border-radius: 5px;
78 | -moz-border-radius: 5px;
79 | border-radius: 5px;
80 |
81 | background-color: rgba(0, 0, 0, 0.1);
82 | padding: 10px;
83 | margin-bottom: 1em;
84 | }
85 |
86 | p.fig-label {
87 | text-align: center;
88 | span { font-weight: bold; }
89 | margin-bottom: 2em;
90 | }
91 |
92 | .video {
93 | margin-top: 10px;
94 | -webkit-box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px;
95 | -moz-box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px;
96 | box-shadow: rgba(0, 0, 0, 0.5) 0 4px 10px;
97 | }
98 |
99 | .video.linux {
100 | background-image: url('/img/video_linux_first_frame.png');
101 | height: 285px;
102 | width: 413px;
103 | margin-left: 30px;
104 | }
105 |
106 | .video.macos {
107 | background-image: url('/img/video_macos_first_frame.png');
108 | height: 285px;
109 | width: 409px;
110 | float: right;
111 | margin-right: 30px;
112 | }
113 |
114 | .steps {
115 | text-align: center;
116 | margin: 0 auto;
117 | margin-top: 20px;
118 | width: 720px;
119 | height: 50px;
120 | }
121 |
122 | .steps h3 {
123 | width: 240px;
124 | float: left;
125 | display: none;
126 | margin-top: 0;
127 | margin-bottom: -40px;
128 | height: 50px;
129 | }
130 |
131 | .nav-tabs {
132 | border-bottom: 1px solid #3b3b3b;
133 | }
134 |
135 | .nav-tabs > li {
136 | margin-bottom: -1px;
137 | }
138 |
139 | .nav-tabs > li > a:hover,
140 | .nav-tabs > li > a:focus {
141 | border-color: #3b3b3b;
142 | }
143 |
144 | .nav-tabs > .active > a,
145 | .nav-tabs > .active > a:hover,
146 | .nav-tabs > .active > a:focus {
147 | color: #aaa;
148 | cursor: default;
149 | background-color: rgba(0, 0, 0, 0.3);
150 | border: 1px solid #3b3b3b;
151 | border-bottom-color: transparent;
152 | }
153 |
154 | .nav > li > a:hover,
155 | .nav > li > a:focus {
156 | text-decoration: none;
157 | color: #0088cc;
158 | background-color: rgba(0, 0, 0, 0.3);
159 | }
160 |
161 | .footer {
162 | width: 100%;
163 | height: 140px;
164 | margin-top: 40px;
165 | padding-top: 30px;
166 | background-color: rgba(0, 0, 0, 0.5);
167 | }
168 | .footer .digitalocean p {
169 | text-align: center;
170 | }
171 |
172 | .social-buttons {
173 | margin-top: 10px;
174 | margin-bottom: -30px;
175 | }
176 |
177 | .social-buttons .twitter {
178 | display: inline;
179 | margin-top: 8px;
180 | margin-right: 17px;
181 | }
182 |
183 | .social-buttons .github {
184 | display: inline;
185 | }
186 |
187 | .alert-success {
188 | color: #fff;
189 | background-color: #00ac7c;
190 | border-color: #00ac7c;
191 | text-shadow: none;
192 | }
193 |
194 | .user-registration input {
195 | color: #333;
196 | background-color: #d6d6d6;
197 | }
198 |
199 | .user-registration .help-block {
200 | color: #d88;
201 | }
202 |
203 | .user-registration button {
204 | margin-top: 0.5em;
205 | }
206 |
207 | .warning {
208 | color: #d88;
209 | }
210 |
211 | .app-container p.loading {
212 | margin: auto;
213 | margin-top: 100px;
214 | width: 200px;
215 | text-align: center;
216 | }
217 |
--------------------------------------------------------------------------------
/priv/static/img/1_step.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/1_step.png
--------------------------------------------------------------------------------
/priv/static/img/2_step.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/2_step.png
--------------------------------------------------------------------------------
/priv/static/img/3_step.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/3_step.png
--------------------------------------------------------------------------------
/priv/static/img/digitalocean.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/digitalocean.png
--------------------------------------------------------------------------------
/priv/static/img/fork-me-on-github-right-orange@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/fork-me-on-github-right-orange@2x.png
--------------------------------------------------------------------------------
/priv/static/img/glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/glyphicons-halflings-regular.448c34a56d699c29117adc64c43affeb.woff2
--------------------------------------------------------------------------------
/priv/static/img/glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/glyphicons-halflings-regular.e18bbf611f2a2e43afc071aa2f4e1512.ttf
--------------------------------------------------------------------------------
/priv/static/img/glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/glyphicons-halflings-regular.f4769f9bdb7466be65088239c12046d1.eot
--------------------------------------------------------------------------------
/priv/static/img/glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/glyphicons-halflings-regular.fa2772327f55d8198301fdb8bcfc8158.woff
--------------------------------------------------------------------------------
/priv/static/img/mac_notr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/mac_notr.png
--------------------------------------------------------------------------------
/priv/static/img/mac_tr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/mac_tr.png
--------------------------------------------------------------------------------
/priv/static/img/rebel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/rebel.png
--------------------------------------------------------------------------------
/priv/static/img/video_linux.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/video_linux.png
--------------------------------------------------------------------------------
/priv/static/img/video_linux_first_frame.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/video_linux_first_frame.png
--------------------------------------------------------------------------------
/priv/static/img/video_mac.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/video_mac.png
--------------------------------------------------------------------------------
/priv/static/img/video_macos.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/video_macos.png
--------------------------------------------------------------------------------
/priv/static/img/video_macos_first_frame.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tmate-io/tmate-master/4a651530e95703c788cbcc0fd3574cac9021d6f5/priv/static/img/video_macos_first_frame.png
--------------------------------------------------------------------------------
/priv/static/js/main.js:
--------------------------------------------------------------------------------
1 | /*
2 | * If you are curious, here is how I generated the videos:
3 | * ffmpeg -an -f x11grab -r 30 -s 460x285 -i :0.0+1920,0 -pix_fmt yuv444p -vcodec libx264 -x264opts crf=0 out.mp4
4 | * mplayer -fps 3000 -vo png out.mp4
5 | * montage *.png -tile x14 -geometry '460x285' out.png
6 | * pngcrush out.png video.png
7 | *
8 | * Safari and Firefox don't like very wide pngs, so we chunk the frames in
9 | * rows. Sadly it makes the png bigger.
10 | */
11 |
12 | $(function() {
13 | var video_linux = $('.video.linux');
14 | var video_macos = $('.video.macos');
15 | var current_frame = 0;
16 | var timer;
17 |
18 | var nextFrameFor = function(video, rows, total_frames, offset) {
19 | var frame = current_frame - offset;
20 | if (frame < 0)
21 | return;
22 |
23 | var frames_per_row = Math.ceil(total_frames / rows);
24 | var x = video.width() * (frame % frames_per_row);
25 | var y = video.height() * Math.floor(frame / frames_per_row);
26 | var position = "-" + x + "px -" + y + "px";
27 | video.css('background-position', position);
28 | }
29 |
30 | var nextFrame = function() {
31 | if (current_frame == 90)
32 | $('.steps .launch').fadeIn(900);
33 | if (current_frame == 250)
34 | $('.steps .share').fadeIn(900);
35 | if (current_frame == 410)
36 | $('.steps .pair').fadeIn(900);
37 |
38 | nextFrameFor(video_linux, 12, 465, 0);
39 | nextFrameFor(video_macos, 6, 231, 234);
40 |
41 | current_frame += 1;
42 | if (current_frame >= 465)
43 | clearTimeout(timer);
44 | };
45 |
46 | var startPlayback = function() {
47 | timer = setInterval(nextFrame, 33);
48 | };
49 |
50 | $(" ").load(function() {
51 | $(" ").load(function() {
52 | video_linux.css('background-image', "url('/img/video_linux.png')");
53 | video_macos.css('background-image', "url('/img/video_macos.png')");
54 | setTimeout(startPlayback, 2000);
55 | });
56 | });
57 | });
58 |
--------------------------------------------------------------------------------
/rel/config.exs:
--------------------------------------------------------------------------------
1 | # Import all plugins from `rel/plugins`
2 | # They can then be used by adding `plugin MyPlugin` to
3 | # either an environment, or release definition, where
4 | # `MyPlugin` is the name of the plugin module.
5 | ~w(rel plugins *.exs)
6 | |> Path.join()
7 | |> Path.wildcard()
8 | |> Enum.map(&Code.eval_file(&1))
9 |
10 | use Distillery.Releases.Config,
11 | # This sets the default release built by `mix distillery.release`
12 | default_release: :default,
13 | # This sets the default environment used by `mix distillery.release`
14 | default_environment: Mix.env()
15 |
16 | # For a full list of config options for both releases
17 | # and environments, visit https://hexdocs.pm/distillery/config/distillery.html
18 |
19 |
20 | # You may define one or more environments in this file,
21 | # an environment's settings will override those of a release
22 | # when building in that environment, this combination of release
23 | # and environment configuration is called a profile
24 |
25 | environment :dev do
26 | # If you are running Phoenix, you should make sure that
27 | # server: true is set and the code reloader is disabled,
28 | # even in dev mode.
29 | # It is recommended that you build with MIX_ENV=prod and pass
30 | # the --env flag to Distillery explicitly if you want to use
31 | # dev mode.
32 | set dev_mode: true
33 | set include_erts: false
34 | end
35 |
36 | environment :prod do
37 | set include_erts: true
38 | set include_src: false
39 | set vm_args: "rel/vm.args"
40 |
41 | set config_providers: [
42 | {Distillery.Releases.Config.Providers.Elixir, ["${RELEASE_ROOT_DIR}/etc/config.exs"]}
43 | ]
44 | set overlays: [
45 | {:copy, "config/config.exs", "etc/config.exs"},
46 | {:copy, "config/prod.exs", "etc/prod.exs"},
47 | ]
48 | end
49 |
50 | # You may define one or more releases in this file.
51 | # If you have not set a default release, or selected one
52 | # when running `mix distillery.release`, the first release in the file
53 | # will be used by default
54 |
55 | release :tmate do
56 | set version: current_version(:tmate)
57 | set applications: [
58 | :runtime_tools
59 | ]
60 | end
61 |
62 |
--------------------------------------------------------------------------------
/rel/plugins/.gitignore:
--------------------------------------------------------------------------------
1 | *.*
2 | !*.exs
3 | !.gitignore
--------------------------------------------------------------------------------
/rel/vm.args:
--------------------------------------------------------------------------------
1 | ## This file provide the arguments provided to the VM at startup
2 | ## You can find a full list of flags and their behaviours at
3 | ## http://erlang.org/doc/man/erl.html
4 |
5 | ## Name of the node
6 | -name <%= release_name %>@${ERL_NODE_NAME}
7 |
8 | ## Cookie for distributed erlang
9 | -setcookie ${ERL_COOKIE}
10 |
11 | ## Heartbeat management; auto-restarts VM if it dies or becomes unresponsive
12 | ## (Disabled by default..use with caution!)
13 | ##-heart
14 |
15 | ## Enable kernel poll and a few async threads
16 | ##+K true
17 | ##+A 5
18 | ## For OTP21+, the +A flag is not used anymore,
19 | ## +SDio replace it to use dirty schedulers
20 | ##+SDio 5
21 |
22 | ## Increase number of concurrent ports/sockets
23 | ##-env ERL_MAX_PORTS 4096
24 |
25 | ## Tweak GC to run more often
26 | ##-env ERL_FULLSWEEP_AFTER 10
27 |
28 | # Enable SMP automatically based on availability
29 | # On OTP21+, this is not needed anymore.
30 | -smp auto
31 |
--------------------------------------------------------------------------------
/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | it cannot be async. For this reason, every test runs
12 | inside a transaction which is reset at the beginning
13 | of the test unless the test case is marked as async.
14 | """
15 |
16 | use ExUnit.CaseTemplate
17 |
18 | using do
19 | quote do
20 | # Import conveniences for testing with channels
21 | use Phoenix.ChannelTest
22 |
23 | # The default endpoint for testing
24 | @endpoint TmateWeb.Endpoint
25 | end
26 | end
27 |
28 | setup tags do
29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Tmate.Repo)
30 |
31 | unless tags[:async] do
32 | Ecto.Adapters.SQL.Sandbox.mode(Tmate.Repo, {:shared, self()})
33 | end
34 |
35 | :ok
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use TmateWeb.ConnCase, async: true`, although
15 | this option is not recommendded for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # Import conveniences for testing with connections
23 | use Phoenix.ConnTest
24 | import Tmate.Factory
25 | alias TmateWeb.Router.Helpers, as: Routes
26 |
27 | # The default endpoint for testing
28 | @endpoint TmateWeb.Endpoint
29 | end
30 | end
31 |
32 | setup tags do
33 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Tmate.Repo)
34 |
35 | unless tags[:async] do
36 | Ecto.Adapters.SQL.Sandbox.mode(Tmate.Repo, {:shared, self()})
37 | end
38 |
39 | {:ok, conn: Phoenix.ConnTest.build_conn()}
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | we enable the SQL sandbox, so changes done to the database
11 | are reverted at the end of every test. If you are using
12 | PostgreSQL, you can even run database tests asynchronously
13 | by setting `use TmateWeb.DataCase, async: true`, although
14 | this option is not recommendded for other databases.
15 | """
16 |
17 | use ExUnit.CaseTemplate
18 |
19 | using do
20 | quote do
21 | alias Tmate.Repo
22 |
23 | import Ecto
24 | import Ecto.Changeset
25 | import Ecto.Query
26 | import Tmate.DataCase
27 | end
28 | end
29 |
30 | setup tags do
31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Tmate.Repo)
32 |
33 | unless tags[:async] do
34 | Ecto.Adapters.SQL.Sandbox.mode(Tmate.Repo, {:shared, self()})
35 | end
36 |
37 | :ok
38 | end
39 |
40 | @doc """
41 | A helper that transforms changeset errors into a map of messages.
42 |
43 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
44 | assert "password is too short" in errors_on(changeset).password
45 | assert %{password: ["password is too short"]} = errors_on(changeset)
46 |
47 | """
48 | def errors_on(changeset) do
49 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
50 | Regex.replace(~r"%{(\w+)}", message, fn _, key ->
51 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
52 | end)
53 | end)
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/test/support/event_case.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.EventCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | model tests.
5 |
6 | You may define functions here to be used as helpers in
7 | your model tests. See `errors_on/2`'s definition as reference.
8 |
9 | Finally, if the test case interacts with the database,
10 | it cannot be async. For this reason, every test runs
11 | inside a transaction which is reset at the beginning
12 | of the test unless the test case is marked as async.
13 | """
14 |
15 | use ExUnit.CaseTemplate
16 |
17 | using do
18 | quote do
19 | alias Tmate.Repo
20 | import Ecto.Query
21 | import Tmate.EventCase
22 | import Tmate.Factory
23 | end
24 | end
25 |
26 |
27 | # Import conveniences for testing with connections
28 | use Phoenix.ConnTest
29 | # The default endpoint for testing
30 | @endpoint TmateWeb.Endpoint
31 |
32 | @doc """
33 | Helper for returning list of errors in model when passed certain data.
34 |
35 | ## Examples
36 |
37 | Given a User model that lists `:name` as a required field and validates
38 | `:password` to be safe, it would return:
39 |
40 | iex> errors_on(%User{}, password: "password")
41 | [password: "is unsafe", name: "is blank"]
42 |
43 | You could then write your assertion like:
44 |
45 | assert {:password, "is unsafe"} in errors_on(%User{}, password: "password")
46 |
47 | You can also create the changeset manually and retrieve the errors
48 | field directly:
49 |
50 | iex> changeset = User.changeset(%User{}, password: "password")
51 | iex> {:password, "is unsafe"} in changeset.errors
52 | true
53 | """
54 | def errors_on(model, data) do
55 | model.__struct__.changeset(model, data).errors
56 | end
57 |
58 | setup tags do
59 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Tmate.Repo)
60 |
61 | unless tags[:async] do
62 | Ecto.Adapters.SQL.Sandbox.mode(Tmate.Repo, {:shared, self()})
63 | end
64 |
65 | {:ok, %{}}
66 | end
67 |
68 | def emit_event(event, timestamp \\ DateTime.utc_now) do
69 | {m, params} = Map.split(event, [:event_type, :entity_id, :generation])
70 | generation = m[:generation] || 1
71 | emit_raw_event(m[:event_type], m[:entity_id], timestamp, generation, params)
72 | event
73 | end
74 |
75 | def emit_raw_event(event_type, entity_id, timestamp, generation, params) do
76 | auth_token = "internal_api_auth_token"
77 | payload = Jason.encode!(%{type: event_type, entity_id: entity_id, timestamp: timestamp,
78 | generation: generation, userdata: auth_token, params: params})
79 |
80 | build_conn()
81 | |> put_req_header("content-type", "application/json")
82 | |> put_req_header("accept", "application/json")
83 | # |> put_req_header("authorization", "Bearer " <> auth_token)
84 | |> post("/internal_api/webhook", payload)
85 | |> json_response(200)
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/test/support/factory.ex:
--------------------------------------------------------------------------------
1 | defmodule Tmate.Factory do
2 | use ExMachina
3 |
4 | def event_session_register_factory do
5 | %{event_type: :session_register,
6 | entity_id: UUID.uuid1,
7 | ip_address: sequence(:ip, &"1.1.1.#{&1}"),
8 | ws_url_fmt: "https://tmate.io/ws/session/%s",
9 | ssh_cmd_fmt: "ssh %s@tmate.io",
10 | stoken: sequence(:token, &"STOKEN___________________RW#{&1}"),
11 | stoken_ro: sequence(:token, &"STOKEN___________________RO#{&1}"),
12 | reconnected: false
13 | }
14 | end
15 |
16 | def event_session_close_factory do
17 | %{event_type: :session_close,
18 | entity_id: UUID.uuid1}
19 | end
20 |
21 | def event_session_join_factory do
22 | %{event_type: :session_join,
23 | id: UUID.uuid1,
24 | ip_address: sequence(:ip, &"1.1.2.#{&1}"),
25 | type: "ssh",
26 | readonly: false}
27 | end
28 |
29 | def event_session_left_factory do
30 | %{event_type: :session_left,
31 | id: sequence(:client_id, & &1)}
32 | end
33 |
34 | def event_session_disconnect_factory do
35 | %{event_type: :session_disconnect,
36 | entity_id: UUID.uuid1}
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | {:ok, _} = Application.ensure_all_started(:ex_machina)
2 |
3 | ExUnit.start()
4 | Mix.Task.run "ecto.create", ["--quiet"]
5 | Mix.Task.run "ecto.migrate", ["--quiet"]
6 | Ecto.Adapters.SQL.Sandbox.mode(Tmate.Repo, :manual)
7 |
--------------------------------------------------------------------------------
/test/tmate_web/controllers/internal_api_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.InternalApiControllerTest do
2 | use TmateWeb.ConnCase
3 | import Tmate.EventCase
4 |
5 | test "webhook" do
6 | # test authentication
7 | end
8 |
9 | describe "/internal_api/session" do
10 | test "returns session" do
11 | session_event = build(:event_session_register)
12 | created_at = DateTime.utc_now
13 | emit_event(session_event, created_at)
14 |
15 | auth_token = "internal_api_auth_token"
16 | response =
17 | build_conn()
18 | |> put_req_header("authorization", "Bearer " <> auth_token)
19 | |> get("/internal_api/session", %{token: session_event.stoken})
20 | |> json_response(200)
21 |
22 | assert response == %{
23 | "id" => session_event.entity_id,
24 | "ssh_cmd_fmt" => session_event.ssh_cmd_fmt,
25 | "created_at" => created_at |> DateTime.truncate(:second) |> DateTime.to_iso8601,
26 | "disconnected_at" => nil,
27 | "closed" => false
28 | }
29 | end
30 |
31 | test "404 with unknown sessions" do
32 | auth_token = "internal_api_auth_token"
33 | build_conn()
34 | |> put_req_header("authorization", "Bearer " <> auth_token)
35 | |> get("/internal_api/session", %{token: "invalid_token"})
36 | |> json_response(404)
37 | end
38 |
39 | test "403 without the auth token" do
40 | auth_token = "invalid_auth_token"
41 | assert_error_sent 401, fn ->
42 | build_conn()
43 | |> put_req_header("authorization", "Bearer " <> auth_token)
44 | |> get("/internal_api/session", %{token: "invalid_token"})
45 | end
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/test/tmate_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.PageControllerTest do
2 | use TmateWeb.ConnCase
3 | end
4 |
--------------------------------------------------------------------------------
/test/tmate_web/controllers/sign_up_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.SignUpControllerTest do
2 | use TmateWeb.ConnCase
3 | # import Tmate.EventCase
4 |
5 | # describe "/register" do
6 | # test "create" do
7 | # params = %{username: "nico", email: "nico@tmate.io"}
8 | # response =
9 | # build_conn()
10 | # |> post("/", params)
11 |
12 | # assert response == []
13 | # end
14 | # end
15 | end
16 |
--------------------------------------------------------------------------------
/test/tmate_web/event_projections/session_test.exs:
--------------------------------------------------------------------------------
1 | defmodule SessionTest do
2 | use Tmate.EventCase, async: true
3 |
4 | alias Tmate.Session
5 | alias Tmate.Client
6 |
7 | test "session_register" do
8 | session_event = build(:event_session_register)
9 |
10 | emit_event(session_event)
11 | assert Repo.one(from Session, select: count("*")) == 1
12 |
13 | emit_event(session_event) # duplicate event, should be okay
14 | assert Repo.one(from Session, select: count("*")) == 1
15 |
16 | session = Repo.get(Session, session_event.entity_id)
17 |
18 | assert session.host_last_ip == session_event.ip_address
19 | assert session.stoken == session_event.stoken
20 | assert session.stoken_ro == session_event.stoken_ro
21 | assert session.ws_url_fmt == session_event.ws_url_fmt
22 | assert session.ssh_cmd_fmt == session_event.ssh_cmd_fmt
23 | end
24 |
25 | test "session_close" do
26 | session_event = emit_event(build(:event_session_register))
27 |
28 | session = Repo.get(Session, session_event.entity_id)
29 | assert session.disconnected_at == nil
30 | assert session.closed == false
31 |
32 | _client_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id))
33 |
34 | assert Repo.one(from c in Client, select: count("*")) == 1
35 | close_event = emit_event(build(:event_session_close, entity_id: session_event.entity_id))
36 | assert Repo.one(from c in Client, select: count("*")) == 0
37 |
38 | session = Repo.get(Session, session_event.entity_id)
39 | assert session.disconnected_at != nil
40 | assert session.closed == true
41 |
42 | emit_event(close_event) # duplicate
43 | end
44 |
45 | test "session_join" do
46 | session_event = emit_event(build(:event_session_register))
47 | client1_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id))
48 | client2_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id,
49 | type: "web", readonly: true))
50 | emit_event(client2_event) # test for duplicate event
51 |
52 | session = Repo.get(Session, session_event.entity_id)
53 | session = Repo.preload(session, :clients)
54 |
55 | clients = session.clients
56 | assert clients |> Enum.count == 2
57 | client1 = clients |> Enum.filter(& &1.id == client1_event.id) |> Enum.at(0)
58 | client2 = clients |> Enum.filter(& &1.id == client2_event.id) |> Enum.at(0)
59 |
60 | assert client1.ip_address == client1_event.ip_address
61 | assert client1.readonly == client1_event.readonly
62 |
63 | assert client2.ip_address == client2_event.ip_address
64 | assert client2.readonly == client2_event.readonly
65 | end
66 |
67 | test "session_left" do
68 | session_event = emit_event(build(:event_session_register))
69 | _client1_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id))
70 | client2_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id))
71 |
72 | session = Repo.get(Session, session_event.entity_id)
73 | session = Repo.preload(session, :clients)
74 | assert session.clients |> Enum.count == 2
75 |
76 | left_event = emit_event(build(:event_session_left, entity_id: session_event.entity_id, id: client2_event.id))
77 |
78 | session = Repo.get(Session, session_event.entity_id)
79 | session = Repo.preload(session, :clients)
80 | assert session.clients |> Enum.count == 1
81 |
82 | emit_event(left_event) # duplicate event
83 |
84 | session = Repo.get(Session, session_event.entity_id)
85 | session = Repo.preload(session, :clients)
86 | assert session.clients |> Enum.count == 1
87 | end
88 |
89 | test "session reconnect" do
90 | session_event = emit_event(build(:event_session_register))
91 |
92 | assert Repo.get(Session, session_event.entity_id).disconnected_at == nil
93 | _disconnect_event = emit_event(build(:event_session_disconnect, entity_id: session_event.entity_id))
94 | assert Repo.get(Session, session_event.entity_id).disconnected_at != nil
95 |
96 | session_event = emit_event(build(:event_session_register, entity_id: session_event.entity_id,
97 | reconnected: true, ssh_cmd_fmt: "new_host"))
98 | assert Repo.get(Session, session_event.entity_id).disconnected_at == nil
99 | assert Repo.get(Session, session_event.entity_id).ssh_cmd_fmt == "new_host"
100 | end
101 |
102 | test "session_disconnect/reconnect with mix generations" do
103 | session_event = emit_event(build(:event_session_register, generation: 1))
104 | _reconnect_event = emit_event(build(:event_session_register, entity_id: session_event.entity_id, generation: 2))
105 |
106 | _client1_event = emit_event(build(:event_session_disconnect, entity_id: session_event.entity_id, generation: 1))
107 |
108 | _client1_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id, generation: 1))
109 |
110 | session = Repo.get(Session, session_event.entity_id)
111 | session = Repo.preload(session, :clients)
112 | assert session.clients |> Enum.count == 0
113 |
114 | _client1_event = emit_event(build(:event_session_join, entity_id: session_event.entity_id, generation: 2))
115 |
116 | session = Repo.get(Session, session_event.entity_id)
117 | session = Repo.preload(session, :clients)
118 | assert session.clients |> Enum.count == 1
119 | end
120 |
121 | # TODO This test should probably be in a seperate file
122 | test "prune_sessions()" do
123 | se1 = emit_event(build(:event_session_register))
124 | se2 = emit_event(build(:event_session_register))
125 | se3 = emit_event(build(:event_session_register))
126 |
127 | now = DateTime.utc_now
128 | _de2 = emit_event(build(:event_session_disconnect, entity_id: se2.entity_id), DateTime.add(now, -1000, :second))
129 | _de3 = emit_event(build(:event_session_disconnect, entity_id: se3.entity_id), DateTime.add(now, -10000, :second))
130 |
131 | assert Repo.one(from Session, select: count("*")) == 3
132 | Tmate.SessionCleaner.prune_sessions({1, "hour"})
133 |
134 | assert (Repo.all(Session) |> Enum.map(& &1.id) |> Enum.sort()) ==
135 | ([se1.entity_id, se2.entity_id] |> Enum.sort())
136 | end
137 |
138 | # TODO This test should probably be in a seperate file
139 | test "check_for_disconnected_sessions" do
140 | defmodule WsApi do
141 | # already disconnected. Should not be checked
142 | @s1_id UUID.uuid1
143 | # will be flagged as disconnected, should be cleanup up
144 | @s2_id UUID.uuid1
145 | # will be flagged as disconnected, but reconnect events is triggered at the same time, no cleanup
146 | @s3_id UUID.uuid1
147 | # remains connected, no cleanup
148 | @s4_id UUID.uuid1
149 |
150 | def get_stale_sessions(session_ids, _base_url) do
151 | assert (session_ids |> Enum.sort()) ==
152 | ([@s2_id, @s3_id, @s4_id] |> Enum.sort())
153 |
154 | emit_event(build(:event_session_register, entity_id: @s3_id, generation: 2, reconnected: true))
155 |
156 | {:ok, [@s2_id, @s3_id]}
157 | end
158 |
159 | def session_ids() do
160 | [@s1_id, @s2_id, @s3_id, @s4_id]
161 | end
162 | end
163 |
164 | [s1_id, s2_id, s3_id, s4_id] = WsApi.session_ids()
165 |
166 | emit_event(build(:event_session_register, entity_id: s1_id))
167 | emit_event(build(:event_session_register, entity_id: s2_id))
168 | emit_event(build(:event_session_register, entity_id: s3_id))
169 | emit_event(build(:event_session_register, entity_id: s4_id))
170 |
171 | emit_event(build(:event_session_disconnect, entity_id: s1_id))
172 |
173 | assert Repo.get(Session, s1_id).disconnected_at != nil
174 | assert Repo.get(Session, s2_id).disconnected_at == nil
175 | assert Repo.get(Session, s3_id).disconnected_at == nil
176 | assert Repo.get(Session, s4_id).disconnected_at == nil
177 |
178 | Tmate.SessionCleaner.check_for_disconnected_sessions(SessionTest.WsApi)
179 |
180 | assert Repo.get(Session, s1_id).disconnected_at != nil
181 | assert Repo.get(Session, s2_id).disconnected_at != nil
182 | assert Repo.get(Session, s3_id).disconnected_at == nil
183 | assert Repo.get(Session, s4_id).disconnected_at == nil
184 | end
185 | end
186 |
--------------------------------------------------------------------------------
/test/tmate_web/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.ErrorViewTest do
2 | use TmateWeb.ConnCase, async: true
3 |
4 | # Bring render/3 and render_to_string/3 for testing custom views
5 | import Phoenix.View
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(TmateWeb.ErrorView, "404.html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(TmateWeb.ErrorView, "500.html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/tmate_web/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.LayoutViewTest do
2 | use TmateWeb.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------
/test/tmate_web/views/page_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule TmateWeb.PageViewTest do
2 | use TmateWeb.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------