├── .dockerignore ├── .env.example ├── .formatter.exs ├── .github └── workflows │ ├── ci.yml │ └── contributor_list.yml ├── .gitignore ├── .tool-versions ├── Dockerfile ├── Makefile ├── README.md ├── assets ├── .babelrc ├── css │ ├── _colors.scss │ ├── _login.scss │ ├── _newsnippet.scss │ ├── _reader.scss │ ├── _search.scss │ ├── _static.scss │ ├── _statistics.scss │ ├── _table.scss │ ├── app.scss │ ├── custom.scss │ └── highlight.scss ├── js │ ├── app.js │ ├── bookmark_lines.js │ ├── livesocket.js │ ├── socket.js │ └── statistics.js ├── package-lock.json ├── package.json ├── static │ ├── favicon.ico │ ├── images │ │ ├── logo.png │ │ └── phoenix.png │ └── robots.txt └── webpack.config.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── releases.exs └── test.exs ├── docker-compose.yaml ├── entrypoint.sh ├── lib ├── exbin.ex ├── exbin │ ├── accounts.ex │ ├── application.ex │ ├── clock.ex │ ├── mailer.ex │ ├── models │ │ ├── accounts │ │ │ ├── user.ex │ │ │ ├── user_notifier.ex │ │ │ └── user_token.ex │ │ └── snippet │ │ │ ├── snippet.ex │ │ │ └── snippets.ex │ ├── repo.ex │ ├── schema.ex │ ├── scrubber.ex │ ├── socket.ex │ ├── stats.ex │ └── util │ │ └── like_injection.ex ├── exbin_web.ex ├── exbin_web │ ├── channels │ │ └── user_socket.ex │ ├── controllers │ │ ├── api_controller.ex │ │ ├── fallback_controller.ex │ │ ├── page_controller.ex │ │ ├── snippet_controller.ex │ │ ├── user_auth.ex │ │ ├── user_confirmation_controller.ex │ │ ├── user_registration_controller.ex │ │ ├── user_reset_password_controller.ex │ │ ├── user_session_controller.ex │ │ └── user_settings_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ │ ├── page_live.ex │ │ └── page_live.html.leex │ ├── plug │ │ ├── api_auth.ex │ │ ├── custom_logo.ex │ │ ├── file_not_found.ex │ │ └── viewcounter.ex │ ├── router.ex │ ├── telemetry.ex │ ├── templates │ │ ├── layout │ │ │ ├── app.html.eex │ │ │ ├── live.html.leex │ │ │ └── root.html.eex │ │ ├── page │ │ │ └── about.html.eex │ │ ├── snippet │ │ │ ├── code.html.eex │ │ │ ├── list.html.eex │ │ │ ├── new.html.eex │ │ │ ├── reader.html.eex │ │ │ └── statistics.html.eex │ │ ├── user_confirmation │ │ │ └── new.html.eex │ │ ├── user_registration │ │ │ └── new.html.eex │ │ ├── user_reset_password │ │ │ ├── edit.html.eex │ │ │ └── new.html.eex │ │ ├── user_session │ │ │ └── new.html.eex │ │ └── user_settings │ │ │ └── edit.html.eex │ └── views │ │ ├── api_view.ex │ │ ├── error_helpers.ex │ │ ├── error_view.ex │ │ ├── layout_view.ex │ │ ├── page_view.ex │ │ ├── snippet_view.ex │ │ ├── user_confirmation_view.ex │ │ ├── user_registration_view.ex │ │ ├── user_reset_password_view.ex │ │ ├── user_session_view.ex │ │ └── user_settings_view.ex └── release.ex ├── mix.exs ├── mix.lock ├── priv ├── 10G.gzip ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── repo │ ├── migrations │ ├── .formatter.exs │ ├── 20180901090114_create_snippet copy.exs │ ├── 20180901090115_create_snippet.exs │ ├── 20210906115754_create_users_auth_tables.exs │ ├── 20210911093308_add_user.exs │ ├── 20210911093842_snippet_belongs_to_user.exs │ ├── 20210911093843_admin.exs │ └── 20220506074330_change_content_type.exs │ └── seeds.exs ├── rel └── overlays │ ├── config.exs │ └── initial_user.exs └── test ├── exbin ├── 0x00_bin_file ├── accounts_test.exs ├── clock_test.exs ├── snippet_test.exs └── stats_test.exs ├── exbin_web ├── controllers │ ├── page_controller_test.exs │ ├── user_auth_test.exs │ ├── user_confirmation_controller_test.exs │ ├── user_registration_controller_test.exs │ ├── user_reset_password_controller_test.exs │ ├── user_session_controller_test.exs │ └── user_settings_controller_test.exs ├── plug │ └── static_files_pipeline_test.exs └── views │ ├── error_view_test.exs │ ├── layout_view_test.exs │ └── page_view_test.exs ├── support ├── channel_case.ex ├── conn_case.ex ├── data_case.ex ├── factory.ex └── fixtures │ └── accounts_fixtures.ex └── test_helper.exs /.dockerignore: -------------------------------------------------------------------------------- 1 | assets/node_modules 2 | _build 3 | .elixir_ls 4 | .github 5 | deps 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SECRET_KEY_BASE="TUvAjMKpIXf+ik05cgmjErbtWVUBmKX70TCtg9ToU3ZC8gdNQoYnCrLAljBuHvKU" 2 | SECRET_SALT="Qrw8mzDAAdvouNi6EvP/vEBwgPw0lCXh2dCANXKbW0HnQElvhB8nETC/q/L+zxxa" 3 | DATABASE_HOST=db 4 | DATABASE_DB=exbin 5 | DATABASE_USER=postgres 6 | DATABASE_PASSWORD=postgres 7 | POOL_SIZE=10 8 | TZ=Europe/Brussels 9 | EPHEMERAL_AGE=60 10 | HTTP_PORT=5000 11 | TCP_PORT=9999 12 | TCP_HOST=0.0.0.0 13 | MAX_SIZE=2048 14 | DEFAULT_VIEW=code 15 | BASE_URL=https://example.com 16 | HOST=example.com 17 | DATABASE_DATA=/tmp/exbindata 18 | API_KEY=devkey 19 | SMTP_USER=me@me.com 20 | SMTP_PASSWORD=supersecretpassword 21 | SMTP_PORT=465 22 | SMTP_FROM=from@from.com 23 | SMTP_RELAY=mail.me.com 24 | HTTPS=true -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :phoenix], 3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}", "rel/overlays/*.{exs}"], 4 | subdirectories: ["priv/*/migrations"], 5 | line_length: 120 6 | ] 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | test: 8 | name: Test app 9 | runs-on: ubuntu-latest 10 | env: 11 | MIX_ENV: test 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - uses: erlef/setup-beam@v1 17 | id: beam 18 | with: 19 | version-file: .tool-versions 20 | version-type: strict 21 | 22 | - name: Restore the deps and _build cache 23 | uses: actions/cache@v4 24 | id: restore-cache 25 | env: 26 | OTP_VERSION: ${{ steps.beam.outputs.otp-version }} 27 | ELIXIR_VERSION: ${{ steps.beam.outputs.elixir-version }} 28 | MIX_LOCK_HASH: ${{ hashFiles('**/mix.lock') }} 29 | with: 30 | path: | 31 | deps 32 | _build 33 | key: ${{ runner.os }}-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ env.MIX_ENV }}-mixlockhash-${{ env.MIX_LOCK_HASH }} 34 | 35 | - name: Install mix dependencies 36 | if: steps.restore-cache.outputs.cache-hit != 'true' 37 | run: mix deps.get 38 | 39 | - name: Compile dependencies 40 | if: steps.restore-cache.outputs.cache-hit != 'true' 41 | run: mix deps.compile 42 | 43 | - name: Compile 44 | run: mix compile --warnings-as-errors --force 45 | 46 | - name: Check Formatting 47 | run: mix format --check-formatted 48 | 49 | # - name: Check unused deps 50 | # run: mix deps.unlock --check-unused 51 | 52 | # - name: Credo 53 | # run: mix credo 54 | 55 | # - name: Run Tests 56 | # run: mix test 57 | 58 | build-and-push: 59 | name: Build and push Docker image 60 | runs-on: ubuntu-latest 61 | 62 | permissions: 63 | contents: read 64 | packages: write 65 | 66 | steps: 67 | - name: Checkout 68 | uses: actions/checkout@v4 69 | 70 | - name: Lowercase image name 71 | run: echo "IMAGE_NAME=$(echo "$IMAGE_NAME" | awk '{print tolower($0)}')" >> $GITHUB_ENV 72 | 73 | - name: Log in to the container registry 74 | uses: docker/login-action@v3 75 | with: 76 | registry: ${{ env.REGISTRY }} 77 | username: ${{ secrets.DOCKER_HUB_USER }} 78 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 79 | 80 | - name: Set up Docker Buildx 81 | uses: docker/setup-buildx-action@v3 82 | 83 | - name: Extract metadata (tags, labels) for Docker 84 | id: meta 85 | uses: docker/metadata-action@v5 86 | with: 87 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 88 | 89 | - name: Build and push 90 | uses: docker/build-push-action@v6 91 | with: 92 | push: true 93 | tags: m1dnight/exbin:latest 94 | -------------------------------------------------------------------------------- /.github/workflows/contributor_list.yml: -------------------------------------------------------------------------------- 1 | name: Contributor List 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | contributor_list: 8 | name: Contributor List 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | - uses: docker://cjdenio/contributor_list:latest 13 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.15.7-otp-25 2 | erlang 25.3.2.7 3 | nodejs 16.11.0 4 | rebar 3.22.1 5 | pnpm 8.14.1 6 | yarn 1.22.19 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # Build Image 3 | FROM elixir:1.15-slim as build 4 | LABEL maintainer "Christophe De Troyer " 5 | 6 | # Install compile-time dependencies 7 | ENV DEBIAN_FRONTEND=noninteractive 8 | RUN apt-get update && apt-get install -y git nodejs npm yarn python3 9 | RUN mkdir /app 10 | WORKDIR /app 11 | 12 | # Install Hex and Rebar 13 | RUN mix do local.hex --force, local.rebar --force 14 | 15 | # set build ENV 16 | ENV MIX_ENV=prod 17 | 18 | # install mix dependencies 19 | COPY mix.exs mix.lock ./ 20 | COPY config config 21 | RUN mix deps.get --only $MIX_ENV 22 | RUN mix deps.compile 23 | 24 | # Build web assets. 25 | COPY assets assets 26 | RUN npm install --prefix ./assets && npm run deploy --prefix ./assets 27 | RUN mix phx.digest 28 | 29 | # Compile entire project. 30 | COPY priv priv 31 | COPY lib lib 32 | COPY rel rel 33 | RUN mix compile 34 | 35 | # Build the entire release. 36 | RUN mix release 37 | 38 | ################################################################################ 39 | # Release Image 40 | 41 | FROM elixir:1.15-slim AS app 42 | 43 | RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y openssl postgresql-client locales curl net-tools procps 44 | 45 | # Set the locale 46 | # Set the locale 47 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && \ 48 | locale-gen 49 | ENV LANG en_US.UTF-8 50 | ENV LANGUAGE en_US:en 51 | ENV LC_ALL en_US.UTF-8 52 | 53 | ENV MIX_ENV=prod 54 | 55 | # Make the working directory for the application. 56 | RUN mkdir /app 57 | WORKDIR /app 58 | 59 | # Copy release from build container to this container. 60 | COPY --from=build /app/_build/prod/rel/ . 61 | COPY entrypoint.sh . 62 | RUN chown -R nobody: /app 63 | USER nobody 64 | 65 | ENV HOME=/app 66 | CMD /bin/bash /app/entrypoint.sh 67 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DOCKER_TAG := $(shell grep 'version:' mix.exs | sed -e 's/.*version: "\(.*\)",/\1/') 2 | DOCKER_IMG := m1dnight/exbin 3 | 4 | .PHONY: build 5 | 6 | build: 7 | docker build -t $(DOCKER_IMG):$(DOCKER_TAG) . 8 | docker build -t $(DOCKER_IMG):latest . 9 | 10 | build-fresh: 11 | docker build --no-cache -t $(DOCKER_IMG):$(DOCKER_TAG) . 12 | docker build --no-cache -t $(DOCKER_IMG):latest . 13 | 14 | push: 15 | docker push $(DOCKER_IMG):$(DOCKER_TAG) 16 | docker push $(DOCKER_IMG):latest 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Exbin 3 | 4 | A pastebin clone written in Phoenix/Elixir. Live [here](https://exbin.call-cc.be). 5 | 6 | I work on this project from time to time, so the development pace is slow. If you want to dive in, feel free. The codebase is relatively small because, well, it's a simple application. 7 | 8 | ## Features 9 | 10 | * Post pastes either publicly or privately 11 | * Opt-in ephemeral snippets. By default these are deleted approximately 60 minutes after creation. 12 | * Views are only incremented once every 24 hours, per client. 13 | * Usage statistics. 14 | * List of all public pastes 15 | * Use `nc` to pipe text and get back the URL. 16 | (e.g., `cat file.txt | nc exbin.call-cc.be 9999`) 17 | * "Raw View" where text is presented as is. Well suited for copy/pasting. 18 | * "Reader View" where text is presented in a more readable manner. Well suited to share prose text. 19 | * "Code View", showing snippets with syntax highlighting. 20 | * The netcat endpoint has an HTTP bomb so that scanners and bots don't return. 21 | 22 | # Installation 23 | 24 | The easiest way to run your own instance of Exbin is by running it in a Docker container. 25 | Make a copy of the configuration file in `rel/overlays/config.exs`. The file contains documentation on how to fill it out. 26 | Mount this file in the docker container to configure your instance. 27 | 28 | 29 | Copy the `docker-compose.yaml` file, and change accordingly. Finally, run it with `docker-compose up`. 30 | 31 | ## Initial User Account 32 | 33 | When installing/running ExBin for the first time, a user will be created for you. 34 | It is highly recommended that you change this user its email and password. 35 | Look for a line like this in the log files. 36 | 37 | ``` 38 | Created a user with email admin@exbin.call-cc.be and password ccbbf2726ac2ce3d3918 39 | ``` 40 | 41 | If there are already users present in the database no user will be created. 42 | The logfile will show this. 43 | 44 | ``` 45 | Did not create a user because there are already registered users in the database. 46 | ``` 47 | 48 | The first user is the only admin user possible. I should probably update this in the future, but not today. 49 | If you already have a bunch of users, you can easily change it by toggling the flag in the database. 50 | 51 | ## Custom Branding in Docker 52 | 53 | In order to configure this you will need to mount the file into your docker container as a volume, and then set the `custom_logo_path` parameter in the `config.exs` file to the full path (inside the container) that the file is mounted at. 54 | Here is an example of what you would add to your `docker-compose.yml` and `config.exs`: 55 | 56 | ```yml 57 | exbin: 58 | restart: always 59 | image: m1dnight/exbin:latest 60 | volumes: 61 | - ./rel/overlays/config.exs:/app/prod/config.exs 62 | ``` 63 | 64 | ```elixir 65 | custom_logo_path: "/my/logo.png", 66 | custom_logo_size: 30, 67 | ``` 68 | 69 | Logo by default is 30x30 pixels, but you can define the size for the width/height attributes of the img tag by setting `custom_logo_size`. 70 | Logos are assumed to be square, so the same value will be used for both height and width. 71 | Any layout errors that come from using sizes other than 30x30 are your problem. :-) 72 | 73 | ## JSON API 74 | 75 | There is a JSON API available. If your install has an API key set (the `api_key` variable in `config.exs`), it is required to post through the API. If it is not set, the API can be freely used. 76 | The payload of the API is JSON, and expects at least the content of the snippet. 77 | 78 | The `api/new` endpoint expects a JSON payload with the keys `content`, `private`, and `ephemeral`. For example: 79 | 80 | ``` 81 | {"content": "this is the content", 82 | "private": true, 83 | "ephemeral": false 84 | } 85 | ``` 86 | 87 | An example request for a snippet without authentication looks like this. 88 | 89 | ``` 90 | $ curl -XPOST -H "Content-type: application/json" -d '{"content": "this is the content", "private": true, "ephemeral": false}' 'https://exbin.call-cc.be/api/new' 91 | {"content":"this is the content","created":"2021-10-01T20:32:38.702101Z","name":"RegelatedDoublemindedness","url":"https://exbin.call-cc.be/RegelatedDoublemindedness"} 92 | ``` 93 | 94 | To use an authenticated endpoint simply add another field to the JSON payload with the token. 95 | 96 | ``` 97 | $ curl -XPOST -H "Content-type: application/json" -d '{"content": "this is the content", "private": true, "ephemeral": false, "token": "supersecret"}' 'https://exbin.call-cc.be/api/new' 98 | {"content":"this is the content","created":"2021-10-01T20:32:38.702101Z","name":"RegelatedDoublemindedness","url":"https://exbin.call-cc.be/RegelatedDoublemindedness"} 99 | ``` 100 | 101 | A snippet can be requested by name using the `api/new` endpoint. No parameters should be given. 102 | An example curl request looks like this. 103 | 104 | ``` 105 | curl 'https://exbin.call-cc.be/api/show/CoppingSuctions' 106 | {"content":"this is the content","created":"2022-05-06T08:40:17.769579Z","name":"CoppingSuctions","url":"https://exbin.call-cc.be/api/show/CoppingSuctions"} 107 | ``` 108 | 109 | # Things To Do 110 | 111 | * Empty snippets are not allowed, but if you use some unprintable chars it still passes. 112 | * Synced paged back or not? 113 | * Rate limit the amount of pastes a user can make. 114 | * Admin page 115 | * Nicer warnings/checks on environment variables instead of crashing immediately. 116 | * Check older issues to see what I missed 117 | * Allow a unique user (reuse from rate limiting) to delete a snippet in the next x minutes, or create a unique delete link or something. 118 | * Maybe use a list to filter out snippets that might contain bad words. Instead of disallowing them, we could drop them from the public list. 119 | * Write some unit tests.. 120 | 121 | 122 | 123 | 124 | ## 👥 Contributors 125 | 126 | 127 | - **[@m1dnight](https://github.com/m1dnight)** 128 | 129 | - **[@joshproehl](https://github.com/joshproehl)** 130 | 131 | - **[@dependabot[bot]](https://github.com/apps/dependabot)** 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /assets/css/_colors.scss: -------------------------------------------------------------------------------- 1 | $background: rgb(34, 34, 34); 2 | $menubackground: rgb(48, 47, 47); 3 | $linenos: rgb(237, 240, 243);; 4 | $detail: rgb(155, 45, 179); 5 | $detailContrast: rgb(237, 240, 243); 6 | $detailTransparent: rgba(255, 0, 255, 0.2); 7 | 8 | $text: rgb(237, 240, 243); 9 | $sans-serif-font: 'Lato', sans-serif; 10 | $monospace-font: 'Fira Mono', monospace; 11 | $serif-font: 'Merriweather', serif; -------------------------------------------------------------------------------- /assets/css/_login.scss: -------------------------------------------------------------------------------- 1 | @import "_colors"; 2 | 3 | .login-container { 4 | max-width: 500px; 5 | margin-left: auto !important; 6 | margin-right: auto; 7 | display: flex; 8 | flex-direction: column; 9 | } 10 | 11 | .logo { 12 | font-size: 20px; 13 | text-align: center; 14 | padding: 20px 20px 0; 15 | margin: 0; 16 | } 17 | 18 | .login-item { 19 | padding: 25px 25px 0; 20 | margin: 20px 20px 0; 21 | 22 | } 23 | 24 | .login-input { 25 | width: 100%; 26 | font-weight: 500; 27 | font-size: 0.875rem; 28 | line-height: 1.125rem; 29 | text-transform: uppercase; 30 | color: $text; 31 | background-color: $menubackground; 32 | -webkit-transition: all 0.2s ease-in-out; 33 | transition: all 0.2s ease-in-out; 34 | display: inline-block; 35 | height: 2.25rem; 36 | padding: 0 1rem; 37 | // margin: 0.05rem 0; 38 | border: 1px solid $text; 39 | border-radius: 2px; 40 | background-image: none; 41 | text-align: center; 42 | line-height: 2.25rem; 43 | vertical-align: middle; 44 | white-space: nowrap; 45 | font-size: 0.875rem; 46 | font-family: inherit; 47 | letter-spacing: 0.03em; 48 | position: relative; 49 | overflow: hidden; 50 | } 51 | 52 | .form-field { 53 | display: -webkit-box; 54 | display: -webkit-flex; 55 | display: -ms-flexbox; 56 | display: flex; 57 | flex-direction: column; 58 | margin-bottom: 2rem; 59 | } 60 | 61 | .error-message, .invalid_feedback { 62 | padding: 0.2rem 1rem 0.2rem 1rem; 63 | margin: 0; 64 | border: 2px solid transparent; 65 | border-radius: 1px !important; 66 | margin-top: .5rem; 67 | margin-bottom: 2rem; 68 | position: relative; 69 | color: #a94442; 70 | background-color: #f2dede; 71 | border-color: #ebccd1; 72 | } 73 | 74 | #links { 75 | text-align: center; 76 | } 77 | -------------------------------------------------------------------------------- /assets/css/_newsnippet.scss: -------------------------------------------------------------------------------- 1 | @import "_colors"; 2 | 3 | // Entire form. 4 | .snippet-form { 5 | display: flex; 6 | flex-direction: column; 7 | height: 100%; 8 | } 9 | 10 | // Menu bar on top of page. 11 | .snippet-settings-row { 12 | flex-grow: 0; 13 | } 14 | 15 | // Snippet content 16 | .snippet-content-row { 17 | flex-grow: 1; 18 | width: 100%; 19 | } 20 | // Individual controls in menu bar. 21 | .setting:first-child { 22 | margin-left: 0; 23 | } 24 | .setting { 25 | display: inline; 26 | margin-left: 1rem; 27 | margin-right: 1rem; 28 | } 29 | 30 | // Checkboxes. 31 | .checkbox { 32 | background-color: $menubackground; 33 | } 34 | 35 | label { 36 | text-transform: uppercase; 37 | } 38 | 39 | // The code textarea. 40 | 41 | .snippet-content { 42 | background-color: $background; 43 | border: 0px solid #f6e4cc; 44 | color: #d2d7d3; 45 | outline: none; 46 | resize: none; 47 | font-family: $monospace-font; 48 | min-width: 100%; 49 | width: 100%; 50 | height: 100%; 51 | margin: 0 !important; 52 | padding: 0 !important; 53 | } 54 | 55 | button { 56 | font-weight: 500; 57 | font-size: 0.875rem; 58 | line-height: 1.125rem; 59 | text-transform: uppercase; 60 | color: $text; 61 | background-color: $menubackground; 62 | -webkit-transition: all 0.2s ease-in-out; 63 | transition: all 0.2s ease-in-out; 64 | display: inline-block; 65 | height: 2.25rem; 66 | padding: 0 1rem; 67 | // margin: 0.05rem 0; 68 | border: 1px solid $text; 69 | border-radius: 2px; 70 | cursor: pointer; 71 | -ms-touch-action: manipulation; 72 | touch-action: manipulation; 73 | background-image: none; 74 | text-align: center; 75 | line-height: 2.25rem; 76 | vertical-align: middle; 77 | white-space: nowrap; 78 | // -webkit-user-select: none; 79 | // -moz-user-select: none; 80 | // -ms-user-select: none; 81 | // user-select: none; 82 | font-size: 0.875rem; 83 | font-family: inherit; 84 | letter-spacing: 0.03em; 85 | position: relative; 86 | overflow: hidden; 87 | } 88 | -------------------------------------------------------------------------------- /assets/css/_reader.scss: -------------------------------------------------------------------------------- 1 | .reader-container { 2 | max-width: 70ch !important; 3 | margin-left: auto !important; 4 | margin-right: auto; 5 | word-wrap: break-word; 6 | } 7 | 8 | 9 | .reader-container code pre { 10 | white-space: pre-wrap; 11 | font-size: 1.1rem; 12 | font-family: $serif-font; 13 | text-align: justify; 14 | text-rendering: optimizeLegibility; 15 | } -------------------------------------------------------------------------------- /assets/css/_search.scss: -------------------------------------------------------------------------------- 1 | @import "_colors"; 2 | 3 | .search-row { 4 | display: flex; 5 | flex-direction: row; 6 | flex-wrap: wrap; 7 | width: 100%; 8 | } 9 | 10 | .input-column { 11 | display: flex; 12 | flex-direction: column; 13 | flex-grow: 1; 14 | flex-shrink: 1; 15 | flex-basis: 0; 16 | width: 100%; 17 | background-color:white; 18 | } 19 | 20 | .status-column { 21 | display: flex; 22 | flex-direction: column; 23 | flex-grow: 0; 24 | flex-shrink: 0; 25 | flex-basis: 0; 26 | background-color:white; 27 | } 28 | 29 | .searchbox { 30 | // float: left; 31 | width: 100%; 32 | height: 100%; 33 | // margin-left: 0px; 34 | // margin-right: 0px; 35 | font-size: 24px; 36 | line-height: 64px; 37 | outline: none; 38 | border: none; 39 | visibility: visible; 40 | padding-left: 16px; 41 | } 42 | 43 | .status { 44 | color:black !important; 45 | display: flex; 46 | align-items: center; 47 | // font-size: 24px; 48 | width: 100%; 49 | height: 100%; 50 | font-size: 24px; 51 | line-height: 64px; 52 | } 53 | 54 | #disconnected { 55 | display: unset; 56 | } 57 | #connected { 58 | display: none; 59 | } -------------------------------------------------------------------------------- /assets/css/_static.scss: -------------------------------------------------------------------------------- 1 | @import "_colors"; 2 | 3 | .static-container { 4 | max-width: 70ch !important; 5 | margin-left: auto !important; 6 | margin-right: auto; 7 | word-wrap: break-word; 8 | font-size: 1.1rem; 9 | font-family: $serif-font; 10 | text-align: justify; 11 | text-rendering: optimizeLegibility; 12 | } 13 | 14 | .static-container code { 15 | color: $detail; 16 | } -------------------------------------------------------------------------------- /assets/css/_statistics.scss: -------------------------------------------------------------------------------- 1 | @import "_colors"; 2 | 3 | .statistics-container { 4 | max-width: 1140px; 5 | margin-left: auto !important; 6 | margin-right: auto; 7 | } 8 | .row { 9 | display: flex; 10 | flex-wrap: wrap; 11 | } 12 | 13 | .card { 14 | flex: 0 0 50%; 15 | max-width: 50%; 16 | text-align: center; 17 | padding-left: 1rem; 18 | padding-right: 1rem; 19 | margin-top: 10px; 20 | margin-bottom: 10px; 21 | } 22 | 23 | .tile { 24 | background-color: $detailTransparent; 25 | padding: 5px; 26 | word-wrap: break-word; 27 | background-clip: border-box; 28 | border: 1px solid rgba(0, 0, 0, 0.125); 29 | border-radius: 0.25rem; 30 | } 31 | 32 | .tile p { 33 | margin: 5px; 34 | } 35 | 36 | .tile p svg { 37 | vertical-align: middle; 38 | } 39 | 40 | .tile h5 { 41 | margin: 5px; 42 | } 43 | -------------------------------------------------------------------------------- /assets/css/_table.scss: -------------------------------------------------------------------------------- 1 | @import "_colors"; 2 | 3 | .snippet-preview { 4 | background-color: $menubackground; 5 | margin-bottom: 1rem; 6 | padding: 1rem; 7 | } 8 | 9 | // Meta data row. 10 | .snippet-meta-data { 11 | display: flex; 12 | flex-direction: row; 13 | padding-bottom: 1rem; 14 | } 15 | 16 | .snippet-meta-data div { 17 | display: flex; 18 | } 19 | 20 | .snippet-meta-data div.meta-icon svg { 21 | vertical-align: middle; 22 | display: flex; 23 | } 24 | 25 | .text-button { 26 | background: none; 27 | border: none; 28 | color: red; 29 | height: unset !important; 30 | line-height: unset; 31 | vertical-align: unset; 32 | padding: unset; 33 | font-size: 1rem; 34 | } 35 | 36 | .text-button:hover { 37 | background: none; 38 | text-decoration: underline; 39 | } 40 | 41 | div.snippet-preview div div.created { 42 | margin-left: auto; 43 | vertical-align: middle; 44 | } 45 | 46 | // Preview content 47 | .snippet-preview-content { 48 | font-family: $monospace-font; 49 | overflow-x: auto !important; 50 | } 51 | 52 | .snippet-preview-content code { 53 | word-break: break-word; 54 | } 55 | 56 | .snippet-preview-content code pre { 57 | white-space: pre-line; 58 | font-size: 1rem; 59 | // font-family: 'Latin Modern', Georgia, Cambria, 'Times New Roman', Times, serif; 60 | text-align: justify; 61 | text-rendering: optimizeLegibility; 62 | } 63 | -------------------------------------------------------------------------------- /assets/css/app.scss: -------------------------------------------------------------------------------- 1 | @import "./custom.scss"; 2 | @import "~highlight.js/styles/xt256.css"; 3 | @import "highlight"; 4 | @import "_colors"; 5 | @import "_newsnippet"; 6 | @import "_reader"; 7 | @import "_table"; 8 | @import "_statistics"; 9 | @import "_search"; 10 | @import "_login"; 11 | @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans&display=swap"); 12 | @import url("https://fonts.googleapis.com/css2?family=Fira+Mono&display=swap"); 13 | @import url("https://fonts.googleapis.com/css2?family=Lato&display=swap"); 14 | @import url("https://fonts.googleapis.com/css2?family=Merriweather&display=swap"); 15 | @import "_static"; 16 | 17 | // @import "~ionicons" 18 | 19 | // Layout 20 | body, 21 | html { 22 | padding-left: 0.1rem; 23 | margin: 0; 24 | background: $background; 25 | height: 100vh; 26 | height: -webkit-fill-available; 27 | max-height: 100vh; 28 | margin: 0 !important; 29 | font-family: $sans-serif-font; 30 | color: $text; 31 | // overflow: hidden; 32 | } 33 | 34 | hr { 35 | color: $detail; 36 | } 37 | .container-fluid { 38 | padding: 0 !important; 39 | margin: 0 !important; 40 | height: 100%; 41 | width: 100%; 42 | height: 100vh; 43 | height: -webkit-fill-available; 44 | max-height: 100vh; 45 | } 46 | 47 | * { 48 | box-sizing: border-box; 49 | } 50 | 51 | .flex-container { 52 | display: flex; 53 | flex-direction: row; 54 | min-height: 100%; 55 | height: 100vh; 56 | height: -webkit-fill-available; 57 | max-height: 100vh; 58 | } 59 | 60 | .code-column { 61 | /* do not grow - initial value: 0 */ 62 | flex-grow: 1; 63 | /* shrink - initial value: 1 */ 64 | flex-shrink: 1; 65 | /* width/height - initial value: auto */ 66 | flex-basis: auto; 67 | min-width: 0; 68 | padding: 1rem; 69 | overflow-x: auto; 70 | overflow-y: auto; 71 | overflow-x: auto; 72 | overflow-y: auto; 73 | } 74 | 75 | // Menu Bar 76 | .menu-column { 77 | position: sticky; 78 | background-color: $menubackground; 79 | box-shadow: -5px 0 8px rgba(48, 47, 47, 0.308); 80 | 81 | flex-grow: 0; 82 | flex-shrink: 0; 83 | /* width/height - initial value: auto */ 84 | flex-basis: auto; 85 | 86 | padding-top: 1rem; 87 | padding-left: 1rem; 88 | padding-right: 2rem; 89 | padding-bottom: 1rem; 90 | 91 | color: $text; 92 | 93 | display: flex; 94 | // flex-wrap: wrap; 95 | flex-direction: column; 96 | 97 | // On pages where no date is shown. 98 | width: 15ch !important; 99 | } 100 | 101 | .iconify { 102 | color: $detail; 103 | } 104 | .iconify-white { 105 | color: $detailContrast; 106 | } 107 | 108 | // Top Menu 109 | .menu-column-top { 110 | margin-bottom: auto; 111 | } 112 | 113 | // Menu entries. 114 | .menu-entry { 115 | margin-top: 0.5rem; 116 | margin-bottom: 0.5rem; 117 | display: flex; 118 | // flex-direction: column; 119 | } 120 | .menu-entry span.icon { 121 | display: inline-flex; 122 | flex: 0 0 auto; 123 | } 124 | 125 | .menu-entry p.title { 126 | text-overflow: ellipsis; 127 | overflow: hidden; 128 | margin: 0; 129 | } 130 | 131 | .branding { 132 | justify-content: center; 133 | text-align: center; 134 | } 135 | 136 | .branding h5 { 137 | margin: 1rem; 138 | font-size: 1rem; 139 | } 140 | 141 | .menu-entry .icon { 142 | display: inline-block; 143 | height: 16px; 144 | width: 16px; 145 | margin-right: 1rem; 146 | } 147 | 148 | .menu-entry .icon svg { 149 | vertical-align: middle; 150 | } 151 | 152 | .menu-entry .title { 153 | vertical-align: middle; 154 | } 155 | 156 | .trim { 157 | overflow: hidden; 158 | text-overflow: ellipsis; 159 | word-wrap: break-word; 160 | } 161 | 162 | // Alerts 163 | .alert { 164 | padding: 0.2rem 1rem 0.2rem 1rem; 165 | margin: 0; 166 | border: 2px solid transparent; 167 | border-radius: 1px !important; 168 | margin-top: 0.5rem; 169 | margin-bottom: 2rem; 170 | position: relative; 171 | } 172 | 173 | .alert .icon_container { 174 | padding: 0; 175 | margin: 0; 176 | margin-right: 0.5rem; 177 | display: inline-block; 178 | min-height: 48px; 179 | min-width: 48px; 180 | } 181 | 182 | .alert .message { 183 | position: absolute; 184 | top: 50%; 185 | transform: translateY(-50%); 186 | } 187 | 188 | .alert-info { 189 | color: #31708f; 190 | background-color: #d9edf7; 191 | border-color: #bce8f1; 192 | } 193 | 194 | .alert-warning { 195 | color: #8a6d3b; 196 | background-color: #fcf8e3; 197 | border-color: #faebcc; 198 | } 199 | 200 | .alert-danger { 201 | color: #a94442; 202 | background-color: #f2dede; 203 | border-color: #ebccd1; 204 | } 205 | 206 | .alert p { 207 | // margin-bottom: 0; 208 | } 209 | 210 | .alert:empty { 211 | display: none; 212 | } 213 | 214 | .invalid-feedback { 215 | color: #a94442; 216 | display: block; 217 | // margin: -1rem 0 2rem; 218 | } 219 | 220 | // Links 221 | a { 222 | color: unset; 223 | } 224 | a:link { 225 | text-decoration: none; 226 | } 227 | 228 | a:visited { 229 | text-decoration: none; 230 | } 231 | 232 | a:hover { 233 | text-decoration: bold; 234 | } 235 | 236 | a:active { 237 | text-decoration: none; 238 | } 239 | 240 | .link { 241 | color: $detail; 242 | } 243 | 244 | .menu-column-top > a { 245 | color: unset; 246 | } 247 | 248 | // Text 249 | 250 | h5 { 251 | font-size: 1.25rem; 252 | } 253 | -------------------------------------------------------------------------------- /assets/css/custom.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1dnight/exbin/fd5d0b7d03b51f921b7d496e09e5ef1d3b2fc43c/assets/css/custom.scss -------------------------------------------------------------------------------- /assets/css/highlight.scss: -------------------------------------------------------------------------------- 1 | @import "_colors"; 2 | 3 | .hljs-ln-numbers { 4 | -webkit-touch-callout: none; 5 | -webkit-user-select: none; 6 | -khtml-user-select: none; 7 | -moz-user-select: none; 8 | -ms-user-select: none; 9 | user-select: none; 10 | 11 | text-align: center; 12 | color: #ccc; 13 | // border-right: 1px solid #ccc; 14 | vertical-align: top; 15 | padding-right: 5px; 16 | margin-right: 100px; 17 | 18 | /* your custom style here */ 19 | } 20 | 21 | /* for block of code */ 22 | .hljs-ln-code { 23 | padding-left: 10px; 24 | } 25 | 26 | .hljs { 27 | background: $background; 28 | } 29 | 30 | .hljs-ln-n { 31 | padding-right: 0.5rem; 32 | color: $linenos; 33 | white-space: nowrap; 34 | } 35 | 36 | .code-column pre { 37 | margin: 0 !important; 38 | padding: 0 !important; 39 | 40 | } 41 | 42 | .code-column pre code { 43 | margin: 0 !important; 44 | padding: 0 !important; 45 | white-space: pre-wrap; /* Since CSS 2.1 */ 46 | overflow-wrap: anywhere; 47 | } 48 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // webpack automatically bundles all modules in your 2 | // entry points. Those entry points can be configured 3 | // in "webpack.config.js". 4 | // 5 | // Import dependencies 6 | // 7 | 8 | // We need to import the CSS so that webpack will load it. 9 | // The MiniCssExtractPlugin is used to separate it out into 10 | // its own CSS file. 11 | import "../css/app.scss" 12 | 13 | // webpack automatically bundles all modules in your 14 | // entry points. Those entry points can be configured 15 | // in "webpack.config.js". 16 | // 17 | // Import deps with the dep name or local files with a relative path, for example: 18 | // 19 | import { Socket } from "phoenix" 20 | // import socket from "./socket" 21 | // 22 | import "phoenix_html" 23 | 24 | // Highlight.js 25 | const hljs = require("highlight.js"); 26 | window.hljs = hljs; 27 | hljs.highlightAll(); 28 | 29 | // Linenumbers for code. 30 | require('highlightjs-line-numbers.js'); 31 | hljs.initLineNumbersOnLoad(); 32 | 33 | import { Iconify } from "@iconify/iconify"; 34 | // import "bookmark_lines.js" 35 | require("./bookmark_lines.js") 36 | 37 | 38 | // This is for chart.js 3, but it doesn't give charts of the same height, for some reason. 39 | import { Chart } from "chart.js/dist/chart.js"; 40 | // import {Chart} from "chart.js/dist/Chart.bundle.js"; 41 | 42 | require("./statistics.js"); 43 | 44 | 45 | 46 | let hooks = {} 47 | hooks.StatusHooks = { 48 | disconnected() { 49 | document.getElementById("connected").style.display = "none"; 50 | document.getElementById("disconnected").style.display = "flex"; 51 | console.log(new Date()) 52 | console.log("disconnected") 53 | }, 54 | reconnected() { 55 | document.getElementById("connected").style.display = "flex"; 56 | document.getElementById("disconnected").style.display = "none"; 57 | console.log(new Date()) 58 | console.log("reconnected") 59 | }, 60 | mounted() { 61 | document.getElementById("connected").style.display = "flex"; 62 | document.getElementById("disconnected").style.display = "none"; 63 | console.log(new Date()) 64 | console.log("connected") 65 | } 66 | } 67 | 68 | import LiveSocket from "phoenix_live_view" 69 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 70 | let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, hooks: hooks }) 71 | liveSocket.connect() 72 | window.liveSocket = liveSocket 73 | -------------------------------------------------------------------------------- /assets/js/bookmark_lines.js: -------------------------------------------------------------------------------- 1 | var addBookmarks = function () { 2 | window.setTimeout(() => { 3 | var elements = document.getElementsByClassName("hljs-ln-n"); 4 | 5 | Array.from(elements).forEach((el) => { 6 | var link = document.createElement("a"); 7 | 8 | // Do stuff here 9 | var ln = el.getAttribute("data-line-number"); 10 | var id = `L${ln}`; 11 | el.setAttribute('id', id); 12 | el.setAttribute('href', `#${id}`); 13 | el.addEventListener('click', (event) => { 14 | console.log("you clicked line " + ln); 15 | document.location.hash = id; 16 | }); 17 | }); 18 | }); 19 | }; 20 | 21 | if (document.readyState === 'interactive' || document.readyState === 'complete') { 22 | addBookmarks(); 23 | } 24 | else { 25 | window.addEventListener('DOMContentLoaded', function () { 26 | addBookmarks(); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /assets/js/livesocket.js: -------------------------------------------------------------------------------- 1 | let hooks = {} 2 | hooks.StatusHooks = { 3 | disconnected() { 4 | document.getElementById("connected").style.display = "none"; 5 | document.getElementById("disconnected").style.display = "unset"; 6 | console.log("disconnected") 7 | }, 8 | reconnected() { 9 | document.getElementById("connected").style.display = "unset"; 10 | document.getElementById("disconnected").style.display = "none"; 11 | console.log("reconnected") 12 | }, 13 | mounted() { 14 | document.getElementById("connected").style.display = "unset"; 15 | document.getElementById("disconnected").style.display = "none"; 16 | console.log("connected") 17 | } 18 | } 19 | 20 | import LiveSocket from "phoenix_live_view" 21 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 22 | let liveSocket = new LiveSocket("/live", Socket, { params: { _csrf_token: csrfToken }, hooks: hooks }) 23 | liveSocket.connect() 24 | window.liveSocket = liveSocket -------------------------------------------------------------------------------- /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/statistics.js: -------------------------------------------------------------------------------- 1 | var purple = 'rgb(255,0,255)'; 2 | var purpleTransparent = 'rgba(255, 0, 255, 0.2)'; 3 | var gray = 'rgb(10, 10, 10)'; 4 | var grayTransparent = 'rgba(10, 10, 10, 0.2)'; 5 | 6 | 7 | //////////////////////////////////////////////////////////////////////////////// 8 | // Private/Public chart. 9 | 10 | if (document.getElementById('publicPrivateRatio')) { 11 | var totalPrivate = JSON.parse(document.getElementById("total-private").dataset.values); 12 | var totalPublic = JSON.parse(document.getElementById("total-public").dataset.values); 13 | 14 | 15 | var ctx = document.getElementById('publicPrivateRatio').getContext('2d'); 16 | 17 | var myChart = new Chart(ctx, { 18 | type: 'doughnut', 19 | options: { 20 | responsive: true, 21 | maintainAspectRatio: false 22 | }, 23 | data: 24 | { 25 | datasets: [{ 26 | data: [totalPrivate, totalPublic], 27 | backgroundColor: [ 28 | purpleTransparent, 29 | grayTransparent 30 | ], 31 | borderColor: [ 32 | purpleTransparent, 33 | grayTransparent 34 | ], 35 | }], 36 | 37 | labels: ['Private', 'Public'] 38 | } 39 | }); 40 | } 41 | 42 | //////////////////////////////////////////////////////////////////////////////// 43 | // Per month chart. 44 | 45 | if (document.getElementById('monthlySnippets')) { 46 | // Get the data from the DOM. 47 | var labels = JSON.parse(document.getElementById("monthly-private").dataset.labels); 48 | var valuesPrivate = JSON.parse(document.getElementById("monthly-private").dataset.values); 49 | var valuesPublic = JSON.parse(document.getElementById("monthly-public").dataset.values); 50 | 51 | var ctx = document.getElementById('monthlySnippets').getContext('2d'); 52 | var myChart = new Chart(ctx, { 53 | type: 'bar', 54 | options: { 55 | responsive: true, 56 | maintainAspectRatio: false, 57 | scales: { 58 | x: { 59 | stacked: true, 60 | grid: { 61 | display: false 62 | } 63 | }, 64 | y: { 65 | stacked: true, 66 | grid: { 67 | display: false 68 | }, 69 | beginAtZero: true, 70 | ticks: { 71 | maxTicksLimit: 4, 72 | precision: 0 73 | } 74 | } 75 | } 76 | }, 77 | data: { 78 | labels: labels, 79 | datasets: [{ 80 | label: '# Private', 81 | data: valuesPrivate, 82 | // Smooth line. 83 | cubicInterpolationMode: 'monotone', 84 | tension: 0.5, 85 | // Color of the line itself. 86 | borderColor: purpleTransparent, 87 | // Color of the dots and the area underneath. 88 | backgroundColor: purpleTransparent, 89 | // Fill the area underneath. 90 | fill: true 91 | }, 92 | { 93 | label: '# Public', 94 | data: valuesPublic, 95 | // Smooth line. 96 | cubicInterpolationMode: 'monotone', 97 | tension: 0.5, 98 | // Color of the line itself. 99 | borderColor: grayTransparent, 100 | // Color of the dots and the area underneath. 101 | backgroundColor: grayTransparent, 102 | // Fill the area underneath. 103 | fill: true 104 | }] 105 | } 106 | }); 107 | } -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "description": " ", 4 | "license": "MIT", 5 | "scripts": { 6 | "deploy": "webpack --mode production", 7 | "watch": "webpack --mode development --watch" 8 | }, 9 | "dependencies": { 10 | "@iconify/iconify": "^2.0.1", 11 | "@mdi/font": "^5.9.55", 12 | "@mdi/js": "^5.9.55", 13 | "chart.js": "^3.3.2", 14 | "highlight.js": "^11.0.0", 15 | "highlightjs-line-numbers.js": "^2.8.0", 16 | "iconify": "^1.3.0", 17 | "phoenix": "file:../deps/phoenix", 18 | "phoenix_html": "file:../deps/phoenix_html", 19 | "phoenix_live_view": "file:../deps/phoenix_live_view", 20 | "sass": "^1.70.0" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.26.0", 24 | "@babel/preset-env": "^7.26.0", 25 | "babel-loader": "^9.2.1", 26 | "copy-webpack-plugin": "^12.0.2", 27 | "css-loader": "^7.1.2", 28 | "hard-source-webpack-plugin": "^0.13.1", 29 | "mini-css-extract-plugin": "^2.9.1", 30 | "node-sass": "^9.0.0", 31 | "sass-loader": "^16.0.2", 32 | "terser-webpack-plugin": "^5.3.10", 33 | "webpack": "^5.95.0", 34 | "webpack-cli": "^5.1.4" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1dnight/exbin/fd5d0b7d03b51f921b7d496e09e5ef1d3b2fc43c/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1dnight/exbin/fd5d0b7d03b51f921b7d496e09e5ef1d3b2fc43c/assets/static/images/logo.png -------------------------------------------------------------------------------- /assets/static/images/phoenix.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1dnight/exbin/fd5d0b7d03b51f921b7d496e09e5ef1d3b2fc43c/assets/static/images/phoenix.png -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const glob = require("glob"); 3 | const HardSourceWebpackPlugin = require("hard-source-webpack-plugin"); 4 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); 5 | const TerserPlugin = require("terser-webpack-plugin"); 6 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 7 | 8 | module.exports = (env, options) => { 9 | const devMode = options.mode !== "production"; 10 | 11 | return { 12 | optimization: { 13 | minimize: true, 14 | minimizer: [new TerserPlugin({ parallel: true })], 15 | }, 16 | entry: { 17 | app: glob.sync("./vendor/**/*.js").concat(["./js/app.js"]), 18 | }, 19 | output: { 20 | filename: "[name].js", 21 | path: path.resolve(__dirname, "../priv/static/js"), 22 | publicPath: "/js/", 23 | }, 24 | devtool: devMode ? "eval-cheap-module-source-map" : undefined, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.js$/, 29 | exclude: /node_modules/, 30 | use: { 31 | loader: "babel-loader", 32 | }, 33 | }, 34 | { 35 | test: /\.[s]?css$/, 36 | use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"], 37 | }, 38 | ], 39 | }, 40 | plugins: [ 41 | new MiniCssExtractPlugin({ filename: "../css/app.css" }), 42 | new CopyWebpackPlugin({ patterns: [{ from: "static/", to: "../" }] }), 43 | ].concat(devMode ? [new HardSourceWebpackPlugin()] : []), 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :exbin, 4 | ecto_repos: [Exbin.Repo] 5 | 6 | # Configures the endpoint 7 | config :exbin, ExbinWeb.Endpoint, 8 | url: [host: "localhost"], 9 | secret_key_base: "L2jznV0Pu2SuhkCPqBWKIxk0PGZW27e6MeB2FCP6akYpm/nlKGx49mnKrZGvlS0X", 10 | render_errors: [view: ExbinWeb.ErrorView, accepts: ~w(html json), layout: false], 11 | pubsub_server: Exbin.PubSub, 12 | live_view: [signing_salt: "EubGfePc"] 13 | 14 | # Configures Elixir's Logger 15 | config :logger, :console, 16 | format: "$time $metadata[$level] $message\n", 17 | metadata: [:request_id] 18 | 19 | # Use Jason for JSON parsing in Phoenix 20 | config :phoenix, :json_library, Jason 21 | 22 | # The default view for a snippet. 23 | # Can be :code, :reader, or :raw. 24 | config :exbin, 25 | default_view: :code, 26 | ephemeral_age: 60, 27 | tcp_port: 9999, 28 | tcp_host: {0, 0, 0, 0}, 29 | base_url: "http://localhost:4000", 30 | max_size: 2048, 31 | timezone: "Europe/Brussels", 32 | apikey: "devkey", 33 | brand: "Exbin Development" 34 | 35 | # Viewcount rate limit configuration. 36 | config :ex_rated, 37 | timeout: 86_400_000, 38 | cleanup_rate: 10_000, 39 | persistent: false 40 | 41 | config :swoosh, :api_client, false 42 | 43 | # Rate limiting 44 | config :hammer, 45 | backend: 46 | {Hammer.Backend.ETS, 47 | [ 48 | # 24 hour 49 | expiry_ms: 60_000 * 60 * 4, 50 | # 10 minutes 51 | cleanup_interval_ms: 60_000 * 10 52 | ]} 53 | 54 | # Import environment specific config. This must remain at the bottom 55 | # of this file so it overrides the configuration defined above. 56 | import_config "#{Mix.env()}.exs" 57 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | # For development, we disable any cache and enable 3 | # debugging and code reloading. 4 | # 5 | # The watchers configuration can be used to run external 6 | # watchers to your application. For example, we use it 7 | # with webpack to recompile .js and .css sources. 8 | config :exbin, ExbinWeb.Endpoint, 9 | http: [port: 4000], 10 | debug_errors: true, 11 | code_reloader: true, 12 | check_origin: false, 13 | watchers: [ 14 | node: [ 15 | "node_modules/webpack/bin/webpack.js", 16 | "--mode", 17 | "development", 18 | "--watch-stdin", 19 | cd: Path.expand("../assets", __DIR__) 20 | ] 21 | ], 22 | live_reload: [ 23 | patterns: [ 24 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 25 | ~r"priv/gettext/.*(po)$", 26 | ~r"lib/exbin_web/(live|views)/.*(ex)$", 27 | ~r"lib/exbin_web/templates/.*(eex)$" 28 | ] 29 | ] 30 | 31 | # Do not include metadata nor timestamps in development logs 32 | config :logger, :console, format: "[$level] $message\n" 33 | 34 | # Set a higher stacktrace during development. Avoid configuring such 35 | # in production as building large stacktraces may be expensive. 36 | config :phoenix, :stacktrace_depth, 20 37 | 38 | # Initialize plugs at runtime for faster development compilation 39 | config :phoenix, :plug_init_mode, :runtime 40 | 41 | ################################################################################ 42 | # Configuration parameters 43 | 44 | # * `default_view`: :code, :reader, or :raw. 45 | # * `ephemeral_age`: The maximum age of a snippet in seconds before it is deleted. 46 | # * `brand`: The brand of the ExBin instance. Defaults to "ExBin" 47 | # * `custom_logo_path`: Path to the file image of your custom logo 48 | # * `custom_logo_size`: Height of the custom logo. 49 | # * `base_url`: The url at which ExBin will be served. 50 | # * `timezone`: The timezone of the ExBin instance. 51 | # * `api_key`: The api key you want to use. Generate a secure one. 52 | 53 | # Netcat 54 | 55 | # * `tcp_port`: Port to listen for connections 56 | # * `tcp_host`: Host to bind to to listen for connections 57 | # * `max_size`: Maximum size in bytes that can be sent using netcat 58 | 59 | config :exbin, 60 | base_url: "https://exbin.call-cc.be", 61 | timezone: "Europe/Brussels", 62 | default_view: :code, 63 | ephemeral_age: 60, 64 | brand: "ExBin", 65 | custom_logo_path: "", 66 | custom_logo_size: 30, 67 | api_key: "AyPwtQANkGPNWStxZT+k4qkifBmraC5EdBrJ2h/AMYwYxJ7wJBu0QsFkueRpSmIO", 68 | tcp_port: 9999, 69 | tcp_host: {0, 0, 0, 0}, 70 | max_size: 2048 71 | 72 | ################################################################################ 73 | # Database parameters 74 | 75 | # * `username`: Username of the database 76 | # * `password`: Password of the database 77 | # * `database`: Name of the database 78 | # * `hostname`: Hostname of the database 79 | 80 | config :exbin, Exbin.Repo, 81 | username: "postgres", 82 | password: "postgres", 83 | database: "exbin_dev", 84 | hostname: "localhost" 85 | 86 | ################################################################################ 87 | # Secrets (used for encryption and stuff) 88 | # Fill in two values that are randomly generated with `openssl rand 64 | openssl enc -A -base64` 89 | 90 | config :exbin, ExbinWeb.Endpoint, 91 | secret_key_base: "Q21q8HqA9Rd24KY9ZwMfeuqlCleNQqUJFWA7RcUHF1B3C7Faeucv2mFbB+Vo6bawBCJMJceoSuNQKnYpREqQuA==", 92 | live_view: "rkEAU575y2/9LVi5hwkSICTWMcLYF5QKZzzFKsi1QtGoO71ooE2vht2uU3k+tkgDsQxWNLu8eXinFSCQUB3zoA==" 93 | 94 | ################################################################################ 95 | # HTTP server configuration 96 | 97 | # * `host`: The host at which this instance will run 98 | # * `port`: The port at which the instance will listen 99 | # * `scheme`: Either http or https 100 | 101 | config :exbin, ExbinWeb.Endpoint, 102 | url: [host: "exbin.call-cc.be", port: 4000, scheme: "http"], 103 | http: [ 104 | port: 4000, 105 | transport_options: [socket_opts: [:inet6]] 106 | ] 107 | 108 | ################################################################################ 109 | # Configuration for logging 110 | 111 | # * `level`: Level for debugging. `:debug` for debugging, `:warning` for production. 112 | 113 | config :logger, 114 | level: :debug 115 | 116 | ################################################################################ 117 | # Configuration for mailing 118 | 119 | # Most of the parameters are self-explanatory. 120 | # If you have a TLS connection to the SMTP server, set `tls` to `true`, and `ssl` to `false`. 121 | 122 | mailing = :local 123 | 124 | if mailing == :local do 125 | config :exbin, Exbin.Mailer, 126 | adapter: Swoosh.Adapters.SMTP, 127 | relay: "smtp.com", 128 | username: "exbin", 129 | password: "exbin", 130 | from: "exbin@exbin.exbin", 131 | ssl: false, 132 | tls: true, 133 | auth: :always, 134 | port: 587, 135 | retries: 2 136 | else 137 | config :exbin, Exbin.Mailer, adapter: Swoosh.Adapters.Local 138 | config :exbin, Exbin.Mailer, from: "exbin@example.com" 139 | config :swoosh, :api_client, false 140 | end 141 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/releases.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Only in tests, remove the complexity from the password hashing algorithm 4 | config :bcrypt_elixir, :log_rounds, 1 5 | 6 | # Configure your database 7 | # 8 | # The MIX_TEST_PARTITION environment variable can be used 9 | # to provide built-in test partitioning in CI environment. 10 | # Run `mix help test` for more information. 11 | config :exbin, Exbin.Repo, 12 | username: "postgres", 13 | password: "postgres", 14 | database: "exbin_test#{System.get_env("MIX_TEST_PARTITION")}", 15 | hostname: "localhost", 16 | pool: Ecto.Adapters.SQL.Sandbox 17 | 18 | # We don't run a server during test. If one is required, 19 | # you can enable the server option below. 20 | config :exbin, ExbinWeb.Endpoint, 21 | http: [port: 4002], 22 | server: false 23 | 24 | # Allow TCP server to start in test env by running on a different port. 25 | # This is a quick hack. We could also alter the supervisor to only run 26 | # run it in specific environments. 27 | config :exbin, 28 | tcp_port: 9998 29 | 30 | # In this env Clock freezing is allowed 31 | config :exbin, Exbin.Clock, freezable: true 32 | 33 | # Print only warnings and errors during test 34 | config :logger, level: :warning 35 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | networks: 4 | internal: 5 | external: false 6 | 7 | services: 8 | exbin: 9 | restart: always 10 | image: m1dnight/exbin:latest 11 | volumes: 12 | - ./rel/overlays/config.exs:/app/prod/config.exs 13 | ports: 14 | - 4000:4000 15 | - 9999:9999 16 | networks: 17 | - internal 18 | depends_on: 19 | - db 20 | 21 | db: 22 | restart: always 23 | image: postgres 24 | volumes: 25 | - ./.data/:/var/lib/postgresql/data 26 | environment: 27 | - POSTGRES_PASSWORD=postgres 28 | - POSTGRES_DB=exbin_dev 29 | - TZ=Europe/Brussels 30 | - PGTZ=Europe/Brussels 31 | networks: 32 | - internal 33 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | bin="/app/prod/bin/prod" 3 | rel="/app/prod" 4 | 5 | # Setup the database. 6 | $bin eval "Exbin.Release.migrate" 7 | 8 | # Initial user. 9 | $bin eval 'Code.eval_file("/app/prod/initial_user.exs")' 10 | 11 | # start the elixir application 12 | exec "$bin" "start" -------------------------------------------------------------------------------- /lib/exbin.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin do 2 | @moduledoc """ 3 | Exbin 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 | @version Mix.Project.config()[:version] 10 | 11 | def version() do 12 | @version 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/exbin/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | use Application 6 | 7 | def start(_type, _args) do 8 | children = [ 9 | # Start the Ecto repository 10 | Exbin.Repo, 11 | # Start the Telemetry supervisor 12 | ExbinWeb.Telemetry, 13 | # Start the PubSub system 14 | {Phoenix.PubSub, name: Exbin.PubSub}, 15 | # Start the Endpoint (http/https) 16 | ExbinWeb.Endpoint, 17 | # Start a worker by calling: Exbin. 18 | Exbin.Scrubber, 19 | # Start the socket server. 20 | Exbin.Netcat, 21 | # STatistics Cache 22 | {Cachex, name: :stats_cache} 23 | ] 24 | 25 | # See https://hexdocs.pm/elixir/Supervisor.html 26 | # for other strategies and supported options 27 | opts = [strategy: :one_for_one, name: Exbin.Supervisor] 28 | Supervisor.start_link(children, opts) 29 | end 30 | 31 | # Tell Phoenix to update the endpoint configuration 32 | # whenever the application is updated. 33 | def config_change(changed, _new, removed) do 34 | ExbinWeb.Endpoint.config_change(changed, removed) 35 | :ok 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/exbin/clock.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Clock do 2 | @moduledoc """ 3 | A wrapper around DateTime.utc_now that allows us to freeze time for testing purposes. 4 | Should be used in place of DateTime.utc_now/0 or Timex.now/0 5 | 6 | Protects the freeze feature so that the methods can never be called in an enviroment 7 | that hasn't specifically requested they be compiled in. No accidental shenanigans. 8 | 9 | Based on https://codeincomplete.com/articles/testing-dates-in-elixir/ 10 | """ 11 | 12 | @config Application.compile_env(:exbin, __MODULE__) || [] 13 | 14 | if @config[:freezable] do 15 | def utc_now do 16 | Process.get(:mock_utc_now) || DateTime.utc_now() 17 | end 18 | 19 | def freeze do 20 | Process.put(:mock_utc_now, utc_now()) 21 | end 22 | 23 | def freeze(%DateTime{} = on) do 24 | Process.put(:mock_utc_now, on) 25 | end 26 | 27 | def unfreeze do 28 | Process.delete(:mock_utc_now) 29 | end 30 | 31 | defmacro time_travel(to, do: block) do 32 | quote do 33 | # Make it so blocks passed in can reference Clock easily. 34 | alias Exbin.Clock 35 | # save the current time if it's been frozen 36 | previous = if Process.get(:mock_utc_now), do: Clock.utc_now(), else: nil 37 | # freeze the clock at the new time 38 | Clock.freeze(unquote(to)) 39 | # run the test block 40 | result = unquote(block) 41 | # reset the clock back to the previous time if it was frozen, or unfreeze if it wasn't 42 | if previous, do: Clock.freeze(previous), else: Clock.unfreeze() 43 | # and return the result 44 | result 45 | end 46 | end 47 | else 48 | defdelegate utc_now, to: DateTime 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/exbin/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Mailer do 2 | use Swoosh.Mailer, otp_app: :exbin 3 | end 4 | -------------------------------------------------------------------------------- /lib/exbin/models/accounts/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Accounts.User do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @derive {Inspect, except: [:password]} 6 | schema "users" do 7 | field :email, :string 8 | field :password, :string, virtual: true 9 | field :hashed_password, :string 10 | field :confirmed_at, :naive_datetime 11 | field :admin, :boolean 12 | has_many :snippets, Exbin.Snippet 13 | timestamps() 14 | end 15 | 16 | @doc """ 17 | A user changeset for registration. 18 | 19 | It is important to validate the length of both email and password. 20 | Otherwise databases may truncate the email without warnings, which 21 | could lead to unpredictable or insecure behaviour. Long passwords may 22 | also be very expensive to hash for certain algorithms. 23 | 24 | ## Options 25 | 26 | * `:hash_password` - Hashes the password so it can be stored securely 27 | in the database and ensures the password field is cleared to prevent 28 | leaks in the logs. If password hashing is not needed and clearing the 29 | password field is not desired (like when using this changeset for 30 | validations on a LiveView form), this option can be set to `false`. 31 | Defaults to `true`. 32 | """ 33 | def registration_changeset(user, attrs, opts \\ []) do 34 | user 35 | |> cast(attrs, [:email, :password, :admin]) 36 | |> validate_email() 37 | |> validate_password(opts) 38 | end 39 | 40 | defp validate_email(changeset) do 41 | changeset 42 | |> validate_required([:email]) 43 | |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces") 44 | |> validate_length(:email, max: 160) 45 | |> unsafe_validate_unique(:email, Exbin.Repo) 46 | |> unique_constraint(:email) 47 | end 48 | 49 | defp validate_password(changeset, opts) do 50 | changeset 51 | |> validate_required([:password]) 52 | |> validate_length(:password, min: 12, max: 80) 53 | # |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character") 54 | # |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character") 55 | # |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character") 56 | |> maybe_hash_password(opts) 57 | end 58 | 59 | defp maybe_hash_password(changeset, opts) do 60 | hash_password? = Keyword.get(opts, :hash_password, true) 61 | password = get_change(changeset, :password) 62 | 63 | if hash_password? && password && changeset.valid? do 64 | changeset 65 | |> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password)) 66 | |> delete_change(:password) 67 | else 68 | changeset 69 | end 70 | end 71 | 72 | @doc """ 73 | A user changeset for changing the email. 74 | 75 | It requires the email to change otherwise an error is added. 76 | """ 77 | def email_changeset(user, attrs) do 78 | user 79 | |> cast(attrs, [:email]) 80 | |> validate_email() 81 | |> case do 82 | %{changes: %{email: _}} = changeset -> changeset 83 | %{} = changeset -> add_error(changeset, :email, "did not change") 84 | end 85 | end 86 | 87 | @doc """ 88 | A user changeset for changing the password. 89 | 90 | ## Options 91 | 92 | * `:hash_password` - Hashes the password so it can be stored securely 93 | in the database and ensures the password field is cleared to prevent 94 | leaks in the logs. If password hashing is not needed and clearing the 95 | password field is not desired (like when using this changeset for 96 | validations on a LiveView form), this option can be set to `false`. 97 | Defaults to `true`. 98 | """ 99 | def password_changeset(user, attrs, opts \\ []) do 100 | user 101 | |> cast(attrs, [:password]) 102 | |> validate_confirmation(:password, message: "does not match password") 103 | |> validate_password(opts) 104 | end 105 | 106 | @doc """ 107 | Confirms the account by setting `confirmed_at`. 108 | """ 109 | def confirm_changeset(user) do 110 | now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) 111 | change(user, confirmed_at: now) 112 | end 113 | 114 | @doc """ 115 | Verifies the password. 116 | 117 | If there is no user or the user doesn't have a password, we call 118 | `Bcrypt.no_user_verify/0` to avoid timing attacks. 119 | """ 120 | def valid_password?(%Exbin.Accounts.User{hashed_password: hashed_password}, password) 121 | when is_binary(hashed_password) and byte_size(password) > 0 do 122 | Bcrypt.verify_pass(password, hashed_password) 123 | end 124 | 125 | def valid_password?(_, _) do 126 | Bcrypt.no_user_verify() 127 | false 128 | end 129 | 130 | @doc """ 131 | Validates the current password otherwise adds an error to the changeset. 132 | """ 133 | def validate_current_password(changeset, password) do 134 | if valid_password?(changeset.data, password) do 135 | changeset 136 | else 137 | add_error(changeset, :current_password, "is not valid") 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/exbin/models/accounts/user_notifier.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Accounts.UserNotifier do 2 | # For simplicity, this module simply logs messages to the terminal. 3 | # You should replace it by a proper email or notification tool, such as: 4 | # 5 | # * Swoosh - https://hexdocs.pm/swoosh 6 | # * Bamboo - https://hexdocs.pm/bamboo 7 | # 8 | # defp deliver(to, body) do 9 | # require Logger 10 | # Logger.debug(body) 11 | # {:ok, %{to: to, body: body}} 12 | # end 13 | require Logger 14 | import Swoosh.Email 15 | 16 | defp deliver(recipient, subject, body) do 17 | sender = Application.get_env(:exbin, Exbin.Mailer)[:from] 18 | 19 | email = 20 | new() 21 | |> to(recipient) 22 | |> from({"ExBin Mailer", sender}) 23 | |> subject(subject) 24 | |> text_body(body) 25 | 26 | with {:ok, _metadata} <- Exbin.Mailer.deliver(email) do 27 | Logger.debug(email) 28 | {:ok, email} 29 | end 30 | end 31 | 32 | @doc """ 33 | Deliver instructions to confirm account. 34 | """ 35 | def deliver_confirmation_instructions(user, url) do 36 | deliver(user.email, "ExBin Account Confirmation", """ 37 | 38 | ============================== 39 | 40 | Hi #{user.email}, 41 | 42 | You can confirm your account by visiting the URL below: 43 | 44 | #{url} 45 | 46 | If you didn't create an account with us, please ignore this. 47 | 48 | ============================== 49 | """) 50 | end 51 | 52 | @doc """ 53 | Deliver instructions to reset a user password. 54 | """ 55 | def deliver_reset_password_instructions(user, url) do 56 | deliver(user.email, "Reset Password ExBin", """ 57 | 58 | ============================== 59 | 60 | Hi #{user.email}, 61 | 62 | You can reset your password by visiting the URL below: 63 | 64 | #{url} 65 | 66 | If you didn't request this change, please ignore this. 67 | 68 | ============================== 69 | """) 70 | end 71 | 72 | @doc """ 73 | Deliver instructions to update a user email. 74 | """ 75 | def deliver_update_email_instructions(user, url) do 76 | deliver(user.email, "Update Email Exbin", """ 77 | 78 | ============================== 79 | 80 | Hi #{user.email}, 81 | 82 | You can change your email by visiting the URL below: 83 | 84 | #{url} 85 | 86 | If you didn't request this change, please ignore this. 87 | 88 | ============================== 89 | """) 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/exbin/models/accounts/user_token.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Accounts.UserToken do 2 | use Ecto.Schema 3 | import Ecto.Query 4 | 5 | @hash_algorithm :sha256 6 | @rand_size 32 7 | 8 | # It is very important to keep the reset password token expiry short, 9 | # since someone with access to the email may take over the account. 10 | @reset_password_validity_in_days 1 11 | @confirm_validity_in_days 7 12 | @change_email_validity_in_days 7 13 | @session_validity_in_days 60 14 | 15 | schema "users_tokens" do 16 | field :token, :binary 17 | field :context, :string 18 | field :sent_to, :string 19 | belongs_to :user, Exbin.Accounts.User 20 | 21 | timestamps(updated_at: false) 22 | end 23 | 24 | @doc """ 25 | Generates a token that will be stored in a signed place, 26 | such as session or cookie. As they are signed, those 27 | tokens do not need to be hashed. 28 | """ 29 | def build_session_token(user) do 30 | token = :crypto.strong_rand_bytes(@rand_size) 31 | {token, %Exbin.Accounts.UserToken{token: token, context: "session", user_id: user.id}} 32 | end 33 | 34 | @doc """ 35 | Checks if the token is valid and returns its underlying lookup query. 36 | 37 | The query returns the user found by the token. 38 | """ 39 | def verify_session_token_query(token) do 40 | query = 41 | from token in token_and_context_query(token, "session"), 42 | join: user in assoc(token, :user), 43 | where: token.inserted_at > ago(@session_validity_in_days, "day"), 44 | select: user 45 | 46 | {:ok, query} 47 | end 48 | 49 | @doc """ 50 | Builds a token with a hashed counter part. 51 | 52 | The non-hashed token is sent to the user email while the 53 | hashed part is stored in the database, to avoid reconstruction. 54 | The token is valid for a week as long as users don't change 55 | their email. 56 | """ 57 | def build_email_token(user, context) do 58 | build_hashed_token(user, context, user.email) 59 | end 60 | 61 | defp build_hashed_token(user, context, sent_to) do 62 | token = :crypto.strong_rand_bytes(@rand_size) 63 | hashed_token = :crypto.hash(@hash_algorithm, token) 64 | 65 | {Base.url_encode64(token, padding: false), 66 | %Exbin.Accounts.UserToken{ 67 | token: hashed_token, 68 | context: context, 69 | sent_to: sent_to, 70 | user_id: user.id 71 | }} 72 | end 73 | 74 | @doc """ 75 | Checks if the token is valid and returns its underlying lookup query. 76 | 77 | The query returns the user found by the token. 78 | """ 79 | def verify_email_token_query(token, context) do 80 | case Base.url_decode64(token, padding: false) do 81 | {:ok, decoded_token} -> 82 | hashed_token = :crypto.hash(@hash_algorithm, decoded_token) 83 | days = days_for_context(context) 84 | 85 | query = 86 | from token in token_and_context_query(hashed_token, context), 87 | join: user in assoc(token, :user), 88 | where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email, 89 | select: user 90 | 91 | {:ok, query} 92 | 93 | :error -> 94 | :error 95 | end 96 | end 97 | 98 | defp days_for_context("confirm"), do: @confirm_validity_in_days 99 | defp days_for_context("reset_password"), do: @reset_password_validity_in_days 100 | 101 | @doc """ 102 | Checks if the token is valid and returns its underlying lookup query. 103 | 104 | The query returns the user token record. 105 | """ 106 | def verify_change_email_token_query(token, context) do 107 | case Base.url_decode64(token, padding: false) do 108 | {:ok, decoded_token} -> 109 | hashed_token = :crypto.hash(@hash_algorithm, decoded_token) 110 | 111 | query = 112 | from token in token_and_context_query(hashed_token, context), 113 | where: token.inserted_at > ago(@change_email_validity_in_days, "day") 114 | 115 | {:ok, query} 116 | 117 | :error -> 118 | :error 119 | end 120 | end 121 | 122 | @doc """ 123 | Returns the given token with the given context. 124 | """ 125 | def token_and_context_query(token, context) do 126 | from Exbin.Accounts.UserToken, where: [token: ^token, context: ^context] 127 | end 128 | 129 | @doc """ 130 | Gets all tokens for the given user for the given contexts. 131 | """ 132 | def user_and_contexts_query(user, :all) do 133 | from t in Exbin.Accounts.UserToken, where: t.user_id == ^user.id 134 | end 135 | 136 | def user_and_contexts_query(user, [_ | _] = contexts) do 137 | from t in Exbin.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/exbin/models/snippet/snippet.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Snippet do 2 | use Exbin.Schema 3 | import Ecto.Changeset 4 | 5 | schema "snippets" do 6 | field(:name, :string) 7 | # empty default to create empty snippets. 8 | field(:content, :binary, default: "") 9 | field(:viewcount, :integer, default: 0) 10 | field(:private, :boolean, default: true) 11 | field(:ephemeral, :boolean, default: false) 12 | belongs_to :user, Exbin.Accounts.User 13 | 14 | timestamps() 15 | end 16 | 17 | @doc """ 18 | Builds a changeset based on the `struct` and `params`. 19 | """ 20 | def changeset(struct, params \\ %{}) do 21 | struct 22 | |> cast(params, [:content, :name, :viewcount, :private, :ephemeral, :user_id]) 23 | |> validate_required([:name, :content]) 24 | |> unique_constraint(:name) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/exbin/models/snippet/snippets.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Snippets do 2 | require Logger 3 | import Ecto.Query 4 | alias Exbin.{Snippet, Repo} 5 | 6 | ############################################################################# 7 | # Search 8 | 9 | def search(query) do 10 | sanitized = LikeInjection.like_sanitize(query) 11 | Logger.debug("Query: #{query}, sanitized: #{sanitized}") 12 | parameter = "%#{sanitized}%" 13 | 14 | Logger.debug("Query: `#{query}`, sanitized: `#{sanitized}`, parameter: `#{parameter}`") 15 | 16 | from(s in Snippet, where: ilike(s.content, ^parameter) and s.private == false, order_by: [desc: s.inserted_at]) 17 | |> Repo.all() 18 | end 19 | 20 | def search(query, user_id) do 21 | sanitized = LikeInjection.like_sanitize(query) 22 | Logger.debug("Query: #{query}, sanitized: #{sanitized}") 23 | parameter = "%#{sanitized}%" 24 | 25 | Logger.debug("Query: `#{query}`, sanitized: `#{sanitized}`, parameter: `#{parameter}`") 26 | 27 | from(s in Snippet, 28 | where: ilike(s.content, ^parameter) and s.user_id == ^user_id, 29 | or_where: ilike(s.content, ^parameter) and s.private == false, 30 | order_by: [desc: s.inserted_at] 31 | ) 32 | |> Repo.all() 33 | end 34 | 35 | def search_stream(query, callback) do 36 | sanitized = LikeInjection.like_sanitize(query) 37 | Logger.debug("Query: #{query}, sanitized: #{sanitized}") 38 | parameter = "%#{sanitized}%" 39 | 40 | Logger.debug("Query: `#{query}`, sanitized: `#{sanitized}`, parameter: `#{parameter}`") 41 | 42 | query = 43 | from(s in Snippet, 44 | where: ilike(s.content, ^parameter) and s.private == false, 45 | order_by: [desc: s.inserted_at] 46 | ) 47 | 48 | stream = Repo.stream(query) 49 | 50 | # Functions for the query stream. 51 | chunk_fun = fn element, {count, acc} -> 52 | {:cont, Enum.reverse([element | acc]), {count + 1, []}} 53 | end 54 | 55 | after_fun = fn 56 | {0, []} -> 57 | {:cont, [], []} 58 | 59 | {_n, []} -> 60 | {:cont, []} 61 | 62 | {_n, acc} -> 63 | {:cont, acc, []} 64 | end 65 | 66 | Repo.transaction(fn -> 67 | stream 68 | |> Stream.chunk_every(1000) 69 | |> Stream.chunk_while({0, []}, chunk_fun, after_fun) 70 | |> Stream.map(callback) 71 | |> Stream.run() 72 | end) 73 | 74 | nil 75 | end 76 | 77 | @spec search_stream(binary, any, any) :: nil 78 | def search_stream(query, user_id, callback) do 79 | sanitized = LikeInjection.like_sanitize(query) 80 | Logger.debug("Query: #{query}, sanitized: #{sanitized}") 81 | parameter = "%#{sanitized}%" 82 | 83 | Logger.debug("Query: `#{query}`, sanitized: `#{sanitized}`, parameter: `#{parameter}`") 84 | 85 | query = 86 | from(s in Snippet, 87 | where: fragment("convert_from(content, 'utf-8') ilike ?", ^parameter) and s.user_id == ^user_id, 88 | or_where: fragment("convert_from(content, 'utf-8') ilike ?", ^parameter) and s.private == false, 89 | order_by: [desc: s.inserted_at] 90 | ) 91 | 92 | stream = Repo.stream(query) 93 | 94 | Repo.transaction(fn -> 95 | stream 96 | |> Stream.chunk_every(10) 97 | |> Stream.map(callback) 98 | |> Stream.run() 99 | end) 100 | 101 | nil 102 | end 103 | 104 | ############################################################################# 105 | # Insert 106 | 107 | def change_snippet(%Snippet{} = snippet) do 108 | Snippet.changeset(snippet, %{}) 109 | end 110 | 111 | @doc """ 112 | Inserts a new snippet into the database. 113 | args: %{"content" => "text", "private" => "false"} 114 | """ 115 | def insert(args) do 116 | Repo.transaction(fn -> 117 | # Generate a unique name. 118 | name = generate_name() 119 | args = Map.merge(args, %{"name" => name}) 120 | 121 | # Insert. 122 | changeset = Snippet.changeset(%Snippet{}, args) 123 | IO.inspect(args, label: "args") 124 | IO.inspect(changeset, label: "changeset") 125 | Repo.insert!(changeset) 126 | end) 127 | end 128 | 129 | ############################################################################# 130 | # Delete 131 | 132 | @doc """ 133 | Deletes a snippet. 134 | """ 135 | def delete_snippet(%Snippet{} = snippet) do 136 | Repo.delete(snippet) 137 | end 138 | 139 | @doc """ 140 | Deletes all snippets that are older than `age` minutes and are ephemeral. 141 | """ 142 | def scrub(age) do 143 | now = DateTime.utc_now() 144 | 145 | q = 146 | from s in Snippet, 147 | where: s.ephemeral == true, 148 | where: fragment("? - ? > ? * interval '1 minutes'", ^now, s.inserted_at, ^age) 149 | 150 | Repo.delete_all(q) 151 | end 152 | 153 | ############################################################################# 154 | # Update 155 | 156 | @doc """ 157 | Increments the viewcount of a snippet by 1 (or more). 158 | """ 159 | def update_viewcount(snippet, delta \\ 1) do 160 | # Note that this will not update every time because of the caching on the snippets! 161 | Repo.transaction(fn -> 162 | s = Repo.get!(Snippet, snippet.id) 163 | s = Snippet.changeset(s, %{viewcount: s.viewcount + delta}) 164 | Repo.update!(s) 165 | end) 166 | end 167 | 168 | ############################################################################# 169 | # Read 170 | 171 | @doc """ 172 | List all snippets. 173 | """ 174 | def list_public_snippets() do 175 | list_snippets(filter: :public) 176 | end 177 | 178 | def list_private_snippets() do 179 | list_snippets(filter: :private) 180 | end 181 | 182 | def list_snippets(opts \\ []) do 183 | limit = Keyword.get(opts, :limit, nil) 184 | filter = Keyword.get(opts, :filter, :all) 185 | 186 | q = from(s in Snippet, order_by: [desc: s.inserted_at]) 187 | 188 | q = 189 | if limit do 190 | q 191 | |> limit(^limit) 192 | else 193 | q 194 | end 195 | 196 | q = 197 | case filter do 198 | :all -> 199 | q 200 | 201 | :private -> 202 | q 203 | |> where([s], s.private == true) 204 | 205 | :public -> 206 | q 207 | |> where([s], s.private == false) 208 | end 209 | 210 | Repo.all(q) 211 | end 212 | 213 | @doc """ 214 | Gets a snippet by its human readable name. 215 | """ 216 | def get_by_name(name) do 217 | snippet = 218 | from(s in Snippet, where: s.name == ^name) 219 | |> Repo.one() 220 | 221 | case snippet do 222 | nil -> 223 | {:error, :not_found} 224 | 225 | snippet -> 226 | {:ok, snippet} 227 | end 228 | end 229 | 230 | def get_by_id(id) do 231 | snippet = 232 | from(s in Snippet, where: s.id == ^id) 233 | |> Repo.one() 234 | 235 | case snippet do 236 | nil -> 237 | {:error, :not_found} 238 | 239 | snippet -> 240 | {:ok, snippet} 241 | end 242 | end 243 | 244 | def list_user_snippets(user_id, opts \\ []) do 245 | limit = Keyword.get(opts, :limit, nil) 246 | 247 | q = 248 | from(s in Snippet, order_by: [desc: s.inserted_at]) 249 | |> where([s], s.user_id == ^user_id) 250 | |> limit(^limit) 251 | 252 | Repo.all(q) 253 | end 254 | 255 | @doc """ 256 | Count all the snippets. 257 | """ 258 | def count_snippets() do 259 | Repo.one(from(s in Snippet, select: count(s.id))) 260 | end 261 | 262 | ############################################################################# 263 | # Helpers 264 | 265 | # @doc """ 266 | # Generates a human-readable name that is not yet present in the database. 267 | # In theory this function can run forever, but in practice it doesn't. 268 | # """ 269 | defp generate_name() do 270 | name = HorseStapleBattery.generate_compound([:verb, :noun]) 271 | 272 | case Repo.one(from(s in Snippet, where: s.name == ^name)) do 273 | nil -> 274 | name 275 | 276 | _ -> 277 | generate_name() 278 | end 279 | end 280 | end 281 | -------------------------------------------------------------------------------- /lib/exbin/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Repo do 2 | use Ecto.Repo, 3 | otp_app: :exbin, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /lib/exbin/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Schema do 2 | @moduledoc """ 3 | A wrapper for Ecto.Schema that allows us to add some additional functionality. 4 | Current functionality includes autogenerating all timestamps using Exbin.Clock, 5 | which allows freezing time during testing. 6 | """ 7 | defmacro __using__(_) do 8 | quote do 9 | use Ecto.Schema 10 | 11 | @timestamps_opts [ 12 | autogenerate: {Exbin.Clock, :utc_now, []}, 13 | type: :utc_datetime_usec 14 | ] 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/exbin/scrubber.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Scrubber do 2 | require Logger 3 | 4 | def child_spec(_arg) do 5 | Periodic.child_spec( 6 | id: __MODULE__, 7 | run: &run/0, 8 | every: :timer.minutes(10), 9 | initial_delay: :timer.minutes(5) 10 | ) 11 | end 12 | 13 | defp run() do 14 | maximum_age = Application.get_env(:exbin, :ephemeral_age) 15 | Logger.debug("Running scrubber for all snippets older than #{maximum_age} minutes.") 16 | Exbin.Snippets.scrub(maximum_age) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/exbin/socket.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Netcat do 2 | use GenServer 3 | require Logger 4 | 5 | def start_link(_args) do 6 | port = Application.get_env(:exbin, :tcp_port) 7 | ip = Application.get_env(:exbin, :tcp_host) 8 | Logger.info("TCP Server listening on #{inspect(ip)}:#{inspect(port)}") 9 | GenServer.start_link(__MODULE__, [ip, port], name: __MODULE__) 10 | end 11 | 12 | def init([ip, port]) do 13 | opts = [:binary, packet: :raw, active: false, reuseaddr: true, ip: ip, exit_on_close: false] 14 | 15 | return_value = 16 | case :gen_tcp.listen(port, opts) do 17 | {:ok, listen_socket} -> 18 | {:ok, %{listener: listen_socket}} 19 | 20 | {:error, reason} -> 21 | {:stop, reason} 22 | end 23 | 24 | GenServer.cast(self(), :accept) 25 | return_value 26 | end 27 | 28 | def handle_info(_, state) do 29 | {:noreply, state} 30 | end 31 | 32 | def handle_cast(:accept, %{listener: s} = state) do 33 | # Accept on the socket for the next connection. 34 | {:ok, client} = :gen_tcp.accept(s) 35 | 36 | # Register the client, and ensure it's not spamming. 37 | {:ok, {client_ip, _port}} = :inet.peername(client) 38 | 39 | case Hammer.check_rate("#{inspect(client_ip)}", 60_000 * 60 * 2, 2) do 40 | {:allow, _count} -> 41 | Task.async(fn -> serve(client) end) 42 | GenServer.cast(self(), :accept) 43 | {:noreply, state} 44 | 45 | {:deny, _limit} -> 46 | :gen_tcp.send(client, "You are rated limited. Try again later.") 47 | :gen_tcp.close(client) 48 | GenServer.cast(self(), :accept) 49 | {:noreply, state} 50 | end 51 | end 52 | 53 | defp serve(client_socket) do 54 | Logger.debug("Serving #{inspect(:inet.peername(client_socket))}") 55 | limit = Application.get_env(:exbin, :max_size) 56 | data = do_rcv(client_socket, <<>>, limit) 57 | 58 | case data do 59 | nil -> 60 | :gen_tcp.send(client_socket, "File larger than #{limit} bytes.\n") 61 | 62 | data -> 63 | Logger.debug("Received #{byte_size(data)} bytes.") 64 | 65 | reply = 66 | if is_http_request?(data) and Application.get_env(:exbin, :http_bomb) do 67 | reply_to_http(data) 68 | else 69 | {:ok, snippet} = Exbin.Snippets.insert(%{"content" => data, "private" => "true"}) 70 | "#{Application.get_env(:exbin, :base_url)}/r/#{snippet.name}\n" 71 | end 72 | 73 | :gen_tcp.send(client_socket, reply) 74 | end 75 | 76 | :gen_tcp.close(client_socket) 77 | end 78 | 79 | defp do_rcv(_socket, _bytes, count) when count <= 0 do 80 | nil 81 | end 82 | 83 | defp do_rcv(socket, bytes, count) do 84 | case :gen_tcp.recv(socket, 0, 1000) do 85 | {:ok, fresh_bytes} -> 86 | do_rcv(socket, bytes <> fresh_bytes, count - byte_size(fresh_bytes)) 87 | 88 | {:error, :closed} -> 89 | IO.puts("Socket closed at the client side.") 90 | bytes 91 | 92 | {:error, e} -> 93 | IO.puts("An error while receiving bytes: #{inspect(e)}") 94 | bytes 95 | end 96 | end 97 | 98 | defp is_http_request?(data) do 99 | Regex.match?(~r/GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD|TRACE|CONNECT/, data) 100 | end 101 | 102 | defp reply_to_http(_request) do 103 | bomb = Application.app_dir(:exbin, "/priv/10G.gzip") 104 | filesize = File.stat!(bomb) 105 | 106 | """ 107 | HTTP/1.1 200 OK\r 108 | Content-Type: text/html; charset=UTF-8\r 109 | Content-Length: #{filesize.size}\r 110 | Content-Encoding: gzip\r 111 | \r 112 | """ <> File.read!(bomb) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/exbin/stats.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Stats do 2 | alias Exbin.{Snippet, Repo, Clock, Accounts.User} 3 | import Ecto.Query 4 | @cache :stats_cache 5 | 6 | @doc """ 7 | Computes the average length of a snippet. 8 | """ 9 | def average_length_() do 10 | query = from(s in Snippet, select: %{len: fragment("avg(length(?))", s.content)}) 11 | 12 | case Repo.one(query) do 13 | %{len: nil} -> 14 | 0.0 15 | 16 | %{len: d} -> 17 | Decimal.to_float(d) 18 | end 19 | end 20 | 21 | def average_length() do 22 | case Cachex.get(@cache, :average_length) do 23 | {:ok, nil} -> 24 | result = average_length_() 25 | Cachex.put(@cache, :average_length, result, ttl: :timer.hours(1)) 26 | result 27 | 28 | {:ok, result} -> 29 | result 30 | end 31 | end 32 | 33 | @doc """ 34 | Count all the users. 35 | """ 36 | def count_users() do 37 | Repo.one(from(s in User, select: count(s.id))) 38 | end 39 | 40 | @doc """ 41 | Count all the snippets. 42 | """ 43 | def count_snippets_() do 44 | Repo.one(from(s in Snippet, select: count(s.id))) 45 | end 46 | 47 | def count_snippets() do 48 | case Cachex.get(@cache, :count_snippets) do 49 | {:ok, nil} -> 50 | result = count_snippets_() 51 | Cachex.put(@cache, :count_snippets, result, ttl: :timer.hours(1)) 52 | result 53 | 54 | {:ok, result} -> 55 | result 56 | end 57 | end 58 | 59 | @doc """ 60 | Compute the total database size. 61 | """ 62 | def database_size_() do 63 | database_name = Application.get_env(:exbin, Exbin.Repo)[:database] 64 | sq = from(s in Snippet, select: %{size: fragment("pg_database_size(?)", ^database_name)}) 65 | query = from v in subquery(sq), select: %{size: v.size}, group_by: fragment("size") 66 | 67 | case Repo.one(query) do 68 | %{size: nil} -> 69 | 0.0 70 | 71 | %{size: d} -> 72 | d 73 | 74 | _ -> 75 | 0.0 76 | end 77 | end 78 | 79 | def database_size() do 80 | case Cachex.get(@cache, :database_size) do 81 | {:ok, nil} -> 82 | result = database_size_() 83 | Cachex.put(@cache, :database_size, result, ttl: :timer.hours(1)) 84 | result 85 | 86 | {:ok, result} -> 87 | result 88 | end 89 | end 90 | 91 | @spec count_public_private :: %{private: any, public: any} 92 | @doc """ 93 | Counts the total of private and public snippets in the database. 94 | """ 95 | def count_public_private_() do 96 | q = 97 | from s in Snippet, 98 | select: %{private?: s.private, total: count()}, 99 | group_by: s.private 100 | 101 | case Repo.all(q) do 102 | [] -> 103 | %{private: 0, public: 0} 104 | 105 | [%{private?: true, total: priv}] -> 106 | %{private: priv, public: 0} 107 | 108 | [%{private?: false, total: publ}] -> 109 | %{private: 0, public: publ} 110 | 111 | [%{private?: false, total: publ}, %{private?: true, total: priv}] -> 112 | %{private: priv, public: publ} 113 | end 114 | end 115 | 116 | def count_public_private() do 117 | case Cachex.get(@cache, :count_public_private) do 118 | {:ok, nil} -> 119 | result = count_public_private_() 120 | Cachex.put(@cache, :count_public_private, result, ttl: :timer.hours(1)) 121 | result 122 | 123 | {:ok, result} -> 124 | result 125 | end 126 | end 127 | 128 | @doc """ 129 | Compute the average views per snippet. 130 | """ 131 | def average_viewcount() do 132 | query = from(s in Snippet, select: avg(s.viewcount)) 133 | 134 | case Repo.one(query) do 135 | nil -> 136 | 0.0 137 | 138 | d -> 139 | Decimal.to_float(d) 140 | end 141 | end 142 | 143 | @doc """ 144 | Returns the most popular public snippet by viewcount. 145 | Breaks a tie in viewcount by choosing the most recently created. 146 | Returns nil if no snippet is found. 147 | """ 148 | def most_popular() do 149 | from(from(s in Snippet, where: s.private == false, order_by: [desc: :viewcount, desc: :inserted_at], limit: 1)) 150 | |> Repo.one() 151 | end 152 | 153 | @doc """ 154 | Groups snippets created per month and returns the totals per month for a year. 155 | 156 | Note that this returns the current month (a partial month) up until the 157 | current time, and the 11 months prior, so it's not quite a full year of data. 158 | Technically it's actually: 11 months + (between 1 day and 1 month) 159 | Any dates that are in the future are filtered out. (Although this should only 160 | happen in cases of database corruption, bad inserts, or changing the app TZ) 161 | 162 | Returns a map keyed with the beginning of the month, truncated to the second, 163 | as a NaiveDateTime, with a {public_count, private_count} tuple for each month. 164 | """ 165 | def count_per_month() do 166 | buckets = empty_month_bucket_map() 167 | 168 | count_per_month_query() 169 | |> Repo.all() 170 | |> Enum.reduce(buckets, fn result, acc -> 171 | target_month_start = NaiveDateTime.truncate(result.month, :second) 172 | {publ, priv} = Map.get(acc, target_month_start) 173 | priv = if result.private, do: result.count, else: priv 174 | publ = if result.private, do: publ, else: result.count 175 | Map.put(acc, target_month_start, {publ, priv}) 176 | end) 177 | # Turn the map into a list now that we no longer need to look up items 178 | |> Enum.into([]) 179 | # And sort the list so the months are in order and the most recent one is last 180 | |> Enum.sort_by(&elem(&1, 0), &Timex.before?/2) 181 | # Prune the oldest element out of the list, as we only actually want 11 months. 182 | |> Enum.drop(1) 183 | end 184 | 185 | # We use this to solve https://github.com/elixir-ecto/ecto/issues/3159 186 | # (Ecto explodes because it doesn't understand that two fragments with the 187 | # same arguments are actually the same fragment, so it incorrectly demands 188 | # that you group_by a field which you're actually grouping by already.) 189 | # 190 | # NOTE: Elixir/Ecto is treating the timestamp columns as DateTimes with 191 | # zones, but in Postgres they're actually "datetime without zone" 192 | # (https://github.com/elixir-ecto/ecto/issues/1868#issuecomment-268169955) 193 | # Because of this when we do manipulations on the Postgres side we actually 194 | # need to first cast the timestamp into ETC/UTC and THEN move it to the 195 | # application TZ. (AT TIME ZONE stamps the TZ onto the timestamp *at the 196 | # same wall clock time*, rather than convert it to that TZ for "timestamp 197 | # without time zone" fields. 198 | defmacrop month_trunc_in_zone_frag(field, tz) do 199 | quote do 200 | fragment( 201 | "date_trunc('month', (? AT TIME ZONE 'Etc/UTC') AT TIME ZONE ?) as month_bucket", 202 | unquote(field), 203 | unquote(tz) 204 | ) 205 | end 206 | end 207 | 208 | # Create a query to get the counts for each month in the last year, grouped by public/private 209 | # (So 2 results per month, assuming each month has both public and private snippets) 210 | defp count_per_month_query() do 211 | app_tz = Application.get_env(:exbin, :timezone) 212 | now = Clock.utc_now() 213 | 214 | from(s in Snippet) 215 | |> select( 216 | [s], 217 | %{ 218 | month: month_trunc_in_zone_frag(s.inserted_at, ^app_tz), 219 | count: fragment("count(*)"), 220 | private: fragment("private") 221 | } 222 | ) 223 | |> where( 224 | [s], 225 | fragment( 226 | "(? AT TIME ZONE 'Etc/UTC') AT TIME ZONE ? >= (? AT TIME ZONE ? - interval '1 year')", 227 | s.inserted_at, 228 | ^app_tz, 229 | ^now, 230 | ^app_tz 231 | ) 232 | ) 233 | |> where( 234 | [s], 235 | fragment("(? AT TIME ZONE 'Etc/UTC') AT TIME ZONE ? <= (? AT TIME ZONE ?)", s.inserted_at, ^app_tz, ^now, ^app_tz) 236 | ) 237 | |> group_by([s], fragment("month_bucket")) 238 | |> group_by([s], s.private) 239 | |> order_by([s], fragment("month_bucket")) 240 | end 241 | 242 | # Generates a a bucket list for 12 months prior to the current time. 243 | # Resulting Map will be keyed with NaiveDateTimes truncated to the 244 | # second, indicating the beginning of the month, with a zeroed' 245 | # {public_count, private_count} tuple as the initial value. 246 | @spec empty_month_bucket_map() :: Map.t() 247 | defp empty_month_bucket_map() do 248 | application_tz = Application.get_env(:exbin, :timezone) 249 | 250 | current_month_start = 251 | Clock.utc_now() 252 | |> DateTime.shift_zone!(application_tz) 253 | |> Timex.beginning_of_month() 254 | |> NaiveDateTime.truncate(:second) 255 | 256 | 0..12 257 | |> Enum.flat_map(fn offset -> 258 | month = Timex.shift(current_month_start, months: -1 * offset) 259 | [{month, {0, 0}}] 260 | end) 261 | |> Enum.into(%{}) 262 | end 263 | end 264 | -------------------------------------------------------------------------------- /lib/exbin/util/like_injection.ex: -------------------------------------------------------------------------------- 1 | defmodule LikeInjection do 2 | import Enum 3 | 4 | # Characters that have special meaning inside the `LIKE` clause of a query. 5 | # 6 | # `%` is a wildcard representing multiple characters. 7 | # `_` is a wildcard representing one character. 8 | # Source: https://stackoverflow.com/questions/712580/list-of-special-characters-for-sql-like-clause 9 | @metachars [?%, ?_] 10 | 11 | # What to replace `LIKE` metacharacters with. We want to prepend a literal 12 | # backslash to each metacharacter. Because String#gsub does its own round of 13 | # interpolation on its second argument, we have to double escape backslashes 14 | # in this String. 15 | @escape ?\\ 16 | 17 | # Public: Escape characters that have special meaning within the `LIKE` clause 18 | # of a SQL query. 19 | # 20 | # value - The String value to be escaped. 21 | # 22 | # Returns a String. 23 | def like_sanitize(value) do 24 | value 25 | |> String.to_charlist() 26 | |> flat_map(fn char -> 27 | if member?(@metachars, char) do 28 | [@escape, char] 29 | else 30 | [char] 31 | end 32 | end) 33 | |> List.to_string() 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/exbin_web.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb 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 ExbinWeb, :controller 9 | use ExbinWeb, :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: ExbinWeb 23 | 24 | import Plug.Conn 25 | import ExbinWeb.Gettext 26 | alias ExbinWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/exbin_web/templates", 34 | namespace: ExbinWeb 35 | 36 | use Phoenix.HTML 37 | # Import convenience functions from controllers 38 | import Phoenix.Controller, 39 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 40 | 41 | # Include shared imports and aliases for views 42 | unquote(view_helpers()) 43 | end 44 | end 45 | 46 | def live_view do 47 | quote do 48 | use Phoenix.LiveView, 49 | layout: {ExbinWeb.LayoutView, "live.html"} 50 | 51 | use Phoenix.HTML 52 | unquote(view_helpers()) 53 | end 54 | end 55 | 56 | def live_component do 57 | quote do 58 | use Phoenix.LiveComponent 59 | use Phoenix.HTML 60 | 61 | unquote(view_helpers()) 62 | end 63 | end 64 | 65 | def router do 66 | quote do 67 | use Phoenix.Router 68 | 69 | import Plug.Conn 70 | import Phoenix.Controller 71 | import Phoenix.LiveView.Router 72 | end 73 | end 74 | 75 | def channel do 76 | quote do 77 | use Phoenix.Channel 78 | import ExbinWeb.Gettext 79 | end 80 | end 81 | 82 | defp view_helpers do 83 | quote do 84 | # Use all HTML functionality (forms, tags, etc) 85 | use Phoenix.HTML 86 | 87 | # Import LiveView helpers (live_render, live_component, live_patch, etc) 88 | import Phoenix.LiveView.Helpers 89 | 90 | # Import basic rendering functionality (render, render_layout, etc) 91 | import Phoenix.View 92 | 93 | import ExbinWeb.ErrorHelpers 94 | import ExbinWeb.Gettext 95 | alias ExbinWeb.Router.Helpers, as: Routes 96 | end 97 | end 98 | 99 | @doc """ 100 | When used, dispatch to the appropriate controller/view/etc. 101 | """ 102 | defmacro __using__(which) when is_atom(which) do 103 | apply(__MODULE__, which, []) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/exbin_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", ExbinWeb.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 | @impl true 19 | def connect(_params, socket, _connect_info) do 20 | {:ok, socket} 21 | end 22 | 23 | # Socket id's are topics that allow you to identify all sockets for a given user: 24 | # 25 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 26 | # 27 | # Would allow you to broadcast a "disconnect" event and terminate 28 | # all active sockets and channels for a given user: 29 | # 30 | # ExbinWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 31 | # 32 | # Returning `nil` makes this socket anonymous. 33 | @impl true 34 | def id(_socket), do: nil 35 | end 36 | -------------------------------------------------------------------------------- /lib/exbin_web/controllers/api_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.APIController do 2 | use ExbinWeb, :controller 3 | 4 | action_fallback ExbinWeb.FallbackController 5 | 6 | plug Hammer.Plug, 7 | [rate_limit: {"new_snippet", 60_000, 2}, by: :ip] 8 | when action == :new 9 | 10 | plug ExbinWeb.ApiAuth when action == :new 11 | 12 | def show(conn, %{"name" => name}) do 13 | with {:ok, snippet} <- Exbin.Snippets.get_by_name(name) do 14 | render(conn, "show.json", snippet: snippet) 15 | end 16 | end 17 | 18 | def new(conn, %{"content" => content, "private" => priv, "ephemeral" => eph}) do 19 | args = %{"content" => content, "private" => priv, "ephemeral" => eph} 20 | 21 | if args["content"] == "" or String.trim(args["content"]) == "" do 22 | {:error, :invalid_content} 23 | else 24 | {:ok, snippet} = Exbin.Snippets.insert(args) 25 | render(conn, "show.json", snippet: snippet) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/exbin_web/controllers/fallback_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.FallbackController do 2 | @moduledoc """ 3 | Translates controller action results into valid `Plug.Conn` responses. 4 | 5 | See `Phoenix.Controller.action_fallback/1` for more details. 6 | """ 7 | use ExbinWeb, :controller 8 | 9 | # This clause handles errors returned by Ecto's insert/update/delete. 10 | def call(conn, {:error, %Ecto.Changeset{} = changeset}) do 11 | conn 12 | |> put_status(:unprocessable_entity) 13 | |> put_view(ExbinWeb.ChangesetView) 14 | |> render("error.json", changeset: changeset) 15 | end 16 | 17 | # This clause is for when an invalid snippet is requested. 18 | def call(conn, {:error, :not_found}) do 19 | IO.inspect("call, #{inspect({:error, :not_found})}", label: "call") 20 | 21 | conn 22 | |> put_status(:not_found) 23 | |> put_view(ExbinWeb.ErrorView) 24 | |> render(:"404") 25 | end 26 | 27 | # This clause is triggered when an invalid snippet is sent. 28 | def call(conn, {:error, _e}) do 29 | conn 30 | |> put_status(:not_found) 31 | |> put_view(ExbinWeb.ErrorView) 32 | |> render(:"400") 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/exbin_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.PageController do 2 | use ExbinWeb, :controller 3 | 4 | def about(conn, _params) do 5 | conn 6 | |> render("about.html") 7 | end 8 | 9 | def static_file_not_found(conn, _params) do 10 | conn 11 | |> put_status(404) 12 | |> text("File Not Found") 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/exbin_web/controllers/snippet_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.SnippetController do 2 | use ExbinWeb, :controller 3 | 4 | # Only increment viewcounter every 24 hours. 5 | plug ExbinWeb.Plug.ViewCounter, [view_interval: 86_400_000] when action in [:view, :readerview, :codeview, :rawview] 6 | 7 | @spec new(Plug.Conn.t(), any) :: Plug.Conn.t() 8 | def new(conn, _params) do 9 | render(conn, "new.html") 10 | end 11 | 12 | def create(conn, %{"snippet" => args}) do 13 | if args["content"] == "" or String.trim(args["content"]) == "" do 14 | conn 15 | |> put_flash(:error, "💩 Empty snippets not allowed.") 16 | |> redirect(to: "/") 17 | else 18 | user = conn.assigns.current_user 19 | 20 | args = 21 | if user do 22 | Map.merge(args, %{"user_id" => user.id}) 23 | else 24 | args 25 | end 26 | 27 | {:ok, snippet} = Exbin.Snippets.insert(args) 28 | redirect(conn, to: "/#{snippet.name}") 29 | end 30 | end 31 | 32 | def view(conn, %{"name" => name}) do 33 | render_snippet(conn, name, Application.get_env(:exbin, :default_view)) 34 | end 35 | 36 | def codeview(conn, %{"name" => name}) do 37 | render_snippet(conn, name, :code) 38 | end 39 | 40 | def readerview(conn, %{"name" => name}) do 41 | render_snippet(conn, name, :reader) 42 | end 43 | 44 | def rawview(conn, %{"name" => name}) do 45 | render_snippet(conn, name, :raw) 46 | end 47 | 48 | def render_snippet(conn, name, view) do 49 | case Exbin.Snippets.get_by_name(name) do 50 | {:error, :not_found} -> 51 | conn 52 | |> put_flash(:error, "💩 Snippet not found.") 53 | |> redirect(to: "/") 54 | 55 | {:ok, snippet} -> 56 | IO.puts(view) 57 | 58 | case view do 59 | :code -> 60 | render(conn, "code.html", snippet: snippet) 61 | 62 | :raw -> 63 | text(conn, snippet.content) 64 | 65 | :reader -> 66 | render(conn, "reader.html", snippet: snippet) 67 | 68 | _ -> 69 | conn 70 | |> put_flash(:error, "💩 An error occured showing this snippet.") 71 | |> redirect(to: "/") 72 | end 73 | end 74 | end 75 | 76 | def list(conn, _params) do 77 | snippets = 78 | if Map.get(conn.assigns, :current_user, nil) != nil and Map.get(conn.assigns, :current_user).admin == true do 79 | Exbin.Snippets.list_snippets(limit: 500) 80 | else 81 | Exbin.Snippets.list_public_snippets() 82 | end 83 | 84 | case snippets do 85 | [] -> 86 | conn 87 | |> put_flash(:error, "😢 There are no public snippets to show!") 88 | |> render("list.html", snippets: []) 89 | 90 | snippets -> 91 | render(conn, "list.html", snippets: snippets) 92 | end 93 | end 94 | 95 | def personal_list(conn, _params) do 96 | user = conn.assigns.current_user 97 | 98 | case Exbin.Snippets.list_user_snippets(user.id) do 99 | [] -> 100 | conn 101 | |> put_flash(:error, "😢 You have no snippets!") 102 | |> render("list.html", snippets: []) 103 | 104 | user_snippets -> 105 | render(conn, "list.html", snippets: user_snippets) 106 | end 107 | end 108 | 109 | def delete(conn, %{"snippet" => snippet_id}) do 110 | {:ok, snippet} = Exbin.Snippets.get_by_id(snippet_id) 111 | 112 | user = conn.assigns.current_user 113 | 114 | case user do 115 | nil -> 116 | conn 117 | |> put_flash(:error, "😢 You can not delete this snippet!") 118 | |> render_snippet(snippet.name, Application.get_env(:exbin, :default_view)) 119 | 120 | user -> 121 | if user.id == snippet.user_id or user.admin == true do 122 | Exbin.Snippets.delete_snippet(snippet) 123 | 124 | conn 125 | |> put_flash(:info, "😄 Snippet deleted!") 126 | |> redirect(to: "/") 127 | else 128 | conn 129 | |> put_flash(:error, "😢 You can not delete this snippet!") 130 | |> render_snippet(snippet.name, Application.get_env(:exbin, :default_view)) 131 | end 132 | end 133 | end 134 | 135 | def statistics(conn, _params) do 136 | data = %{ 137 | monthly: Exbin.Stats.count_per_month(), 138 | avg_views: Exbin.Stats.average_viewcount(), 139 | avg_length: Exbin.Stats.average_length(), 140 | privpub: Exbin.Stats.count_public_private(), 141 | most_viewed: Exbin.Stats.most_popular(), 142 | user_count: Exbin.Stats.count_users(), 143 | database_size: Exbin.Stats.database_size() 144 | } 145 | 146 | render(conn, "statistics.html", data: data) 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/exbin_web/controllers/user_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserAuth do 2 | import Plug.Conn 3 | import Phoenix.Controller 4 | 5 | alias Exbin.Accounts 6 | alias ExbinWeb.Router.Helpers, as: Routes 7 | 8 | # Make the remember me cookie valid for 60 days. 9 | # If you want bump or reduce this value, also change 10 | # the token expiry itself in UserToken. 11 | @max_age 60 * 60 * 24 * 60 12 | @remember_me_cookie "_exbin_web_user_remember_me" 13 | @remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"] 14 | 15 | @doc """ 16 | Logs the user in. 17 | 18 | It renews the session ID and clears the whole session 19 | to avoid fixation attacks. See the renew_session 20 | function to customize this behaviour. 21 | 22 | It also sets a `:live_socket_id` key in the session, 23 | so LiveView sessions are identified and automatically 24 | disconnected on log out. The line can be safely removed 25 | if you are not using LiveView. 26 | """ 27 | def log_in_user(conn, user, params \\ %{}) do 28 | token = Accounts.generate_user_session_token(user) 29 | user_return_to = get_session(conn, :user_return_to) 30 | 31 | conn 32 | |> renew_session() 33 | |> put_session(:user_token, token) 34 | |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") 35 | |> maybe_write_remember_me_cookie(token, params) 36 | |> redirect(to: user_return_to || signed_in_path(conn)) 37 | end 38 | 39 | defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do 40 | put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options) 41 | end 42 | 43 | defp maybe_write_remember_me_cookie(conn, _token, _params) do 44 | conn 45 | end 46 | 47 | # This function renews the session ID and erases the whole 48 | # session to avoid fixation attacks. If there is any data 49 | # in the session you may want to preserve after log in/log out, 50 | # you must explicitly fetch the session data before clearing 51 | # and then immediately set it after clearing, for example: 52 | # 53 | # defp renew_session(conn) do 54 | # preferred_locale = get_session(conn, :preferred_locale) 55 | # 56 | # conn 57 | # |> configure_session(renew: true) 58 | # |> clear_session() 59 | # |> put_session(:preferred_locale, preferred_locale) 60 | # end 61 | # 62 | defp renew_session(conn) do 63 | conn 64 | |> configure_session(renew: true) 65 | |> clear_session() 66 | end 67 | 68 | @doc """ 69 | Logs the user out. 70 | 71 | It clears all session data for safety. See renew_session. 72 | """ 73 | def log_out_user(conn) do 74 | user_token = get_session(conn, :user_token) 75 | user_token && Accounts.delete_session_token(user_token) 76 | 77 | if live_socket_id = get_session(conn, :live_socket_id) do 78 | ExbinWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) 79 | end 80 | 81 | conn 82 | |> renew_session() 83 | |> delete_resp_cookie(@remember_me_cookie) 84 | |> redirect(to: "/") 85 | end 86 | 87 | @doc """ 88 | Authenticates the user by looking into the session 89 | and remember me token. 90 | """ 91 | def fetch_current_user(conn, _opts) do 92 | {user_token, conn} = ensure_user_token(conn) 93 | user = user_token && Accounts.get_user_by_session_token(user_token) 94 | assign(conn, :current_user, user) 95 | end 96 | 97 | defp ensure_user_token(conn) do 98 | if user_token = get_session(conn, :user_token) do 99 | {user_token, conn} 100 | else 101 | conn = fetch_cookies(conn, signed: [@remember_me_cookie]) 102 | 103 | if user_token = conn.cookies[@remember_me_cookie] do 104 | {user_token, put_session(conn, :user_token, user_token)} 105 | else 106 | {nil, conn} 107 | end 108 | end 109 | end 110 | 111 | @doc """ 112 | Used for routes that require the user to not be authenticated. 113 | """ 114 | def redirect_if_user_is_authenticated(conn, _opts) do 115 | if conn.assigns[:current_user] do 116 | conn 117 | |> redirect(to: signed_in_path(conn)) 118 | |> halt() 119 | else 120 | conn 121 | end 122 | end 123 | 124 | @doc """ 125 | Used for routes that require the user to be authenticated. 126 | 127 | If you want to enforce the user email is confirmed before 128 | they use the application at all, here would be a good place. 129 | """ 130 | def require_authenticated_user(conn, _opts) do 131 | if conn.assigns[:current_user] do 132 | conn 133 | else 134 | conn 135 | |> put_flash(:error, "You must log in to access this page.") 136 | |> maybe_store_return_to() 137 | |> redirect(to: Routes.user_session_path(conn, :new)) 138 | |> halt() 139 | end 140 | end 141 | 142 | defp maybe_store_return_to(%{method: "GET"} = conn) do 143 | put_session(conn, :user_return_to, current_path(conn)) 144 | end 145 | 146 | defp maybe_store_return_to(conn), do: conn 147 | 148 | defp signed_in_path(_conn), do: "/" 149 | end 150 | -------------------------------------------------------------------------------- /lib/exbin_web/controllers/user_confirmation_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserConfirmationController do 2 | use ExbinWeb, :controller 3 | 4 | alias Exbin.Accounts 5 | 6 | def new(conn, _params) do 7 | render(conn, "new.html") 8 | end 9 | 10 | def create(conn, %{"user" => %{"email" => email}}) do 11 | if user = Accounts.get_user_by_email(email) do 12 | Accounts.deliver_user_confirmation_instructions( 13 | user, 14 | &Routes.user_confirmation_url(conn, :confirm, &1) 15 | ) 16 | end 17 | 18 | # Regardless of the outcome, show an impartial success/error message. 19 | conn 20 | |> put_flash( 21 | :info, 22 | "If your email is in our system and it has not been confirmed yet, " <> 23 | "you will receive an email with instructions shortly." 24 | ) 25 | |> redirect(to: "/") 26 | end 27 | 28 | # Do not log in the user after confirmation to avoid a 29 | # leaked token giving the user access to the account. 30 | def confirm(conn, %{"token" => token}) do 31 | case Accounts.confirm_user(token) do 32 | {:ok, _} -> 33 | conn 34 | |> put_flash(:info, "User confirmed successfully.") 35 | |> redirect(to: "/") 36 | 37 | :error -> 38 | # If there is a current user and the account was already confirmed, 39 | # then odds are that the confirmation link was already visited, either 40 | # by some automation or by the user themselves, so we redirect without 41 | # a warning message. 42 | case conn.assigns do 43 | %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> 44 | redirect(conn, to: "/") 45 | 46 | %{} -> 47 | conn 48 | |> put_flash(:error, "User confirmation link is invalid or it has expired.") 49 | |> redirect(to: "/") 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/exbin_web/controllers/user_registration_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserRegistrationController do 2 | use ExbinWeb, :controller 3 | 4 | alias Exbin.Accounts 5 | alias Exbin.Accounts.User 6 | alias ExbinWeb.UserAuth 7 | 8 | def new(conn, _params) do 9 | changeset = Accounts.change_user_registration(%User{}) 10 | render(conn, "new.html", changeset: changeset) 11 | end 12 | 13 | def create(conn, %{"user" => user_params}) do 14 | case Accounts.register_user(user_params) do 15 | {:ok, user} -> 16 | {:ok, _} = 17 | Accounts.deliver_user_confirmation_instructions( 18 | user, 19 | &Routes.user_confirmation_url(conn, :confirm, &1) 20 | ) 21 | 22 | conn 23 | |> put_flash(:info, "User created successfully.") 24 | |> UserAuth.log_in_user(user) 25 | 26 | {:error, %Ecto.Changeset{} = changeset} -> 27 | render(conn, "new.html", changeset: changeset) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/exbin_web/controllers/user_reset_password_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserResetPasswordController do 2 | use ExbinWeb, :controller 3 | 4 | alias Exbin.Accounts 5 | 6 | plug :get_user_by_reset_password_token when action in [:edit, :update] 7 | 8 | def new(conn, _params) do 9 | render(conn, "new.html") 10 | end 11 | 12 | def create(conn, %{"user" => %{"email" => email}}) do 13 | if user = Accounts.get_user_by_email(email) do 14 | Accounts.deliver_user_reset_password_instructions( 15 | user, 16 | &Routes.user_reset_password_url(conn, :edit, &1) 17 | ) 18 | end 19 | 20 | # Regardless of the outcome, show an impartial success/error message. 21 | conn 22 | |> put_flash( 23 | :info, 24 | "If your email is in our system, you will receive instructions to reset your password shortly." 25 | ) 26 | |> redirect(to: "/") 27 | end 28 | 29 | def edit(conn, _params) do 30 | render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user)) 31 | end 32 | 33 | # Do not log in the user after reset password to avoid a 34 | # leaked token giving the user access to the account. 35 | def update(conn, %{"user" => user_params}) do 36 | case Accounts.reset_user_password(conn.assigns.user, user_params) do 37 | {:ok, _} -> 38 | conn 39 | |> put_flash(:info, "Password reset successfully.") 40 | |> redirect(to: Routes.user_session_path(conn, :new)) 41 | 42 | {:error, changeset} -> 43 | render(conn, "edit.html", changeset: changeset) 44 | end 45 | end 46 | 47 | defp get_user_by_reset_password_token(conn, _opts) do 48 | %{"token" => token} = conn.params 49 | 50 | if user = Accounts.get_user_by_reset_password_token(token) do 51 | conn |> assign(:user, user) |> assign(:token, token) 52 | else 53 | conn 54 | |> put_flash(:error, "Reset password link is invalid or it has expired.") 55 | |> redirect(to: "/") 56 | |> halt() 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/exbin_web/controllers/user_session_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserSessionController do 2 | use ExbinWeb, :controller 3 | 4 | alias Exbin.Accounts 5 | alias ExbinWeb.UserAuth 6 | 7 | def new(conn, _params) do 8 | render(conn, "new.html", error_message: nil) 9 | end 10 | 11 | def create(conn, %{"user" => user_params}) do 12 | %{"email" => email, "password" => password} = user_params 13 | 14 | if user = Accounts.get_user_by_email_and_password(email, password) do 15 | UserAuth.log_in_user(conn, user, user_params) 16 | else 17 | render(conn, "new.html", error_message: "Invalid email or password.") 18 | end 19 | end 20 | 21 | def delete(conn, _params) do 22 | conn 23 | |> put_flash(:info, "Logged out successfully.") 24 | |> UserAuth.log_out_user() 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/exbin_web/controllers/user_settings_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserSettingsController do 2 | use ExbinWeb, :controller 3 | 4 | alias Exbin.Accounts 5 | alias ExbinWeb.UserAuth 6 | 7 | plug :assign_email_and_password_changesets 8 | 9 | def edit(conn, _params) do 10 | render(conn, "edit.html") 11 | end 12 | 13 | def update(conn, %{"action" => "update_email"} = params) do 14 | %{"current_password" => password, "user" => user_params} = params 15 | user = conn.assigns.current_user 16 | 17 | case Accounts.apply_user_email(user, password, user_params) do 18 | {:ok, applied_user} -> 19 | Accounts.deliver_update_email_instructions( 20 | applied_user, 21 | user.email, 22 | &Routes.user_settings_url(conn, :confirm_email, &1) 23 | ) 24 | 25 | conn 26 | |> put_flash( 27 | :info, 28 | "A link to confirm your email change has been sent to the new address." 29 | ) 30 | |> redirect(to: Routes.user_settings_path(conn, :edit)) 31 | 32 | {:error, changeset} -> 33 | render(conn, "edit.html", email_changeset: changeset) 34 | end 35 | end 36 | 37 | def update(conn, %{"action" => "update_password"} = params) do 38 | %{"current_password" => password, "user" => user_params} = params 39 | user = conn.assigns.current_user 40 | 41 | case Accounts.update_user_password(user, password, user_params) do 42 | {:ok, user} -> 43 | conn 44 | |> put_flash(:info, "Password updated successfully.") 45 | |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit)) 46 | |> UserAuth.log_in_user(user) 47 | 48 | {:error, changeset} -> 49 | render(conn, "edit.html", password_changeset: changeset) 50 | end 51 | end 52 | 53 | def confirm_email(conn, %{"token" => token}) do 54 | case Accounts.update_user_email(conn.assigns.current_user, token) do 55 | :ok -> 56 | conn 57 | |> put_flash(:info, "Email changed successfully.") 58 | |> redirect(to: Routes.user_settings_path(conn, :edit)) 59 | 60 | :error -> 61 | conn 62 | |> put_flash(:error, "Email change link is invalid or it has expired.") 63 | |> redirect(to: Routes.user_settings_path(conn, :edit)) 64 | end 65 | end 66 | 67 | defp assign_email_and_password_changesets(conn, _opts) do 68 | user = conn.assigns.current_user 69 | 70 | conn 71 | |> assign(:email_changeset, Accounts.change_user_email(user)) 72 | |> assign(:password_changeset, Accounts.change_user_password(user)) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/exbin_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :exbin 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_exbin_key", 10 | signing_salt: "/tULamPJ" 11 | ] 12 | 13 | socket "/socket", ExbinWeb.UserSocket, 14 | websocket: true, 15 | longpoll: false 16 | 17 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 18 | 19 | # Serve at "/" the static files from "priv/static" directory. 20 | # 21 | # You should set gzip to true if you are running phx.digest 22 | # when deploying your static files in production. 23 | plug Plug.Static, 24 | at: "/", 25 | from: :exbin, 26 | gzip: false, 27 | only: ~w(css fonts images js favicon.ico robots.txt) 28 | 29 | # Code reloading can be explicitly enabled under the 30 | # :code_reloader configuration of your endpoint. 31 | if code_reloading? do 32 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 33 | plug Phoenix.LiveReloader 34 | plug Phoenix.CodeReloader 35 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :exbin 36 | end 37 | 38 | plug Phoenix.LiveDashboard.RequestLogger, 39 | param_key: "request_logger", 40 | cookie_key: "request_logger" 41 | 42 | plug Plug.RequestId 43 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 44 | 45 | plug Plug.Parsers, 46 | parsers: [:urlencoded, :multipart, :json], 47 | pass: ["*/*"], 48 | json_decoder: Phoenix.json_library() 49 | 50 | plug Plug.MethodOverride 51 | plug Plug.Head 52 | plug Plug.Session, @session_options 53 | plug ExbinWeb.Router 54 | end 55 | -------------------------------------------------------------------------------- /lib/exbin_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.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 ExbinWeb.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: :exbin 24 | end 25 | -------------------------------------------------------------------------------- /lib/exbin_web/live/page_live.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.PageLive do 2 | use ExbinWeb, :live_view 3 | require Logger 4 | 5 | @impl true 6 | def mount(_params, %{"user_token" => token}, socket) do 7 | socket = assign(socket, :token, token) 8 | {:ok, assign(socket, query: "", snippets: [])} 9 | end 10 | 11 | @impl true 12 | def mount(_params, _assigns, socket) do 13 | {:ok, assign(socket, query: "", snippets: [])} 14 | end 15 | 16 | @impl true 17 | def handle_event("suggest", %{"q" => query}, socket) do 18 | this = self() 19 | 20 | unless String.trim(query) == "" do 21 | case Map.get(socket.assigns, :token) do 22 | nil -> 23 | Exbin.Snippets.search_stream(query, fn results -> 24 | send(this, {:query_result, results}) 25 | end) 26 | 27 | t -> 28 | user = Exbin.Accounts.get_user_by_session_token(t) 29 | 30 | Exbin.Snippets.search_stream(query, user.id, fn results -> 31 | send(this, {:query_result, results}) 32 | end) 33 | end 34 | end 35 | 36 | {:noreply, assign(socket, query: query, snippets: [])} 37 | end 38 | 39 | @impl true 40 | def handle_info({:query_result, snippets}, socket) do 41 | current_results = Map.get(socket.assigns, :snippets, []) 42 | socket = assign(socket, snippets: current_results ++ snippets) 43 | {:noreply, socket} 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/exbin_web/live/page_live.html.leex: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 | 6 |
7 | 8 |
9 | 10 |
11 |
12 | 13 | 14 |
15 |
16 | 17 |
18 |
19 | 20 | 21 | <%= for snippet <- @snippets do %> 22 | 23 |
24 | 37 |
38 |
<%= snippet.content |> ExbinWeb.SnippetView.summary()  %>
39 |
40 |
41 |
42 | <% end %> 43 |
44 | -------------------------------------------------------------------------------- /lib/exbin_web/plug/api_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.ApiAuth do 2 | import Plug.Conn 3 | 4 | def init(opts) do 5 | opts 6 | end 7 | 8 | def call(conn, _opts) do 9 | token = Map.get(conn.body_params, "token", nil) 10 | 11 | if valid_token?(token) do 12 | conn 13 | else 14 | conn 15 | |> send_resp( 16 | 500, 17 | ~s({"error": "invalid token"}) 18 | ) 19 | |> halt() 20 | end 21 | end 22 | 23 | # Checks if the passed token matches the one in the config. 24 | defp valid_token?(token) do 25 | global_token = Application.get_env(:exbin, :apikey) 26 | 27 | case {global_token, token} do 28 | # No token means no auth! 29 | {nil, _} -> 30 | true 31 | 32 | {expected, given} -> 33 | expected == given 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/exbin_web/plug/custom_logo.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.Plug.CustomLogo do 2 | @moduledoc """ 3 | Allows defining a path via Application configuration that will be served from 4 | the filesystem. 5 | """ 6 | alias Plug.Conn 7 | 8 | def init(opts), do: opts 9 | 10 | def call(%Conn{request_path: "/files/" <> requested_filename} = conn, _opts) do 11 | with {:ok, dirname, basename} <- custom_logo_paths_tuple(), 12 | true <- String.equivalent?(requested_filename, basename) do 13 | Plug.run(conn, [{Plug.Static, [at: "/files", from: dirname, only: [basename]]}]) 14 | else 15 | _ -> conn 16 | end 17 | end 18 | 19 | # Note that the shortest possible path is "/d/f". This may potentially be 20 | # incorrect if running on different platforms, or maybe in specific relative 21 | # path scenarios, however we will issue an injunction to the user to use only 22 | # absolute paths anyway, so this should be fine... 23 | defp custom_logo_paths_tuple() do 24 | case Application.get_env(:exbin, :custom_logo_path) do 25 | path when is_bitstring(path) and byte_size(path) > 4 -> {:ok, Path.dirname(path), Path.basename(path)} 26 | _ -> nil 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/exbin_web/plug/file_not_found.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.Plug.FileNotFound do 2 | import Plug.Conn 3 | 4 | def init(opts), do: opts 5 | 6 | def call(conn, _opts) do 7 | conn 8 | |> put_resp_content_type("text/plain") 9 | |> send_resp(404, "File Not Found") 10 | |> halt 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/exbin_web/plug/viewcounter.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.Plug.ViewCounter do 2 | require Logger 3 | 4 | def init(opts) do 5 | opts 6 | end 7 | 8 | def call(conn, opts) do 9 | # The snippet name and identifier for client. 10 | %{"name" => name} = conn.path_params 11 | client = identifier(conn) 12 | view_interval = opts[:view_interval] 13 | 14 | Logger.debug("#{client} is viewing snippet #{name} (#{view_interval})") 15 | 16 | case ExRated.check_rate({name, client}, view_interval, 1) do 17 | {:ok, _} -> 18 | update_viewcount(name) 19 | 20 | {:error, _} -> 21 | :ok 22 | end 23 | 24 | conn 25 | end 26 | 27 | defp update_viewcount(name) do 28 | case Exbin.Snippets.get_by_name(name) do 29 | {:error, :not_found} -> 30 | {:error, "snippet not found"} 31 | 32 | {:ok, snippet} -> 33 | Exbin.Snippets.update_viewcount(snippet) 34 | end 35 | end 36 | 37 | defp identifier(conn) do 38 | path = Enum.join(conn.path_info, "/") 39 | ip = conn.remote_ip |> Tuple.to_list() |> Enum.join(".") 40 | cookie = conn.req_cookies["_exbin_key"] 41 | id = "#{ip}:#{path}:#{cookie}" 42 | :crypto.hash(:md5, id) |> Base.encode16() 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/exbin_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.Router do 2 | use ExbinWeb, :router 3 | 4 | import ExbinWeb.UserAuth 5 | 6 | pipeline :browser do 7 | plug :accepts, ["html"] 8 | plug :fetch_session 9 | plug :fetch_live_flash 10 | plug :put_root_layout, {ExbinWeb.LayoutView, :root} 11 | plug :protect_from_forgery 12 | plug :put_secure_browser_headers 13 | plug :fetch_current_user 14 | end 15 | 16 | pipeline :api do 17 | plug :accepts, ["json"] 18 | end 19 | 20 | pipeline :custom_files do 21 | plug ExbinWeb.Plug.CustomLogo 22 | end 23 | 24 | if Mix.env() == :dev do 25 | scope "/dev" do 26 | pipe_through :browser 27 | 28 | forward "/mailbox", Plug.Swoosh.MailboxPreview 29 | end 30 | end 31 | 32 | scope "/files", ExbinWeb do 33 | pipe_through [:custom_files] 34 | match :*, "/*not_found", Plug.FileNotFound, [] 35 | end 36 | 37 | scope "/api", ExbinWeb do 38 | pipe_through :api 39 | 40 | post "/new", APIController, :new 41 | get "/show/:name", APIController, :show 42 | end 43 | 44 | scope "/snippet", ExbinWeb do 45 | pipe_through :browser 46 | 47 | get "/new", SnippetController, :new 48 | post "/new", SnippetController, :create 49 | post "/delete", SnippetController, :delete 50 | get "/list", SnippetController, :list 51 | get "/statistics", SnippetController, :statistics 52 | end 53 | 54 | scope "/", ExbinWeb do 55 | pipe_through :browser 56 | 57 | # Static Pages 58 | get "/about", PageController, :about 59 | 60 | live "/search", PageLive, :index 61 | # Snippets. 62 | get "/", SnippetController, :new 63 | get "/b/:name", SnippetController, :readerview 64 | get "/c/:name", SnippetController, :codeview 65 | get "/r/:name", SnippetController, :rawview 66 | get "/:name", SnippetController, :view 67 | end 68 | 69 | # Other scopes may use custom stacks. 70 | # scope "/api", ExbinWeb do 71 | # pipe_through :api 72 | # end 73 | 74 | # Enables LiveDashboard only for development 75 | # 76 | # If you want to use the LiveDashboard in production, you should put 77 | # it behind authentication and allow only admins to access it. 78 | # If your application does not have an admins-only section yet, 79 | # you can use Plug.BasicAuth to set up some basic authentication 80 | # as long as you are also using SSL (which you should anyway). 81 | if Mix.env() in [:dev, :test] do 82 | import Phoenix.LiveDashboard.Router 83 | 84 | scope "/" do 85 | pipe_through :browser 86 | live_dashboard "/dashboard", metrics: ExbinWeb.Telemetry 87 | end 88 | end 89 | 90 | ## Authentication routes 91 | 92 | scope "/", ExbinWeb do 93 | pipe_through [:browser, :redirect_if_user_is_authenticated] 94 | 95 | get "/users/register", UserRegistrationController, :new 96 | post "/users/register", UserRegistrationController, :create 97 | get "/users/log_in", UserSessionController, :new 98 | post "/users/log_in", UserSessionController, :create 99 | get "/users/reset_password", UserResetPasswordController, :new 100 | post "/users/reset_password", UserResetPasswordController, :create 101 | get "/users/reset_password/:token", UserResetPasswordController, :edit 102 | put "/users/reset_password/:token", UserResetPasswordController, :update 103 | end 104 | 105 | scope "/", ExbinWeb do 106 | pipe_through [:browser, :require_authenticated_user] 107 | 108 | get "/users/settings", UserSettingsController, :edit 109 | put "/users/settings", UserSettingsController, :update 110 | get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email 111 | get "/users/snippets", SnippetController, :personal_list 112 | end 113 | 114 | scope "/", ExbinWeb do 115 | pipe_through [:browser] 116 | 117 | delete "/users/log_out", UserSessionController, :delete 118 | get "/users/confirm", UserConfirmationController, :new 119 | post "/users/confirm", UserConfirmationController, :create 120 | get "/users/confirm/:token", UserConfirmationController, :confirm 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/exbin_web/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.Telemetry do 2 | use Supervisor 3 | import Telemetry.Metrics 4 | 5 | def start_link(arg) do 6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_arg) do 11 | children = [ 12 | # Telemetry poller will execute the given period measurements 13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics 14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} 15 | # Add reporters as children of your supervision tree. 16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()} 17 | ] 18 | 19 | Supervisor.init(children, strategy: :one_for_one) 20 | end 21 | 22 | def metrics do 23 | [ 24 | # Phoenix Metrics 25 | summary("phoenix.endpoint.stop.duration", 26 | unit: {:native, :millisecond} 27 | ), 28 | summary("phoenix.router_dispatch.stop.duration", 29 | tags: [:route], 30 | unit: {:native, :millisecond} 31 | ), 32 | 33 | # Database Metrics 34 | summary("exbin.repo.query.total_time", unit: {:native, :millisecond}), 35 | summary("exbin.repo.query.decode_time", unit: {:native, :millisecond}), 36 | summary("exbin.repo.query.query_time", unit: {:native, :millisecond}), 37 | summary("exbin.repo.query.queue_time", unit: {:native, :millisecond}), 38 | summary("exbin.repo.query.idle_time", unit: {:native, :millisecond}), 39 | 40 | # VM Metrics 41 | summary("vm.memory.total", unit: {:byte, :kilobyte}), 42 | summary("vm.total_run_queue_lengths.total"), 43 | summary("vm.total_run_queue_lengths.cpu"), 44 | summary("vm.total_run_queue_lengths.io") 45 | ] 46 | end 47 | 48 | defp periodic_measurements do 49 | [ 50 | # A module, function and arguments to be invoked periodically. 51 | # This function must call :telemetry.execute/3 and a metric must be added above. 52 | # {ExbinWeb, :count_users, []} 53 | ] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/layout/app.html.eex: -------------------------------------------------------------------------------- 1 | <%= @inner_content %> 2 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/layout/live.html.leex: -------------------------------------------------------------------------------- 1 | <%= @inner_content %> 2 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/page/about.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

