Author SHA1 Message Date
supercmmetry 347608b38a feat(utils): write helper fn to verify paste id
Signed-off-by: supercmmetry <vishaals2000@gmail.com>
2021-08-31 09:12:39 +05:30
sphericalkat 1d3a492814 chore: bump npm dependencies
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-25 13:33:45 +05:30
sphericalkat 4388101be3 Merge pull request #6 from gargakshit/feature/esbuild 2021-08-22 17:03:29 +05:30
Akshit Garg a3734cc6ab refactor(styles): move the imports to the top
- Esbuild needs css imports to be on the top of the file for
  them to resolve properly

Signed-off-by: Akshit Garg <garg.akshit@gmail.com>
2021-08-21 18:10:24 +05:30
Akshit Garg 8c99eb04a4 refactor(styles): use JetBrains Mono variable
- Remove Google fonts' "JetBrains Mono" as it didn't support variable
  and add the variable ttf for it

Signed-off-by: Akshit Garg <garg.akshit@gmail.com>
2021-08-21 18:01:27 +05:30
Akshit Garg 0ed4427322 refactor(assets): remove webpack.config.js
Signed-off-by: Akshit Garg <garg.akshit@gmail.com>
2021-08-21 17:50:45 +05:30
Akshit Garg 3f5b1d10ac refactor(assets): swap webpack for esbuild
- Remove all webpack and babel related npm dependencies
- Add esbuild and esbuild-plugin-postcss2 as a dependency
- Add a custom `build.js` script to build css and js with esbuild
  and copy static files to the priv/static directory
- Modify `config/dev.exs` to use that build script as a watcher instead
  of webpack
- Modify `package.json` to use that build script in instead of webpack
- Modify `KetbinWeb.Endpoint` to serve the assets directory instead of
  css and js directories from static
- Modify the `app.html.eex` layout to use `assets` directory instead of
  separate css and js directories

Signed-off-by: Akshit Garg <garg.akshit@gmail.com>
2021-08-21 17:41:46 +05:30
Akshit Garg 12ec98d15e refactor(config): use secret files in dev mode
- Refactor `config/dev.exs` to use `config/dev.secret.exs` instead of
  fetching the SMTP information from the environment and using hardcoded
  database credentials. This is useful when different developers have
  different database credentials and they use different SMTP adapters
- Add a sample `dev.secret.exs` in `config/dev.secret.sample.exs`. Also
  gitignore the origin `dev.secret.exs` file
- Add instructions on how to setup the config file for development in
  `README.md`
- Add `Plug.Swoosh.MailboxPreview` plug on "/mailbox" route in
  development mode for easier development with e-mails

