Compare commits
No commits in common. "master" and "develop" have entirely different histories.
@ -1,5 +1,38 @@
|
||||
/target
|
||||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
|
||||
# Where 3rd-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
|
||||
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||
/.fetch
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
ketbin-*.tar
|
||||
|
||||
# If NPM crashes, it generates a log, let's ignore it too.
|
||||
npm-debug.log
|
||||
|
||||
# The directory NPM downloads your dependencies sources to.
|
||||
/assets/node_modules/
|
||||
|
||||
# Since we are building assets from assets/,
|
||||
# we ignore priv/static. You may want to comment
|
||||
# this depending on your deployment strategy.
|
||||
/priv/static/
|
||||
.idea
|
||||
.env
|
||||
.cargo
|
||||
*.dump
|
||||
/native/ketbin_utils_syntax/target
|
||||
/native/ketbin_utils_syntax/.cargo
|
||||
.elixir_ls
|
5
.formatter.exs
Normal file
5
.formatter.exs
Normal file
@ -0,0 +1,5 @@
|
||||
[
|
||||
import_deps: [:ecto, :phoenix],
|
||||
inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
|
||||
subdirectories: ["priv/*/migrations"]
|
||||
]
|
12
.github/workflows/fly.yml
vendored
Normal file
12
.github/workflows/fly.yml
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
name: Fly Deploy
|
||||
on: [push]
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to fly.io
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
- run: flyctl deploy --remote-only
|
42
.gitignore
vendored
42
.gitignore
vendored
@ -1,5 +1,41 @@
|
||||
/target
|
||||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
|
||||
# Where 3rd-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
|
||||
# Ignore .fetch files in case you like to edit your project deps locally.
|
||||
/.fetch
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
ketbin-*.tar
|
||||
|
||||
# If NPM crashes, it generates a log, let's ignore it too.
|
||||
npm-debug.log
|
||||
|
||||
# The directory NPM downloads your dependencies sources to.
|
||||
/assets/node_modules/
|
||||
|
||||
# Since we are building assets from assets/,
|
||||
# we ignore priv/static. You may want to comment
|
||||
# this depending on your deployment strategy.
|
||||
/priv/static/
|
||||
/priv/native/
|
||||
.idea
|
||||
.env
|
||||
.cargo
|
||||
*.dump
|
||||
.envrc
|
||||
|
||||
# Secret config files
|
||||
config/*.secret.exs
|
||||
|
2068
Cargo.lock
generated
2068
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
41
Cargo.toml
41
Cargo.toml
@ -1,41 +0,0 @@
|
||||
[package]
|
||||
name = "katbin"
|
||||
version = "0.1.0"
|
||||
authors = ["ATechnoHazard <amolele@gmail.com>"]
|
||||
license = "MIT"
|
||||
description = "Small, lightweight pastebin+URL shortener written in Rust."
|
||||
repository = "https://github.com/ATechnoHazard/katbin"
|
||||
homepage = "https://github.com/ATechnoHazard/katbin"
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
rocket = "0.4.10"
|
||||
rocket_contrib = { version = "0.4.10", default-features = false, features = ["json"] }
|
||||
serde = "1.0.126"
|
||||
serde_json = "1.0.64"
|
||||
serde_derive = "1.0.126"
|
||||
diesel = { version = "1.4.6", features = ["postgres", "r2d2", "chrono"] }
|
||||
diesel_migrations = "1.4.0"
|
||||
dotenv = "0.15.0"
|
||||
jsonwebtoken = "7.2.0"
|
||||
slog = "2.7.0"
|
||||
slog-term = "2.8.0"
|
||||
anyhow = "1.0.40"
|
||||
slog-async = "2.6.0"
|
||||
bcrypt = "0.9.0"
|
||||
chrono = "0.4.19"
|
||||
uuid = { version = "0.8.2", features = ["serde", "v4"] }
|
||||
jirachi = { version = "0.1.9", features = ["collision-resistant"] }
|
||||
rocket_cors = "0.5.2"
|
||||
regex = "1.5.4"
|
||||
|
||||
[dev-dependencies]
|
||||
diesel_cli = { version = "1.4.1", default-features = false, features = ["postgres"] }
|
||||
jirachi_cli = "0.1.2"
|
||||
|
||||
[profile.release]
|
||||
lto = "fat"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
57
Dockerfile
57
Dockerfile
@ -1,19 +1,54 @@
|
||||
FROM rustlang/rust:nightly AS builder
|
||||
FROM elixir:alpine AS build
|
||||
|
||||
COPY . .
|
||||
# install build dependencies
|
||||
RUN apk add --no-cache build-base npm git curl py-pip rust cargo
|
||||
|
||||
RUN cargo build --release
|
||||
# prepare build dir
|
||||
WORKDIR /app
|
||||
|
||||
# CMD ["sh", "-c", "ROCKET_KEEP_ALIVE=0 ./target/release/katbin"]
|
||||
# install hex + rebar
|
||||
RUN mix local.hex --force && \
|
||||
mix local.rebar --force
|
||||
|
||||
FROM debian:buster
|
||||
# set build ENV
|
||||
ENV MIX_ENV=prod
|
||||
|
||||
RUN apt-get update && apt-get install -y libpq-dev
|
||||
# install mix dependencies
|
||||
COPY mix.exs mix.lock ./
|
||||
COPY config config
|
||||
RUN mix do deps.get, deps.compile
|
||||
|
||||
COPY --from=builder \
|
||||
/target/release/katbin\
|
||||
/usr/local/bin/
|
||||
# build assets
|
||||
COPY assets/package.json assets/package-lock.json ./assets/
|
||||
RUN npm --prefix ./assets ci --progress=false --no-audit --loglevel=error
|
||||
|
||||
WORKDIR /root
|
||||
COPY priv priv
|
||||
COPY assets assets
|
||||
COPY lib lib
|
||||
COPY native native
|
||||
RUN npm run --prefix ./assets deploy
|
||||
RUN mix phx.digest
|
||||
|
||||
CMD /usr/local/bin/katbin
|
||||
# uncomment COPY if rel/ exists
|
||||
# COPY rel rel
|
||||
RUN mix do compile, release
|
||||
|
||||
# prepare release image
|
||||
FROM alpine AS app
|
||||
RUN apk add --no-cache openssl ncurses-libs libstdc++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN chown nobody:nobody /app
|
||||
|
||||
USER nobody:nobody
|
||||
|
||||
COPY --from=build --chown=nobody:nobody /app/_build/prod/rel/ketbin ./
|
||||
|
||||
COPY --chown=nobody:nobody startup.sh startup.sh
|
||||
|
||||
RUN chmod +x /app/startup.sh
|
||||
|
||||
ENV HOME=/app
|
||||
|
||||
CMD ["/app/startup.sh"]
|
||||
|
8
LICENSE
8
LICENSE
@ -1,8 +0,0 @@
|
||||
Copyright 2020 SphericalKat
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
7
Makefile
Normal file
7
Makefile
Normal file
@ -0,0 +1,7 @@
|
||||
.PHONY: image
|
||||
image:
|
||||
docker build -t atechnohazard/katbin-elixir .
|
||||
|
||||
.PHONY: push
|
||||
push: image
|
||||
docker push atechnohazard/katbin-elixir
|
1
Procfile
1
Procfile
@ -1 +0,0 @@
|
||||
web: ROCKET_PORT=$PORT ROCKET_KEEP_ALIVE=0 ./target/release/katbin
|
54
README.md
54
README.md
@ -1,2 +1,52 @@
|
||||
# katbin
|
||||
Small, lightweight pastebin+URL shortener written in Rust.
|
||||
# Katbin
|
||||
|
||||
To start your Phoenix server:
|
||||
|
||||
* Copy `config/dev.secret.sample.exs` to `config/dev.secret.exs`
|
||||
* Fill in the SMTP and database configuration in `config/dev.secret.exs`
|
||||
* Install dependencies with `mix deps.get`
|
||||
* Create and migrate your database with `mix ecto.setup`
|
||||
* Install Node.js dependencies with `npm install` inside the `assets` directory
|
||||
* Start Phoenix endpoint with `mix phx.server`
|
||||
|
||||
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
|
||||
|
||||
# Self hosting
|
||||
|
||||
We recommend using our [official docker image](https://hub.docker.com/r/atechnohazard/katbin-elixir). If you wish to build your own docker image, the provided Dockerfile should work out of the box. If you run into any problems, please open an issue.
|
||||
|
||||
## Populating the environment
|
||||
Copy the provided `sample.env` file to `.env`
|
||||
```sh
|
||||
cp sample.env .env
|
||||
```
|
||||
|
||||
Fill in this new file with the required environment variables.
|
||||
|
||||
## Using the official docker image
|
||||
```sh
|
||||
docker run --env-file .env atechnohazard/katbin-elixir
|
||||
```
|
||||
|
||||
## Building the docker image
|
||||
```sh
|
||||
git clone https://github.com/SphericalKat/katbin
|
||||
cd katbin
|
||||
docker build -t <username>/katbin .
|
||||
```
|
||||
|
||||
## Running the built docker image
|
||||
```sh
|
||||
docker run --env-file .env <username>/katbin
|
||||
```
|
||||
|
||||
For other methods of self hosting, please check the [official Phoenix deployment guides](https://hexdocs.pm/phoenix/deployment.html).
|
||||
|
||||
|
||||
## Learn more
|
||||
|
||||
* Official website: https://www.phoenixframework.org/
|
||||
* Guides: https://hexdocs.pm/phoenix/overview.html
|
||||
* Docs: https://hexdocs.pm/phoenix
|
||||
* Forum: https://elixirforum.com/c/phoenix-forum
|
||||
* Source: https://github.com/phoenixframework/phoenix
|
||||
|
23
Rocket.toml
23
Rocket.toml
@ -1,23 +0,0 @@
|
||||
[development]
|
||||
address = "localhost"
|
||||
port = 8000
|
||||
keep_alive = 5
|
||||
log = "normal"
|
||||
limits = { forms = 32768 }
|
||||
|
||||
[staging]
|
||||
address = "0.0.0.0"
|
||||
port = 8000
|
||||
keep_alive = 5
|
||||
log = "normal"
|
||||
limits = { forms = 32768 }
|
||||
|
||||
[production]
|
||||
address = "0.0.0.0"
|
||||
port = 8000
|
||||
keep_alive = 5
|
||||
log = "critical"
|
||||
limits = { forms = 32768 }
|
||||
|
||||
#[global]
|
||||
#template_dir = "resources/templates/"
|
5
assets/.babelrc
Normal file
5
assets/.babelrc
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"presets": [
|
||||
"@babel/preset-env"
|
||||
]
|
||||
}
|
38
assets/build.js
Normal file
38
assets/build.js
Normal file
@ -0,0 +1,38 @@
|
||||
const fs = require("fs-extra");
|
||||
const esbuild = require("esbuild");
|
||||
const { default: postCSSPlugin } = require("esbuild-plugin-postcss2");
|
||||
|
||||
// PostCSS Plugins
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const tailwindcss = require("tailwindcss");
|
||||
const postcssImport = require("postcss-import");
|
||||
|
||||
const productionBuild = process.env.NODE_ENV === "production";
|
||||
fs.copySync("static/", "../priv/static/");
|
||||
|
||||
console.log("[build.js] [info] Copying static files from static/");
|
||||
|
||||
if (!productionBuild) {
|
||||
console.log("[build.js] [info] Starting to watch assets for changes");
|
||||
} else {
|
||||
console.log("[build.js] [info] Building assets in production mode");
|
||||
}
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
entryPoints: ["js/app.js"],
|
||||
bundle: true,
|
||||
outfile: "../priv/static/assets/app.js",
|
||||
minify: productionBuild,
|
||||
watch: !productionBuild,
|
||||
external: ["*.ttf"],
|
||||
plugins: [
|
||||
postCSSPlugin({
|
||||
plugins: [postcssImport, tailwindcss, autoprefixer],
|
||||
}),
|
||||
],
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`[build.js] [error] ${e}`);
|
||||
process.exit(1);
|
||||
});
|
128
assets/css/app.css
Normal file
128
assets/css/app.css
Normal file
@ -0,0 +1,128 @@
|
||||
/* This file is for your main application css. */
|
||||
@import "tailwindcss/base";
|
||||
@import "tailwindcss/components";
|
||||
@import "tailwindcss/utilities";
|
||||
@import "./highlight.css";
|
||||
@import "./markdown.css";
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono";
|
||||
src: url(/fonts/jetbrains_mono_variable.ttf);
|
||||
font-weight: 100 1000;
|
||||
font-stretch: 25% 151%;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.17em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 0.83em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.67em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px
|
||||
}
|
||||
|
||||
img {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #ff9800;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
code {
|
||||
white-space: pre;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
textarea {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner,
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 8px;
|
||||
@apply bg-amber;
|
||||
}
|
||||
|
||||
.alert {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #1ed98e;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background-color: #ff9800;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
}
|
239
assets/css/highlight.css
Normal file
239
assets/css/highlight.css
Normal file
@ -0,0 +1,239 @@
|
||||
.code {
|
||||
color: #c0c5ce;
|
||||
background-color: #2b303b;
|
||||
}
|
||||
.variable.parameter.function {
|
||||
color: #c0c5ce;
|
||||
}
|
||||
.comment {
|
||||
color: #65737e;
|
||||
}
|
||||
.punctuation.definition.comment {
|
||||
color: #65737e;
|
||||
}
|
||||
.punctuation.definition.string {
|
||||
color: #c0c5ce;
|
||||
}
|
||||
.punctuation.definition.variable {
|
||||
color: #c0c5ce;
|
||||
}
|
||||
.punctuation.definition.string {
|
||||
color: #c0c5ce;
|
||||
}
|
||||
.punctuation.definition.parameters {
|
||||
color: #c0c5ce;
|
||||
}
|
||||
.punctuation.definition.string {
|
||||
color: #c0c5ce;
|
||||
}
|
||||
.punctuation.definition.array {
|
||||
color: #c0c5ce;
|
||||
}
|
||||
.none {
|
||||
color: #c0c5ce;
|
||||
}
|
||||
.keyword.operator {
|
||||
color: #c0c5ce;
|
||||
}
|
||||
.keyword {
|
||||
color: #b48ead;
|
||||
}
|
||||
.variable {
|
||||
color: #bf616a;
|
||||
}
|
||||
.variable.other.dollar.only.js {
|
||||
color: #bf616a;
|
||||
}
|
||||
.entity.name.function {
|
||||
color: #8fa1b3;
|
||||
}
|
||||
.meta.require {
|
||||
color: #8fa1b3;
|
||||
}
|
||||
.support.function.any-method {
|
||||
color: #8fa1b3;
|
||||
}
|
||||
.variable.function {
|
||||
color: #8fa1b3;
|
||||
}
|
||||
.support.class {
|
||||
color: #ebcb8b;
|
||||
}
|
||||
.entity.name.class {
|
||||
color: #ebcb8b;
|
||||
}
|
||||
.entity.name.type.class {
|
||||
color: #ebcb8b;
|
||||
}
|
||||
.meta.class {
|
||||
color: #eff1f5;
|
||||
}
|
||||
.keyword.other.special-method {
|
||||
color: #8fa1b3;
|
||||
}
|
||||
.storage {
|
||||
color: #b48ead;
|
||||
}
|
||||
.support.function {
|
||||
color: #96b5b4;
|
||||
}
|
||||
.string {
|
||||
color: #a3be8c;
|
||||
}
|
||||
.constant.other.symbol {
|
||||
color: #a3be8c;
|
||||
}
|
||||
.entity.other.inherited-class {
|
||||
color: #a3be8c;
|
||||
}
|
||||
.constant.numeric {
|
||||
color: #d08770;
|
||||
}
|
||||
.none {
|
||||
color: #d08770;
|
||||
}
|
||||
.none {
|
||||
color: #d08770;
|
||||
}
|
||||
.constant {
|
||||
color: #d08770;
|
||||
}
|
||||
.entity.name.tag {
|
||||
color: #bf616a;
|
||||
}
|
||||
.entity.other.attribute-name {
|
||||
color: #d08770;
|
||||
}
|
||||
.entity.other.attribute-name.id {
|
||||
color: #8fa1b3;
|
||||
}
|
||||
.punctuation.definition.entity {
|
||||
color: #8fa1b3;
|
||||
}
|
||||
.meta.selector {
|
||||
color: #b48ead;
|
||||
}
|
||||
.none {
|
||||
color: #d08770;
|
||||
}
|
||||
.markup.heading {
|
||||
color: #8fa1b3;
|
||||
}
|
||||
.punctuation.definition.heading {
|
||||
color: #8fa1b3;
|
||||
}
|
||||
.entity.name.section {
|
||||
color: #8fa1b3;
|
||||
}
|
||||
.keyword.other.unit {
|
||||
color: #d08770;
|
||||
}
|
||||
.markup.bold {
|
||||
color: #ebcb8b;
|
||||
font-weight: 700;
|
||||
}
|
||||
.punctuation.definition.bold {
|
||||
color: #ebcb8b;
|
||||
font-weight: 700;
|
||||
}
|
||||
.markup.italic {
|
||||
color: #b48ead;
|
||||
font-style: italic;
|
||||
}
|
||||
.punctuation.definition.italic {
|
||||
color: #b48ead;
|
||||
font-style: italic;
|
||||
}
|
||||
.markup.raw.inline {
|
||||
color: #a3be8c;
|
||||
}
|
||||
.string.other.link {
|
||||
color: #bf616a;
|
||||
}
|
||||
.meta.link {
|
||||
color: #d08770;
|
||||
}
|
||||
.meta.image {
|
||||
color: #d08770;
|
||||
}
|
||||
.markup.list {
|
||||
color: #bf616a;
|
||||
}
|
||||
.markup.quote {
|
||||
color: #d08770;
|
||||
}
|
||||
.meta.separator {
|
||||
color: #c0c5ce;
|
||||
background-color: #4f5b66;
|
||||
}
|
||||
.markup.inserted {
|
||||
color: #a3be8c;
|
||||
}
|
||||
.markup.inserted.git_gutter {
|
||||
color: #a3be8c;
|
||||
}
|
||||
.markup.deleted {
|
||||
color: #bf616a;
|
||||
}
|
||||
.markup.deleted.git_gutter {
|
||||
color: #bf616a;
|
||||
}
|
||||
.markup.changed {
|
||||
color: #b48ead;
|
||||
}
|
||||
.markup.changed.git_gutter {
|
||||
color: #b48ead;
|
||||
}
|
||||
.markup.ignored {
|
||||
color: #4f5b66;
|
||||
}
|
||||
.markup.ignored.git_gutter {
|
||||
color: #4f5b66;
|
||||
}
|
||||
.markup.untracked {
|
||||
color: #4f5b66;
|
||||
}
|
||||
.markup.untracked.git_gutter {
|
||||
color: #4f5b66;
|
||||
}
|
||||
.constant.other.color {
|
||||
color: #96b5b4;
|
||||
}
|
||||
.string.regexp {
|
||||
color: #96b5b4;
|
||||
}
|
||||
.constant.character.escape {
|
||||
color: #96b5b4;
|
||||
}
|
||||
.punctuation.section.embedded {
|
||||
color: #ab7967;
|
||||
}
|
||||
.variable.interpolation {
|
||||
color: #ab7967;
|
||||
}
|
||||
.invalid.illegal {
|
||||
color: #2b303b;
|
||||
background-color: #bf616a;
|
||||
}
|
||||
.markup.deleted.git_gutter {
|
||||
color: #f92672;
|
||||
}
|
||||
.markup.inserted.git_gutter {
|
||||
color: #a6e22e;
|
||||
}
|
||||
.markup.changed.git_gutter {
|
||||
color: #967efb;
|
||||
}
|
||||
.markup.ignored.git_gutter {
|
||||
color: #565656;
|
||||
}
|
||||
.markup.untracked.git_gutter {
|
||||
color: #565656;
|
||||
}
|
||||
.source .block {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
span .block {
|
||||
display: inline;
|
||||
}
|
179
assets/css/markdown.css
Normal file
179
assets/css/markdown.css
Normal file
@ -0,0 +1,179 @@
|
||||
.markdown p {
|
||||
font-family: sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.markdown p > * {
|
||||
font-family: sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.markdown h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
font-size: 2em;
|
||||
@apply text-amber;
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
font-size: 1.5em;
|
||||
@apply text-amber;
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
font-size: 1.17em;
|
||||
@apply text-amber;
|
||||
}
|
||||
|
||||
.markdown h4 {
|
||||
font-size: 1em;
|
||||
@apply text-amber;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.markdown h5 {
|
||||
font-size: 0.83em;
|
||||
@apply text-amber;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.markdown h6 {
|
||||
font-size: 0.75em;
|
||||
@apply text-amber;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
padding: 0 1em;
|
||||
margin: 0 0 20px;
|
||||
font-size: 17.5px;
|
||||
border-left: 4px solid #8b949e;
|
||||
}
|
||||
|
||||
.markdown img {
|
||||
max-width: 35%;
|
||||
vertical-align: middle;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.markdown table {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
tbody > tr:nth-child(odd) > td,
|
||||
.table-striped > tbody > tr:nth-child(odd) > th {
|
||||
background-color: #1b1b1b;
|
||||
}
|
||||
.table-striped > tbody > tr:nth-child(odd) > td,
|
||||
.table-striped > tbody > tr:nth-child(odd) > th {
|
||||
background-color: #1b1b1b;
|
||||
}
|
||||
table > thead > tr > th,
|
||||
table > tbody > tr > th,
|
||||
table > tfoot > tr > th,
|
||||
table > thead > tr > td,
|
||||
table > tbody > tr > td,
|
||||
table > tfoot > tr > td {
|
||||
padding: 8px;
|
||||
line-height: 1.42857143;
|
||||
vertical-align: top;
|
||||
border: 1px solid #3b434b;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
border: 0;
|
||||
border-top: 1px solid #3b434b;
|
||||
}
|
||||
|
||||
.markdown {
|
||||
/* border: 1px solid #3b434b; */
|
||||
border-radius: 6px;
|
||||
padding-right: 32px;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #1b1b1b;
|
||||
border-radius: 6px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.markdown code pre tt {
|
||||
font-family: "JetbrainsMono", SFMono-Regular, Consolas, Liberation Mono, Menlo,
|
||||
monospace;
|
||||
}
|
||||
|
||||
.markdown blockquote > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown blockquote,
|
||||
.markdown p,
|
||||
.markdown ol,
|
||||
.markdown pre,
|
||||
.markdown table,
|
||||
.markdown ul {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
padding-bottom: 0.3em;
|
||||
border-bottom: 1px solid #3b434b;
|
||||
}
|
||||
|
||||
.markdown li {
|
||||
display: list-item;
|
||||
text-align: -webkit-match-parent;
|
||||
font-family: sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.markdown ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown ol,
|
||||
ul {
|
||||
padding: 0;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.markdown strong {
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.markdown code.inline {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
background-color: rgb(240 246 252 / 15%);
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
font-size: 80%;
|
||||
margin: 0;
|
||||
padding: 0.1em 0.4em;
|
||||
}
|
||||
|
||||
.markdown ul {
|
||||
list-style: disc;
|
||||
}
|
||||
.markdown ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
.markdown ul, ol {
|
||||
padding: 0;
|
||||
margin-left: 40px;
|
||||
}
|
15
assets/js/app.js
Normal file
15
assets/js/app.js
Normal file
@ -0,0 +1,15 @@
|
||||
// We need to import the CSS so that webpack will load it.
|
||||
// The MiniCssExtractPlugin is used to separate it out into
|
||||
// its own CSS file.
|
||||
import "../css/app.css";
|
||||
|
||||
// webpack automatically bundles all modules in your
|
||||
// entry points. Those entry points can be configured
|
||||
// in "webpack.config.js".
|
||||
//
|
||||
// Import deps with the dep name or local files with a relative path, for example:
|
||||
//
|
||||
// import {Socket} from "phoenix"
|
||||
// import socket from "./socket"
|
||||
//
|
||||
import "phoenix_html";
|
63
assets/js/socket.js
Normal file
63
assets/js/socket.js
Normal file
@ -0,0 +1,63 @@
|
||||
// NOTE: The contents of this file will only be executed if
|
||||
// you uncomment its entry in "assets/js/app.js".
|
||||
|
||||
// To use Phoenix channels, the first step is to import Socket,
|
||||
// and connect at the socket path in "lib/web/endpoint.ex".
|
||||
//
|
||||
// Pass the token on params as below. Or remove it
|
||||
// from the params if you are not using authentication.
|
||||
import {Socket} from "phoenix"
|
||||
|
||||
let socket = new Socket("/socket", {params: {token: window.userToken}})
|
||||
|
||||
// When you connect, you'll often need to authenticate the client.
|
||||
// For example, imagine you have an authentication plug, `MyAuth`,
|
||||
// which authenticates the session and assigns a `:current_user`.
|
||||
// If the current user exists you can assign the user's token in
|
||||
// the connection for use in the layout.
|
||||
//
|
||||
// In your "lib/web/router.ex":
|
||||
//
|
||||
// pipeline :browser do
|
||||
// ...
|
||||
// plug MyAuth
|
||||
// plug :put_user_token
|
||||
// end
|
||||
//
|
||||
// defp put_user_token(conn, _) do
|
||||
// if current_user = conn.assigns[:current_user] do
|
||||
// token = Phoenix.Token.sign(conn, "user socket", current_user.id)
|
||||
// assign(conn, :user_token, token)
|
||||
// else
|
||||
// conn
|
||||
// end
|
||||
// end
|
||||
//
|
||||
// Now you need to pass this token to JavaScript. You can do so
|
||||
// inside a script tag in "lib/web/templates/layout/app.html.eex":
|
||||
//
|
||||
// <script>window.userToken = "<%= assigns[:user_token] %>";</script>
|
||||
//
|
||||
// You will need to verify the user token in the "connect/3" function
|
||||
// in "lib/web/channels/user_socket.ex":
|
||||
//
|
||||
// def connect(%{"token" => token}, socket, _connect_info) do
|
||||
// # max_age: 1209600 is equivalent to two weeks in seconds
|
||||
// case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
|
||||
// {:ok, user_id} ->
|
||||
// {:ok, assign(socket, :user, user_id)}
|
||||
// {:error, reason} ->
|
||||
// :error
|
||||
// end
|
||||
// end
|
||||
//
|
||||
// Finally, connect to the socket:
|
||||
socket.connect()
|
||||
|
||||
// Now that you are connected, you can join channels with a topic:
|
||||
let channel = socket.channel("topic:subtopic", {})
|
||||
channel.join()
|
||||
.receive("ok", resp => { console.log("Joined successfully", resp) })
|
||||
.receive("error", resp => { console.log("Unable to join", resp) })
|
||||
|
||||
export default socket
|
3399
assets/package-lock.json
generated
Normal file
3399
assets/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
assets/package.json
Normal file
22
assets/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"repository": {},
|
||||
"description": " ",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"deploy": "NODE_ENV=production node build.js",
|
||||
"watch": "node build.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"phoenix": "file:../deps/phoenix",
|
||||
"phoenix_html": "file:../deps/phoenix_html"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.3.1",
|
||||
"esbuild": "^0.12.22",
|
||||
"esbuild-plugin-postcss2": "0.0.9",
|
||||
"fs-extra": "^10.0.0",
|
||||
"postcss": "^8.3.6",
|
||||
"postcss-import": "^15.0.0",
|
||||
"tailwindcss": "^3.4.3"
|
||||
}
|
||||
}
|
7
assets/postcss.config.js
Normal file
7
assets/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-import": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
BIN
assets/static/favicon.ico
Normal file
BIN
assets/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.6 KiB |
BIN
assets/static/fonts/jetbrains_mono_variable.ttf
Normal file
BIN
assets/static/fonts/jetbrains_mono_variable.ttf
Normal file
Binary file not shown.
BIN
assets/static/images/phoenix.png
Normal file
BIN
assets/static/images/phoenix.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
5
assets/static/robots.txt
Normal file
5
assets/static/robots.txt
Normal file
@ -0,0 +1,5 @@
|
||||
# See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
|
||||
#
|
||||
# To ban all spiders from the entire site uncomment the next two lines:
|
||||
# User-agent: *
|
||||
# Disallow: /
|
22
assets/tailwind.config.js
Normal file
22
assets/tailwind.config.js
Normal file
@ -0,0 +1,22 @@
|
||||
module.exports = {
|
||||
content: [
|
||||
"../lib/**/*.ex",
|
||||
"../lib/**/*.leex",
|
||||
"../lib/**/*.eex",
|
||||
"../lib/**/*.heex",
|
||||
"./js/**/*.js",
|
||||
],
|
||||
darkMode: 'media', // or 'media' or 'class'
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
amber: "#ff9800",
|
||||
"light-grey": "#212121",
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
31
config/config.exs
Normal file
31
config/config.exs
Normal file
@ -0,0 +1,31 @@
|
||||
# This file is responsible for configuring your application
|
||||
# and its dependencies with the aid of the Mix.Config module.
|
||||
#
|
||||
# This configuration file is loaded before any dependency and
|
||||
# is restricted to this project.
|
||||
|
||||
# General application configuration
|
||||
import Config
|
||||
|
||||
config :ketbin,
|
||||
ecto_repos: [Ketbin.Repo]
|
||||
|
||||
# Configures the endpoint
|
||||
config :ketbin, KetbinWeb.Endpoint,
|
||||
url: [host: "localhost"],
|
||||
secret_key_base: "9uGXQJhm7f2+kO7HKKWUmgBpaRK6oMt795GQKFNlnxdhUjG0Xpy/XddOb03H1aH2",
|
||||
render_errors: [view: KetbinWeb.ErrorView, accepts: ~w(html json), layout: false],
|
||||
pubsub_server: Ketbin.PubSub,
|
||||
live_view: [signing_salt: "76BWv42A"]
|
||||
|
||||
# Configures Elixir's Logger
|
||||
config :logger, :console,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
metadata: [:request_id]
|
||||
|
||||
# Use Jason for JSON parsing in Phoenix
|
||||
config :phoenix, :json_library, Jason
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{Mix.env()}.exs"
|
67
config/dev.exs
Normal file
67
config/dev.exs
Normal file
@ -0,0 +1,67 @@
|
||||
import Config
|
||||
|
||||
# For development, we disable any cache and enable
|
||||
# debugging and code reloading.
|
||||
#
|
||||
# The watchers configuration can be used to run external
|
||||
# watchers to your application. For example, we use it
|
||||
# with webpack to recompile .js and .css sources.
|
||||
config :ketbin, KetbinWeb.Endpoint,
|
||||
http: [port: 4000],
|
||||
debug_errors: true,
|
||||
code_reloader: true,
|
||||
check_origin: false,
|
||||
watchers: [
|
||||
node: [
|
||||
"build.js",
|
||||
cd: Path.expand("../assets", __DIR__)
|
||||
]
|
||||
]
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# In order to use HTTPS in development, a self-signed
|
||||
# certificate can be generated by running the following
|
||||
# Mix task:
|
||||
#
|
||||
# mix phx.gen.cert
|
||||
#
|
||||
# Note that this task requires Erlang/OTP 20 or later.
|
||||
# Run `mix help phx.gen.cert` for more information.
|
||||
#
|
||||
# The `http:` config above can be replaced with:
|
||||
#
|
||||
# https: [
|
||||
# port: 4001,
|
||||
# cipher_suite: :strong,
|
||||
# keyfile: "priv/cert/selfsigned_key.pem",
|
||||
# certfile: "priv/cert/selfsigned.pem"
|
||||
# ],
|
||||
#
|
||||
# If desired, both `http:` and `https:` keys can be
|
||||
# configured to run both http and https servers on
|
||||
# different ports.
|
||||
|
||||
# Watch static and templates for browser reloading.
|
||||
config :ketbin, KetbinWeb.Endpoint,
|
||||
live_reload: [
|
||||
patterns: [
|
||||
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
|
||||
~r"priv/gettext/.*(po)$",
|
||||
~r"lib/ketbin_web/(live|views)/.*(ex)$",
|
||||
~r"lib/ketbin_web/templates/.*(eex)$"
|
||||
]
|
||||
]
|
||||
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :console, format: "[$level] $message\n"
|
||||
|
||||
# Set a higher stacktrace during development. Avoid configuring such
|
||||
# in production as building large stacktraces may be expensive.
|
||||
config :phoenix, :stacktrace_depth, 20
|
||||
|
||||
# Initialize plugs at runtime for faster development compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
# Import the secrets config
|
||||
import_config "dev.secret.exs"
|
24
config/dev.secret.sample.exs
Normal file
24
config/dev.secret.sample.exs
Normal file
@ -0,0 +1,24 @@
|
||||
import Mix.Config
|
||||
|
||||
# Configure your database
|
||||
config :ketbin, Ketbin.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
database: "ketbin_dev",
|
||||
hostname: "localhost",
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
pool_size: 10
|
||||
|
||||
smtp_relay = "<Your SMTP Relay Here>"
|
||||
username = "<Your SMTP username here>"
|
||||
password = "<Your SMTP password here>"
|
||||
|
||||
# configure mailer
|
||||
config :ketbin, Ketbin.Mailer,
|
||||
adapter: Swoosh.Adapters.SMTP,
|
||||
relay: smtp_relay,
|
||||
username: username,
|
||||
password: password,
|
||||
tls: :always,
|
||||
auth: :always,
|
||||
port: 587
|
55
config/prod.exs
Normal file
55
config/prod.exs
Normal file
@ -0,0 +1,55 @@
|
||||
import Config
|
||||
|
||||
# For production, don't forget to configure the url host
|
||||
# to something meaningful, Phoenix uses this information
|
||||
# when generating URLs.
|
||||
#
|
||||
# Note we also include the path to a cache manifest
|
||||
# containing the digested version of static files. This
|
||||
# manifest is generated by the `mix phx.digest` task,
|
||||
# which you should run after static files are built and
|
||||
# before starting your production server.
|
||||
config :ketbin, KetbinWeb.Endpoint,
|
||||
url: [host: "katb.in", port: 80],
|
||||
cache_static_manifest: "priv/static/cache_manifest.json"
|
||||
|
||||
# Do not print debug messages in production
|
||||
config :logger, level: :info
|
||||
|
||||
# ## SSL Support
|
||||
#
|
||||
# To get SSL working, you will need to add the `https` key
|
||||
# to the previous section and set your `:url` port to 443:
|
||||
#
|
||||
# config :ketbin, KetbinWeb.Endpoint,
|
||||
# ...
|
||||
# url: [host: "example.com", port: 443],
|
||||
# https: [
|
||||
# port: 443,
|
||||
# cipher_suite: :strong,
|
||||
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
|
||||
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH"),
|
||||
# transport_options: [socket_opts: [:inet6]]
|
||||
# ]
|
||||
#
|
||||
# The `cipher_suite` is set to `:strong` to support only the
|
||||
# latest and more secure SSL ciphers. This means old browsers
|
||||
# and clients may not be supported. You can set it to
|
||||
# `:compatible` for wider support.
|
||||
#
|
||||
# `:keyfile` and `:certfile` expect an absolute path to the key
|
||||
# and cert in disk or a relative path inside priv, for example
|
||||
# "priv/ssl/server.key". For all supported SSL configuration
|
||||
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
|
||||
#
|
||||
# We also recommend setting `force_ssl` in your endpoint, ensuring
|
||||
# no data is ever sent via http, always redirecting to https:
|
||||
#
|
||||
# config :ketbin, KetbinWeb.Endpoint,
|
||||
# force_ssl: [hsts: true]
|
||||
#
|
||||
# Check `Plug.SSL` for all available options in `force_ssl`.
|
||||
|
||||
# Finally import the config/prod.secret.exs which loads secrets
|
||||
# and configuration from environment variables.
|
||||
# import_config "prod.secret.exs"
|
71
config/releases.exs
Normal file
71
config/releases.exs
Normal file
@ -0,0 +1,71 @@
|
||||
# In this file, we load production configuration and secrets
|
||||
# from environment variables. You can also hardcode secrets,
|
||||
# although such is generally not recommended and you have to
|
||||
# remember to add this file to your .gitignore.
|
||||
import Config
|
||||
|
||||
database_url =
|
||||
System.get_env("DATABASE_URL") ||
|
||||
raise """
|
||||
environment variable DATABASE_URL is missing.
|
||||
For example: ecto://USER:PASS@HOST/DATABASE
|
||||
"""
|
||||
|
||||
config :ketbin, Ketbin.Repo,
|
||||
ssl: false,
|
||||
# verify: :verify_peer,
|
||||
socket_options: [:inet6],
|
||||
url: database_url,
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
|
||||
# cacertfile: "priv/cert.pem"
|
||||
secret_key_base =
|
||||
System.get_env("SECRET_KEY_BASE") ||
|
||||
raise """
|
||||
environment variable SECRET_KEY_BASE is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
"""
|
||||
|
||||
config :ketbin, KetbinWeb.Endpoint,
|
||||
http: [
|
||||
port: String.to_integer(System.get_env("PORT") || "4000"),
|
||||
transport_options: [socket_opts: [:inet6]]
|
||||
],
|
||||
secret_key_base: secret_key_base,
|
||||
server: true
|
||||
|
||||
smtp_relay =
|
||||
System.get_env("SWOOSH_SMTP_RELAY") ||
|
||||
raise """
|
||||
environment variable SWOOSH_SMTP_RELAY is missing.
|
||||
"""
|
||||
|
||||
username =
|
||||
System.get_env("SWOOSH_USERNAME") ||
|
||||
raise """
|
||||
environment variable SWOOSH_USERNAME is missing.
|
||||
"""
|
||||
|
||||
password =
|
||||
System.get_env("SWOOSH_PASSWORD") ||
|
||||
raise """
|
||||
environment variable SWOOSH_PASSWORD is missing.
|
||||
"""
|
||||
|
||||
# configure mailer
|
||||
config :ketbin, Ketbin.Mailer,
|
||||
adapter: Swoosh.Adapters.SMTP,
|
||||
relay: smtp_relay,
|
||||
username: username,
|
||||
password: password,
|
||||
tls: :always,
|
||||
auth: :always,
|
||||
port: 587
|
||||
|
||||
# ## Using releases (Elixir v1.9+)
|
||||
#
|
||||
# If you are doing OTP releases, you need to instruct Phoenix
|
||||
# to start each relevant endpoint:
|
||||
#
|
||||
#
|
||||
# Then you can assemble a release by calling `mix release`.
|
||||
# See `mix help release` for more information.
|
25
config/test.exs
Normal file
25
config/test.exs
Normal file
@ -0,0 +1,25 @@
|
||||
import Config
|
||||
|
||||
# Only in tests, remove the complexity from the password hashing algorithm
|
||||
config :bcrypt_elixir, :log_rounds, 1
|
||||
|
||||
# Configure your database
|
||||
#
|
||||
# The MIX_TEST_PARTITION environment variable can be used
|
||||
# to provide built-in test partitioning in CI environment.
|
||||
# Run `mix help test` for more information.
|
||||
config :ketbin, Ketbin.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
database: "ketbin_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
hostname: "localhost",
|
||||
pool: Ecto.Adapters.SQL.Sandbox
|
||||
|
||||
# We don't run a server during test. If one is required,
|
||||
# you can enable the server option below.
|
||||
config :ketbin, KetbinWeb.Endpoint,
|
||||
http: [port: 4002],
|
||||
server: false
|
||||
|
||||
# Print only warnings and errors during test
|
||||
config :logger, level: :warn
|
@ -1,29 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: front-service
|
||||
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: front-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: front-service
|
||||
spec:
|
||||
nodeSelector:
|
||||
"beta.kubernetes.io/os": linux
|
||||
containers:
|
||||
- name: front-service
|
||||
image: atechnohazard/katbin-web
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
name: front-service
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
requests:
|
||||
cpu: 200m
|
||||
imagePullPolicy: Always
|
@ -1,51 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: katbin-service
|
||||
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: katbin-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: katbin-service
|
||||
spec:
|
||||
nodeSelector:
|
||||
"beta.kubernetes.io/os": linux
|
||||
containers:
|
||||
- name: katbin-service
|
||||
image: atechnohazard/katbin
|
||||
env:
|
||||
- name: KATBIN_POOL_SIZE
|
||||
value: '5'
|
||||
- name: JIRACHI_POOL_SIZE
|
||||
value: '5'
|
||||
- name: ROCKET_PORT
|
||||
value: '5000'
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: katbin-secrets
|
||||
key: DATABASE_URL
|
||||
- name: JIRACHI_DB_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: katbin-secrets
|
||||
key: DATABASE_URL
|
||||
- name: ROCKET_SECRET_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: katbin-secrets
|
||||
key: ROCKET_SECRET_KEY
|
||||
ports:
|
||||
- containerPort: 5000
|
||||
name: katbin-service
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
requests:
|
||||
cpu: 200m
|
||||
imagePullPolicy: Always
|
@ -1,34 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: katbin-ingress
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- api.katb.in
|
||||
- katb.in
|
||||
secretName: katbin-tls
|
||||
rules:
|
||||
- host: api.katb.in
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: katbin-service
|
||||
port:
|
||||
number: 80
|
||||
- host: katb.in
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: front-service
|
||||
port:
|
||||
number: 81
|
@ -1,31 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
annotations:
|
||||
service.beta.kubernetes.io/do-loadbalancer-enable-proxy-protocol: 'true'
|
||||
service.beta.kubernetes.io/do-loadbalancer-hostname: "workaround.katb.in"
|
||||
labels:
|
||||
helm.sh/chart: ingress-nginx-3.23.0
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/version: 0.44.0
|
||||
app.kubernetes.io/managed-by: Helm
|
||||
app.kubernetes.io/component: controller
|
||||
name: ingress-nginx-controller
|
||||
namespace: ingress-nginx
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
externalTrafficPolicy: Local
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
protocol: TCP
|
||||
targetPort: http
|
||||
- name: https
|
||||
port: 443
|
||||
protocol: TCP
|
||||
targetPort: https
|
||||
selector:
|
||||
app.kubernetes.io/name: ingress-nginx
|
||||
app.kubernetes.io/instance: ingress-nginx
|
||||
app.kubernetes.io/component: controller
|
@ -1,9 +0,0 @@
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: katbin-service-podbudget
|
||||
spec:
|
||||
maxUnavailable: 0
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: katbin-service
|
@ -1,19 +0,0 @@
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
# You must replace this email address with your own.
|
||||
# Let's Encrypt will use this to contact you about expiring
|
||||
# certificates, and issues related to your account.
|
||||
email: amolele@gmail.com
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
privateKeySecretRef:
|
||||
# Secret resource that will be used to store the account's private key.
|
||||
name: letsencrypt-prod
|
||||
# Add a single challenge solver, HTTP01 using nginx
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
@ -1,10 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: front-service
|
||||
spec:
|
||||
ports:
|
||||
- port: 81
|
||||
targetPort: 5000
|
||||
selector:
|
||||
app: front-service
|
@ -1,10 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: katbin-service
|
||||
spec:
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 5000
|
||||
selector:
|
||||
app: katbin-service
|
@ -1,19 +0,0 @@
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-staging
|
||||
spec:
|
||||
acme:
|
||||
# You must replace this email address with your own.
|
||||
# Let's Encrypt will use this to contact you about expiring
|
||||
# certificates, and issues related to your account.
|
||||
email: amolele@gmail.com
|
||||
server: https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
privateKeySecretRef:
|
||||
# Secret resource that will be used to store the account's private key.
|
||||
name: letsencrypt-staging
|
||||
# Add a single challenge solver, HTTP01 using nginx
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
class: nginx
|
@ -1,5 +0,0 @@
|
||||
# For documentation on how to configure this file,
|
||||
# see postgres/guides/configuring-diesel-cli
|
||||
|
||||
[print_schema]
|
||||
file = "src/schema.rs"
|
45
fly.toml
Normal file
45
fly.toml
Normal file
@ -0,0 +1,45 @@
|
||||
# fly.toml file generated for katbin on 2022-05-06T17:46:21+05:30
|
||||
|
||||
app = "katbin"
|
||||
|
||||
kill_signal = "SIGTERM"
|
||||
kill_timeout = 5
|
||||
processes = []
|
||||
|
||||
# [deploy]
|
||||
# release_command = "/app/bin/migrate"
|
||||
|
||||
[env]
|
||||
PHX_HOST = "katbin.fly.dev"
|
||||
PORT = "8080"
|
||||
|
||||
[experimental]
|
||||
allowed_public_ports = []
|
||||
auto_rollback = true
|
||||
|
||||
[[services]]
|
||||
http_checks = []
|
||||
internal_port = 8080
|
||||
processes = ["app"]
|
||||
protocol = "tcp"
|
||||
script_checks = []
|
||||
|
||||
[services.concurrency]
|
||||
hard_limit = 10000
|
||||
soft_limit = 6000
|
||||
type = "connections"
|
||||
|
||||
[[services.ports]]
|
||||
force_https = true
|
||||
handlers = ["http"]
|
||||
port = 80
|
||||
|
||||
[[services.ports]]
|
||||
handlers = ["tls", "http"]
|
||||
port = 443
|
||||
|
||||
[[services.tcp_checks]]
|
||||
grace_period = "1s"
|
||||
interval = "15s"
|
||||
restart_limit = 0
|
||||
timeout = "2s"
|
9
lib/ketbin.ex
Normal file
9
lib/ketbin.ex
Normal file
@ -0,0 +1,9 @@
|
||||
defmodule Ketbin do
|
||||
@moduledoc """
|
||||
Ketbin keeps the contexts that define your domain
|
||||
and business logic.
|
||||
|
||||
Contexts are also responsible for managing your data, regardless
|
||||
if it comes from the database, an external API or others.
|
||||
"""
|
||||
end
|
349
lib/ketbin/accounts.ex
Normal file
349
lib/ketbin/accounts.ex
Normal file
@ -0,0 +1,349 @@
|
||||
defmodule Ketbin.Accounts do
|
||||
@moduledoc """
|
||||
The Accounts context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Ketbin.Repo
|
||||
alias Ketbin.Accounts.{User, UserToken, UserNotifier}
|
||||
|
||||
## Database getters
|
||||
|
||||
@doc """
|
||||
Gets a user by email.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email("foo@example.com")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email("unknown@example.com")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_email(email) when is_binary(email) do
|
||||
Repo.get_by(User, email: email)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a user by email and password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_email_and_password(email, password)
|
||||
when is_binary(email) and is_binary(password) do
|
||||
user = Repo.get_by(User, email: email)
|
||||
if User.valid_password?(user, password), do: user
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single user.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the User does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user!(123)
|
||||
%User{}
|
||||
|
||||
iex> get_user!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_user!(id), do: Repo.get!(User, id)
|
||||
|
||||
## User registration
|
||||
|
||||
@doc """
|
||||
Registers a user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> register_user(%{field: value})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> register_user(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def register_user(attrs) do
|
||||
%User{}
|
||||
|> User.registration_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking user changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_registration(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_registration(%User{} = user, attrs \\ %{}) do
|
||||
User.registration_changeset(user, attrs, hash_password: false)
|
||||
end
|
||||
|
||||
## Settings
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user email.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_email(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_email(user, attrs \\ %{}) do
|
||||
User.email_changeset(user, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Emulates that the email will change without actually changing
|
||||
it in the database.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> apply_user_email(user, "valid password", %{email: ...})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> apply_user_email(user, "invalid password", %{email: ...})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def apply_user_email(user, password, attrs) do
|
||||
user
|
||||
|> User.email_changeset(attrs)
|
||||
|> User.validate_current_password(password)
|
||||
|> Ecto.Changeset.apply_action(:update)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user email using the given token.
|
||||
|
||||
If the token matches, the user email is updated and the token is deleted.
|
||||
The confirmed_at date is also updated to the current time.
|
||||
"""
|
||||
def update_user_email(user, token) do
|
||||
context = "change:#{user.email}"
|
||||
|
||||
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
|
||||
%UserToken{sent_to: email} <- Repo.one(query),
|
||||
{:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do
|
||||
:ok
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp user_email_multi(user, email, context) do
|
||||
changeset = user |> User.email_changeset(%{email: email}) |> User.confirm_changeset()
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, changeset)
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, [context]))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delivers the update email instructions to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
|
||||
when is_function(update_email_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
|
||||
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_password(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_password(user, attrs \\ %{}) do
|
||||
User.password_changeset(user, attrs, hash_password: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_user_password(user, "valid password", %{password: ...})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> update_user_password(user, "invalid password", %{password: ...})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_user_password(user, password, attrs) do
|
||||
changeset =
|
||||
user
|
||||
|> User.password_changeset(attrs)
|
||||
|> User.validate_current_password(password)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, changeset)
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
## Session
|
||||
|
||||
@doc """
|
||||
Generates a session token.
|
||||
"""
|
||||
def generate_user_session_token(user) do
|
||||
{token, user_token} = UserToken.build_session_token(user)
|
||||
Repo.insert!(user_token)
|
||||
token
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user with the given signed token.
|
||||
"""
|
||||
def get_user_by_session_token(token) do
|
||||
{:ok, query} = UserToken.verify_session_token_query(token)
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes the signed token with the given context.
|
||||
"""
|
||||
def delete_session_token(token) do
|
||||
Repo.delete_all(UserToken.token_and_context_query(token, "session"))
|
||||
:ok
|
||||
end
|
||||
|
||||
## Confirmation
|
||||
|
||||
@doc """
|
||||
Delivers the confirmation email instructions to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1))
|
||||
{:error, :already_confirmed}
|
||||
|
||||
"""
|
||||
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
|
||||
when is_function(confirmation_url_fun, 1) do
|
||||
if user.confirmed_at do
|
||||
{:error, :already_confirmed}
|
||||
else
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms a user by the given token.
|
||||
|
||||
If the token matches, the user account is marked as confirmed
|
||||
and the token is deleted.
|
||||
"""
|
||||
def confirm_user(token) do
|
||||
with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"),
|
||||
%User{} = user <- Repo.one(query),
|
||||
{:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do
|
||||
{:ok, user}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp confirm_user_multi(user) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, User.confirm_changeset(user))
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, ["confirm"]))
|
||||
end
|
||||
|
||||
## Reset password
|
||||
|
||||
@doc """
|
||||
Delivers the reset password email to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
|
||||
when is_function(reset_password_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user by reset password token.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_reset_password_token("validtoken")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_reset_password_token("invalidtoken")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_reset_password_token(token) do
|
||||
with {:ok, query} <- UserToken.verify_email_token_query(token, "reset_password"),
|
||||
%User{} = user <- Repo.one(query) do
|
||||
user
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Resets the user password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def reset_user_password(user, attrs) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:user, User.password_changeset(user, attrs))
|
||||
|> Ecto.Multi.delete_all(:tokens, UserToken.user_and_contexts_query(user, :all))
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
139
lib/ketbin/accounts/user.ex
Normal file
139
lib/ketbin/accounts/user.ex
Normal file
@ -0,0 +1,139 @@
|
||||
defmodule Ketbin.Accounts.User do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@derive {Inspect, except: [:password]}
|
||||
schema "users" do
|
||||
field :email, :string
|
||||
field :password, :string, virtual: true
|
||||
field :hashed_password, :string
|
||||
field :confirmed_at, :naive_datetime
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for registration.
|
||||
|
||||
It is important to validate the length of both email and password.
|
||||
Otherwise databases may truncate the email without warnings, which
|
||||
could lead to unpredictable or insecure behaviour. Long passwords may
|
||||
also be very expensive to hash for certain algorithms.
|
||||
|
||||
## Options
|
||||
|
||||
* `:hash_password` - Hashes the password so it can be stored securely
|
||||
in the database and ensures the password field is cleared to prevent
|
||||
leaks in the logs. If password hashing is not needed and clearing the
|
||||
password field is not desired (like when using this changeset for
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def registration_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email, :password])
|
||||
|> validate_email()
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
defp validate_email(changeset) do
|
||||
changeset
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|
||||
|> validate_length(:email, max: 160)
|
||||
|> unsafe_validate_unique(:email, Ketbin.Repo)
|
||||
|> unique_constraint(:email)
|
||||
end
|
||||
|
||||
defp validate_password(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:password])
|
||||
|> validate_length(:password, min: 8, max: 80)
|
||||
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
|
||||
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
|
||||
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|
||||
|> maybe_hash_password(opts)
|
||||
end
|
||||
|
||||
defp maybe_hash_password(changeset, opts) do
|
||||
hash_password? = Keyword.get(opts, :hash_password, true)
|
||||
password = get_change(changeset, :password)
|
||||
|
||||
if hash_password? && password && changeset.valid? do
|
||||
changeset
|
||||
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|
||||
|> delete_change(:password)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the email.
|
||||
|
||||
It requires the email to change otherwise an error is added.
|
||||
"""
|
||||
def email_changeset(user, attrs) do
|
||||
user
|
||||
|> cast(attrs, [:email])
|
||||
|> validate_email()
|
||||
|> case do
|
||||
%{changes: %{email: _}} = changeset -> changeset
|
||||
%{} = changeset -> add_error(changeset, :email, "did not change")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the password.
|
||||
|
||||
## Options
|
||||
|
||||
* `:hash_password` - Hashes the password so it can be stored securely
|
||||
in the database and ensures the password field is cleared to prevent
|
||||
leaks in the logs. If password hashing is not needed and clearing the
|
||||
password field is not desired (like when using this changeset for
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def password_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:password])
|
||||
|> validate_confirmation(:password, message: "does not match password")
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms the account by setting `confirmed_at`.
|
||||
"""
|
||||
def confirm_changeset(user) do
|
||||
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
change(user, confirmed_at: now)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies the password.
|
||||
|
||||
If there is no user or the user doesn't have a password, we call
|
||||
`Bcrypt.no_user_verify/0` to avoid timing attacks.
|
||||
"""
|
||||
def valid_password?(%Ketbin.Accounts.User{hashed_password: hashed_password}, password)
|
||||
when is_binary(hashed_password) and byte_size(password) > 0 do
|
||||
Bcrypt.verify_pass(password, hashed_password)
|
||||
end
|
||||
|
||||
def valid_password?(_, _) do
|
||||
Bcrypt.no_user_verify()
|
||||
false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates the current password otherwise adds an error to the changeset.
|
||||
"""
|
||||
def validate_current_password(changeset, password) do
|
||||
if valid_password?(changeset.data, password) do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :current_password, "is not valid")
|
||||
end
|
||||
end
|
||||
end
|
96
lib/ketbin/accounts/user_notifier.ex
Normal file
96
lib/ketbin/accounts/user_notifier.ex
Normal file
@ -0,0 +1,96 @@
|
||||
defmodule Ketbin.Accounts.UserNotifier do
|
||||
import Swoosh.Email
|
||||
# For simplicity, this module simply logs messages to the terminal.
|
||||
# You should replace it by a proper email or notification tool, such as:
|
||||
#
|
||||
# * Swoosh - https://hexdocs.pm/swoosh
|
||||
# * Bamboo - https://hexdocs.pm/bamboo
|
||||
#
|
||||
defp deliver(to, body, subject) do
|
||||
require Logger
|
||||
Logger.debug(body)
|
||||
|
||||
Task.start(fn ->
|
||||
new()
|
||||
|> to(to)
|
||||
|> from({"Katbin", "noreply@katb.in"})
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|> Ketbin.Mailer.deliver()
|
||||
end)
|
||||
|
||||
{:ok, %{to: to, body: body}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to confirm account.
|
||||
"""
|
||||
def deliver_confirmation_instructions(user, url) do
|
||||
deliver(
|
||||
user.email,
|
||||
"""
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can confirm your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't create an account with us, please ignore this.
|
||||
|
||||
==============================
|
||||
""",
|
||||
"Account confirmation"
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to reset a user password.
|
||||
"""
|
||||
def deliver_reset_password_instructions(user, url) do
|
||||
deliver(
|
||||
user.email,
|
||||
"""
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can reset your password by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""",
|
||||
"Password reset requested"
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to update a user email.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
deliver(
|
||||
user.email,
|
||||
"""
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can change your email by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""",
|
||||
"Email update requested"
|
||||
)
|
||||
end
|
||||
end
|
139
lib/ketbin/accounts/user_token.ex
Normal file
139
lib/ketbin/accounts/user_token.ex
Normal file
@ -0,0 +1,139 @@
|
||||
defmodule Ketbin.Accounts.UserToken do
|
||||
use Ecto.Schema
|
||||
import Ecto.Query
|
||||
|
||||
@hash_algorithm :sha256
|
||||
@rand_size 32
|
||||
|
||||
# It is very important to keep the reset password token expiry short,
|
||||
# since someone with access to the email may take over the account.
|
||||
@reset_password_validity_in_days 1
|
||||
@confirm_validity_in_days 7
|
||||
@change_email_validity_in_days 7
|
||||
@session_validity_in_days 60
|
||||
|
||||
schema "users_tokens" do
|
||||
field :token, :binary
|
||||
field :context, :string
|
||||
field :sent_to, :string
|
||||
belongs_to :user, Ketbin.Accounts.User
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a token that will be stored in a signed place,
|
||||
such as session or cookie. As they are signed, those
|
||||
tokens do not need to be hashed.
|
||||
"""
|
||||
def build_session_token(user) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
{token, %Ketbin.Accounts.UserToken{token: token, context: "session", user_id: user.id}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user found by the token.
|
||||
"""
|
||||
def verify_session_token_query(token) do
|
||||
query =
|
||||
from token in token_and_context_query(token, "session"),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(@session_validity_in_days, "day"),
|
||||
select: user
|
||||
|
||||
{:ok, query}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds a token with a hashed counter part.
|
||||
|
||||
The non-hashed token is sent to the user email while the
|
||||
hashed part is stored in the database, to avoid reconstruction.
|
||||
The token is valid for a week as long as users don't change
|
||||
their email.
|
||||
"""
|
||||
def build_email_token(user, context) do
|
||||
build_hashed_token(user, context, user.email)
|
||||
end
|
||||
|
||||
defp build_hashed_token(user, context, sent_to) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
hashed_token = :crypto.hash(@hash_algorithm, token)
|
||||
|
||||
{Base.url_encode64(token, padding: false),
|
||||
%Ketbin.Accounts.UserToken{
|
||||
token: hashed_token,
|
||||
context: context,
|
||||
sent_to: sent_to,
|
||||
user_id: user.id
|
||||
}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user found by the token.
|
||||
"""
|
||||
def verify_email_token_query(token, context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
days = days_for_context(context)
|
||||
|
||||
query =
|
||||
from token in token_and_context_query(hashed_token, context),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(^days, "day") and token.sent_to == user.email,
|
||||
select: user
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp days_for_context("confirm"), do: @confirm_validity_in_days
|
||||
defp days_for_context("reset_password"), do: @reset_password_validity_in_days
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user token record.
|
||||
"""
|
||||
def verify_change_email_token_query(token, context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
|
||||
query =
|
||||
from token in token_and_context_query(hashed_token, context),
|
||||
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the given token with the given context.
|
||||
"""
|
||||
def token_and_context_query(token, context) do
|
||||
from Ketbin.Accounts.UserToken, where: [token: ^token, context: ^context]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all tokens for the given user for the given contexts.
|
||||
"""
|
||||
def user_and_contexts_query(user, :all) do
|
||||
from t in Ketbin.Accounts.UserToken, where: t.user_id == ^user.id
|
||||
end
|
||||
|
||||
def user_and_contexts_query(user, [_ | _] = contexts) do
|
||||
from t in Ketbin.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts
|
||||
end
|
||||
end
|
34
lib/ketbin/application.ex
Normal file
34
lib/ketbin/application.ex
Normal file
@ -0,0 +1,34 @@
|
||||
defmodule Ketbin.Application do
|
||||
# See https://hexdocs.pm/elixir/Application.html
|
||||
# for more information on OTP Applications
|
||||
@moduledoc false
|
||||
|
||||
use Application
|
||||
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
# Start the Ecto repository
|
||||
Ketbin.Repo,
|
||||
# Start the Telemetry supervisor
|
||||
KetbinWeb.Telemetry,
|
||||
# Start the PubSub system
|
||||
{Phoenix.PubSub, name: Ketbin.PubSub},
|
||||
# Start the Endpoint (http/https)
|
||||
KetbinWeb.Endpoint
|
||||
# Start a worker by calling: Ketbin.Worker.start_link(arg)
|
||||
# {Ketbin.Worker, arg}
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
# for other strategies and supported options
|
||||
opts = [strategy: :one_for_one, name: Ketbin.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
# Tell Phoenix to update the endpoint configuration
|
||||
# whenever the application is updated.
|
||||
def config_change(changed, _new, removed) do
|
||||
KetbinWeb.Endpoint.config_change(changed, removed)
|
||||
:ok
|
||||
end
|
||||
end
|
3
lib/ketbin/mailer.ex
Normal file
3
lib/ketbin/mailer.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule Ketbin.Mailer do
|
||||
use Swoosh.Mailer, otp_app: :ketbin
|
||||
end
|
108
lib/ketbin/pastes.ex
Normal file
108
lib/ketbin/pastes.ex
Normal file
@ -0,0 +1,108 @@
|
||||
defmodule Ketbin.Pastes do
|
||||
@moduledoc """
|
||||
The Pastes context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Ketbin.Repo
|
||||
|
||||
alias Ketbin.Pastes.Paste
|
||||
|
||||
@doc """
|
||||
Returns the list of pastes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_pastes()
|
||||
[%Paste{}, ...]
|
||||
|
||||
"""
|
||||
def list_pastes do
|
||||
Repo.all(Paste)
|
||||
end
|
||||
|
||||
def list_pastes_by_user(user_id) do
|
||||
Repo.all(from(p in Paste, where: p.belongs_to == ^user_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single paste.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Paste does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_paste!(123)
|
||||
%Paste{}
|
||||
|
||||
iex> get_paste!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_paste!(id), do: Repo.get!(Paste, id)
|
||||
|
||||
@doc """
|
||||
Creates a paste.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_paste(%{field: value})
|
||||
{:ok, %Paste{}}
|
||||
|
||||
iex> create_paste(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_paste(attrs \\ %{}) do
|
||||
%Paste{}
|
||||
|> Paste.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a paste.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_paste(paste, %{field: new_value})
|
||||
{:ok, %Paste{}}
|
||||
|
||||
iex> update_paste(paste, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_paste(%Paste{} = paste, attrs) do
|
||||
paste
|
||||
|> Paste.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a paste.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_paste(paste)
|
||||
{:ok, %Paste{}}
|
||||
|
||||
iex> delete_paste(paste)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_paste(%Paste{} = paste) do
|
||||
Repo.delete(paste)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking paste changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_paste(paste)
|
||||
%Ecto.Changeset{data: %Paste{}}
|
||||
|
||||
"""
|
||||
def change_paste(%Paste{} = paste, attrs \\ %{}) do
|
||||
Paste.changeset(paste, attrs)
|
||||
end
|
||||
end
|
23
lib/ketbin/pastes/paste.ex
Normal file
23
lib/ketbin/pastes/paste.ex
Normal file
@ -0,0 +1,23 @@
|
||||
defmodule Ketbin.Pastes.Paste do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
@derive {Jason.Encoder, only: [:content, :is_url, :belongs_to]}
|
||||
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@derive {Phoenix.Param, key: :id}
|
||||
schema "pastes" do
|
||||
field :content, :string
|
||||
field :is_url, :boolean, default: false
|
||||
field :belongs_to, :id
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(paste, attrs) do
|
||||
paste
|
||||
|> cast(attrs, [:is_url, :content, :id, :belongs_to])
|
||||
|> validate_required([:is_url, :content])
|
||||
|> unique_constraint(:id, name: :pastes_pkey)
|
||||
end
|
||||
end
|
33
lib/ketbin/pastes/utils.ex
Normal file
33
lib/ketbin/pastes/utils.ex
Normal file
@ -0,0 +1,33 @@
|
||||
defmodule Ketbin.Pastes.Utils do
|
||||
defp rand_vowel do
|
||||
String.graphemes("aeiou")
|
||||
|> Enum.take_random(1)
|
||||
|> Enum.at(0)
|
||||
end
|
||||
|
||||
defp rand_consonant do
|
||||
String.graphemes("bcdfghjklmnpqrstvwxyz")
|
||||
|> Enum.take_random(1)
|
||||
|> Enum.at(0)
|
||||
end
|
||||
|
||||
def generate_key(length \\ 10) do
|
||||
random = Enum.random([0, 1])
|
||||
|
||||
Enum.map(0..length, fn i ->
|
||||
if Integer.mod(i, 2) == random, do: rand_consonant(), else: rand_vowel()
|
||||
end)
|
||||
|> List.to_string()
|
||||
end
|
||||
|
||||
def is_url?(url) do
|
||||
try do
|
||||
uri = URI.parse(url)
|
||||
|
||||
uri.scheme != nil && uri.host =~ "." &&
|
||||
Enum.member?(["https", "http", "mailto"], uri.scheme) && !(uri.host =~ "katb.in")
|
||||
rescue
|
||||
FunctionClauseError -> false
|
||||
end
|
||||
end
|
||||
end
|
5
lib/ketbin/repo.ex
Normal file
5
lib/ketbin/repo.ex
Normal file
@ -0,0 +1,5 @@
|
||||
defmodule Ketbin.Repo do
|
||||
use Ecto.Repo,
|
||||
otp_app: :ketbin,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
end
|
10
lib/ketbin/utils/syntax.ex
Normal file
10
lib/ketbin/utils/syntax.ex
Normal file
@ -0,0 +1,10 @@
|
||||
defmodule Ketbin.Utils.Syntax do
|
||||
use Rustler, otp_app: :ketbin, crate: "ketbin_utils_syntax"
|
||||
|
||||
# When your NIF is loaded, it will override this function.
|
||||
def add(_a, _b), do: error()
|
||||
|
||||
def highlight_text(_text, _lang), do: error()
|
||||
|
||||
defp error(), do: :erlang.nif_error(:nif_not_loaded)
|
||||
end
|
81
lib/ketbin_web.ex
Normal file
81
lib/ketbin_web.ex
Normal file
@ -0,0 +1,81 @@
|
||||
defmodule KetbinWeb do
|
||||
@moduledoc """
|
||||
The entrypoint for defining your web interface, such
|
||||
as controllers, views, channels and so on.
|
||||
|
||||
This can be used in your application as:
|
||||
|
||||
use KetbinWeb, :controller
|
||||
use KetbinWeb, :view
|
||||
|
||||
The definitions below will be executed for every view,
|
||||
controller, etc, so keep them short and clean, focused
|
||||
on imports, uses and aliases.
|
||||
|
||||
Do NOT define functions inside the quoted expressions
|
||||
below. Instead, define any helper function in modules
|
||||
and import those modules here.
|
||||
"""
|
||||
|
||||
def controller do
|
||||
quote do
|
||||
use Phoenix.Controller, namespace: KetbinWeb
|
||||
|
||||
import Plug.Conn
|
||||
import KetbinWeb.Gettext
|
||||
alias KetbinWeb.Router.Helpers, as: Routes
|
||||
end
|
||||
end
|
||||
|
||||
def view do
|
||||
quote do
|
||||
use Phoenix.View,
|
||||
root: "lib/ketbin_web/templates",
|
||||
namespace: KetbinWeb
|
||||
|
||||
# Import convenience functions from controllers
|
||||
import Phoenix.Controller,
|
||||
only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]
|
||||
|
||||
# Include shared imports and aliases for views
|
||||
unquote(view_helpers())
|
||||
end
|
||||
end
|
||||
|
||||
def router do
|
||||
quote do
|
||||
use Phoenix.Router
|
||||
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
end
|
||||
end
|
||||
|
||||
def channel do
|
||||
quote do
|
||||
use Phoenix.Channel
|
||||
import KetbinWeb.Gettext
|
||||
end
|
||||
end
|
||||
|
||||
defp view_helpers do
|
||||
quote do
|
||||
# Use all HTML functionality (forms, tags, etc)
|
||||
use Phoenix.HTML
|
||||
|
||||
# Import basic rendering functionality (render, render_layout, etc)
|
||||
import Phoenix.View
|
||||
|
||||
import KetbinWeb.ErrorHelpers
|
||||
import KetbinWeb.Gettext
|
||||
alias KetbinWeb.Router.Helpers, as: Routes
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
When used, dispatch to the appropriate controller/view/etc.
|
||||
"""
|
||||
defmacro __using__(which) when is_atom(which) do
|
||||
apply(__MODULE__, which, [])
|
||||
end
|
||||
end
|
35
lib/ketbin_web/channels/user_socket.ex
Normal file
35
lib/ketbin_web/channels/user_socket.ex
Normal file
@ -0,0 +1,35 @@
|
||||
defmodule KetbinWeb.UserSocket do
|
||||
use Phoenix.Socket
|
||||
|
||||
## Channels
|
||||
# channel "room:*", KetbinWeb.RoomChannel
|
||||
|
||||
# Socket params are passed from the client and can
|
||||
# be used to verify and authenticate a user. After
|
||||
# verification, you can put default assigns into
|
||||
# the socket that will be set for all channels, ie
|
||||
#
|
||||
# {:ok, assign(socket, :user_id, verified_user_id)}
|
||||
#
|
||||
# To deny connection, return `:error`.
|
||||
#
|
||||
# See `Phoenix.Token` documentation for examples in
|
||||
# performing token verification on connect.
|
||||
@impl true
|
||||
def connect(_params, socket, _connect_info) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
# Socket id's are topics that allow you to identify all sockets for a given user:
|
||||
#
|
||||
# def id(socket), do: "user_socket:#{socket.assigns.user_id}"
|
||||
#
|
||||
# Would allow you to broadcast a "disconnect" event and terminate
|
||||
# all active sockets and channels for a given user:
|
||||
#
|
||||
# KetbinWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
|
||||
#
|
||||
# Returning `nil` makes this socket anonymous.
|
||||
@impl true
|
||||
def id(_socket), do: nil
|
||||
end
|
45
lib/ketbin_web/controllers/api/paste_controller.ex
Normal file
45
lib/ketbin_web/controllers/api/paste_controller.ex
Normal file
@ -0,0 +1,45 @@
|
||||
defmodule KetbinWeb.Api.PasteController do
|
||||
use KetbinWeb, :controller
|
||||
|
||||
alias Ketbin.Pastes
|
||||
alias Ketbin.Pastes.Utils
|
||||
|
||||
def show(conn, %{"id" => id}) do
|
||||
[head | _tail] = String.split(id, ".")
|
||||
|
||||
# fetch paste from db
|
||||
paste = Pastes.get_paste!(head)
|
||||
render(conn, "paste.json", paste: paste)
|
||||
end
|
||||
|
||||
def create(%{assigns: %{current_user: current_user}} = conn, %{"paste" => paste_params}) do
|
||||
# generate phonetic key
|
||||
id = Utils.generate_key()
|
||||
|
||||
# check if content is a url
|
||||
is_url =
|
||||
Map.get(paste_params, "content")
|
||||
|> Utils.is_url?()
|
||||
|
||||
# put id and is_url values into changeset
|
||||
paste_params =
|
||||
Map.put(paste_params, "id", id)
|
||||
|> Map.put("is_url", is_url)
|
||||
|> Map.put("belongs_to", current_user && current_user.id)
|
||||
|
||||
# attempt to create a paste
|
||||
case Pastes.create_paste(paste_params) do
|
||||
# all good
|
||||
{:ok, paste} ->
|
||||
conn
|
||||
|> put_status(:created)
|
||||
|> render("paste.json", paste: paste)
|
||||
|
||||
# something went wrong, bail
|
||||
{:error, %Ecto.Changeset{} = _changeset} ->
|
||||
conn
|
||||
|> put_status(:internal_server_error)
|
||||
|> render("error.json")
|
||||
end
|
||||
end
|
||||
end
|
142
lib/ketbin_web/controllers/page_controller.ex
Normal file
142
lib/ketbin_web/controllers/page_controller.ex
Normal file
@ -0,0 +1,142 @@
|
||||
defmodule KetbinWeb.PageController do
|
||||
require Logger
|
||||
|
||||
use KetbinWeb, :controller
|
||||
|
||||
alias Ketbin.Pastes
|
||||
alias Ketbin.Pastes.Paste
|
||||
alias Ketbin.Pastes.Utils
|
||||
|
||||
def index(conn, _params) do
|
||||
changeset = Pastes.change_paste(%Paste{})
|
||||
render(conn, "index.html", changeset: changeset)
|
||||
end
|
||||
|
||||
def show(%{assigns: %{show_edit: show_edit}} = conn, %{"id" => id}) do
|
||||
[head | tail] = String.split(id, ".")
|
||||
|
||||
# fetch paste from db
|
||||
paste =
|
||||
try do
|
||||
Pastes.get_paste!(head)
|
||||
rescue
|
||||
Ecto.NoResultsError ->
|
||||
Pastes.get_paste!(id)
|
||||
end
|
||||
|
||||
# paste is a url, redirect
|
||||
# regular paste, show content
|
||||
if paste.is_url do
|
||||
redirect(conn,
|
||||
external: paste.content |> String.replace("\r", "") |> String.replace("\n", "")
|
||||
)
|
||||
else
|
||||
render(conn, "show.html",
|
||||
paste: paste,
|
||||
show_edit: show_edit,
|
||||
extension: List.first(tail) || "",
|
||||
page_title: paste.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def showlink(%{assigns: %{show_edit: show_edit}} = conn, %{"id" => id}) do
|
||||
[head | tail] = String.split(id, ".")
|
||||
paste = Pastes.get_paste!(head)
|
||||
|
||||
render(conn, "shorten.html",
|
||||
paste: paste,
|
||||
show_edit: show_edit,
|
||||
extension: if(tail == [], do: "", else: tail),
|
||||
page_title: paste.id
|
||||
)
|
||||
end
|
||||
|
||||
def raw(conn, %{"id" => id}) do
|
||||
paste = Pastes.get_paste!(id)
|
||||
text(conn, paste.content)
|
||||
end
|
||||
|
||||
def create(%{assigns: %{current_user: current_user}} = conn, %{"paste" => paste_params}) do
|
||||
# if custom url exists, use it as id, else generate phonetic id
|
||||
id =
|
||||
case Map.get(paste_params, "custom_url", "") do
|
||||
custom_url when custom_url != "" -> custom_url
|
||||
_ -> Utils.generate_key()
|
||||
end
|
||||
|
||||
# check if content is a url
|
||||
is_url =
|
||||
Map.get(paste_params, "content")
|
||||
|> Utils.is_url?()
|
||||
|
||||
# put id and is_url values into changeset
|
||||
paste_params =
|
||||
Map.put(paste_params, "id", id)
|
||||
|> Map.put("is_url", is_url)
|
||||
|> Map.put("belongs_to", current_user && current_user.id)
|
||||
|
||||
# attempt to create a paste
|
||||
case Pastes.create_paste(paste_params) do
|
||||
# all good, redirect
|
||||
{:ok, paste} ->
|
||||
unless is_url do
|
||||
conn
|
||||
# is a regular paste, take to regular route
|
||||
|> redirect(to: Routes.page_path(conn, :show, paste))
|
||||
else
|
||||
conn
|
||||
# is a url, take to route with /v/ prefix
|
||||
|> redirect(to: Routes.page_path(conn, :showlink, paste))
|
||||
end
|
||||
|
||||
# something went wrong, bail
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
case changeset.errors[:id] do
|
||||
{"has already been taken", _} ->
|
||||
render(conn, "index.html", changeset: changeset, already_taken: true)
|
||||
|
||||
_ ->
|
||||
render(conn, "index.html", changeset: changeset)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def edit(conn, %{"id" => id}) do
|
||||
paste = Pastes.get_paste!(id)
|
||||
changeset = Pastes.change_paste(paste)
|
||||
render(conn, "edit.html", paste: paste, changeset: changeset)
|
||||
end
|
||||
|
||||
def update(conn, %{"id" => id, "paste" => paste_params}) do
|
||||
paste = Pastes.get_paste!(id)
|
||||
|
||||
# check if content is a url
|
||||
is_url =
|
||||
Map.get(paste_params, "content")
|
||||
|> Utils.is_url?()
|
||||
|
||||
paste_params = Map.put(paste_params, "is_url", is_url)
|
||||
|
||||
case Pastes.update_paste(paste, paste_params) do
|
||||
{:ok, paste} ->
|
||||
unless is_url do
|
||||
conn
|
||||
|> put_flash(:info, "Paste updated successfully.")
|
||||
|> redirect(to: Routes.page_path(conn, :show, paste))
|
||||
else
|
||||
conn
|
||||
|> put_flash(:info, "Paste updated successfully.")
|
||||
|> redirect(to: Routes.page_path(conn, :showlink, paste))
|
||||
end
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render(conn, "edit.html", paste: paste, changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def pastes(%{assigns: %{current_user: current_user}} = conn, _params) do
|
||||
pastes = Pastes.list_pastes_by_user(current_user.id)
|
||||
render(conn, "pastes.html", pastes: pastes)
|
||||
end
|
||||
end
|
179
lib/ketbin_web/controllers/user_auth.ex
Normal file
179
lib/ketbin_web/controllers/user_auth.ex
Normal file
@ -0,0 +1,179 @@
|
||||
defmodule KetbinWeb.UserAuth do
|
||||
import Plug.Conn
|
||||
import Phoenix.Controller
|
||||
|
||||
alias Ketbin.Accounts
|
||||
alias Ketbin.Pastes
|
||||
alias KetbinWeb.Router.Helpers, as: Routes
|
||||
|
||||
# Make the remember me cookie valid for 60 days.
|
||||
# If you want bump or reduce this value, also change
|
||||
# the token expiry itself in UserToken.
|
||||
@max_age 60 * 60 * 24 * 60
|
||||
@remember_me_cookie "_ketbin_web_user_remember_me"
|
||||
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
|
||||
|
||||
@doc """
|
||||
Logs the user in.
|
||||
|
||||
It renews the session ID and clears the whole session
|
||||
to avoid fixation attacks. See the renew_session
|
||||
function to customize this behaviour.
|
||||
|
||||
It also sets a `:live_socket_id` key in the session,
|
||||
so LiveView sessions are identified and automatically
|
||||
disconnected on log out. The line can be safely removed
|
||||
if you are not using LiveView.
|
||||
"""
|
||||
def log_in_user(conn, user, params \\ %{}) do
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
user_return_to = get_session(conn, :user_return_to)
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|
||||
|> maybe_write_remember_me_cookie(token, params)
|
||||
|> put_flash(:info, "Logged in successfully!")
|
||||
|> redirect(to: user_return_to || signed_in_path(conn))
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}) do
|
||||
put_resp_cookie(conn, @remember_me_cookie, token, @remember_me_options)
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, _token, _params) do
|
||||
conn
|
||||
end
|
||||
|
||||
# This function renews the session ID and erases the whole
|
||||
# session to avoid fixation attacks. If there is any data
|
||||
# in the session you may want to preserve after log in/log out,
|
||||
# you must explicitly fetch the session data before clearing
|
||||
# and then immediately set it after clearing, for example:
|
||||
#
|
||||
# defp renew_session(conn) do
|
||||
# preferred_locale = get_session(conn, :preferred_locale)
|
||||
#
|
||||
# conn
|
||||
# |> configure_session(renew: true)
|
||||
# |> clear_session()
|
||||
# |> put_session(:preferred_locale, preferred_locale)
|
||||
# end
|
||||
#
|
||||
defp renew_session(conn) do
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user out.
|
||||
|
||||
It clears all session data for safety. See renew_session.
|
||||
"""
|
||||
def log_out_user(conn) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
user_token && Accounts.delete_session_token(user_token)
|
||||
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
KetbinWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session()
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates the user by looking into the session
|
||||
and remember me token.
|
||||
"""
|
||||
def fetch_current_user(conn, _opts) do
|
||||
{user_token, conn} = ensure_user_token(conn)
|
||||
user = user_token && Accounts.get_user_by_session_token(user_token)
|
||||
assign(conn, :current_user, user)
|
||||
end
|
||||
|
||||
def owns_paste(%{params: %{"id" => id}, assigns: %{current_user: user}} = conn, _params) do
|
||||
[head | _tail] = String.split(id, ".")
|
||||
|
||||
paste =
|
||||
try do
|
||||
Pastes.get_paste!(head)
|
||||
rescue
|
||||
Ecto.NoResultsError ->
|
||||
Pastes.get_paste!(id)
|
||||
end
|
||||
|
||||
assign(conn, :show_edit, (user && user.id == paste.belongs_to) || false)
|
||||
end
|
||||
|
||||
def ensure_owns_paste(%{params: %{"id" => id}, assigns: %{current_user: user}} = conn, _params) do
|
||||
[head | _tail] = String.split(id, ".")
|
||||
paste = Pastes.get_paste!(head)
|
||||
allow_edit = (user && user.id == paste.belongs_to) || false
|
||||
|
||||
unless allow_edit do
|
||||
conn
|
||||
|> put_flash(:error, "You don't own this paste!")
|
||||
|> redirect(to: Routes.page_path(conn, :show, id))
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_user_token(conn) do
|
||||
if user_token = get_session(conn, :user_token) do
|
||||
{user_token, conn}
|
||||
else
|
||||
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
||||
|
||||
if user_token = conn.cookies[@remember_me_cookie] do
|
||||
{user_token, put_session(conn, :user_token, user_token)}
|
||||
else
|
||||
{nil, conn}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Used for routes that require the user to not be authenticated.
|
||||
"""
|
||||
def redirect_if_user_is_authenticated(conn, _opts) do
|
||||
if conn.assigns[:current_user] do
|
||||
conn
|
||||
|> redirect(to: signed_in_path(conn))
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Used for routes that require the user to be authenticated.
|
||||
|
||||
If you want to enforce the user email is confirmed before
|
||||
they use the application at all, here would be a good place.
|
||||
"""
|
||||
def require_authenticated_user(conn, _opts) do
|
||||
if conn.assigns[:current_user] do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must log in to access this page.")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: Routes.user_session_path(conn, :new))
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
||||
put_session(conn, :user_return_to, current_path(conn))
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(conn), do: conn
|
||||
|
||||
defp signed_in_path(_conn), do: "/"
|
||||
end
|
53
lib/ketbin_web/controllers/user_confirmation_controller.ex
Normal file
53
lib/ketbin_web/controllers/user_confirmation_controller.ex
Normal file
@ -0,0 +1,53 @@
|
||||
defmodule KetbinWeb.UserConfirmationController do
|
||||
use KetbinWeb, :controller
|
||||
|
||||
alias Ketbin.Accounts
|
||||
|
||||
def new(conn, _params) do
|
||||
render(conn, "new.html")
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"email" => email}}) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_user_confirmation_instructions(
|
||||
user,
|
||||
&Routes.user_confirmation_url(conn, :confirm, &1)
|
||||
)
|
||||
end
|
||||
|
||||
# Regardless of the outcome, show an impartial success/error message.
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"If your email is in our system and it has not been confirmed yet, " <>
|
||||
"you will receive an email with instructions shortly."
|
||||
)
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
|
||||
# Do not log in the user after confirmation to avoid a
|
||||
# leaked token giving the user access to the account.
|
||||
def confirm(conn, %{"token" => token}) do
|
||||
case Accounts.confirm_user(token) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_flash(:info, "User confirmed successfully.")
|
||||
|> redirect(to: "/")
|
||||
|
||||
:error ->
|
||||
# If there is a current user and the account was already confirmed,
|
||||
# then odds are that the confirmation link was already visited, either
|
||||
# by some automation or by the user themselves, so we redirect without
|
||||
# a warning message.
|
||||
case conn.assigns do
|
||||
%{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) ->
|
||||
redirect(conn, to: "/")
|
||||
|
||||
%{} ->
|
||||
conn
|
||||
|> put_flash(:error, "User confirmation link is invalid or it has expired.")
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
31
lib/ketbin_web/controllers/user_registration_controller.ex
Normal file
31
lib/ketbin_web/controllers/user_registration_controller.ex
Normal file
@ -0,0 +1,31 @@
|
||||
defmodule KetbinWeb.UserRegistrationController do
|
||||
use KetbinWeb, :controller
|
||||
|
||||
alias Ketbin.Accounts
|
||||
alias Ketbin.Accounts.User
|
||||
alias KetbinWeb.UserAuth
|
||||
|
||||
def new(conn, _params) do
|
||||
changeset = Accounts.change_user_registration(%User{})
|
||||
render(conn, "new.html", changeset: changeset)
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => user_params}) do
|
||||
case Accounts.register_user(user_params) do
|
||||
{:ok, user} ->
|
||||
{:ok, _} =
|
||||
Accounts.deliver_user_confirmation_instructions(
|
||||
user,
|
||||
&Routes.user_confirmation_url(conn, :confirm, &1)
|
||||
)
|
||||
|
||||
conn
|
||||
|> put_flash(:info, "User created successfully.")
|
||||
|> UserAuth.log_in_user(user)
|
||||
|> redirect(to: "/users/confirm")
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render(conn, "new.html", changeset: changeset)
|
||||
end
|
||||
end
|
||||
end
|
59
lib/ketbin_web/controllers/user_reset_password_controller.ex
Normal file
59
lib/ketbin_web/controllers/user_reset_password_controller.ex
Normal file
@ -0,0 +1,59 @@
|
||||
defmodule KetbinWeb.UserResetPasswordController do
|
||||
use KetbinWeb, :controller
|
||||
|
||||
alias Ketbin.Accounts
|
||||
|
||||
plug :get_user_by_reset_password_token when action in [:edit, :update]
|
||||
|
||||
def new(conn, _params) do
|
||||
render(conn, "new.html")
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => %{"email" => email}}) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_user_reset_password_instructions(
|
||||
user,
|
||||
&Routes.user_reset_password_url(conn, :edit, &1)
|
||||
)
|
||||
end
|
||||
|
||||
# Regardless of the outcome, show an impartial success/error message.
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"If your email is in our system, you will receive instructions to reset your password shortly."
|
||||
)
|
||||
|> redirect(to: "/")
|
||||
end
|
||||
|
||||
def edit(conn, _params) do
|
||||
render(conn, "edit.html", changeset: Accounts.change_user_password(conn.assigns.user))
|
||||
end
|
||||
|
||||
# Do not log in the user after reset password to avoid a
|
||||
# leaked token giving the user access to the account.
|
||||
def update(conn, %{"user" => user_params}) do
|
||||
case Accounts.reset_user_password(conn.assigns.user, user_params) do
|
||||
{:ok, _} ->
|
||||
conn
|
||||
|> put_flash(:info, "Password reset successfully.")
|
||||
|> redirect(to: Routes.user_session_path(conn, :new))
|
||||
|
||||
{:error, changeset} ->
|
||||
render(conn, "edit.html", changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_user_by_reset_password_token(conn, _opts) do
|
||||
%{"token" => token} = conn.params
|
||||
|
||||
if user = Accounts.get_user_by_reset_password_token(token) do
|
||||
conn |> assign(:user, user) |> assign(:token, token)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Reset password link is invalid or it has expired.")
|
||||
|> redirect(to: "/")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
end
|
28
lib/ketbin_web/controllers/user_session_controller.ex
Normal file
28
lib/ketbin_web/controllers/user_session_controller.ex
Normal file
@ -0,0 +1,28 @@
|
||||
defmodule KetbinWeb.UserSessionController do
|
||||
use KetbinWeb, :controller
|
||||
|
||||
alias Ketbin.Accounts
|
||||
alias KetbinWeb.UserAuth
|
||||
|
||||
def new(conn, _params) do
|
||||
render(conn, "new.html", error_message: nil)
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => user_params}) do
|
||||
%{"email" => email, "password" => password} = user_params
|
||||
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
UserAuth.log_in_user(conn, user, user_params)
|
||||
else
|
||||
render(conn, "new.html", error_message: "Invalid email or password")
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> UserAuth.log_out_user()
|
||||
|
||||
conn
|
||||
end
|
||||
end
|
76
lib/ketbin_web/controllers/user_settings_controller.ex
Normal file
76
lib/ketbin_web/controllers/user_settings_controller.ex
Normal file
@ -0,0 +1,76 @@
|
||||
defmodule KetbinWeb.UserSettingsController do
|
||||
use KetbinWeb, :controller
|
||||
|
||||
alias Ketbin.Accounts
|
||||
alias KetbinWeb.UserAuth
|
||||
|
||||
plug :assign_email_and_password_changesets
|
||||
|
||||
def edit(conn, _params) do
|
||||
render(conn, "edit.html")
|
||||
end
|
||||
|
||||
def update(conn, %{"action" => "update_email"} = params) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
user = conn.assigns.current_user
|
||||
|
||||
case Accounts.apply_user_email(user, password, user_params) do
|
||||
{:ok, applied_user} ->
|
||||
Accounts.deliver_update_email_instructions(
|
||||
applied_user,
|
||||
user.email,
|
||||
&Routes.user_settings_url(conn, :confirm_email, &1)
|
||||
)
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"A link to confirm your email change has been sent to the new address."
|
||||
)
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
|
||||
{:error, changeset} ->
|
||||
render(conn, "edit.html", email_changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"action" => "update_password"} = params) do
|
||||
%{"current_password" => password, "user" => user_params} = params
|
||||
user = conn.assigns.current_user
|
||||
|
||||
case Accounts.update_user_password(user, password, user_params) do
|
||||
{:ok, user} ->
|
||||
conn
|
||||
|> put_flash(:info, "Password updated successfully.")
|
||||
|> put_session(:user_return_to, Routes.user_settings_path(conn, :edit))
|
||||
|> UserAuth.log_in_user(user)
|
||||
|
||||
conn
|
||||
|
||||
{:error, changeset} ->
|
||||
render(conn, "edit.html", password_changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def confirm_email(conn, %{"token" => token}) do
|
||||
case Accounts.update_user_email(conn.assigns.current_user, token) do
|
||||
:ok ->
|
||||
conn
|
||||
|> put_flash(:info, "Email changed successfully.")
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
|
||||
:error ->
|
||||
conn
|
||||
|> put_flash(:error, "Email change link is invalid or it has expired.")
|
||||
|> redirect(to: Routes.user_settings_path(conn, :edit))
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_email_and_password_changesets(conn, _opts) do
|
||||
user = conn.assigns.current_user
|
||||
|
||||
conn
|
||||
|> assign(:email_changeset, Accounts.change_user_email(user))
|
||||
|> assign(:password_changeset, Accounts.change_user_password(user))
|
||||
end
|
||||
end
|
56
lib/ketbin_web/endpoint.ex
Normal file
56
lib/ketbin_web/endpoint.ex
Normal file
@ -0,0 +1,56 @@
|
||||
defmodule KetbinWeb.Endpoint do
|
||||
use Phoenix.Endpoint, otp_app: :ketbin
|
||||
|
||||
# The session will be stored in the cookie and signed,
|
||||
# this means its contents can be read but not tampered with.
|
||||
# Set :encryption_salt if you would also like to encrypt it.
|
||||
@session_options [
|
||||
store: :cookie,
|
||||
key: "_ketbin_key",
|
||||
signing_salt: "HmwUQdXk"
|
||||
]
|
||||
|
||||
socket "/socket", KetbinWeb.UserSocket,
|
||||
websocket: true,
|
||||
longpoll: false
|
||||
|
||||
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
|
||||
|
||||
# Serve at "/" the static files from "priv/static" directory.
|
||||
#
|
||||
# You should set gzip to true if you are running phx.digest
|
||||
# when deploying your static files in production.
|
||||
plug Plug.Static,
|
||||
at: "/",
|
||||
from: :ketbin,
|
||||
gzip: false,
|
||||
only: ~w(assets fonts images favicon.ico robots.txt)
|
||||
|
||||
# Code reloading can be explicitly enabled under the
|
||||
# :code_reloader configuration of your endpoint.
|
||||
if code_reloading? do
|
||||
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
|
||||
plug Phoenix.LiveReloader
|
||||
plug Phoenix.CodeReloader
|
||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :ketbin
|
||||
end
|
||||
|
||||
plug Phoenix.LiveDashboard.RequestLogger,
|
||||
param_key: "request_logger",
|
||||
cookie_key: "request_logger"
|
||||
|
||||
plug Plug.RequestId
|
||||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||
|
||||
plug Plug.Parsers,
|
||||
parsers: [:urlencoded, :multipart, :json],
|
||||
length: 10_000_000,
|
||||
pass: ["*/*"],
|
||||
json_decoder: Phoenix.json_library()
|
||||
|
||||
plug Plug.MethodOverride
|
||||
plug Plug.Head
|
||||
plug Plug.Session, @session_options
|
||||
plug CORSPlug
|
||||
plug KetbinWeb.Router
|
||||
end
|
24
lib/ketbin_web/gettext.ex
Normal file
24
lib/ketbin_web/gettext.ex
Normal file
@ -0,0 +1,24 @@
|
||||
defmodule KetbinWeb.Gettext do
|
||||
@moduledoc """
|
||||
A module providing Internationalization with a gettext-based API.
|
||||
|
||||
By using [Gettext](https://hexdocs.pm/gettext),
|
||||
your module gains a set of macros for translations, for example:
|
||||
|
||||
import KetbinWeb.Gettext
|
||||
|
||||
# Simple translation
|
||||
gettext("Here is the string to translate")
|
||||
|
||||
# Plural translation
|
||||
ngettext("Here is the string to translate",
|
||||
"Here are the strings to translate",
|
||||
3)
|
||||
|
||||
# Domain-based translation
|
||||
dgettext("errors", "Here is the error message to translate")
|
||||
|
||||
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
|
||||
"""
|
||||
use Gettext, otp_app: :ketbin
|
||||
end
|
115
lib/ketbin_web/router.ex
Normal file
115
lib/ketbin_web/router.ex
Normal file
@ -0,0 +1,115 @@
|
||||
defmodule KetbinWeb.Router do
|
||||
use KetbinWeb, :router
|
||||
|
||||
import KetbinWeb.UserAuth
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
plug :fetch_flash
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :fetch_current_user
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
plug :fetch_session
|
||||
plug :fetch_current_user
|
||||
end
|
||||
|
||||
# scope to ensure user is authenticated
|
||||
scope "/", KetbinWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
get "/pastes", PageController, :pastes
|
||||
end
|
||||
|
||||
scope "/", KetbinWeb do
|
||||
pipe_through :browser
|
||||
|
||||
get "/", PageController, :index
|
||||
get "/:id/raw", PageController, :raw
|
||||
|
||||
post "/", PageController, :create
|
||||
end
|
||||
|
||||
# scope to check if user is owner of paste
|
||||
scope "/", KetbinWeb do
|
||||
pipe_through [:browser, :owns_paste]
|
||||
|
||||
get "/:id", PageController, :show
|
||||
get "/v/:id", PageController, :showlink
|
||||
end
|
||||
|
||||
# scope to ensure user is owner of paste
|
||||
scope "/", KetbinWeb do
|
||||
pipe_through [:browser, :ensure_owns_paste]
|
||||
|
||||
get "/edit/:id", PageController, :edit
|
||||
patch "/:id", PageController, :update
|
||||
put "/:id", PageController, :update
|
||||
end
|
||||
|
||||
scope "/api", KetbinWeb.Api, as: :api do
|
||||
pipe_through :api
|
||||
|
||||
resources "/paste", PasteController, only: [:show, :index, :create]
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
# scope "/api", KetbinWeb do
|
||||
# pipe_through :api
|
||||
# end
|
||||
|
||||
# Enables LiveDashboard only for development
|
||||
#
|
||||
# If you want to use the LiveDashboard in production, you should put
|
||||
# it behind authentication and allow only admins to access it.
|
||||
# If your application does not have an admins-only section yet,
|
||||
# you can use Plug.BasicAuth to set up some basic authentication
|
||||
# as long as you are also using SSL (which you should anyway).
|
||||
if Mix.env() in [:dev, :test] do
|
||||
import Phoenix.LiveDashboard.Router
|
||||
|
||||
scope "/" do
|
||||
pipe_through :browser
|
||||
|
||||
live_dashboard "/dashboard", metrics: KetbinWeb.Telemetry
|
||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||
end
|
||||
end
|
||||
|
||||
## Authentication routes
|
||||
|
||||
scope "/", KetbinWeb do
|
||||
pipe_through [:browser, :redirect_if_user_is_authenticated]
|
||||
|
||||
get "/users/register", UserRegistrationController, :new
|
||||
post "/users/register", UserRegistrationController, :create
|
||||
get "/users/log_in", UserSessionController, :new
|
||||
post "/users/log_in", UserSessionController, :create
|
||||
get "/users/reset_password", UserResetPasswordController, :new
|
||||
post "/users/reset_password", UserResetPasswordController, :create
|
||||
get "/users/reset_password/:token", UserResetPasswordController, :edit
|
||||
put "/users/reset_password/:token", UserResetPasswordController, :update
|
||||
end
|
||||
|
||||
scope "/", KetbinWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
get "/users/settings", UserSettingsController, :edit
|
||||
put "/users/settings", UserSettingsController, :update
|
||||
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
|
||||
end
|
||||
|
||||
scope "/", KetbinWeb do
|
||||
pipe_through [:browser]
|
||||
|
||||
delete "/users/log_out", UserSessionController, :delete
|
||||
get "/users/confirm", UserConfirmationController, :new
|
||||
post "/users/confirm", UserConfirmationController, :create
|
||||
get "/users/confirm/:token", UserConfirmationController, :confirm
|
||||
|
||||
end
|
||||
end
|
55
lib/ketbin_web/telemetry.ex
Normal file
55
lib/ketbin_web/telemetry.ex
Normal file
@ -0,0 +1,55 @@
|
||||
defmodule KetbinWeb.Telemetry do
|
||||
use Supervisor
|
||||
import Telemetry.Metrics
|
||||
|
||||
def start_link(arg) do
|
||||
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_arg) do
|
||||
children = [
|
||||
# Telemetry poller will execute the given period measurements
|
||||
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
|
||||
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
|
||||
# Add reporters as children of your supervision tree.
|
||||
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def metrics do
|
||||
[
|
||||
# Phoenix Metrics
|
||||
summary("phoenix.endpoint.stop.duration",
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
summary("phoenix.router_dispatch.stop.duration",
|
||||
tags: [:route],
|
||||
unit: {:native, :millisecond}
|
||||
),
|
||||
|
||||
# Database Metrics
|
||||
summary("ketbin.repo.query.total_time", unit: {:native, :millisecond}),
|
||||
summary("ketbin.repo.query.decode_time", unit: {:native, :millisecond}),
|
||||
summary("ketbin.repo.query.query_time", unit: {:native, :millisecond}),
|
||||
summary("ketbin.repo.query.queue_time", unit: {:native, :millisecond}),
|
||||
summary("ketbin.repo.query.idle_time", unit: {:native, :millisecond}),
|
||||
|
||||
# VM Metrics
|
||||
summary("vm.memory.total", unit: {:byte, :kilobyte}),
|
||||
summary("vm.total_run_queue_lengths.total"),
|
||||
summary("vm.total_run_queue_lengths.cpu"),
|
||||
summary("vm.total_run_queue_lengths.io")
|
||||
]
|
||||
end
|
||||
|
||||
defp periodic_measurements do
|
||||
[
|
||||
# A module, function and arguments to be invoked periodically.
|
||||
# This function must call :telemetry.execute/3 and a metric must be added above.
|
||||
# {KetbinWeb, :count_users, []}
|
||||
]
|
||||
end
|
||||
end
|
30
lib/ketbin_web/templates/layout/_user_menu.html.heex
Normal file
30
lib/ketbin_web/templates/layout/_user_menu.html.heex
Normal file
@ -0,0 +1,30 @@
|
||||
<script>
|
||||
function toggleDropdown() {
|
||||
const dropdown = document.querySelector('#dropdown');
|
||||
const dropdownLabel = document.querySelector('#dropdown-label');
|
||||
if (dropdown.classList.contains('hidden')) {
|
||||
dropdown.classList.remove('hidden');
|
||||
dropdown.classList.add('absolute');
|
||||
dropdownLabel.innerHTML = dropdownLabel.innerHTML.replace('▼', '▲');
|
||||
} else {
|
||||
dropdown.classList.add('hidden');
|
||||
dropdown.classList.remove('absolute');
|
||||
dropdownLabel.innerHTML = dropdownLabel.innerHTML.replace('▲', '▼');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<ul>
|
||||
<%= if @current_user do %>
|
||||
<div class="relative">
|
||||
<li id="dropdown-label" onclick="toggleDropdown()" class="text-[#ff9800] cursor-pointer select-none"><%= @current_user.email %> ▼</li>
|
||||
<div id="dropdown" class="hidden right-0 z-50 bg-[#313131] px-4 py-2">
|
||||
<li><%= link "Settings", to: Routes.user_settings_path(@conn, :edit) %></li>
|
||||
<li><%= link "Log out", to: Routes.user_session_path(@conn, :delete), method: :delete %></li>
|
||||
<li><%= link "My Pastes", to: Routes.page_path(@conn, :pastes) %></li>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<li><%= link "Register", to: Routes.user_registration_path(@conn, :new) %></li>
|
||||
<li><%= link "Log in", to: Routes.user_session_path(@conn, :new) %></li>
|
||||
<% end %>
|
||||
</ul>
|
100
lib/ketbin_web/templates/layout/app.html.heex
Normal file
100
lib/ketbin_web/templates/layout/app.html.heex
Normal file
@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<%= if Map.has_key?(assigns, :page_title) do %>
|
||||
<title>Katbin - <%= @page_title %></title>
|
||||
<% else %>
|
||||
<title>Katbin</title>
|
||||
<% end %>
|
||||
<title itemprop="name">Katbin</title>
|
||||
|
||||
<meta property="og:type" content="object">
|
||||
<meta property="og:title" content="Katbin">
|
||||
<meta property="og:image" content="/favicon.ico">
|
||||
|
||||
<meta name="twitter:title" content="Katbin">
|
||||
<meta itemprop="name" content="Katbin">
|
||||
<meta name="application-name" content="Katbin">
|
||||
<meta property="og:site_name" content="Katbin">
|
||||
<meta name="theme-color" content="#1a1a1a">
|
||||
<meta property="og:locale" content="en">
|
||||
<meta name="language" content="en">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:site" content="@Spherical_Kat">
|
||||
<meta name="twitter:image" content="/favicon.ico">
|
||||
|
||||
<meta name="robots" content="index,follow">
|
||||
<meta name="googlebot" content="index,follow">
|
||||
<meta name="keywords" content="paste,pastebin,url,urlshortener,url-shortener,code,programming,bin,cat,kat,paste,share,save,login">
|
||||
<meta name="coverage" content="Worldwide">
|
||||
<meta name="distribution" content="Global">
|
||||
<meta name="HandheldFriendly" content="True">
|
||||
<meta name="HandheldFriendly" content="True">
|
||||
<meta name="apple-mobile-web-app-title" content="Katbin">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-touch-fullscreen" content="yes">
|
||||
|
||||
<%= if assigns[:paste] do %>
|
||||
<meta name="description" content={@paste.content}>
|
||||
<meta itemprop="description" content={@paste.content}>
|
||||
<meta property="og:description" content={@paste.content}>
|
||||
<meta name="twitter:description" content={@paste.content}>
|
||||
|
||||
<meta name="url" content={"https://katb.in/#{@paste.id}"}>
|
||||
<meta name="twitter:url" content={"https://katb.in/#{@paste.id}"}>
|
||||
<meta property="og:url" content={"https://katb.in/#{@paste.id}"}>
|
||||
<% else %>
|
||||
<meta name="description" content="Paste, save, share! A pastebin and URL shortener for all your needs.">
|
||||
<meta property="og:description" content="Paste, save, share! A pastebin and URL shortener for all your needs.">
|
||||
<meta itemprop="og:description" content="Paste, save, share! A pastebin and URL shortener for all your needs.">
|
||||
<meta name="twitter:description" content="Paste, save, share! A pastebin and URL shortener for all your needs.">
|
||||
|
||||
<meta name="url" content="https://katb.in">
|
||||
<meta name="twitter:url" content="https://katb.in">
|
||||
<meta property="og:url" content="https://katb.in">
|
||||
<% end %>
|
||||
|
||||
<link rel="stylesheet" href={ Routes.static_path(@conn, "/assets/app.css") }/>
|
||||
<script defer type="text/javascript" src={ Routes.static_path(@conn, "/assets/app.js") }></script>
|
||||
</head>
|
||||
<body class="flex flex-col ">
|
||||
<header class="flex w-full justify-between items-center py-3 px-6">
|
||||
<a href={ Routes.page_path(@conn, :index) }>
|
||||
<span class="font-semibold text-xl tracking-tight">
|
||||
<span class="text-amber text-xl"><Kat</span>bin/>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav role="navigation">
|
||||
<ul>
|
||||
<%= if function_exported?(Routes, :live_dashboard_path, 2) do %>
|
||||
<li>
|
||||
<%# <%= link "LiveDashboard", to: Routes.live_dashboard_path(@conn, :home) %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<%= render "_user_menu.html", assigns %>
|
||||
</nav>
|
||||
|
||||
</header>
|
||||
<main class="flex flex-col w-full h-full max-h-full overflow-hidden bg-light-grey" role="main">
|
||||
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
|
||||
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
|
||||
<%= @inner_content %>
|
||||
</main>
|
||||
<footer class="font-bold">
|
||||
<div class="flex px-4 py-1 text-xs sm:text-base justify-between text-amber" style="background: #1a1a1a; font-family: JetbrainsMono">
|
||||
<a href="https://kat.bio">
|
||||
© <%= DateTime.utc_now().year %> SphericalKat
|
||||
</a>
|
||||
<a href="https://github.com/sphericalkat/katbin">
|
||||
Fork me!
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
1
lib/ketbin_web/templates/page/edit.html.heex
Normal file
1
lib/ketbin_web/templates/page/edit.html.heex
Normal file
@ -0,0 +1 @@
|
||||
<%= render "form.html", Map.put(assigns, :action, Routes.page_path(@conn, :update, @paste)) |> Map.put(:is_edit, true) %>
|
40
lib/ketbin_web/templates/page/form.html.heex
Normal file
40
lib/ketbin_web/templates/page/form.html.heex
Normal file
@ -0,0 +1,40 @@
|
||||
<%= form_for @changeset, @action, [class: "flex flex-col w-full h-full relative", id: "page_form"], fn f -> %>
|
||||
<script>
|
||||
document.addEventListener("keydown", function(e) {
|
||||
if ((window.navigator.platform.match("Mac") ? e.metaKey : e.ctrlKey) && e.keyCode == 83) {
|
||||
e.preventDefault();
|
||||
document.getElementById("page_form").submit()
|
||||
}
|
||||
}, false);
|
||||
</script>
|
||||
<%= if @changeset.action do %>
|
||||
<div class="w-full text-center bg-amber">
|
||||
<p>Oops, something went wrong!</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if assigns[:already_taken] do %>
|
||||
<div class="w-full text-center bg-amber">
|
||||
<p>This custom URL has already been taken.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="w-full h-full">
|
||||
<%= textarea f, :content, [class: "w-full h-full px-6 py-4 outline-none bg-light-grey font-bold resize-none", placeholder: "> Paste, save, share! (Pasting just a URL will shorten it!)"] %>
|
||||
<div class="flex absolute top-0 right-0 p-4">
|
||||
<%= if @current_user && !assigns[:is_edit] do %>
|
||||
<div>
|
||||
<%= text_input f, :custom_url, [class: "px-2 mr-2 outline-none text-black px-2 py-1", placeholder: "Custom URL"] %>
|
||||
</div>
|
||||
<% end %>
|
||||
<button type="submit">
|
||||
<svg
|
||||
class="h-6 w-6 cursor-pointer fill-current text-white hover:text-amber"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24">
|
||||
<path d="M17.6 3.6c-.4-.4-.9-.6-1.4-.6H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7.8c0-.5-.2-1-.6-1.4l-2.8-2.8zM12 19c-1.7 0-3-1.3-3-3s1.3-3 3-3 3 1.3 3 3-1.3 3-3 3zm1-10H7c-1.1 0-2-.9-2-2s.9-2 2-2h6c1.1 0 2 .9 2 2s-.9 2-2 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
1
lib/ketbin_web/templates/page/index.html.heex
Normal file
1
lib/ketbin_web/templates/page/index.html.heex
Normal file
@ -0,0 +1 @@
|
||||
<%= render "form.html", Map.put(assigns, :action, Routes.page_path(@conn, :create)) %>
|
11
lib/ketbin_web/templates/page/pastes.html.heex
Normal file
11
lib/ketbin_web/templates/page/pastes.html.heex
Normal file
@ -0,0 +1,11 @@
|
||||
<div class="flex relative flex-col w-full h-full">
|
||||
<ul class="break-word py-4 h-full w-full overflow-y-auto">
|
||||
<%= for paste <- @pastes do %>
|
||||
<li class="flex flex-row items-center justify-between">
|
||||
<a href={ Routes.page_path(@conn, :show, paste) } class="">
|
||||
https://katb.in/v/<%= paste.id %>
|
||||
</a>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
16
lib/ketbin_web/templates/page/shorten.html.heex
Normal file
16
lib/ketbin_web/templates/page/shorten.html.heex
Normal file
@ -0,0 +1,16 @@
|
||||
<div class="flex relative flex-col w-full h-full">
|
||||
<div class="flex absolute top-0 right-0 p-4">
|
||||
<%= if @show_edit do%>
|
||||
<a href={ Routes.page_path(@conn, :edit, @paste.id) } class="text-white hover:text-amber">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 cursor-pointer fill-current">
|
||||
<path d="M3 17.46v3.04c0 .28.22.5.5.5h3.04c.13 0 .26-.05.35-.15L17.81 9.94l-3.75-3.75L3.15 17.1c-.1.1-.15.22-.15.36zM20.71 7.04a.996.996 0 0 0 0-1.41l-2.34-2.34a.996.996 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="break-word px-6 py-4 h-full w-full overflow-y-auto">
|
||||
<span>
|
||||
Your shortened url is: <a href={Routes.page_path(@conn, :show, @paste.id) }>https://katb.in/<%= @paste.id %></a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
16
lib/ketbin_web/templates/page/show.html.heex
Normal file
16
lib/ketbin_web/templates/page/show.html.heex
Normal file
@ -0,0 +1,16 @@
|
||||
<div class="flex relative flex-col w-full h-full">
|
||||
<div class="flex absolute top-0 right-0 p-4">
|
||||
<%= if @show_edit do%>
|
||||
<a href={ Routes.page_path(@conn, :edit, @paste.id) } class="text-white hover:text-amber">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-6 w-6 cursor-pointer fill-current">
|
||||
<path d="M3 17.46v3.04c0 .28.22.5.5.5h3.04c.13 0 .26-.05.35-.15L17.81 9.94l-3.75-3.75L3.15 17.1c-.1.1-.15.22-.15.36zM20.71 7.04a.996.996 0 0 0 0-1.41l-2.34-2.34a.996.996 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= if @extension == "md" do%>
|
||||
<div class="break-word px-6 py-4 h-full w-full markdown overflow-y-auto"><%= raw Earmark.as_html!(@paste.content, escape: true, gfm_tables: true) |> HtmlSanitizeEx.markdown_html %></div>
|
||||
<% else %>
|
||||
<code class="break-word px-6 py-4 h-full w-full overflow-y-auto"><%= raw Ketbin.Utils.Syntax.highlight_text(@paste.content, @extension) |> HtmlSanitizeEx.html5 %></code>
|
||||
<% end %>
|
||||
</div>
|
19
lib/ketbin_web/templates/user_confirmation/new.html.heex
Normal file
19
lib/ketbin_web/templates/user_confirmation/new.html.heex
Normal file
@ -0,0 +1,19 @@
|
||||
<div class="flex flex-col w-full h-full justify-center items-center">
|
||||
<h1 class="font-bold text-4xl text-amber pt-4">Resend confirmation instructions</h1>
|
||||
|
||||
<%= form_for :user, Routes.user_confirmation_path(@conn, :create), [class: "flex flex-col h-full justify-center items-start m-auto"], fn f -> %>
|
||||
<div class="flex flex-col w-full">
|
||||
<%= label f, :email %>
|
||||
<%= email_input f, :email, [class: "text-black px-2 py-1 outline-none", required: true] %>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber mt-4 rounded-sm px-2 py-1">
|
||||
<%= submit "Resend confirmation instructions" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p class="mb-4 text-amber">
|
||||
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
|
||||
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
|
||||
</p>
|
||||
</div>
|
32
lib/ketbin_web/templates/user_registration/new.html.heex
Normal file
32
lib/ketbin_web/templates/user_registration/new.html.heex
Normal file
@ -0,0 +1,32 @@
|
||||
<div class="flex flex-col w-full h-full justify-center items-center">
|
||||
<h1 class="font-bold text-4xl text-amber pt-4">Register</h1>
|
||||
|
||||
<%= form_for @changeset, Routes.user_registration_path(@conn, :create), [class: "flex flex-col h-full justify-center items-start m-auto"], fn f -> %>
|
||||
<%= if @changeset.action do %>
|
||||
<div class="alert alert-danger">
|
||||
<p>Oops, something went wrong! Please check the errors below.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<%= label f, :email %>
|
||||
<%= email_input f, :email, [class: "text-black px-2 py-1 outline-none", required: true] %>
|
||||
<%= error_tag f, :email %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col mt-2 w-full">
|
||||
<%= label f, :password, [class: ""] %>
|
||||
<%= password_input f, :password, [class: "text-black px-2 py-1 outline-none", required: true] %>
|
||||
<%= error_tag f, :password %>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber mt-4 rounded-sm px-2 py-1">
|
||||
<%= submit "Sign up" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p class="mb-4 text-amber">
|
||||
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %> <span>|</span>
|
||||
<%= link "Forgot password?", to: Routes.user_reset_password_path(@conn, :new) %>
|
||||
</p>
|
||||
</div>
|
32
lib/ketbin_web/templates/user_reset_password/edit.html.heex
Normal file
32
lib/ketbin_web/templates/user_reset_password/edit.html.heex
Normal file
@ -0,0 +1,32 @@
|
||||
<div class="flex flex-col w-full h-full justify-center items-center">
|
||||
<h1 class="font-bold text-4xl text-amber pt-4">Reset password</h1>
|
||||
|
||||
<%= form_for @changeset, Routes.user_reset_password_path(@conn, :update, @token), [class: "flex flex-col h-full justify-center items-start m-auto"], fn f -> %>
|
||||
<%= if @changeset.action do %>
|
||||
<div class="alert alert-danger">
|
||||
<p>Oops, something went wrong! Please check the errors below.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<%= label f, :password, "New password" %>
|
||||
<%= password_input f, :password, [class: "text-black px-2 py-1 outline-none", required: true] %>
|
||||
<%= error_tag f, :password %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<%= label f, :password_confirmation, "Confirm new password" %>
|
||||
<%= password_input f, :password_confirmation, [class: "text-black px-2 py-1 outline-none", required: true] %>
|
||||
<%= error_tag f, :password_confirmation %>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber mt-4 rounded-sm px-2 py-1">
|
||||
<%= submit "Reset password" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p class="mb-4 text-amber">
|
||||
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
|
||||
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
|
||||
</p>
|
||||
</div>
|
19
lib/ketbin_web/templates/user_reset_password/new.html.heex
Normal file
19
lib/ketbin_web/templates/user_reset_password/new.html.heex
Normal file
@ -0,0 +1,19 @@
|
||||
<div class="flex flex-col w-full h-full justify-center items-center">
|
||||
<h1 class="font-bold text-4xl text-amber pt-4">Forgot your password?</h1>
|
||||
|
||||
<%= form_for :user, Routes.user_reset_password_path(@conn, :create), [class: "flex flex-col h-full justify-center items-start m-auto"], fn f -> %>
|
||||
<div class="flex flex-col w-full">
|
||||
<%= label f, :email %>
|
||||
<%= email_input f, :email, [class: "text-black px-2 py-1 outline-none", required: true] %>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber mt-4 rounded-sm px-2 py-1">
|
||||
<%= submit "Send instructions to reset password" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p class="mb-4 text-amber">
|
||||
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
|
||||
<%= link "Log in", to: Routes.user_session_path(@conn, :new) %>
|
||||
</p>
|
||||
</div>
|
34
lib/ketbin_web/templates/user_session/new.html.heex
Normal file
34
lib/ketbin_web/templates/user_session/new.html.heex
Normal file
@ -0,0 +1,34 @@
|
||||
<div class="flex flex-col w-full h-full justify-center items-center">
|
||||
<h1 class="font-bold text-4xl text-amber pt-4">Log in</h1>
|
||||
|
||||
<%= form_for @conn, Routes.user_session_path(@conn, :create), [as: :user, class: "flex flex-col h-full justify-center items-start m-auto"], fn f -> %>
|
||||
<%= if @error_message do %>
|
||||
<div class="alert alert-danger">
|
||||
<p><%= @error_message %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<%= label f, :email %>
|
||||
<%= email_input f, :email, [class: "text-black px-2 py-1 outline-none", required: true] %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col mt-2 w-full">
|
||||
<%= label f, :password %>
|
||||
<%= password_input f, :password, [class: "text-black px-2 py-1 outline-none", required: true] %>
|
||||
</div>
|
||||
<div class="flex mt-2 w-full justify-center items-center">
|
||||
<%= checkbox f, :remember_me, [class: "mr-2 outline-none"] %>
|
||||
<%= label f, :remember_me, "Keep me logged in for 60 days" %>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber mt-4 rounded-sm px-2 py-1">
|
||||
<%= submit "Log in" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p class="mb-4 text-amber">
|
||||
<%= link "Register", to: Routes.user_registration_path(@conn, :new) %> |
|
||||
<%= link "Forgot your password?", to: Routes.user_reset_password_path(@conn, :new) %>
|
||||
</p>
|
||||
</div>
|
66
lib/ketbin_web/templates/user_settings/edit.html.heex
Normal file
66
lib/ketbin_web/templates/user_settings/edit.html.heex
Normal file
@ -0,0 +1,66 @@
|
||||
<h1 class="font-bold text-4xl text-amber pt-4 w-full text-center">Settings</h1>
|
||||
|
||||
<div class="flex w-full h-full justify-center items-center">
|
||||
<%= form_for @email_changeset, Routes.user_settings_path(@conn, :update), [class: "flex flex-col h-full justify-center items-start m-auto"], fn f -> %>
|
||||
<h3 class="font-bold text-2xl text-amber pb-4">Change email</h3>
|
||||
|
||||
<%= if @email_changeset.action do %>
|
||||
<div class="alert alert-danger">
|
||||
<p>Oops, something went wrong! Please check the errors below.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= hidden_input f, :action, name: "action", value: "update_email" %>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<%= label f, :email %>
|
||||
<%= email_input f, :email, [class: "text-black px-2 py-1 outline-none", required: true] %>
|
||||
<%= error_tag f, :email %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<%= label f, :current_password, for: "current_password_for_email" %>
|
||||
<%= password_input f, :current_password, [class: "text-black px-2 py-1 outline-none", required: true, name: "current_password", id: "current_password_for_email"]%>
|
||||
<%= error_tag f, :current_password %>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber mt-4 rounded-sm px-2 py-1">
|
||||
<%= submit "Change email" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%= form_for @password_changeset, Routes.user_settings_path(@conn, :update), [class: "flex flex-col h-full justify-center items-start m-auto"], fn f -> %>
|
||||
<h3 class="font-bold text-2xl text-amber pb-4">Change password</h3>
|
||||
|
||||
<%= if @password_changeset.action do %>
|
||||
<div class="alert alert-danger">
|
||||
<p>Oops, something went wrong! Please check the errors below.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= hidden_input f, :action, name: "action", value: "update_password" %>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<%= label f, :password, "New password" %>
|
||||
<%= password_input f, :password, [class: "text-black px-2 py-1 outline-none", required: true] %>
|
||||
<%= error_tag f, :password %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<%= label f, :password_confirmation, "Confirm new password" %>
|
||||
<%= password_input f, :password_confirmation, [class: "text-black px-2 py-1 outline-none", required: true] %>
|
||||
<%= error_tag f, :password_confirmation %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full">
|
||||
<%= label f, :current_password, for: "current_password_for_password" %>
|
||||
<%= password_input f, :current_password, [class: "text-black px-2 py-1 outline-none", required: true, name: "current_password", id: "current_password_for_password"] %>
|
||||
<%= error_tag f, :current_password %>
|
||||
</div>
|
||||
|
||||
<div class="bg-amber mt-4 rounded-sm px-2 py-1">
|
||||
<%= submit "Change password" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
18
lib/ketbin_web/views/api/paste_view.ex
Normal file
18
lib/ketbin_web/views/api/paste_view.ex
Normal file
@ -0,0 +1,18 @@
|
||||
defmodule KetbinWeb.Api.PasteView do
|
||||
use KetbinWeb, :view
|
||||
|
||||
def render("paste.json", %{paste: paste}) do
|
||||
%{
|
||||
id: paste.id,
|
||||
content: paste.content,
|
||||
is_url: paste.is_url
|
||||
}
|
||||
end
|
||||
|
||||
def render("error.json", _assigns) do
|
||||
%{
|
||||
success: false,
|
||||
msg: "Something went wrong"
|
||||
}
|
||||
end
|
||||
end
|
47
lib/ketbin_web/views/error_helpers.ex
Normal file
47
lib/ketbin_web/views/error_helpers.ex
Normal file
@ -0,0 +1,47 @@
|
||||
defmodule KetbinWeb.ErrorHelpers do
|
||||
@moduledoc """
|
||||
Conveniences for translating and building error messages.
|
||||
"""
|
||||
|
||||
use Phoenix.HTML
|
||||
|
||||
@doc """
|
||||
Generates tag for inlined form input errors.
|
||||
"""
|
||||
def error_tag(form, field) do
|
||||
Enum.map(Keyword.get_values(form.errors, field), fn error ->
|
||||
content_tag(:span, translate_error(error),
|
||||
class: "absolute top-0 left-0 px-6 py-4",
|
||||
phx_feedback_for: input_name(form, field)
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Translates an error message using gettext.
|
||||
"""
|
||||
def translate_error({msg, opts}) do
|
||||
# When using gettext, we typically pass the strings we want
|
||||
# to translate as a static argument:
|
||||
#
|
||||
# # Translate "is invalid" in the "errors" domain
|
||||
# dgettext("errors", "is invalid")
|
||||
#
|
||||
# # Translate the number of files with plural rules
|
||||
# dngettext("errors", "1 file", "%{count} files", count)
|
||||
#
|
||||
# Because the error messages we show in our forms and APIs
|
||||
# are defined inside Ecto, we need to translate them dynamically.
|
||||
# This requires us to call the Gettext module passing our gettext
|
||||
# backend as first argument.
|
||||
#
|
||||
# Note we use the "errors" domain, which means translations
|
||||
# should be written to the errors.po file. The :count option is
|
||||
# set by Ecto and indicates we should also apply plural rules.
|
||||
if count = opts[:count] do
|
||||
Gettext.dngettext(KetbinWeb.Gettext, "errors", msg, msg, count, opts)
|
||||
else
|
||||
Gettext.dgettext(KetbinWeb.Gettext, "errors", msg, opts)
|
||||
end
|
||||
end
|
||||
end
|
16
lib/ketbin_web/views/error_view.ex
Normal file
16
lib/ketbin_web/views/error_view.ex
Normal file
@ -0,0 +1,16 @@
|
||||
defmodule KetbinWeb.ErrorView do
|
||||
use KetbinWeb, :view
|
||||
|
||||
# If you want to customize a particular status code
|
||||
# for a certain format, you may uncomment below.
|
||||
# def render("500.html", _assigns) do
|
||||
# "Internal Server Error"
|
||||
# end
|
||||
|
||||
# By default, Phoenix returns the status message from
|
||||
# the template name. For example, "404.html" becomes
|
||||
# "Not Found".
|
||||
def template_not_found(template, _assigns) do
|
||||
Phoenix.Controller.status_message_from_template(template)
|
||||
end
|
||||
end
|
3
lib/ketbin_web/views/layout_view.ex
Normal file
3
lib/ketbin_web/views/layout_view.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule KetbinWeb.LayoutView do
|
||||
use KetbinWeb, :view
|
||||
end
|
3
lib/ketbin_web/views/page_view.ex
Normal file
3
lib/ketbin_web/views/page_view.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule KetbinWeb.PageView do
|
||||
use KetbinWeb, :view
|
||||
end
|
3
lib/ketbin_web/views/user_confirmation_view.ex
Normal file
3
lib/ketbin_web/views/user_confirmation_view.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule KetbinWeb.UserConfirmationView do
|
||||
use KetbinWeb, :view
|
||||
end
|
3
lib/ketbin_web/views/user_registration_view.ex
Normal file
3
lib/ketbin_web/views/user_registration_view.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule KetbinWeb.UserRegistrationView do
|
||||
use KetbinWeb, :view
|
||||
end
|
3
lib/ketbin_web/views/user_reset_password_view.ex
Normal file
3
lib/ketbin_web/views/user_reset_password_view.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule KetbinWeb.UserResetPasswordView do
|
||||
use KetbinWeb, :view
|
||||
end
|
3
lib/ketbin_web/views/user_session_view.ex
Normal file
3
lib/ketbin_web/views/user_session_view.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule KetbinWeb.UserSessionView do
|
||||
use KetbinWeb, :view
|
||||
end
|
3
lib/ketbin_web/views/user_settings_view.ex
Normal file
3
lib/ketbin_web/views/user_settings_view.ex
Normal file
@ -0,0 +1,3 @@
|
||||
defmodule KetbinWeb.UserSettingsView do
|
||||
use KetbinWeb, :view
|
||||
end
|
32
lib/release.ex
Normal file
32
lib/release.ex
Normal file
@ -0,0 +1,32 @@
|
||||
defmodule Ketbin.Release do
|
||||
@moduledoc """
|
||||
This module is used by the entrypoint to migrate the database
|
||||
"""
|
||||
|
||||
@app :ketbin
|
||||
|
||||
require Logger
|
||||
|
||||
def migrate do
|
||||
Application.ensure_all_started(@app)
|
||||
load_app()
|
||||
|
||||
for repo <- repos() do
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
||||
end
|
||||
end
|
||||
|
||||
def rollback(repo, version) do
|
||||
load_app()
|
||||
|
||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||
end
|
||||
|
||||
defp repos do
|
||||
Application.fetch_env!(@app, :ecto_repos)
|
||||
end
|
||||
|
||||
defp load_app do
|
||||
Application.load(@app)
|
||||
end
|
||||
end
|
@ -1,6 +0,0 @@
|
||||
-- This file was automatically created by Diesel to setup helper functions
|
||||
-- and other internal bookkeeping. This file is safe to edit, any future
|
||||
-- changes will be added to existing projects as new migrations.
|
||||
|
||||
DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass);
|
||||
DROP FUNCTION IF EXISTS diesel_set_updated_at();
|
@ -1,36 +0,0 @@
|
||||
-- This file was automatically created by Diesel to setup helper functions
|
||||
-- and other internal bookkeeping. This file is safe to edit, any future
|
||||
-- changes will be added to existing projects as new migrations.
|
||||
|
||||
|
||||
|
||||
|
||||
-- Sets up a trigger for the given table to automatically set a column called
|
||||
-- `updated_at` whenever the row is modified (unless `updated_at` was included
|
||||
-- in the modified columns)
|
||||
--
|
||||
-- # Example
|
||||
--
|
||||
-- ```sql
|
||||
-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW());
|
||||
--
|
||||
-- SELECT diesel_manage_updated_at('users');
|
||||
-- ```
|
||||
CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$
|
||||
BEGIN
|
||||
EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s
|
||||
FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF (
|
||||
NEW IS DISTINCT FROM OLD AND
|
||||
NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at
|
||||
) THEN
|
||||
NEW.updated_at := current_timestamp;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS users
|
@ -1,7 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS users
|
||||
(
|
||||
id VARCHAR PRIMARY KEY,
|
||||
username VARCHAR,
|
||||
password VARCHAR,
|
||||
activated BOOLEAN
|
||||
)
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user