3 | Exbin 4 |

5 |

6 | Exbin is a pastebin clone written in Elixir/Phoenix. You can find the source code here on github. 8 | The current version is <%= Exbin.version() %>. 9 |

10 | 11 |

12 | Privacy 13 |

14 |

If you create a paste it will be only visible for the people you show the link to. Each link is generated 15 | with a horesebatterystaple kind of identifier, so it’s not impossible to guess an identifier. Note that 16 | you can make a paste public, which will make it appear publicly. The pastes are hidden by default, unless 17 | you opt-in to publishing it publicly. 18 |

19 | 20 |

21 | Duration 22 |

23 |

24 | Pastes are stored indefinitely, unless they are marked as “ephemeral”. In that case they are removed approximately <%= Timex.Duration.from_minutes(Application.get_env(:exbin, :ephemeral_age)) |> Timex.Format.Duration.Formatters.Humanized.format %> after creation. 25 |

26 | 27 |

28 | Netcat 29 |

30 |

31 | Exbin also support netcatting a textfile. Suppose you are configuring some server and you quickly want to 32 | get data out to your other workstation. You can pipe the file to Exbin, and manually type over an 33 | easy-to-read URL. For example cat myfile.txt | nc <%= ExbinWeb.Endpoint.host() %> <%= Application.get_env(:exbin, :tcp_port) %> will return a URL to 34 | your console which you can type over. All these pastes are private, of course! 35 |