Signed-off-by: Akshit Garg <garg.akshit@gmail.com>
2021-08-21 16:47:31 +05:30
sphericalkat 74ed02c4da chore(build): remove node-sass and sass-loader
fixes freebsd builds

Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-20 07:00:07 +05:30
sphericalkat caf3535e74 feat(mailer): send mail asynchronously
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-20 03:00:17 +05:30
sphericalkat d734e8ad02 refactor(users): add styles for remaining user management templates
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-20 02:27:16 +05:30
sphericalkat 36ad9d1000 feat(users): wire up forgot password to swoosh
closes #4

Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-20 02:09:14 +05:30
sphericalkat 816332bdd6 chore(native): run cargo fmt
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-17 03:24:30 +05:30
sphericalkat 92146cd3dc fix(build): add rust and cargo dependency to builder image
fixes #1

Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-17 03:22:52 +05:30
sphericalkat 2a8ee4cd1e fix(ui): fix lopsided logo
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-17 03:01:29 +05:30
sphericalkat 8b7c362611 fix(pastes): sanitize paste content while rendering
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-17 03:00:24 +05:30
sphericalkat 182a5b1e55 feat(pastes): decide highlighting rules using extension
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-17 02:29:24 +05:30
sphericalkat 425fb2b571 feat(pastes): use rustler for highlight NIF
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-17 02:05:58 +05:30
sphericalkat ed731994d7 chore(nif): add nif stubs
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-16 14:10:22 +05:30
sphericalkat 024b8a7c64 chore: generate a rustler project
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-16 13:58:58 +05:30
sphericalkat 478bea7efb fix(pastes): replacing a paste with a URL now shortens the URL
fixes #5

Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-16 13:53:22 +05:30
sphericalkat 47acc97955 fix(users): fix styles for login page
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-15 05:02:33 +05:30
sphericalkat 7374d7189e refactor(styles): unify look and feel across pages
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-15 04:53:50 +05:30
sphericalkat 0618654ad5 feat(pastes): handle edit permissions using plugs
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-15 04:47:44 +05:30
sphericalkat 58498d47d4 refactor(ui): make alerts look nicer
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-15 04:12:26 +05:30
sphericalkat a195688bdc feat(pastes): implement editing for logged in users
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-15 04:05:39 +05:30
sphericalkat f5525d2d20 feat(paste): add user id if user is logged in
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-15 03:20:03 +05:30
sphericalkat c99df5e8bd feat(user): style register page
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-15 02:18:34 +05:30
sphericalkat 16a0c7ec98 fix(ui): fix misc ui bugs
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-15 01:25:18 +05:30
sphericalkat 75ac6be3a6 feat(pastes): add raw paste handler
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-14 15:19:16 +05:30
sphericalkat 40b55e47de fix(build): prevent all styles from being purged
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-14 15:18:55 +05:30
sphericalkat 0091de08b0 chore(build): add makefile
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-14 14:55:22 +05:30
sphericalkat 5d05a87263 chore: prepare for prod
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-14 01:19:00 +05:30
sphericalkat d369c75fa8 chore(assets): migrate to yarn
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-13 14:17:33 +05:30
sphericalkat 30bbc9b78d chore(build): create dockerfile for release deployment
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-13 14:12:49 +05:30
sphericalkat aa86d9a4b9 chore(styles): clean up unused styles
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-13 12:13:59 +05:30
sphericalkat f411017280 feat(pastes): set up redirection routes
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-13 07:54:07 +05:30
sphericalkat 750d62cc73 feat(pastes): add support for url pastes
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-13 07:46:10 +05:30
sphericalkat 1b54edf7b3 feat(utils): add a phonetic key generator
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-13 07:12:13 +05:30
sphericalkat 13728f2ef8 feat(ui): create idiomatic UI matching previous katbin iteration
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-13 06:33:43 +05:30
sphericalkat c6c54347a7 feat(pastes): add page to view pastes
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-13 05:47:13 +05:30
sphericalkat d818c7033a fix(models): prevent IDs being empty while inserting
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-12 04:29:58 +05:30
sphericalkat 99ad54cf68 chore: generate resources for pastes
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-12 03:28:28 +05:30
sphericalkat 5a7bece725 feat(ui): implement header ui
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-12 02:47:23 +05:30
sphericalkat 92ee2be4cc chore(web): add tailwindcss
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-11 13:46:02 +05:30
sphericalkat 127287f2f6 refactor(user): reduce password length limit to 8 characters
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-11 13:35:35 +05:30
sphericalkat 721bb05cc1 chore(auth): generate code for auth-authz
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-11 12:27:23 +05:30
sphericalkat babcfd62a3 chore(mix): add phx_gen_auth to dependencies
Signed-off-by: SphericalKat <amolele@gmail.com>
2021-08-11 12:25:21 +05:30
sphericalkat 59e55ff848 Revert "feat(user): add ecto migration for user"
This reverts commit 9613fcebde.
2021-08-09 14:38:20 +05:30
supercmmetry 9613fcebde feat(user): add ecto migration for user
Signed-off-by: supercmmetry <vishaals2000@gmail.com>
2021-08-09 14:28:53 +05:30
supercmmetry 6f81702291 chore(git): Initial commit
Signed-off-by: supercmmetry <vishaals2000@gmail.com>
2021-08-09 14:18:23 +05:30
165 changed files with 9978 additions and 3222 deletions