36 |
37 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/snippet/code.html.eex: -------------------------------------------------------------------------------- 1 |
<%= @snippet.content %>
2 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/snippet/list.html.eex: -------------------------------------------------------------------------------- 1 |
2 | <%= for snippet <- @snippets do %> 3 | 4 |
5 | 38 |
39 |
<%= snippet.content |> summary()  %>
40 |
41 |
42 |
43 | <% end %> 44 |
45 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/snippet/new.html.eex: -------------------------------------------------------------------------------- 1 | 2 | <%= form_for @conn, Routes.snippet_path(@conn, :create), [as: :snippet, class: "snippet-form"], fn f -> %> 3 |
4 |
5 | <%= checkbox f, :private, value: true, class: "checkbox"%> 6 | 7 |
8 |
9 | <%= checkbox f, :ephemeral, value: false, class: "checkbox"%> 10 | 11 |
12 | <%= submit "Submit", class: "setting" %> 13 |
14 |
15 | <%= textarea f, :content, class: "snippet-content" , placeholder: "...", autofocus: true%> 16 |
17 | <% end %> 18 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/snippet/reader.html.eex: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
<%= readerify(@snippet.content) %>
5 |
6 |
7 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/snippet/statistics.html.eex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |
9 |
10 | 11 |
12 | 13 |
14 | 15 |
16 |
17 | 18 |
19 |
20 |
21 |

Private Snippets

22 |
<%= @data.privpub.private %>
23 |
24 |
25 | 26 |
27 |
28 |

Public Snippets

29 |
<%= @data.privpub.public %>
30 |
31 |
32 |
33 | 34 |
35 |
36 |
37 |

Average Length

38 |
<%= @data.avg_length |> trunc() %>
39 |
40 |
41 | 42 |
43 |
44 |

Most Viewed

45 | <%= if @data.most_viewed != nil do %> 46 | 47 |
<%= @data.most_viewed.name %>
48 |
49 | <% else %> 50 |
/
51 | <% end %> 52 |
53 |
54 |
55 |
56 |
57 |
58 |

Users

59 |
<%= @data.user_count %>
60 |
61 |
62 |
63 |
64 |

Database Size

65 |
<%= @data.database_size |> human_readable_size %>
66 |
67 |
68 |
69 |
70 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/user_confirmation/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 20 |
21 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/user_registration/new.html.eex: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 32 |
33 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/user_reset_password/edit.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 31 |
32 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/user_reset_password/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 13 | 17 |
18 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/user_session/new.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 27 |
28 | -------------------------------------------------------------------------------- /lib/exbin_web/templates/user_settings/edit.html.eex: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 31 | 32 | 33 | 66 |
67 | -------------------------------------------------------------------------------- /lib/exbin_web/views/api_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.APIView do 2 | use ExbinWeb, :view 3 | alias ExbinWeb.APIView 4 | 5 | ############################################################################# 6 | # API Views 7 | 8 | def render("show.json", %{snippet: snippet}) do 9 | render_one(snippet, APIView, "snippet.json") 10 | end 11 | 12 | def render("snippet.json", %{api: snippet}) do 13 | url = ExbinWeb.Router.Helpers.snippet_url(ExbinWeb.Endpoint, :view, snippet.name) 14 | %{content: snippet.content, name: snippet.name, created: snippet.inserted_at, url: url} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/exbin_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.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), 14 | class: "invalid-feedback", 15 | phx_feedback_for: input_name(form, field) 16 | ) 17 | end) 18 | end 19 | 20 | @doc """ 21 | Translates an error message using gettext. 22 | """ 23 | def translate_error({msg, opts}) do 24 | # When using gettext, we typically pass the strings we want 25 | # to translate as a static argument: 26 | # 27 | # # Translate "is invalid" in the "errors" domain 28 | # dgettext("errors", "is invalid") 29 | # 30 | # # Translate the number of files with plural rules 31 | # dngettext("errors", "1 file", "%{count} files", count) 32 | # 33 | # Because the error messages we show in our forms and APIs 34 | # are defined inside Ecto, we need to translate them dynamically. 35 | # This requires us to call the Gettext module passing our gettext 36 | # backend as first argument. 37 | # 38 | # Note we use the "errors" domain, which means translations 39 | # should be written to the errors.po file. The :count option is 40 | # set by Ecto and indicates we should also apply plural rules. 41 | if count = opts[:count] do 42 | Gettext.dngettext(ExbinWeb.Gettext, "errors", msg, msg, count, opts) 43 | else 44 | Gettext.dgettext(ExbinWeb.Gettext, "errors", msg, opts) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/exbin_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.ErrorView do 2 | use ExbinWeb, :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 | -------------------------------------------------------------------------------- /lib/exbin_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.LayoutView do 2 | use ExbinWeb, :view 3 | 4 | def format_date(date) do 5 | Timex.format!(date, "{0D}/{0M}/{YY}") 6 | end 7 | 8 | def time_left(date) do 9 | maximum_age = Application.get_env(:exbin, :ephemeral_age) 10 | time_to_delete = Timex.shift(date, minutes: maximum_age) 11 | now = Timex.now() 12 | IO.inspect(time_to_delete, label: "Time to delete") 13 | IO.inspect(now, label: "Now") 14 | 15 | case Timex.diff(time_to_delete, now, :hours) do 16 | 0 -> 17 | diff_minutes = Timex.diff(time_to_delete, now, :minutes) 18 | # If the snippet should have been deleted already. 19 | if diff_minutes < 0 do 20 | "0 minutes" 21 | else 22 | "#{diff_minutes} min." 23 | end 24 | 25 | h -> 26 | "#{h + 1} hours" 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/exbin_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.PageView do 2 | use ExbinWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/exbin_web/views/snippet_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.SnippetView do 2 | use ExbinWeb, :view 3 | 4 | ############################################################################# 5 | # Size of database. 6 | 7 | def human_readable_size(v) do 8 | Sizeable.filesize(v) 9 | end 10 | 11 | ############################################################################# 12 | # Generate the raw strings to embed the statistics data in the html. 13 | 14 | def month_label(date) do 15 | Timex.format!(date, "%B ’%y", :strftime) 16 | end 17 | 18 | def month_labels(dataset) do 19 | dataset 20 | |> Enum.map(&elem(&1, 0)) 21 | |> Enum.map(&month_label/1) 22 | |> Enum.map(fn m -> "\"#{m}\"" end) 23 | |> Enum.join(",") 24 | |> (fn s -> "[" <> s <> "]" end).() 25 | end 26 | 27 | def count_labels(dataset, priv) do 28 | dataset 29 | |> Enum.map(fn {_, {pub, prv}} -> if priv == :public, do: pub, else: prv end) 30 | |> Enum.join(",") 31 | |> (fn s -> "[" <> s <> "]" end).() 32 | end 33 | 34 | ############################################################################# 35 | # Date formatting. 36 | 37 | def human_readable_date(snippet) do 38 | Timex.format!(snippet.inserted_at, "{D}/{0M}/{YYYY}") 39 | end 40 | 41 | def format_age(date) do 42 | days = Timex.diff(Timex.now(), date, :days) 43 | hours = Timex.diff(Timex.now(), date, :hours) 44 | minutes = Timex.diff(Timex.now(), date, :minutes) 45 | 46 | case {days, hours, minutes} do 47 | {0, 0, 0} -> 48 | "Just now" 49 | 50 | {0, 0, m} -> 51 | "#{m} minutes ago" 52 | 53 | {0, h, _} -> 54 | "#{h} hours ago" 55 | 56 | {d, _, _} -> 57 | cond do 58 | d < 7 -> 59 | "#{d} days ago" 60 | 61 | true -> 62 | Timex.format!(date, "{0D}/{0M}/{YY}") 63 | end 64 | end 65 | end 66 | 67 | ############################################################################# 68 | # Summarize the snippet content (i.e., first lines). 69 | 70 | def summary(content) do 71 | content 72 | |> String.trim() 73 | |> (fn s -> Regex.replace(~r/[\n\r]/, s, " ") end).() 74 | |> (fn s -> Regex.replace(~r/(\s)+/, s, " ") end).() 75 | |> String.split(" ") 76 | |> Enum.take(50) 77 | |> Enum.join(" ") 78 | end 79 | 80 | ############################################################################# 81 | # Sanitize snippet to make it reader friendly. 82 | 83 | def readerify(content) do 84 | content 85 | |> String.trim() 86 | |> String.split("\n") 87 | |> Enum.map(fn line -> 88 | line 89 | |> String.trim() 90 | |> String.capitalize() 91 | end) 92 | |> Enum.join("\n") 93 | |> (fn s -> Regex.replace(~r/(\n)(\n)+/, s, "\n\n") end).() 94 | |> (fn s -> Regex.replace(~r/( )( )+/, s, " ") end).() 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/exbin_web/views/user_confirmation_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserConfirmationView do 2 | use ExbinWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/exbin_web/views/user_registration_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserRegistrationView do 2 | use ExbinWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/exbin_web/views/user_reset_password_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserResetPasswordView do 2 | use ExbinWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/exbin_web/views/user_session_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserSessionView do 2 | use ExbinWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/exbin_web/views/user_settings_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserSettingsView do 2 | use ExbinWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/release.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Release do 2 | @app :exbin 3 | 4 | def migrate do 5 | load_app() 6 | 7 | for repo <- repos() do 8 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) 9 | end 10 | end 11 | 12 | def rollback(repo, version) do 13 | load_app() 14 | {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) 15 | end 16 | 17 | defp repos do 18 | Application.fetch_env!(@app, :ecto_repos) 19 | end 20 | 21 | defp load_app do 22 | Application.load(@app) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Exbin.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :exbin, 7 | version: "0.1.9", 8 | elixir: "~> 1.12", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | compilers: [:phoenix] ++ Mix.compilers(), 11 | start_permanent: Mix.env() == :prod, 12 | aliases: aliases(), 13 | deps: deps(), 14 | default_release: :prod, 15 | releases: [ 16 | prod: [ 17 | overlays: "rel/overlays", 18 | config_providers: [{Config.Reader, {:system, "RELEASE_ROOT", "/config.exs"}}] 19 | ] 20 | ] 21 | ] 22 | end 23 | 24 | # Configuration for the OTP application. 25 | # 26 | # Type `mix help compile.app` for more information. 27 | def application do 28 | [ 29 | mod: {Exbin.Application, []}, 30 | extra_applications: [:logger, :runtime_tools, :ex_rated, :swoosh, :gen_smtp] 31 | ] 32 | end 33 | 34 | # Specifies which paths to compile per environment. 35 | defp elixirc_paths(:test), do: ["lib", "test/support"] 36 | defp elixirc_paths(_), do: ["lib"] 37 | 38 | # Specifies your project dependencies. 39 | # 40 | # Type `mix help deps` for examples and options. 41 | defp deps do 42 | [ 43 | {:bcrypt_elixir, "~> 2.0"}, 44 | {:phoenix, "~> 1.5.9"}, 45 | {:phoenix_ecto, "~> 4.1"}, 46 | {:ecto_sql, "~> 3.4"}, 47 | {:postgrex, ">= 0.0.0"}, 48 | {:phoenix_html, "~> 2.11"}, 49 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 50 | {:phoenix_live_dashboard, "~> 0.4"}, 51 | {:telemetry_metrics, "~> 0.4"}, 52 | {:telemetry_poller, "~> 0.4"}, 53 | {:gettext, "~> 0.11"}, 54 | {:jason, "~> 1.0"}, 55 | {:plug_cowboy, "~> 2.0"}, 56 | {:horsestaplebattery, "~> 0.1.0"}, 57 | {:timex, "~> 3.7"}, 58 | {:parent, "~> 0.12.0"}, 59 | {:ex_rated, "~> 2.0"}, 60 | {:phoenix_live_view, "~> 0.15.7"}, 61 | {:phx_gen_auth, "~> 0.7", only: [:dev], runtime: false}, 62 | {:swoosh, "~> 1.5"}, 63 | {:gen_smtp, "~> 1.0"}, 64 | {:sizeable, "~> 1.0"}, 65 | {:cachex, "~> 3.4"}, 66 | {:hammer, "~> 6.0"}, 67 | {:hammer_plug, "~> 2.1"} 68 | ] 69 | end 70 | 71 | # Aliases are shortcuts or tasks specific to the current project. 72 | # For example, to install project dependencies and perform other setup tasks, run: 73 | # 74 | # $ mix setup 75 | # 76 | # See the documentation for `Mix` for more info on aliases. 77 | defp aliases do 78 | [ 79 | setup: ["deps.get", "ecto.setup", "cmd npm install --prefix assets"], 80 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 81 | "ecto.reset": ["ecto.drop", "ecto.setup"], 82 | test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"] 83 | ] 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /priv/10G.gzip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/m1dnight/exbin/fd5d0b7d03b51f921b7d496e09e5ef1d3b2fc43c/priv/10G.gzip -------------------------------------------------------------------------------- /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/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180901090114_create_snippet copy.exs: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Repo.Migrations.CreateSnippet do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:snippets) do 6 | add(:content, :text) 7 | add(:name, :string, primary_key: true) 8 | add(:viewcount, :integer, default: 0) 9 | add(:private, :boolean, default: true) 10 | 11 | timestamps(type: :utc_datetime_usec) 12 | end 13 | 14 | create(unique_index(:snippets, [:name])) 15 | create(index(:snippets, ["length(content)"])) 16 | # create(index(:snippets, ["DATE(inserted_at AT TIME ZONE 'UTC')"])) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180901090115_create_snippet.exs: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Repo.Migrations.UpdateSnippet do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:snippets) do 6 | add(:ephemeral, :boolean, default: false) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210906115754_create_users_auth_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Repo.Migrations.CreateUsersAuthTables do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute "CREATE EXTENSION IF NOT EXISTS citext", "" 6 | 7 | create table(:users) do 8 | add :email, :citext, null: false 9 | add :hashed_password, :string, null: false 10 | add :confirmed_at, :naive_datetime 11 | timestamps() 12 | end 13 | 14 | create unique_index(:users, [:email]) 15 | 16 | create table(:users_tokens) do 17 | add :user_id, references(:users, on_delete: :delete_all), null: false 18 | add :token, :binary, null: false 19 | add :context, :string, null: false 20 | add :sent_to, :string 21 | timestamps(updated_at: false) 22 | end 23 | 24 | create index(:users_tokens, [:user_id]) 25 | create unique_index(:users_tokens, [:context, :token]) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210911093308_add_user.exs: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Repo.Migrations.AddUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210911093842_snippet_belongs_to_user.exs: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Repo.Migrations.SnippetBelongsToUser do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:snippets) do 6 | add :user_id, references(:users) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20210911093843_admin.exs: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Repo.Migrations.AddAdmin do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table(:users) do 6 | add :admin, :boolean, default: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220506074330_change_content_type.exs: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Repo.Migrations.ChangeContentType do 2 | use Ecto.Migration 3 | 4 | def up do 5 | execute """ 6 | ALTER TABLE snippets 7 | ALTER COLUMN content TYPE bytea 8 | USING content::bytea; 9 | """ 10 | end 11 | 12 | def down do 13 | execute """ 14 | create or replace function convert_binary_to_text(bin in bytea) returns text 15 | as $$ 16 | declare 17 | begin 18 | return convert_from(bin, 'utf-8'); 19 | end; 20 | $$ language plpgsql; 21 | """ 22 | 23 | execute """ 24 | ALTER TABLE snippets 25 | ALTER COLUMN content TYPE text 26 | USING (convert_binary_to_text(content));; 27 | """ 28 | end 29 | end 30 | 31 | # Raw SQL: 32 | # select * from snippets; 33 | 34 | # ALTER TABLE snippets 35 | # ALTER COLUMN content TYPE bytea 36 | # USING content::bytea; 37 | 38 | # create or replace function convert_binary_to_text(bin in bytea) returns text 39 | # as $$ 40 | # declare 41 | # begin 42 | # return convert_from(bin, 'utf-8'); 43 | # end; 44 | # $$ language plpgsql; 45 | 46 | # ALTER TABLE snippets 47 | # ALTER COLUMN content TYPE text 48 | # USING (convert_binary_to_text(content));; 49 | 50 | # select * from snippets; 51 | -------------------------------------------------------------------------------- /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 | # Exbin.Repo.insert!(%Exbin.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | 13 | # test@test.com 14 | -------------------------------------------------------------------------------- /rel/overlays/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | ################################################################################ 4 | # Configuration parameters 5 | # 6 | # * `default_view`: :code, :reader, or :raw. 7 | # * `ephemeral_age`: The maximum age of a snippet in seconds before it is deleted. 8 | # * `brand`: The brand of the ExBin instance. Defaults to "ExBin" 9 | # * `custom_logo_path`: Path to the file image of your custom logo 10 | # * `custom_logo_size`: Height of the custom logo. 11 | # * `base_url`: The url at which ExBin will be served. 12 | # * `timezone`: The timezone of the ExBin instance. 13 | # * `api_key`: The api key you want to use. Generate a secure one. 14 | # 15 | # Netcat 16 | # 17 | # * `tcp_port`: Port to listen for connections 18 | # * `tcp_host`: Host to bind to to listen for connections 19 | # * `max_size`: Maximum size in bytes that can be sent using netcat 20 | # * `http_bomb`: Enable the http bomb on the netcat endpoint. 21 | 22 | config :exbin, 23 | base_url: "https://exbin.call-cc.be", 24 | timezone: "Europe/Brussels", 25 | default_view: :code, 26 | ephemeral_age: 60, 27 | brand: "ExBin", 28 | custom_logo_path: "/my/logo.png", 29 | custom_logo_size: 30, 30 | api_key: "AyPwtQANkGPNWStxZT+k4qkifBmraC5EdBrJ2h/AMYwYxJ7wJBu0QsFkueRpSmIO", 31 | tcp_port: 9999, 32 | tcp_host: {0, 0, 0, 0}, 33 | max_size: 2048, 34 | http_bomb: true 35 | 36 | ################################################################################ 37 | # Database parameters 38 | # 39 | # * `username`: Username of the database 40 | # * `password`: Password of the database 41 | # * `database`: Name of the database 42 | # * `hostname`: Hostname of the database 43 | 44 | config :exbin, Exbin.Repo, 45 | username: "postgres", 46 | password: "postgres", 47 | database: "exbin_dev", 48 | hostname: "db" 49 | 50 | ################################################################################ 51 | # Secrets (used for encryption and stuff) 52 | # Fill in two values that are randomly generated with `openssl rand 64 | openssl enc -A -base64` 53 | config :exbin, ExbinWeb.Endpoint, 54 | secret_key_base: "Q21q8HqA9Rd24KY9ZwMfeuqlCleNQqUJFWA7RcUHF1B3C7Faeucv2mFbB+Vo6bawBCJMJceoSuNQKnYpREqQuA==", 55 | live_view: "rkEAU575y2/9LVi5hwkSICTWMcLYF5QKZzzFKsi1QtGoO71ooE2vht2uU3k+tkgDsQxWNLu8eXinFSCQUB3zoA==" 56 | 57 | ################################################################################ 58 | # HTTP server configuration 59 | # 60 | # * `host`: The host at which this instance will run 61 | # * `port`: The port at which the instance will listen 62 | # * `scheme`: Either http or https 63 | 64 | config :exbin, ExbinWeb.Endpoint, 65 | url: [host: "exbin.call-cc.be", port: 4000, scheme: "http"], 66 | http: [ 67 | port: 4000, 68 | transport_options: [socket_opts: [:inet6]] 69 | ] 70 | 71 | ################################################################################ 72 | # Configuration for logging 73 | # 74 | # * `level`: Level for debugging. `:debug` for debugging, `:warning` for production. 75 | 76 | config :logger, 77 | level: :debug 78 | 79 | ################################################################################ 80 | # Configuration for mailing 81 | # 82 | # Most of the parameters are self-explanatory. 83 | # 84 | # If you have a TLS connection to the SMTP server, set `tls` to `true`, and `ssl` to `false`. 85 | 86 | mailing = :local 87 | mailing = :internet 88 | 89 | if mailing == :internet do 90 | config :exbin, Exbin.Mailer, 91 | adapter: Swoosh.Adapters.SMTP, 92 | relay: "smtp.com", 93 | username: "exbin", 94 | password: "exbin", 95 | from: "exbin@exbin.exbin", 96 | ssl: false, 97 | tls: true, 98 | auth: :always, 99 | port: 587, 100 | retries: 2 101 | else 102 | config :exbin, Exbin.Mailer, adapter: Swoosh.Adapters.Local 103 | config :exbin, Exbin.Mailer, from: "exbin@example.com" 104 | config :swoosh, :api_client, false 105 | end 106 | -------------------------------------------------------------------------------- /rel/overlays/initial_user.exs: -------------------------------------------------------------------------------- 1 | defmodule Exbin.InitialUser do 2 | @app :exbin 3 | 4 | defp load_app do 5 | Application.load(@app) 6 | Application.ensure_all_started(@app, :permanent) 7 | end 8 | 9 | defp current_users() do 10 | Exbin.Stats.count_users() 11 | end 12 | 13 | # https://dev.to/diogoko/random-strings-in-elixir-e8i 14 | defp insert_first_user() do 15 | pass = for _ <- 1..20, into: "", do: <> 16 | 17 | user_data = %{ 18 | email: "admin@exbin.call-cc.be", 19 | password: pass, 20 | admin: true 21 | } 22 | 23 | IO.puts("Created a user with email #{user_data.email} and password #{user_data.password}") 24 | {:ok, user} = Exbin.Accounts.register_user(user_data) 25 | user 26 | end 27 | 28 | def initial_user() do 29 | load_app() 30 | 31 | case current_users() do 32 | 0 -> 33 | insert_first_user() 34 | 35 | _ -> 36 | IO.puts("Did not create a user because there are already registered users in the database.") 37 | :ok 38 | end 39 | end 40 | end 41 | 42 | Exbin.InitialUser.initial_user() 43 | -------------------------------------------------------------------------------- /test/exbin/clock_test.exs: -------------------------------------------------------------------------------- 1 | # It's worth noting that ths test tests the features of Clock that work 2 | # in the test environment ONLY, as the functionality differs based on 3 | # compile_env. It seems weird to test code that's only use in testing, 4 | # but hey, why not? 5 | # One note is that we have to fudge a little bit on testing equality of 6 | # "now()" functions, so we use the Timex.equal function truncated to 7 | # 1-second granularity, basically assuming that the two datetimes 8 | # are equivalent if they're within a single second of each other. 9 | # It's not perfect, but it's close enough. 10 | # Unfortunately because of this we have to sleep for 1.5 seconds in 11 | # testing to ensure that the datetimes aren't equal if checked less 12 | # than a second apart. Fortunately due to :async, this shouldn't 13 | # slow down the overall tests too much. 14 | defmodule Exbin.ClockTest do 15 | use ExUnit.Case, async: true 16 | require Exbin.Clock 17 | alias Exbin.Clock 18 | 19 | describe "utc_now/0" do 20 | test "returns correct datetime" do 21 | assert Timex.equal?(Clock.utc_now(), DateTime.utc_now()) 22 | end 23 | end 24 | 25 | defmodule FreezeTest do 26 | use ExUnit.Case, async: true 27 | require Exbin.Clock 28 | alias Exbin.Clock 29 | 30 | describe "freezing the clock" do 31 | test "does not change the internal time while Clock is frozen" do 32 | Clock.freeze() 33 | start_time = Clock.utc_now() 34 | Process.sleep(1500) 35 | end_time = Clock.utc_now() 36 | Clock.unfreeze() 37 | 38 | assert start_time == end_time 39 | end 40 | 41 | test "freezes to requested time if one is given" do 42 | target = ~U[2001-12-25 12:17:39.0000Z] 43 | Clock.freeze(target) 44 | assert Clock.utc_now() == target 45 | Clock.unfreeze() 46 | refute Clock.utc_now() == target 47 | end 48 | end 49 | end 50 | 51 | defmodule ThawTest do 52 | use ExUnit.Case, async: true 53 | require Exbin.Clock 54 | alias Exbin.Clock 55 | 56 | describe "unfreeze/0" do 57 | test "restores to correct time after unfreezing" do 58 | Clock.freeze() 59 | Process.sleep(1500) 60 | refute Timex.equal?(Clock.utc_now(), DateTime.utc_now()) 61 | Clock.unfreeze() 62 | 63 | assert Timex.equal?(Clock.utc_now(), DateTime.utc_now()) 64 | end 65 | end 66 | end 67 | 68 | defmodule TimeTravelTest do 69 | use ExUnit.Case, async: true 70 | require Exbin.Clock 71 | alias Exbin.Clock 72 | 73 | describe "time_travel" do 74 | test "executes a block with time frozen at the given time, and restores to real time after block" do 75 | target = ~U[2001-12-25 12:17:39.0000Z] 76 | 77 | Clock.time_travel target do 78 | assert Clock.utc_now() == target 79 | Process.sleep(1500) 80 | assert Clock.utc_now() == target 81 | end 82 | 83 | assert Timex.equal?(Clock.utc_now(), DateTime.utc_now()) 84 | end 85 | 86 | test "re-freezes time to where it was if it was frozen before the block" do 87 | long_ago = ~U[2001-12-25 12:17:39.0000Z] 88 | 89 | Clock.freeze() 90 | frozen_time = Clock.utc_now() 91 | 92 | Clock.time_travel long_ago do 93 | assert Clock.utc_now() == long_ago 94 | end 95 | 96 | assert Timex.equal?(Clock.utc_now(), frozen_time) 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/exbin/snippet_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Exbin.SnippetTest do 2 | use Exbin.DataCase, async: true 3 | alias Exbin.{Repo, Snippet} 4 | import Exbin.Factory 5 | 6 | test "errors if name is empty" do 7 | changeset = Snippet.changeset(build(:snippet, %{name: ""})) 8 | assert %{name: ["can't be blank"]} = errors_on(changeset) 9 | end 10 | 11 | test "errors if name is not unique" do 12 | insert!(:snippet, %{name: "DupeSnippet"}) 13 | assert %Snippet{name: "DupeSnippet"} = Repo.one(Snippet) 14 | dupe_changeset = Snippet.changeset(%Snippet{}, %{name: "DupeSnippet", content: "not empty"}) 15 | {:error, error_cs} = Repo.insert(dupe_changeset) 16 | assert %{name: ["has already been taken"]} = errors_on(error_cs) 17 | end 18 | 19 | test "defaults content to an empty string" do 20 | insert!(:snippet, %{name: "BasicSnippet"}) 21 | assert %Snippet{name: "BasicSnippet", content: ""} = Repo.one(Snippet) 22 | end 23 | 24 | test "defaults viewcount to 0" do 25 | insert!(:snippet, %{name: "NeverViewed"}) 26 | assert %Snippet{viewcount: 0, name: "NeverViewed"} = Repo.one(Snippet) 27 | end 28 | 29 | test "defaults private to true" do 30 | insert!(:snippet, %{name: "ItsPrivate"}) 31 | assert %Snippet{private: true, name: "ItsPrivate"} = Repo.one(Snippet) 32 | end 33 | 34 | test "defaults ephemeral to false" do 35 | insert!(:snippet, %{name: "Ghostly"}) 36 | assert %Snippet{ephemeral: false, name: "Ghostly"} = Repo.one(Snippet) 37 | end 38 | 39 | test "0x0 char in binary data" do 40 | content = File.read!("test/exbin/0x00_bin_file") 41 | insert!(:snippet, %{name: "Test", content: content}) 42 | assert %Snippet{content: _content, name: "Test"} = Repo.one(Snippet) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/exbin_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.PageControllerTest do 2 | use ExbinWeb.ConnCase, async: true 3 | 4 | test "GET /", %{conn: conn} do 5 | conn = get(conn, "/") 6 | assert html_response(conn, 200) =~ "Exbin Development" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/exbin_web/controllers/user_auth_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserAuthTest do 2 | use ExbinWeb.ConnCase, async: true 3 | 4 | alias Exbin.Accounts 5 | alias ExbinWeb.UserAuth 6 | import Exbin.AccountsFixtures 7 | 8 | @remember_me_cookie "_exbin_web_user_remember_me" 9 | 10 | setup %{conn: conn} do 11 | conn = 12 | conn 13 | |> Map.replace!(:secret_key_base, ExbinWeb.Endpoint.config(:secret_key_base)) 14 | |> init_test_session(%{}) 15 | 16 | %{user: user_fixture(), conn: conn} 17 | end 18 | 19 | describe "log_in_user/3" do 20 | test "stores the user token in the session", %{conn: conn, user: user} do 21 | conn = UserAuth.log_in_user(conn, user) 22 | assert token = get_session(conn, :user_token) 23 | assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}" 24 | assert redirected_to(conn) == "/" 25 | assert Accounts.get_user_by_session_token(token) 26 | end 27 | 28 | test "clears everything previously stored in the session", %{conn: conn, user: user} do 29 | conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user) 30 | refute get_session(conn, :to_be_removed) 31 | end 32 | 33 | test "redirects to the configured path", %{conn: conn, user: user} do 34 | conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user) 35 | assert redirected_to(conn) == "/hello" 36 | end 37 | 38 | test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do 39 | conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) 40 | assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie] 41 | 42 | assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie] 43 | assert signed_token != get_session(conn, :user_token) 44 | assert max_age == 5_184_000 45 | end 46 | end 47 | 48 | describe "logout_user/1" do 49 | test "erases session and cookies", %{conn: conn, user: user} do 50 | user_token = Accounts.generate_user_session_token(user) 51 | 52 | conn = 53 | conn 54 | |> put_session(:user_token, user_token) 55 | |> put_req_cookie(@remember_me_cookie, user_token) 56 | |> fetch_cookies() 57 | |> UserAuth.log_out_user() 58 | 59 | refute get_session(conn, :user_token) 60 | refute conn.cookies[@remember_me_cookie] 61 | assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] 62 | assert redirected_to(conn) == "/" 63 | refute Accounts.get_user_by_session_token(user_token) 64 | end 65 | 66 | test "broadcasts to the given live_socket_id", %{conn: conn} do 67 | live_socket_id = "users_sessions:abcdef-token" 68 | ExbinWeb.Endpoint.subscribe(live_socket_id) 69 | 70 | conn 71 | |> put_session(:live_socket_id, live_socket_id) 72 | |> UserAuth.log_out_user() 73 | 74 | assert_receive %Phoenix.Socket.Broadcast{ 75 | event: "disconnect", 76 | topic: "users_sessions:abcdef-token" 77 | } 78 | end 79 | 80 | test "works even if user is already logged out", %{conn: conn} do 81 | conn = conn |> fetch_cookies() |> UserAuth.log_out_user() 82 | refute get_session(conn, :user_token) 83 | assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie] 84 | assert redirected_to(conn) == "/" 85 | end 86 | end 87 | 88 | describe "fetch_current_user/2" do 89 | test "authenticates user from session", %{conn: conn, user: user} do 90 | user_token = Accounts.generate_user_session_token(user) 91 | conn = conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_user([]) 92 | assert conn.assigns.current_user.id == user.id 93 | end 94 | 95 | test "authenticates user from cookies", %{conn: conn, user: user} do 96 | logged_in_conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"}) 97 | 98 | user_token = logged_in_conn.cookies[@remember_me_cookie] 99 | %{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie] 100 | 101 | conn = 102 | conn 103 | |> put_req_cookie(@remember_me_cookie, signed_token) 104 | |> UserAuth.fetch_current_user([]) 105 | 106 | assert get_session(conn, :user_token) == user_token 107 | assert conn.assigns.current_user.id == user.id 108 | end 109 | 110 | test "does not authenticate if data is missing", %{conn: conn, user: user} do 111 | _ = Accounts.generate_user_session_token(user) 112 | conn = UserAuth.fetch_current_user(conn, []) 113 | refute get_session(conn, :user_token) 114 | refute conn.assigns.current_user 115 | end 116 | end 117 | 118 | describe "redirect_if_user_is_authenticated/2" do 119 | test "redirects if user is authenticated", %{conn: conn, user: user} do 120 | conn = conn |> assign(:current_user, user) |> UserAuth.redirect_if_user_is_authenticated([]) 121 | assert conn.halted 122 | assert redirected_to(conn) == "/" 123 | end 124 | 125 | test "does not redirect if user is not authenticated", %{conn: conn} do 126 | conn = UserAuth.redirect_if_user_is_authenticated(conn, []) 127 | refute conn.halted 128 | refute conn.status 129 | end 130 | end 131 | 132 | describe "require_authenticated_user/2" do 133 | test "redirects if user is not authenticated", %{conn: conn} do 134 | conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([]) 135 | assert conn.halted 136 | assert redirected_to(conn) == Routes.user_session_path(conn, :new) 137 | assert get_flash(conn, :error) == "You must log in to access this page." 138 | end 139 | 140 | test "stores the path to redirect to on GET", %{conn: conn} do 141 | halted_conn = 142 | %{conn | request_path: "/foo", query_string: ""} 143 | |> fetch_flash() 144 | |> UserAuth.require_authenticated_user([]) 145 | 146 | assert halted_conn.halted 147 | assert get_session(halted_conn, :user_return_to) == "/foo" 148 | 149 | halted_conn = 150 | %{conn | request_path: "/foo", query_string: "bar=baz"} 151 | |> fetch_flash() 152 | |> UserAuth.require_authenticated_user([]) 153 | 154 | assert halted_conn.halted 155 | assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz" 156 | 157 | halted_conn = 158 | %{conn | request_path: "/foo?bar", method: "POST"} 159 | |> fetch_flash() 160 | |> UserAuth.require_authenticated_user([]) 161 | 162 | assert halted_conn.halted 163 | refute get_session(halted_conn, :user_return_to) 164 | end 165 | 166 | test "does not redirect if user is authenticated", %{conn: conn, user: user} do 167 | conn = conn |> assign(:current_user, user) |> UserAuth.require_authenticated_user([]) 168 | refute conn.halted 169 | refute conn.status 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /test/exbin_web/controllers/user_confirmation_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserConfirmationControllerTest do 2 | use ExbinWeb.ConnCase, async: true 3 | 4 | alias Exbin.Accounts 5 | alias Exbin.Repo 6 | import Exbin.AccountsFixtures 7 | 8 | setup do 9 | %{user: user_fixture()} 10 | end 11 | 12 | describe "GET /users/confirm" do 13 | test "renders the confirmation page", %{conn: conn} do 14 | conn = get(conn, Routes.user_confirmation_path(conn, :new)) 15 | response = html_response(conn, 200) 16 | assert response =~ "Resent confirmation instructions" 17 | end 18 | end 19 | 20 | describe "POST /users/confirm" do 21 | @tag :capture_log 22 | test "sends a new confirmation token", %{conn: conn, user: user} do 23 | conn = 24 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 25 | "user" => %{"email" => user.email} 26 | }) 27 | 28 | assert redirected_to(conn) == "/" 29 | assert get_flash(conn, :info) =~ "If your email is in our system" 30 | assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "confirm" 31 | end 32 | 33 | test "does not send confirmation token if User is confirmed", %{conn: conn, user: user} do 34 | Repo.update!(Accounts.User.confirm_changeset(user)) 35 | 36 | conn = 37 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 38 | "user" => %{"email" => user.email} 39 | }) 40 | 41 | assert redirected_to(conn) == "/" 42 | assert get_flash(conn, :info) =~ "If your email is in our system" 43 | refute Repo.get_by(Accounts.UserToken, user_id: user.id) 44 | end 45 | 46 | test "does not send confirmation token if email is invalid", %{conn: conn} do 47 | conn = 48 | post(conn, Routes.user_confirmation_path(conn, :create), %{ 49 | "user" => %{"email" => "unknown@example.com"} 50 | }) 51 | 52 | assert redirected_to(conn) == "/" 53 | assert get_flash(conn, :info) =~ "If your email is in our system" 54 | assert Repo.all(Accounts.UserToken) == [] 55 | end 56 | end 57 | 58 | describe "GET /users/confirm/:token" do 59 | test "confirms the given token once", %{conn: conn, user: user} do 60 | token = 61 | extract_user_token(fn url -> 62 | Accounts.deliver_user_confirmation_instructions(user, url) 63 | end) 64 | 65 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token)) 66 | assert redirected_to(conn) == "/" 67 | assert get_flash(conn, :info) =~ "User confirmed successfully" 68 | assert Accounts.get_user!(user.id).confirmed_at 69 | refute get_session(conn, :user_token) 70 | assert Repo.all(Accounts.UserToken) == [] 71 | 72 | # When not logged in 73 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, token)) 74 | assert redirected_to(conn) == "/" 75 | assert get_flash(conn, :error) =~ "User confirmation link is invalid or it has expired" 76 | 77 | # When logged in 78 | conn = 79 | build_conn() 80 | |> log_in_user(user) 81 | |> get(Routes.user_confirmation_path(conn, :confirm, token)) 82 | 83 | assert redirected_to(conn) == "/" 84 | refute get_flash(conn, :error) 85 | end 86 | 87 | test "does not confirm email with invalid token", %{conn: conn, user: user} do 88 | conn = get(conn, Routes.user_confirmation_path(conn, :confirm, "oops")) 89 | assert redirected_to(conn) == "/" 90 | assert get_flash(conn, :error) =~ "User confirmation link is invalid or it has expired" 91 | refute Accounts.get_user!(user.id).confirmed_at 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/exbin_web/controllers/user_registration_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserRegistrationControllerTest do 2 | use ExbinWeb.ConnCase, async: true 3 | 4 | import Exbin.AccountsFixtures 5 | 6 | describe "GET /users/register" do 7 | test "renders registration page", %{conn: conn} do 8 | conn = get(conn, Routes.user_registration_path(conn, :new)) 9 | response = html_response(conn, 200) 10 | assert response =~ "Register" 11 | assert response =~ "Log in" 12 | assert response =~ "Register" 13 | end 14 | 15 | test "redirects if already logged in", %{conn: conn} do 16 | conn = conn |> log_in_user(user_fixture()) |> get(Routes.user_registration_path(conn, :new)) 17 | assert redirected_to(conn) == "/" 18 | end 19 | end 20 | 21 | describe "POST /users/register" do 22 | @tag :capture_log 23 | test "creates account and logs the user in", %{conn: conn} do 24 | email = unique_user_email() 25 | 26 | conn = 27 | post(conn, Routes.user_registration_path(conn, :create), %{ 28 | "user" => valid_user_attributes(email: email) 29 | }) 30 | 31 | assert get_session(conn, :user_token) 32 | assert redirected_to(conn) =~ "/" 33 | 34 | # Now do a logged in request and assert on the menu 35 | conn = get(conn, "/") 36 | response = html_response(conn, 200) 37 | assert response =~ email 38 | assert response =~ "Settings" 39 | assert response =~ "Log out" 40 | end 41 | 42 | test "render errors for invalid data", %{conn: conn} do 43 | conn = 44 | post(conn, Routes.user_registration_path(conn, :create), %{ 45 | "user" => %{"email" => "with spaces", "password" => "too short"} 46 | }) 47 | 48 | response = html_response(conn, 200) 49 | assert response =~ "must have the @ sign and no spaces" 50 | assert response =~ "should be at least 12 character" 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/exbin_web/controllers/user_reset_password_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserResetPasswordControllerTest do 2 | use ExbinWeb.ConnCase, async: true 3 | 4 | alias Exbin.Accounts 5 | alias Exbin.Repo 6 | import Exbin.AccountsFixtures 7 | 8 | setup do 9 | %{user: user_fixture()} 10 | end 11 | 12 | describe "GET /users/reset_password" do 13 | test "renders the reset password page", %{conn: conn} do 14 | conn = get(conn, Routes.user_reset_password_path(conn, :new)) 15 | response = html_response(conn, 200) 16 | assert response =~ "Forgot your password?" 17 | end 18 | end 19 | 20 | describe "POST /users/reset_password" do 21 | @tag :capture_log 22 | test "sends a new reset password token", %{conn: conn, user: user} do 23 | conn = 24 | post(conn, Routes.user_reset_password_path(conn, :create), %{ 25 | "user" => %{"email" => user.email} 26 | }) 27 | 28 | assert redirected_to(conn) == "/" 29 | assert get_flash(conn, :info) =~ "If your email is in our system" 30 | assert Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "reset_password" 31 | end 32 | 33 | test "does not send reset password token if email is invalid", %{conn: conn} do 34 | conn = 35 | post(conn, Routes.user_reset_password_path(conn, :create), %{ 36 | "user" => %{"email" => "unknown@example.com"} 37 | }) 38 | 39 | assert redirected_to(conn) == "/" 40 | assert get_flash(conn, :info) =~ "If your email is in our system" 41 | assert Repo.all(Accounts.UserToken) == [] 42 | end 43 | end 44 | 45 | describe "GET /users/reset_password/:token" do 46 | setup %{user: user} do 47 | token = 48 | extract_user_token(fn url -> 49 | Accounts.deliver_user_reset_password_instructions(user, url) 50 | end) 51 | 52 | %{token: token} 53 | end 54 | 55 | test "renders reset password", %{conn: conn, token: token} do 56 | conn = get(conn, Routes.user_reset_password_path(conn, :edit, token)) 57 | assert html_response(conn, 200) =~ "Reset Password" 58 | end 59 | 60 | test "does not render reset password with invalid token", %{conn: conn} do 61 | conn = get(conn, Routes.user_reset_password_path(conn, :edit, "oops")) 62 | assert redirected_to(conn) == "/" 63 | assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" 64 | end 65 | end 66 | 67 | describe "PUT /users/reset_password/:token" do 68 | setup %{user: user} do 69 | token = 70 | extract_user_token(fn url -> 71 | Accounts.deliver_user_reset_password_instructions(user, url) 72 | end) 73 | 74 | %{token: token} 75 | end 76 | 77 | test "resets password once", %{conn: conn, user: user, token: token} do 78 | conn = 79 | put(conn, Routes.user_reset_password_path(conn, :update, token), %{ 80 | "user" => %{ 81 | "password" => "new valid password", 82 | "password_confirmation" => "new valid password" 83 | } 84 | }) 85 | 86 | assert redirected_to(conn) == Routes.user_session_path(conn, :new) 87 | refute get_session(conn, :user_token) 88 | assert get_flash(conn, :info) =~ "Password reset successfully" 89 | assert Accounts.get_user_by_email_and_password(user.email, "new valid password") 90 | end 91 | 92 | test "does not reset password on invalid data", %{conn: conn, token: token} do 93 | conn = 94 | put(conn, Routes.user_reset_password_path(conn, :update, token), %{ 95 | "user" => %{ 96 | "password" => "too short", 97 | "password_confirmation" => "does not match" 98 | } 99 | }) 100 | 101 | response = html_response(conn, 200) 102 | assert response =~ "Reset Password" 103 | assert response =~ "should be at least 12 character(s)" 104 | assert response =~ "does not match password" 105 | end 106 | 107 | test "does not reset password with invalid token", %{conn: conn} do 108 | conn = put(conn, Routes.user_reset_password_path(conn, :update, "oops")) 109 | assert redirected_to(conn) == "/" 110 | assert get_flash(conn, :error) =~ "Reset password link is invalid or it has expired" 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/exbin_web/controllers/user_session_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserSessionControllerTest do 2 | use ExbinWeb.ConnCase, async: true 3 | 4 | import Exbin.AccountsFixtures 5 | 6 | setup do 7 | %{user: user_fixture()} 8 | end 9 | 10 | describe "GET /users/log_in" do 11 | test "renders log in page", %{conn: conn} do 12 | conn = get(conn, Routes.user_session_path(conn, :new)) 13 | response = html_response(conn, 200) 14 | assert response =~ "Log in" 15 | assert response =~ "Log in" 16 | assert response =~ "Register" 17 | end 18 | 19 | test "redirects if already logged in", %{conn: conn, user: user} do 20 | conn = conn |> log_in_user(user) |> get(Routes.user_session_path(conn, :new)) 21 | assert redirected_to(conn) == "/" 22 | end 23 | end 24 | 25 | describe "POST /users/log_in" do 26 | test "logs the user in", %{conn: conn, user: user} do 27 | conn = 28 | post(conn, Routes.user_session_path(conn, :create), %{ 29 | "user" => %{"email" => user.email, "password" => valid_user_password()} 30 | }) 31 | 32 | assert get_session(conn, :user_token) 33 | assert redirected_to(conn) =~ "/" 34 | 35 | # Now do a logged in request and assert on the menu 36 | conn = get(conn, "/") 37 | response = html_response(conn, 200) 38 | assert response =~ user.email 39 | assert response =~ "Settings" 40 | assert response =~ "Log out" 41 | end 42 | 43 | test "logs the user in with remember me", %{conn: conn, user: user} do 44 | conn = 45 | post(conn, Routes.user_session_path(conn, :create), %{ 46 | "user" => %{ 47 | "email" => user.email, 48 | "password" => valid_user_password(), 49 | "remember_me" => "true" 50 | } 51 | }) 52 | 53 | assert conn.resp_cookies["_exbin_web_user_remember_me"] 54 | assert redirected_to(conn) =~ "/" 55 | end 56 | 57 | test "logs the user in with return to", %{conn: conn, user: user} do 58 | conn = 59 | conn 60 | |> init_test_session(user_return_to: "/foo/bar") 61 | |> post(Routes.user_session_path(conn, :create), %{ 62 | "user" => %{ 63 | "email" => user.email, 64 | "password" => valid_user_password() 65 | } 66 | }) 67 | 68 | assert redirected_to(conn) == "/foo/bar" 69 | end 70 | 71 | test "emits error message with invalid credentials", %{conn: conn, user: user} do 72 | conn = 73 | post(conn, Routes.user_session_path(conn, :create), %{ 74 | "user" => %{"email" => user.email, "password" => "invalid_password"} 75 | }) 76 | 77 | response = html_response(conn, 200) 78 | assert response =~ "Login to ExBin" 79 | assert response =~ "Invalid email or password" 80 | end 81 | end 82 | 83 | describe "DELETE /users/log_out" do 84 | test "logs the user out", %{conn: conn, user: user} do 85 | conn = conn |> log_in_user(user) |> delete(Routes.user_session_path(conn, :delete)) 86 | assert redirected_to(conn) == "/" 87 | refute get_session(conn, :user_token) 88 | assert get_flash(conn, :info) =~ "Logged out successfully" 89 | end 90 | 91 | test "succeeds even if the user is not logged in", %{conn: conn} do 92 | conn = delete(conn, Routes.user_session_path(conn, :delete)) 93 | assert redirected_to(conn) == "/" 94 | refute get_session(conn, :user_token) 95 | assert get_flash(conn, :info) =~ "Logged out successfully" 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /test/exbin_web/controllers/user_settings_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.UserSettingsControllerTest do 2 | use ExbinWeb.ConnCase, async: true 3 | 4 | alias Exbin.Accounts 5 | import Exbin.AccountsFixtures 6 | 7 | setup :register_and_log_in_user 8 | 9 | describe "GET /users/settings" do 10 | test "renders settings page", %{conn: conn} do 11 | conn = get(conn, Routes.user_settings_path(conn, :edit)) 12 | response = html_response(conn, 200) 13 | assert response =~ "Change E-mail" 14 | end 15 | 16 | test "redirects if user is not logged in" do 17 | conn = build_conn() 18 | conn = get(conn, Routes.user_settings_path(conn, :edit)) 19 | assert redirected_to(conn) == Routes.user_session_path(conn, :new) 20 | end 21 | end 22 | 23 | describe "PUT /users/settings (change password form)" do 24 | test "updates the user password and resets tokens", %{conn: conn, user: user} do 25 | new_password_conn = 26 | put(conn, Routes.user_settings_path(conn, :update), %{ 27 | "action" => "update_password", 28 | "current_password" => valid_user_password(), 29 | "user" => %{ 30 | "password" => "new valid password", 31 | "password_confirmation" => "new valid password" 32 | } 33 | }) 34 | 35 | assert redirected_to(new_password_conn) == Routes.user_settings_path(conn, :edit) 36 | assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token) 37 | assert get_flash(new_password_conn, :info) =~ "Password updated successfully" 38 | assert Accounts.get_user_by_email_and_password(user.email, "new valid password") 39 | end 40 | 41 | test "does not update password on invalid data", %{conn: conn} do 42 | old_password_conn = 43 | put(conn, Routes.user_settings_path(conn, :update), %{ 44 | "action" => "update_password", 45 | "current_password" => "invalid", 46 | "user" => %{ 47 | "password" => "too short", 48 | "password_confirmation" => "does not match" 49 | } 50 | }) 51 | 52 | response = html_response(old_password_conn, 200) 53 | assert response =~ "Change E-mail" 54 | assert response =~ "should be at least 12 character(s)" 55 | assert response =~ "does not match password" 56 | assert response =~ "is not valid" 57 | 58 | assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token) 59 | end 60 | end 61 | 62 | describe "PUT /users/settings (change email form)" do 63 | @tag :capture_log 64 | test "updates the user email", %{conn: conn, user: user} do 65 | conn = 66 | put(conn, Routes.user_settings_path(conn, :update), %{ 67 | "action" => "update_email", 68 | "current_password" => valid_user_password(), 69 | "user" => %{"email" => unique_user_email()} 70 | }) 71 | 72 | assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) 73 | assert get_flash(conn, :info) =~ "A link to confirm your email" 74 | assert Accounts.get_user_by_email(user.email) 75 | end 76 | 77 | test "does not update email on invalid data", %{conn: conn} do 78 | conn = 79 | put(conn, Routes.user_settings_path(conn, :update), %{ 80 | "action" => "update_email", 81 | "current_password" => "invalid", 82 | "user" => %{"email" => "with spaces"} 83 | }) 84 | 85 | response = html_response(conn, 200) 86 | assert response =~ "Change E-mail" 87 | assert response =~ "must have the @ sign and no spaces" 88 | assert response =~ "is not valid" 89 | end 90 | end 91 | 92 | describe "GET /users/settings/confirm_email/:token" do 93 | setup %{user: user} do 94 | email = unique_user_email() 95 | 96 | token = 97 | extract_user_token(fn url -> 98 | Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url) 99 | end) 100 | 101 | %{token: token, email: email} 102 | end 103 | 104 | test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do 105 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 106 | assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) 107 | assert get_flash(conn, :info) =~ "Email changed successfully" 108 | refute Accounts.get_user_by_email(user.email) 109 | assert Accounts.get_user_by_email(email) 110 | 111 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 112 | assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) 113 | assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" 114 | end 115 | 116 | test "does not update email with invalid token", %{conn: conn, user: user} do 117 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, "oops")) 118 | assert redirected_to(conn) == Routes.user_settings_path(conn, :edit) 119 | assert get_flash(conn, :error) =~ "Email change link is invalid or it has expired" 120 | assert Accounts.get_user_by_email(user.email) 121 | end 122 | 123 | test "redirects if user is not logged in", %{token: token} do 124 | conn = build_conn() 125 | conn = get(conn, Routes.user_settings_path(conn, :confirm_email, token)) 126 | assert redirected_to(conn) == Routes.user_session_path(conn, :new) 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /test/exbin_web/plug/static_files_pipeline_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.Plug.StaticFilesPipelineTest do 2 | # I don't like that this test is designed to test the entire pipeline, 3 | # including CustomLogo plug and NotFound plug, as well as any future 4 | # custom files things, however they're pretty inter-linked and we need 5 | # to be able to do all-up integration tests, so this seems like the 6 | # best way to do it. 7 | 8 | defmodule IfNoCustomLogoSet do 9 | use ExbinWeb.ConnCase, async: true 10 | 11 | describe "application layout" do 12 | test "sets correct logo path and dimensions if no custom logo path set", %{conn: conn} do 13 | conn = get(conn, "/") 14 | assert html_response(conn, 200) =~ "src=\"/images/logo.png\" width=\"30\" height=\"30\"" 15 | end 16 | 17 | test "default logo is available", %{conn: conn} do 18 | # This test is here because it tests that our *normal* Plug.Static for assets is working correctly with the correct paths 19 | conn = get(conn, "/images/logo.png") 20 | assert {"content-type", "image/png"} in conn.resp_headers 21 | end 22 | end 23 | end 24 | 25 | defmodule WithCustomLogoSet do 26 | use ExbinWeb.ConnCase, async: false 27 | import Exbin.Factory 28 | 29 | setup do 30 | orig_custom_logo_path = Application.get_env(:exbin, :custom_logo_path) 31 | orig_custom_logo_size = Application.get_env(:exbin, :custom_logo_size) 32 | 33 | # Because the plug doesn't actually care about the file type we can use a 34 | # plain text file, which will be easier to validate. 35 | test_file = "/tmp/exbin_static_#{:rand.uniform()}/test_#{:rand.uniform()}.txt" 36 | File.mkdir!(Path.dirname(test_file)) 37 | File.write!(test_file, "#{test_file}") 38 | Application.put_env(:exbin, :custom_logo_path, test_file) 39 | 40 | on_exit(fn -> 41 | Application.put_env(:exbin, :custom_logo_path, orig_custom_logo_path) 42 | Application.put_env(:exbin, :custom_logo_size, orig_custom_logo_size) 43 | File.rm!(test_file) 44 | File.rmdir!(Path.dirname(test_file)) 45 | end) 46 | 47 | {:ok, test_file_path: test_file} 48 | end 49 | 50 | test "does not interfere with snippets starting with the string 'files'", %{conn: conn} do 51 | insert!(:snippet, %{name: "filesAreCool", content: "filesAreCool works great!"}) 52 | conn = get(conn, "/filesAreCool") 53 | assert html_response(conn, 200) =~ "filesAreCool works great!" 54 | end 55 | 56 | test "properly sets custom logo path and dimensions in the layout", %{conn: conn} do 57 | r = :rand.uniform() 58 | s = Enum.random(10..50) 59 | Application.put_env(:exbin, :custom_logo_path, "/tmp/test_logo_filename_#{r}.png") 60 | Application.put_env(:exbin, :custom_logo_size, s) 61 | conn = get(conn, "/") 62 | assert html_response(conn, 200) =~ "src=\"/files/test_logo_filename_#{r}.png\" width=\"#{s}\" height=\"#{s}\"" 63 | end 64 | 65 | test "correctly shows error if requested file not in static path", %{conn: conn} do 66 | conn = get(conn, "/files/definitely_not_a_file_#{:rand.uniform()}.not-a-png") 67 | assert text_response(conn, 404) == "File Not Found" 68 | end 69 | 70 | test "correctly serves file from static folder if proper path given", %{conn: conn, test_file_path: test_file} do 71 | conn = get(conn, "/files/#{Path.basename(test_file)}") 72 | 73 | assert conn.state == :file 74 | assert conn.status == 200 75 | # The file is created with it's own path as the contents so that we can verify we've got the right one 76 | assert conn.resp_body == test_file 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/exbin_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.ErrorViewTest do 2 | use ExbinWeb.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(ExbinWeb.ErrorView, "404.html", []) == "Not Found" 9 | end 10 | 11 | test "renders 500.html" do 12 | assert render_to_string(ExbinWeb.ErrorView, "500.html", []) == "Internal Server Error" 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/exbin_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.LayoutViewTest do 2 | use ExbinWeb.ConnCase, async: true 3 | 4 | # When testing helpers, you may want to import Phoenix.HTML and 5 | # use functions such as safe_to_string() to convert the helper 6 | # result into an HTML string. 7 | # import Phoenix.HTML 8 | end 9 | -------------------------------------------------------------------------------- /test/exbin_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.PageViewTest do 2 | use ExbinWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.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 | 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 ExbinWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import ExbinWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint ExbinWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Exbin.Repo) 33 | 34 | unless tags[:async] do 35 | Ecto.Adapters.SQL.Sandbox.mode(Exbin.Repo, {:shared, self()}) 36 | end 37 | 38 | :ok 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ExbinWeb.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 ExbinWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | import Plug.Conn 24 | import Phoenix.ConnTest 25 | import ExbinWeb.ConnCase 26 | 27 | alias ExbinWeb.Router.Helpers, as: Routes 28 | 29 | # The default endpoint for testing 30 | @endpoint ExbinWeb.Endpoint 31 | end 32 | end 33 | 34 | setup tags do 35 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Exbin.Repo) 36 | 37 | unless tags[:async] do 38 | Ecto.Adapters.SQL.Sandbox.mode(Exbin.Repo, {:shared, self()}) 39 | end 40 | 41 | {:ok, conn: Phoenix.ConnTest.build_conn()} 42 | end 43 | 44 | @doc """ 45 | Setup helper that registers and logs in users. 46 | 47 | setup :register_and_log_in_user 48 | 49 | It stores an updated connection and a registered user in the 50 | test context. 51 | """ 52 | def register_and_log_in_user(%{conn: conn}) do 53 | user = Exbin.AccountsFixtures.user_fixture() 54 | %{conn: log_in_user(conn, user), user: user} 55 | end 56 | 57 | @doc """ 58 | Logs the given `user` into the `conn`. 59 | 60 | It returns an updated `conn`. 61 | """ 62 | def log_in_user(conn, user) do 63 | token = Exbin.Accounts.generate_user_session_token(user) 64 | 65 | conn 66 | |> Phoenix.ConnTest.init_test_session(%{}) 67 | |> Plug.Conn.put_session(:user_token, token) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.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 Exbin.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias Exbin.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import Exbin.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Exbin.Repo) 32 | 33 | unless tags[:async] do 34 | Ecto.Adapters.SQL.Sandbox.mode(Exbin.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/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.Factory do 2 | alias Exbin.Repo 3 | 4 | def build(:snippet) do 5 | %Exbin.Snippet{name: "TestSnippet_#{random_string()}"} 6 | end 7 | 8 | def build(factory_name, attributes) do 9 | factory_name |> build() |> struct!(attributes) 10 | end 11 | 12 | def insert!(factory_name, attributes \\ []) do 13 | factory_name |> build(attributes) |> Repo.insert!() 14 | end 15 | 16 | # Generate a random string, of a random length (unless specified) that can be used to ensure uniqueness of fields 17 | defp random_string(length \\ Enum.random(12..64)) do 18 | :crypto.strong_rand_bytes(length) |> Base.url_encode64() |> binary_part(0, length) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/support/fixtures/accounts_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Exbin.AccountsFixtures do 2 | @moduledoc """ 3 | This module defines test helpers for creating 4 | entities via the `Exbin.Accounts` context. 5 | """ 6 | 7 | def unique_user_email, do: "user#{System.unique_integer()}@example.com" 8 | def valid_user_password, do: "hello world!" 9 | 10 | def valid_user_attributes(attrs \\ %{}) do 11 | Enum.into(attrs, %{ 12 | email: unique_user_email(), 13 | password: valid_user_password() 14 | }) 15 | end 16 | 17 | def user_fixture(attrs \\ %{}) do 18 | {:ok, user} = 19 | attrs 20 | |> valid_user_attributes() 21 | |> Exbin.Accounts.register_user() 22 | 23 | user 24 | end 25 | 26 | def extract_user_token(fun) do 27 | {:ok, captured} = fun.(&"[TOKEN]#{&1}[TOKEN]") 28 | [_, token, _] = String.split(captured.text_body, "[TOKEN]") 29 | token 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | Ecto.Adapters.SQL.Sandbox.mode(Exbin.Repo, :manual) 3 | --------------------------------------------------------------------------------