No files matched your search

+37 -4
View File
@@ -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
View File
@@ -0,0 +1,5 @@
[
import_deps: [:ecto, :phoenix],
inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
subdirectories: ["priv/*/migrations"]
]
+39 -3
View File
@@ -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
Generated
-2068
View File
File diff suppressed because it is too large. Load diff
-41
View File
@@ -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"
+46 -11
View File
@@ -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:3.9 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
View File
@@ -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
View File
@@ -0,0 +1,7 @@
.PHONY: image
image:
docker build -t atechnohazard/katbin-elixir .
.PHONY: push
push:
docker push atechnohazard/katbin-elixir
-1
View File
@@ -1 +0,0 @@
web: ROCKET_PORT=$PORT ROCKET_KEEP_ALIVE=0 ./target/release/katbin
+22 -2
View File
@@ -1,2 +1,22 @@
# katbin
Small, lightweight pastebin+URL shortener written in Rust.
# Ketbin
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.
Ready to run in production? Please [check our 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
View File
@@ -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
View File
@@ -0,0 +1,5 @@
{
"presets": [
"@babel/preset-env"
]
}
+38
View 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 watching 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);
});
+65
View File
@@ -0,0 +1,65 @@
/* This file is for your main application css. */
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
@import "./highlight.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;
}
html,
body {
width: 100%;
height: 100%;
}
a:hover {
color: #ff9800;
}
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;
}
.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
View 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;
}
+15
View 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
View 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
+4168
View File
File diff suppressed because it is too large. Load diff
+22
View 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": "^14.0.2",
"tailwindcss": "^2.2.7"
}
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
plugins: {
"postcss-import": {},
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+5
View 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: /
+21
View File
@@ -0,0 +1,21 @@
module.exports = {
purge: [
"../lib/**/*.ex",
"../lib/**/*.leex",
"../lib/**/*.eex",
"./js/**/*.js",
],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {
colors: {
amber: '#ff9800',
'light-grey': '#212121',
},
},
},
variants: {
extend: {},
},
plugins: [],
};
+31
View 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
use Mix.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
View File
@@ -0,0 +1,67 @@
use Mix.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
View 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
View File
@@ -0,0 +1,55 @@
use Mix.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: "dev.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"
+68
View File
@@ -0,0 +1,68 @@
# 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: true,
verify: :verify_peer,
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
View File
@@ -0,0 +1,25 @@
use Mix.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
-29
View File
@@ -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
-51
View File
@@ -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
-34
View File
@@ -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
-31
View File
@@ -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
-9
View File
@@ -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
-19
View File
@@ -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
-10
View File
@@ -1,10 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: front-service
spec:
ports:
- port: 81
targetPort: 5000
selector:
app: front-service
-10
View File
@@ -1,10 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: katbin-service
spec:
ports:
- port: 80
targetPort: 5000
selector:
app: katbin-service
-19
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
# For documentation on how to configure this file,
# see postgres/guides/configuring-diesel-cli
[print_schema]
file = "src/schema.rs"
+9
View 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
View 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
View 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
View 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
View 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
View 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
View File
@@ -0,0 +1,3 @@
defmodule Ketbin.Mailer do
use Swoosh.Mailer, otp_app: :ketbin
end
+104
View File
@@ -0,0 +1,104 @@
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
@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
+19
View File
@@ -0,0 +1,19 @@
defmodule Ketbin.Pastes.Paste do
use Ecto.Schema
import Ecto.Changeset
@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
end
@doc false
def changeset(paste, attrs) do
paste
|> cast(attrs, [:is_url, :content, :id, :belongs_to])
|> validate_required([:is_url, :content])
end
end
+35
View File
@@ -0,0 +1,35 @@
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
def is_valid_name?(name) do
case name do
x when x in ["pastes", "users"] -> false
_ -> Regex.match?(~r/^[a-zA-Z0-9_]*$/, name)
end
end
end
+5
View File
@@ -0,0 +1,5 @@
defmodule Ketbin.Repo do
use Ecto.Repo,
otp_app: :ketbin,
adapter: Ecto.Adapters.Postgres
end
+10
View 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
View 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
View 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
@@ -0,0 +1,148 @@
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 = Pastes.get_paste!(head)
# paste is a url, redirect
# regular paste, show content
if paste.is_url do
redirect(conn, external: paste.content)
else
render(conn, "show.html", paste: paste, show_edit: show_edit, extension: List.first(tail) || "")
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, "show.html", paste: paste, show_edit: show_edit, extension: (if tail == [], do: "", else: tail))
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
# 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, 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} ->
render(conn, "index.html", changeset: changeset)
end
end
def create_custom(%{assigns: %{current_user: current_user}} = conn, %{"paste" => paste_params}) do
# Get ID from params
id = Map.get(paste_params, "id")
# todo: put_flash and preserve paste content
if not Utils.is_valid_name?(id) do
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} ->
render(conn, "index.html", changeset: changeset)
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
end
@@ -0,0 +1,63 @@
defmodule KetbinWeb.PasteController do
use KetbinWeb, :controller
alias Ketbin.Pastes
alias Ketbin.Pastes.Paste
def index(conn, _params) do
pastes = Pastes.list_pastes()
render(conn, "index.html", pastes: pastes)
end
def new(conn, _params) do
changeset = Pastes.change_paste(%Paste{})
render(conn, "new.html", changeset: changeset)
end
def create(conn, %{"paste" => paste_params}) do
# paste_params = Map.put(paste_params, "id", s)
case Pastes.create_paste(paste_params) do
{:ok, paste} ->
conn
|> put_flash(:info, "Paste created successfully.")
|> redirect(to: Routes.paste_path(conn, :show, paste))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
def show(conn, %{"id" => id}) do
paste = Pastes.get_paste!(id)
render(conn, "show.html", paste: paste)
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)
case Pastes.update_paste(paste, paste_params) do
{:ok, paste} ->
conn
|> put_flash(:info, "Paste updated successfully.")
|> redirect(to: Routes.paste_path(conn, :show, paste))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "edit.html", paste: paste, changeset: changeset)
end
end
def delete(conn, %{"id" => id}) do
paste = Pastes.get_paste!(id)
{:ok, _paste} = Pastes.delete_paste(paste)
conn
|> put_flash(:info, "Paste deleted successfully.")
|> redirect(to: Routes.paste_path(conn, :index))
end
end
+170
View File
@@ -0,0 +1,170 @@
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 = Pastes.get_paste!(head)
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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,26 @@
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()
end
end
@@ -0,0 +1,74 @@
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)
{: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
+54
View File
@@ -0,0 +1,54 @@
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],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug KetbinWeb.Router
end
+24
View 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
+101
View File
@@ -0,0 +1,101 @@
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"]
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
# 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
resources "/pastes", PasteController
end
end
+55
View 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
@@ -0,0 +1,10 @@
<ul>
<%= if @current_user do %>
<li><%= @current_user.email %></li>
<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>
<% 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>
@@ -0,0 +1,47 @@
<!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"/>
<title>Katbin</title>
<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">&lt;Kat</span>bin/&gt;
</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://sphericalk.at">
© <%= DateTime.utc_now().year %> SphericalKat
</a>
<a href="https://github.com/sphericalkat/katbin-elixir">
Fork me!
</a>
</div>
</footer>
</body>
</html>
@@ -0,0 +1 @@
<%= render "form.html", Map.put(assigns, :action, Routes.page_path(@conn, :update, @paste)) %>
@@ -0,0 +1,21 @@
<%= form_for @changeset, @action, [class: "flex flex-col w-full h-full relative"], fn f -> %>
<%= if @changeset.action do %>
<div class="w-full text-center bg-amber">
<p>Oops, something went wrong!</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">
<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 %>
@@ -0,0 +1 @@
<%= render "form.html", Map.put(assigns, :action, Routes.page_path(@conn, :create)) %>
@@ -0,0 +1,12 @@
<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>
<code class="break-word px-6 py-4 h-full w-full nomarkdown overflow-y-auto"><%= raw Ketbin.Utils.Syntax.highlight_text(@paste.content, @extension) |> HtmlSanitizeEx.html5 %></code>
</div>
@@ -0,0 +1,5 @@
<h1>Edit Paste</h1>
<%= render "form.html", Map.put(assigns, :action, Routes.paste_path(@conn, :update, @paste)) %>
<span><%= link "Back", to: Routes.paste_path(@conn, :index) %></span>
@@ -0,0 +1,19 @@
<%= form_for @changeset, @action, fn f -> %>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<%= label f, :is_url %>
<%= checkbox f, :is_url %>
<%= error_tag f, :is_url %>
<%= label f, :content %>
<%= textarea f, :content %>
<%= error_tag f, :content %>
<div>
<%= submit "Save" %>
</div>
<% end %>
@@ -0,0 +1,28 @@
<h1>Listing Pastes</h1>
<table>
<thead>
<tr>
<th>Is url</th>
<th>Content</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for paste <- @pastes do %>
<tr>
<td><%= paste.is_url %></td>
<td><%= paste.content %></td>
<td>
<span><%= link "Show", to: Routes.paste_path(@conn, :show, paste) %></span>
<span><%= link "Edit", to: Routes.paste_path(@conn, :edit, paste) %></span>
<span><%= link "Delete", to: Routes.paste_path(@conn, :delete, paste), method: :delete, data: [confirm: "Are you sure?"] %></span>
</td>
</tr>
<% end %>
</tbody>
</table>
<span><%= link "New Paste", to: Routes.paste_path(@conn, :new) %></span>
@@ -0,0 +1 @@
<%= render "form.html", Map.put(assigns, :action, Routes.paste_path(@conn, :create)) %>
@@ -0,0 +1,18 @@
<h1>Show Paste</h1>
<ul>
<li>
<strong>Is url:</strong>
<%= @paste.is_url %>
</li>
<li>
<strong>Content:</strong>
<%= @paste.content %>
</li>
</ul>
<span><%= link "Edit", to: Routes.paste_path(@conn, :edit, @paste) %></span>
<span><%= link "Back", to: Routes.paste_path(@conn, :index) %></span>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
+47
View 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
View 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
View File
@@ -0,0 +1,3 @@
defmodule KetbinWeb.LayoutView do
use KetbinWeb, :view
end
+3
View File
@@ -0,0 +1,3 @@
defmodule KetbinWeb.PageView do
use KetbinWeb, :view
end
+3
View File
@@ -0,0 +1,3 @@
defmodule KetbinWeb.PasteView do
use KetbinWeb, :view
end
@@ -0,0 +1,3 @@
defmodule KetbinWeb.UserConfirmationView do
use KetbinWeb, :view
end
@@ -0,0 +1,3 @@
defmodule KetbinWeb.UserRegistrationView do
use KetbinWeb, :view
end
@@ -0,0 +1,3 @@
defmodule KetbinWeb.UserResetPasswordView do
use KetbinWeb, :view
end
@@ -0,0 +1,3 @@
defmodule KetbinWeb.UserSessionView do
use KetbinWeb, :view
end
@@ -0,0 +1,3 @@
defmodule KetbinWeb.UserSettingsView do
use KetbinWeb, :view
end
+32
View 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
View File
Whitespace-only changes.
@@ -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
)
Loaded 100 of 165 files, more files were not shown because too many files have changed in this diff. Show more