This commit is contained in:
jane 2021-06-14 18:04:46 -04:00
parent 70a315165f
commit 51766c1175
30 changed files with 5841 additions and 1627 deletions

View File

@ -1,9 +1,9 @@
{ {
"secret": "TEST_SECRET", "secret": "TEST_SECRET",
"https": false, "https": false,
"alter_db": true, "alter_db": true,
"port": 8080, "port": 8080,
"db_url": "postgres://postgres:@127.0.0.1/todo", "db_url": "postgres://postgres:@127.0.0.1/todo",
"cert": "", "cert": "",
"cert_key": "" "cert_key": ""
} }

View File

@ -1,21 +1,21 @@
const fs = require('fs'); const fs = require('fs');
if (!global.config) { if (!global.config) {
global.config = {} global.config = {}
const cfg = JSON.parse(fs.readFileSync('./config.json')); const cfg = JSON.parse(fs.readFileSync('./config.json'));
if (cfg) { if (cfg) {
global.config = cfg; global.config = cfg;
} }
} }
class Config { class Config {
get config() { get config() {
return global.config; return global.config;
} }
set config(dat) { set config(dat) {
global.config = dat; global.config = dat;
} }
} }
module.exports = new Config(); module.exports = new Config();

View File

@ -1,77 +1,77 @@
const Sequelize = require('sequelize'); const Sequelize = require('sequelize');
const Config = require('./config.js'); const Config = require('./config.js');
if (!Config.config.db_url) { if (!Config.config.db_url) {
console.error("No database url found. please set `db_url` in config.json"); console.error("No database url found. please set `db_url` in config.json");
process.exit(); process.exit();
} }
const db = new Sequelize(Config.config.db_url); const db = new Sequelize(Config.config.db_url);
const User = db.define('User', { const User = db.define('User', {
id: { id: {
type: Sequelize.DataTypes.UUID, type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.UUIDV4, defaultValue: Sequelize.UUIDV4,
allowNull: false, allowNull: false,
primaryKey: true, primaryKey: true,
unique: true unique: true
}, },
email: { email: {
type: Sequelize.DataTypes.STRING, type: Sequelize.DataTypes.STRING,
allowNull: false, allowNull: false,
unique: true unique: true
}, },
password_hash: { password_hash: {
type: Sequelize.DataTypes.STRING, type: Sequelize.DataTypes.STRING,
allowNull: true allowNull: true
} }
}); });
const Todo = db.define('Todo', { const Todo = db.define('Todo', {
id: { id: {
type: Sequelize.DataTypes.UUID, type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.UUIDV4, defaultValue: Sequelize.UUIDV4,
allowNull: false, allowNull: false,
primaryKey: true, primaryKey: true,
unique: true unique: true
}, },
content: { content: {
type: Sequelize.DataTypes.TEXT, type: Sequelize.DataTypes.TEXT,
allowNull: false allowNull: false
} }
}); });
const Tag = db.define('Tag', { const Tag = db.define('Tag', {
id: { id: {
type: Sequelize.DataTypes.UUID, type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.UUIDV4, defaultValue: Sequelize.UUIDV4,
allowNull: false, allowNull: false,
primaryKey: true, primaryKey: true,
unique: true unique: true
}, },
content: { content: {
type: Sequelize.DataTypes.STRING, type: Sequelize.DataTypes.STRING,
allowNull: false allowNull: false
} }
}); });
User.hasMany(Todo); User.hasMany(Todo);
Todo.hasMany(Tag); Todo.hasMany(Tag);
let options = { let options = {
alter: false alter: false
}; };
if (Config.config.alter_db) { if (Config.config.alter_db) {
options.alter = true; options.alter = true;
} }
User.sync(options); User.sync(options);
module.exports = { module.exports = {
db: db, db: db,
constructors: { constructors: {
user: () => { return User.build(); } user: () => { return User.build(); }
}, },
schemas: { schemas: {
user: User user: User
} }
} }

View File

@ -1,62 +1,62 @@
const http = require('http'); const http = require('http');
const https = require('https'); const https = require('https');
const cors = require('cors'); const cors = require('cors');
const express = require('express'); const express = require('express');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const Config = require('./config.js'); const Config = require('./config.js');
const UserInterface = require('./user.js'); const UserInterface = require('./user.js');
let credentials = {}; let credentials = {};
if (Config.config.https) { if (Config.config.https) {
if ( if (
fs.existsSync(Config.config.cert) && fs.existsSync(Config.config.cert) &&
fs.existsSync(Config.config.cert_key) fs.existsSync(Config.config.cert_key)
) { ) {
credentials.key = fs.readFileSync(Config.config.cert_key); credentials.key = fs.readFileSync(Config.config.cert_key);
credentials.cert = fs.readFileSync(Config.config.cert); credentials.cert = fs.readFileSync(Config.config.cert);
} }
} }
let app = express(); let app = express();
app.use(cors()); app.use(cors());
app.use(cookieParser()); app.use(cookieParser());
// force https // force https
app.use((req, res, next) => { app.use((req, res, next) => {
if (Config.config.https) { if (Config.config.https) {
if (req.headers['x-forwarded-proto'] !== 'https') { if (req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect(`https://${req.headers.host}${req.url}`); return res.redirect(`https://${req.headers.host}${req.url}`);
} }
} }
return next(); return next();
}); });
if (!Config.config.secret) { if (!Config.config.secret) {
console.error('No password secret found. please set `secret` in config.json'); console.error('No password secret found. please set `secret` in config.json');
process.exit(); process.exit();
} else if (Config.config.https && Config.config.secret == 'TEST_SECRET') { } else if (Config.config.https && Config.config.secret == 'TEST_SECRET') {
console.error('please do not use the testing secret in production.'); console.error('please do not use the testing secret in production.');
process.exit(); process.exit();
} }
app.use('/api/user', UserInterface.router); app.use('/api/user', UserInterface.router);
// serve static files last // serve static files last
app.use(express.static('./static')); app.use(express.static('./static'));
// DISABLED: no longer needs to serve static files // DISABLED: no longer needs to serve static files
// due to frontend being employed in elm // due to frontend being employed in elm
if (Config.config.https) { if (Config.config.https) {
var server = https.createServer(credentials, app); var server = https.createServer(credentials, app);
server.listen(Config.config.port || 8080); server.listen(Config.config.port || 8080);
} else { } else {
var server = http.createServer(app); var server = http.createServer(app);
server.listen(Config.config.port || 8080); server.listen(Config.config.port || 8080);
} }
console.log( console.log(
`listening on port ${Config.config.port || 8080}` + `listening on port ${Config.config.port || 8080}` +
` with https ${Config.config.https ? 'enabled' : 'disabled'}` ` with https ${Config.config.https ? 'enabled' : 'disabled'}`
); );

View File

@ -1,235 +1,235 @@
const express = require('express'); const express = require('express');
const crypto = require('crypto'); const crypto = require('crypto');
const Config = require('./config.js'); const Config = require('./config.js');
const Database = require('./db_interface.js'); const Database = require('./db_interface.js');
let router = express.Router(); let router = express.Router();
router.use(express.json()); router.use(express.json());
let session_entropy = {}; let session_entropy = {};
user_cache = {}; user_cache = {};
email_cache = {}; email_cache = {};
async function get_user_details(id) { async function get_user_details(id) {
if (!id) { if (!id) {
return undefined; return undefined;
} }
console.log(`search for user with id ${id}`); console.log(`search for user with id ${id}`);
if (!user_cache[id]) { if (!user_cache[id]) {
let user = await Database.schemas.user.findOne({where: {id: id}}); let user = await Database.schemas.user.findOne({where: {id: id}});
if (!user) { if (!user) {
return undefined; return undefined;
} }
user_cache[user.id] = { user_cache[user.id] = {
id: user.id, id: user.id,
email: user.email, email: user.email,
password_hash: user.password_hash, password_hash: user.password_hash,
}; };
email_cache[user.email] = user.id; email_cache[user.email] = user.id;
} }
console.log(`returning ${JSON.stringify(user_cache[id])}`); console.log(`returning ${JSON.stringify(user_cache[id])}`);
return user_cache[id]; return user_cache[id];
} }
async function get_user_details_by_email(email) { async function get_user_details_by_email(email) {
if (!email) { if (!email) {
return undefined; return undefined;
} }
console.log(`search for user with email ${email}}`); console.log(`search for user with email ${email}}`);
if (!email_cache[email] || !user_cache[email_cache[email]]) { if (!email_cache[email] || !user_cache[email_cache[email]]) {
let user = await Database.schemas.user.findOne({where: {email: email}}); let user = await Database.schemas.user.findOne({where: {email: email}});
if (!user) { if (!user) {
return undefined; return undefined;
} }
user_cache[user.id] = { user_cache[user.id] = {
id: user.id, id: user.id,
email: user.email, email: user.email,
password_hash: user.password_hash, password_hash: user.password_hash,
}; };
email_cache[user.email] = user.id; email_cache[user.email] = user.id;
} }
console.log(`returning ${JSON.stringify(user_cache[email_cache[email]])}`); console.log(`returning ${JSON.stringify(user_cache[email_cache[email]])}`);
return user_cache[email_cache[email]]; return user_cache[email_cache[email]];
} }
router.get('/byEmail/:email', async (req, res) => { router.get('/byEmail/:email', async (req, res) => {
if (!req.params?.email) { if (!req.params?.email) {
res.status(400).json({ res.status(400).json({
error: 'email is a required parameter', error: 'email is a required parameter',
}); });
} }
let user = get_user_details_by_email(req.params.email); let user = get_user_details_by_email(req.params.email);
console.log(user); console.log(user);
if (user != null) { if (user != null) {
res.json({ res.json({
id: user.id, id: user.id,
email: user.email, email: user.email,
}); });
} else { } else {
res.sendStatus(404); res.sendStatus(404);
} }
}); });
function hash(secret, password) { function hash(secret, password) {
let pw_hash = crypto.pbkdf2Sync( let pw_hash = crypto.pbkdf2Sync(
password, password,
secret, secret,
Config.config.key?.iterations || 1000, Config.config.key?.iterations || 1000,
Config.config.key?.length || 64, Config.config.key?.length || 64,
'sha512' 'sha512'
); );
return pw_hash.toString('base64'); return pw_hash.toString('base64');
} }
function verify(secret, password, hash) { function verify(secret, password, hash) {
let pw_hash = crypto.pbkdf2Sync( let pw_hash = crypto.pbkdf2Sync(
password, password,
secret, secret,
Config.config.key?.iterations || 1000, Config.config.key?.iterations || 1000,
Config.config.key?.length || 64, Config.config.key?.length || 64,
'sha512' 'sha512'
); );
return hash === pw_hash.toString('base64'); return hash === pw_hash.toString('base64');
} }
function hash_password(password) { function hash_password(password) {
return hash(Config.config.secret, password); return hash(Config.config.secret, password);
} }
function verify_password(password, hash) { function verify_password(password, hash) {
return verify(Config.config.secret, password, hash); return verify(Config.config.secret, password, hash);
} }
function get_session_token(id, token) { function get_session_token(id, token) {
session_entropy[id] = crypto.randomBytes(Config.config.session_entropy || 32); session_entropy[id] = crypto.randomBytes(Config.config.session_entropy || 32);
return hash(session_entropy[id], token); return hash(session_entropy[id], token);
} }
function verify_session_token(id, hash, token) { function verify_session_token(id, hash, token) {
if (session_entropy[id]) { if (session_entropy[id]) {
return verify(session_entropy[id], hash, token); return verify(session_entropy[id], hash, token);
} else { } else {
return false; return false;
} }
} }
async function enforce_session_login(req, res, next) { async function enforce_session_login(req, res, next) {
let userid = req.cookies?.userid; let userid = req.cookies?.userid;
let session_token = req.cookies?._session; let session_token = req.cookies?._session;
console.log('a', userid, session_token); console.log('a', userid, session_token);
if (!userid || !session_token) { if (!userid || !session_token) {
return res.sendStatus(401); return res.sendStatus(401);
} }
let user = await get_user_details(userid); let user = await get_user_details(userid);
if (!user) { if (!user) {
return res.sendStatus(401); return res.sendStatus(401);
} }
let verified_session = verify_session_token( let verified_session = verify_session_token(
userid, userid,
user.password_hash, user.password_hash,
session_token session_token
); );
if (!verified_session) { if (!verified_session) {
return res.sendStatus(401); return res.sendStatus(401);
} }
return next(); return next();
} }
router.post('/new', async (req, res) => { router.post('/new', async (req, res) => {
if (!req.body?.email || !req.body?.password) { if (!req.body?.email || !req.body?.password) {
return res.status(400).json({ return res.status(400).json({
error: 'must have email and password fields', error: 'must have email and password fields',
}); });
} }
let user = await get_user_details_by_email(req.body.email); let user = await get_user_details_by_email(req.body.email);
console.log(user); console.log(user);
if (user != null) { if (user != null) {
return res.status(403).json({ return res.status(403).json({
error: `email ${req.body.email} is already in use.`, error: `email ${req.body.email} is already in use.`,
}); });
} else { } else {
let user = await Database.schemas.user.create({ let user = await Database.schemas.user.create({
email: String(req.body.email), email: String(req.body.email),
password_hash: hash_password(req.body.password), password_hash: hash_password(req.body.password),
}); });
return res.json({ return res.json({
id: user.id, id: user.id,
email: user.email, email: user.email,
}); });
} }
}); });
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
if (!req.body?.email || !req.body?.password) { if (!req.body?.email || !req.body?.password) {
return res.status(400).json({ return res.status(400).json({
error: 'must have email and password fields', error: 'must have email and password fields',
}); });
} }
let user = await get_user_details_by_email(req.body.email); let user = await get_user_details_by_email(req.body.email);
if (!user) { if (!user) {
return res.status(401).json({ return res.status(401).json({
error: 'incorrect email or password', error: 'incorrect email or password',
}); });
} }
let verified = verify_password(req.body.password, user.password_hash); let verified = verify_password(req.body.password, user.password_hash);
if (!verified) { if (!verified) {
return res.status(401).json({ return res.status(401).json({
error: 'incorrect email or password', error: 'incorrect email or password',
}); });
} }
res.cookie('userid', user.id, { res.cookie('userid', user.id, {
httpOnly: true, httpOnly: true,
secure: true, secure: true,
}); });
res.cookie('_session', get_session_token(user.id, user.password_hash), { res.cookie('_session', get_session_token(user.id, user.password_hash), {
httpOnly: true, httpOnly: true,
secure: true, secure: true,
}); });
return res.sendStatus(204); return res.sendStatus(204);
}); });
router.get('/:id([a-f0-9-]+)', async (req, res) => { router.get('/:id([a-f0-9-]+)', async (req, res) => {
console.log(req.params); console.log(req.params);
if (!req.params?.id) { if (!req.params?.id) {
return res.status(400).json({ return res.status(400).json({
error: 'must have id parameter', error: 'must have id parameter',
}); });
} }
let id = req.params?.id; let id = req.params?.id;
console.log(id); console.log(id);
let user = await get_user_details(id); let user = await get_user_details(id);
console.log(user); console.log(user);
if (user != null) { if (user != null) {
return res.json({ return res.json({
id: user.id, id: user.id,
email: user.email, email: user.email,
}); });
} else { } else {
return res.sendStatus(404); return res.sendStatus(404);
} }
}); });
router.use('/authorized', enforce_session_login); router.use('/authorized', enforce_session_login);
router.get('/authorized', async (req, res) => { router.get('/authorized', async (req, res) => {
let userid = req.cookies?.userid; let userid = req.cookies?.userid;
let user = await get_user_details(userid); let user = await get_user_details(userid);
return res.json({ return res.json({
authorized: true, authorized: true,
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
}, },
}); });
}); });
module.exports = { module.exports = {
router: router, router: router,
enforce_session_login: enforce_session_login, enforce_session_login: enforce_session_login,
}; };

View File

@ -1,290 +1,290 @@
port module Api exposing (Cred, addServerError, application, decodeErrors, delete, get, login, logout, post, put, register, settings, storeCredWith, username, viewerChanges) port module Api exposing (Cred, addServerError, application, decodeErrors, delete, get, login, logout, post, put, register, settings, storeCredWith, username, viewerChanges)
{-| This module is responsible for communicating to the Conduit API. {-| This module is responsible for communicating to the Conduit API.
It exposes an opaque Endpoint type which is guaranteed to point to the correct URL. It exposes an opaque Endpoint type which is guaranteed to point to the correct URL.
-} -}
import Api.Endpoint as Endpoint exposing (Endpoint) import Api.Endpoint as Endpoint exposing (Endpoint)
import Avatar exposing (Avatar) import Avatar exposing (Avatar)
import Browser import Browser
import Browser.Navigation as Nav import Browser.Navigation as Nav
import Http exposing (Body, Expect) import Http exposing (Body, Expect)
import Json.Decode as Decode exposing (Decoder, Value, decodeString, field, string) import Json.Decode as Decode exposing (Decoder, Value, decodeString, field, string)
import Json.Encode as Encode import Json.Encode as Encode
import Url exposing (Url) import Url exposing (Url)
import Username exposing (Username) import Username exposing (Username)
-- CRED -- CRED
{-| The authentication credentials for the Viewer (that is, the currently logged-in user.) {-| The authentication credentials for the Viewer (that is, the currently logged-in user.)
This includes: This includes:
- The cred's Username - The cred's Username
- The cred's authentication token - The cred's authentication token
By design, there is no way to access the token directly as a String. By design, there is no way to access the token directly as a String.
It can be encoded for persistence, and it can be added to a header It can be encoded for persistence, and it can be added to a header
to a HttpBuilder for a request, but that's it. to a HttpBuilder for a request, but that's it.
This token should never be rendered to the end user, and with this API, it This token should never be rendered to the end user, and with this API, it
can't be! can't be!
-} -}
type Cred type Cred
= Cred Username String = Cred Username String
username : Cred -> Username username : Cred -> Username
username (Cred val _) = username (Cred val _) =
val val
credHeader : Cred -> Http.Header credHeader : Cred -> Http.Header
credHeader (Cred _ str) = credHeader (Cred _ str) =
Http.header "authorization" ("Token " ++ str) Http.header "authorization" ("Token " ++ str)
{-| It's important that this is never exposed! {-| It's important that this is never exposed!
We expose `login` and `application` instead, so we can be certain that if anyone We expose `login` and `application` instead, so we can be certain that if anyone
ever has access to a `Cred` value, it came from either the login API endpoint ever has access to a `Cred` value, it came from either the login API endpoint
or was passed in via flags. or was passed in via flags.
-} -}
credDecoder : Decoder Cred credDecoder : Decoder Cred
credDecoder = credDecoder =
Decode.succeed Cred Decode.succeed Cred
|> field "username" Username.decoder |> field "username" Username.decoder
|> field "token" Decode.string |> field "token" Decode.string
-- PERSISTENCE -- PERSISTENCE
decode : Decoder (Cred -> viewer) -> Value -> Result Decode.Error viewer decode : Decoder (Cred -> viewer) -> Value -> Result Decode.Error viewer
decode decoder value = decode decoder value =
-- It's stored in localStorage as a JSON String; -- It's stored in localStorage as a JSON String;
-- first decode the Value as a String, then -- first decode the Value as a String, then
-- decode that String as JSON. -- decode that String as JSON.
Decode.decodeValue Decode.string value Decode.decodeValue Decode.string value
|> Result.andThen (\str -> Decode.decodeString (Decode.field "user" (decoderFromCred decoder)) str) |> Result.andThen (\str -> Decode.decodeString (Decode.field "user" (decoderFromCred decoder)) str)
port onStoreChange : (Value -> msg) -> Sub msg port onStoreChange : (Value -> msg) -> Sub msg
viewerChanges : (Maybe viewer -> msg) -> Decoder (Cred -> viewer) -> Sub msg viewerChanges : (Maybe viewer -> msg) -> Decoder (Cred -> viewer) -> Sub msg
viewerChanges toMsg decoder = viewerChanges toMsg decoder =
onStoreChange (\value -> toMsg (decodeFromChange decoder value)) onStoreChange (\value -> toMsg (decodeFromChange decoder value))
decodeFromChange : Decoder (Cred -> viewer) -> Value -> Maybe viewer decodeFromChange : Decoder (Cred -> viewer) -> Value -> Maybe viewer
decodeFromChange viewerDecoder val = decodeFromChange viewerDecoder val =
-- It's stored in localStorage as a JSON String; -- It's stored in localStorage as a JSON String;
-- first decode the Value as a String, then -- first decode the Value as a String, then
-- decode that String as JSON. -- decode that String as JSON.
Decode.decodeValue (storageDecoder viewerDecoder) val Decode.decodeValue (storageDecoder viewerDecoder) val
|> Result.toMaybe |> Result.toMaybe
storeCredWith : Cred -> Avatar -> Cmd msg storeCredWith : Cred -> Avatar -> Cmd msg
storeCredWith (Cred uname token) avatar = storeCredWith (Cred uname token) avatar =
let let
json = json =
Encode.object Encode.object
[ ( "user" [ ( "user"
, Encode.object , Encode.object
[ ( "username", Username.encode uname ) [ ( "username", Username.encode uname )
, ( "token", Encode.string token ) , ( "token", Encode.string token )
, ( "image", Avatar.encode avatar ) , ( "image", Avatar.encode avatar )
] ]
) )
] ]
in in
storeCache (Just json) storeCache (Just json)
logout : Cmd msg logout : Cmd msg
logout = logout =
storeCache Nothing storeCache Nothing
port storeCache : Maybe Value -> Cmd msg port storeCache : Maybe Value -> Cmd msg
-- SERIALIZATION -- SERIALIZATION
-- APPLICATION -- APPLICATION
application : application :
Decoder (Cred -> viewer) Decoder (Cred -> viewer)
-> ->
{ init : Maybe viewer -> Url -> Nav.Key -> ( model, Cmd msg ) { init : Maybe viewer -> Url -> Nav.Key -> ( model, Cmd msg )
, onUrlChange : Url -> msg , onUrlChange : Url -> msg
, onUrlRequest : Browser.UrlRequest -> msg , onUrlRequest : Browser.UrlRequest -> msg
, subscriptions : model -> Sub msg , subscriptions : model -> Sub msg
, update : msg -> model -> ( model, Cmd msg ) , update : msg -> model -> ( model, Cmd msg )
, view : model -> Browser.Document msg , view : model -> Browser.Document msg
} }
-> Program Value model msg -> Program Value model msg
application viewerDecoder config = application viewerDecoder config =
let let
init flags url navKey = init flags url navKey =
let let
maybeViewer = maybeViewer =
Decode.decodeValue Decode.string flags Decode.decodeValue Decode.string flags
|> Result.andThen (Decode.decodeString (storageDecoder viewerDecoder)) |> Result.andThen (Decode.decodeString (storageDecoder viewerDecoder))
|> Result.toMaybe |> Result.toMaybe
in in
config.init maybeViewer url navKey config.init maybeViewer url navKey
in in
Browser.application Browser.application
{ init = init { init = init
, onUrlChange = config.onUrlChange , onUrlChange = config.onUrlChange
, onUrlRequest = config.onUrlRequest , onUrlRequest = config.onUrlRequest
, subscriptions = config.subscriptions , subscriptions = config.subscriptions
, update = config.update , update = config.update
, view = config.view , view = config.view
} }
storageDecoder : Decoder (Cred -> viewer) -> Decoder viewer storageDecoder : Decoder (Cred -> viewer) -> Decoder viewer
storageDecoder viewerDecoder = storageDecoder viewerDecoder =
Decode.field "user" (decoderFromCred viewerDecoder) Decode.field "user" (decoderFromCred viewerDecoder)
-- HTTP -- HTTP
get : Endpoint -> Maybe Cred -> Decoder a -> Cmd a get : Endpoint -> Maybe Cred -> Decoder a -> Cmd a
get url maybeCred decoder = get url maybeCred decoder =
Endpoint.request Endpoint.request
{ method = "GET" { method = "GET"
, url = url , url = url
, expect = Http.expectJson decoder , expect = Http.expectJson decoder
, headers = , headers =
case maybeCred of case maybeCred of
Just cred -> Just cred ->
[ credHeader cred ] [ credHeader cred ]
Nothing -> Nothing ->
[] []
, body = Http.emptyBody , body = Http.emptyBody
, timeout = Nothing , timeout = Nothing
, withCredentials = False , withCredentials = False
} }
put : Endpoint -> Cred -> Body -> Decoder a -> Cmd a put : Endpoint -> Cred -> Body -> Decoder a -> Cmd a
put url cred body decoder = put url cred body decoder =
Endpoint.request Endpoint.request
{ method = "PUT" { method = "PUT"
, url = url , url = url
, expect = Http.expectJson decoder , expect = Http.expectJson decoder
, headers = [ credHeader cred ] , headers = [ credHeader cred ]
, body = body , body = body
, timeout = Nothing , timeout = Nothing
, withCredentials = False , withCredentials = False
} }
post : Endpoint -> Maybe Cred -> Body -> Decoder a -> Cmd a post : Endpoint -> Maybe Cred -> Body -> Decoder a -> Cmd a
post url maybeCred body decoder = post url maybeCred body decoder =
Endpoint.request Endpoint.request
{ method = "POST" { method = "POST"
, url = url , url = url
, expect = Http.expectJson decoder , expect = Http.expectJson decoder
, headers = , headers =
case maybeCred of case maybeCred of
Just cred -> Just cred ->
[ credHeader cred ] [ credHeader cred ]
Nothing -> Nothing ->
[] []
, body = body , body = body
, timeout = Nothing , timeout = Nothing
, withCredentials = False , withCredentials = False
} }
delete : Endpoint -> Cred -> Body -> Decoder a -> Cmd a delete : Endpoint -> Cred -> Body -> Decoder a -> Cmd a
delete url cred body decoder = delete url cred body decoder =
Endpoint.request Endpoint.request
{ method = "DELETE" { method = "DELETE"
, url = url , url = url
, expect = Http.expectJson decoder , expect = Http.expectJson decoder
, headers = [ credHeader cred ] , headers = [ credHeader cred ]
, body = body , body = body
, timeout = Nothing , timeout = Nothing
, withCredentials = False , withCredentials = False
} }
login : Http.Body -> Decoder (Cred -> a) -> Cmd a login : Http.Body -> Decoder (Cred -> a) -> Cmd a
login body decoder = login body decoder =
post Endpoint.login Nothing body (Decode.field "user" (decoderFromCred decoder)) post Endpoint.login Nothing body (Decode.field "user" (decoderFromCred decoder))
register : Http.Body -> Decoder (Cred -> a) -> Cmd a register : Http.Body -> Decoder (Cred -> a) -> Cmd a
register body decoder = register body decoder =
post Endpoint.users Nothing body (Decode.field "user" (decoderFromCred decoder)) post Endpoint.users Nothing body (Decode.field "user" (decoderFromCred decoder))
settings : Cred -> Http.Body -> Decoder (Cred -> a) -> Cmd a settings : Cred -> Http.Body -> Decoder (Cred -> a) -> Cmd a
settings cred body decoder = settings cred body decoder =
put Endpoint.user cred body (Decode.field "user" (decoderFromCred decoder)) put Endpoint.user cred body (Decode.field "user" (decoderFromCred decoder))
decoderFromCred : Decoder (Cred -> a) -> Decoder a decoderFromCred : Decoder (Cred -> a) -> Decoder a
decoderFromCred decoder = decoderFromCred decoder =
Decode.map2 (\fromCred cred -> fromCred cred) Decode.map2 (\fromCred cred -> fromCred cred)
decoder decoder
credDecoder credDecoder
-- ERRORS -- ERRORS
addServerError : List String -> List String addServerError : List String -> List String
addServerError list = addServerError list =
"Server error" :: list "Server error" :: list
{-| Many API endpoints include an "errors" field in their BadStatus responses. {-| Many API endpoints include an "errors" field in their BadStatus responses.
-} -}
decodeErrors : Http.Error -> List String decodeErrors : Http.Error -> List String
decodeErrors error = decodeErrors error =
case error of case error of
Http.BadStatus errid -> Http.BadStatus errid ->
[ Int.toString errid ] [ Int.toString errid ]
err -> err ->
[ "Server error" ] [ "Server error" ]
errorsDecoder : Decoder (List String) errorsDecoder : Decoder (List String)
errorsDecoder = errorsDecoder =
Decode.keyValuePairs (Decode.list Decode.string) Decode.keyValuePairs (Decode.list Decode.string)
|> Decode.map (List.concatMap fromPair) |> Decode.map (List.concatMap fromPair)
fromPair : ( String, List String ) -> List String fromPair : ( String, List String ) -> List String
fromPair ( field, errors ) = fromPair ( field, errors ) =
List.map (\error -> field ++ " " ++ error) errors List.map (\error -> field ++ " " ++ error) errors
-- LOCALSTORAGE KEYS -- LOCALSTORAGE KEYS
cacheStorageKey : String cacheStorageKey : String
cacheStorageKey = cacheStorageKey =
"cache" "cache"
credStorageKey : String credStorageKey : String
credStorageKey = credStorageKey =
"cred" "cred"

View File

@ -1,99 +1,99 @@
module Api.Endpoint exposing (Endpoint, login, request, tags, todo, todoList, user, users) module Api.Endpoint exposing (Endpoint, login, request, tags, todo, todoList, user, users)
import Http import Http
import Todo.UUID as UUID exposing (UUID) import Todo.UUID as UUID exposing (UUID)
import Url.Builder exposing (QueryParameter) import Url.Builder exposing (QueryParameter)
import Username exposing (Username) import Username exposing (Username)
{-| Http.request, except it takes an Endpoint instead of a Url. {-| Http.request, except it takes an Endpoint instead of a Url.
-} -}
request : request :
{ body : Http.Body { body : Http.Body
, expect : Http.Expect a , expect : Http.Expect a
, headers : List Http.Header , headers : List Http.Header
, method : String , method : String
, timeout : Maybe Float , timeout : Maybe Float
, url : Endpoint , url : Endpoint
, tracker : Maybe String , tracker : Maybe String
} }
-> Cmd a -> Cmd a
request config = request config =
Http.request Http.request
{ body = config.body { body = config.body
, expect = config.expect , expect = config.expect
, headers = config.headers , headers = config.headers
, method = config.method , method = config.method
, timeout = config.timeout , timeout = config.timeout
, url = unwrap config.url , url = unwrap config.url
, tracker = config.tracker , tracker = config.tracker
} }
-- TYPES -- TYPES
{-| Get a URL to the Conduit API. {-| Get a URL to the Conduit API.
This is not publicly exposed, because we want to make sure the only way to get one of these URLs is from this module. This is not publicly exposed, because we want to make sure the only way to get one of these URLs is from this module.
-} -}
type Endpoint type Endpoint
= Endpoint String = Endpoint String
unwrap : Endpoint -> String unwrap : Endpoint -> String
unwrap (Endpoint str) = unwrap (Endpoint str) =
str str
url : List String -> List QueryParameter -> Endpoint url : List String -> List QueryParameter -> Endpoint
url paths queryParams = url paths queryParams =
-- NOTE: Url.Builder takes care of percent-encoding special URL characters. -- NOTE: Url.Builder takes care of percent-encoding special URL characters.
-- See https://package.elm-lang.org/packages/elm/url/latest/Url#percentEncode -- See https://package.elm-lang.org/packages/elm/url/latest/Url#percentEncode
Url.Builder.crossOrigin "https://conduit.productionready.io" Url.Builder.crossOrigin "https://conduit.productionready.io"
("api" :: paths) ("api" :: paths)
queryParams queryParams
|> Endpoint |> Endpoint
-- ENDPOINTS -- ENDPOINTS
login : Endpoint login : Endpoint
login = login =
url [ "users", "login" ] [] url [ "users", "login" ] []
user : Endpoint user : Endpoint
user = user =
url [ "user" ] [] url [ "user" ] []
users : Endpoint users : Endpoint
users = users =
url [ "users" ] [] url [ "users" ] []
follow : Username -> Endpoint follow : Username -> Endpoint
follow uname = follow uname =
url [ "profiles", Username.toString uname, "follow" ] [] url [ "profiles", Username.toString uname, "follow" ] []
-- ARTICLE ENDPOINTS -- ARTICLE ENDPOINTS
todo : UUID -> Endpoint todo : UUID -> Endpoint
todo uuid = todo uuid =
url [ "articles", UUID.toString uuid ] [] url [ "articles", UUID.toString uuid ] []
todoList : List QueryParameter -> Endpoint todoList : List QueryParameter -> Endpoint
todoList params = todoList params =
url [ "articles" ] params url [ "articles" ] params
tags : Endpoint tags : Endpoint
tags = tags =
url [ "tags" ] [] url [ "tags" ] []

View File

@ -1,46 +1,46 @@
module Asset exposing (Image, defaultAvatar, error, loading, src) module Asset exposing (Image, defaultAvatar, error, loading, src)
{-| Assets, such as images, videos, and audio. (We only have images for now.) {-| Assets, such as images, videos, and audio. (We only have images for now.)
We should never expose asset URLs directly; this module should be in charge of We should never expose asset URLs directly; this module should be in charge of
all of them. One source of truth! all of them. One source of truth!
-} -}
import Html exposing (Attribute, Html) import Html exposing (Attribute, Html)
import Html.Attributes as Attr import Html.Attributes as Attr
type Image type Image
= Image String = Image String
-- IMAGES -- IMAGES
error : Image error : Image
error = error =
image "error.jpg" image "error.jpg"
loading : Image loading : Image
loading = loading =
image "loading.svg" image "loading.svg"
defaultAvatar : Image defaultAvatar : Image
defaultAvatar = defaultAvatar =
image "smiley-cyrus.jpg" image "smiley-cyrus.jpg"
image : String -> Image image : String -> Image
image filename = image filename =
Image ("/assets/images/" ++ filename) Image ("/assets/images/" ++ filename)
-- USING IMAGES -- USING IMAGES
src : Image -> Attribute msg src : Image -> Attribute msg
src (Image url) = src (Image url) =
Attr.src url Attr.src url

View File

@ -1,56 +1,56 @@
module Avatar exposing (Avatar, decoder, encode, src, toMaybeString) module Avatar exposing (Avatar, decoder, encode, src, toMaybeString)
import Asset import Asset
import Html exposing (Attribute) import Html exposing (Attribute)
import Html.Attributes import Html.Attributes
import Json.Decode as Decode exposing (Decoder) import Json.Decode as Decode exposing (Decoder)
import Json.Encode as Encode exposing (Value) import Json.Encode as Encode exposing (Value)
-- TYPES -- TYPES
type Avatar type Avatar
= Avatar (Maybe String) = Avatar (Maybe String)
-- CREATE -- CREATE
decoder : Decoder Avatar decoder : Decoder Avatar
decoder = decoder =
Decode.map Avatar (Decode.nullable Decode.string) Decode.map Avatar (Decode.nullable Decode.string)
-- TRANSFORM -- TRANSFORM
encode : Avatar -> Value encode : Avatar -> Value
encode (Avatar maybeUrl) = encode (Avatar maybeUrl) =
case maybeUrl of case maybeUrl of
Just url -> Just url ->
Encode.string url Encode.string url
Nothing -> Nothing ->
Encode.null Encode.null
src : Avatar -> Attribute msg src : Avatar -> Attribute msg
src (Avatar maybeUrl) = src (Avatar maybeUrl) =
case maybeUrl of case maybeUrl of
Nothing -> Nothing ->
Asset.src Asset.defaultAvatar Asset.src Asset.defaultAvatar
Just "" -> Just "" ->
Asset.src Asset.defaultAvatar Asset.src Asset.defaultAvatar
Just url -> Just url ->
Html.Attributes.src url Html.Attributes.src url
toMaybeString : Avatar -> Maybe String toMaybeString : Avatar -> Maybe String
toMaybeString (Avatar maybeUrl) = toMaybeString (Avatar maybeUrl) =
maybeUrl maybeUrl

View File

@ -1,38 +1,38 @@
module Email exposing (Email, decoder, encode, toString) module Email exposing (Email, decoder, encode, toString)
import Json.Decode as Decode exposing (Decoder) import Json.Decode as Decode exposing (Decoder)
import Json.Encode as Encode exposing (Value) import Json.Encode as Encode exposing (Value)
{-| An email address. {-| An email address.
Having this as a custom type that's separate from String makes certain Having this as a custom type that's separate from String makes certain
mistakes impossible. Consider this function: mistakes impossible. Consider this function:
updateEmailAddress : Email -> String -> Http.Request updateEmailAddress : Email -> String -> Http.Request
updateEmailAddress email password = ... updateEmailAddress email password = ...
(The server needs your password to confirm that you should be allowed (The server needs your password to confirm that you should be allowed
to update the email address.) to update the email address.)
Because Email is not a type alias for String, but is instead a separate Because Email is not a type alias for String, but is instead a separate
custom type, it is now impossible to mix up the argument order of the custom type, it is now impossible to mix up the argument order of the
email and the password. If we do, it won't compile! email and the password. If we do, it won't compile!
If Email were instead defined as `type alias Email = String`, we could If Email were instead defined as `type alias Email = String`, we could
call updateEmailAddress password email and it would compile (and never call updateEmailAddress password email and it would compile (and never
work properly). work properly).
This way, we make it impossible for a bug like that to compile! This way, we make it impossible for a bug like that to compile!
-} -}
type Email type Email
= Email String = Email String
toString : Email -> String toString : Email -> String
toString (Email str) = toString (Email str) =
str str
encode : Email -> Value encode : Email -> Value
encode (Email str) = encode (Email str) =
Encode.string str Encode.string str
decoder : Decoder Email decoder : Decoder Email
decoder = decoder =
Decode.map Email Decode.string Decode.map Email Decode.string

View File

@ -1,57 +1,57 @@
module NavRow exposing (..) module NavRow exposing (..)
import CommonElements exposing (..) import CommonElements exposing (..)
import Element exposing (..) import Element exposing (..)
import Element.Region as Region import Element.Region as Region
import Model exposing (..) import Model exposing (..)
import Request exposing (..) import Request exposing (..)
import Url import Url
getNavRow : Model -> Element Msg getNavRow : Model -> Element Msg
getNavRow model = getNavRow model =
row row
[ Region.navigation [ Region.navigation
--, explain Debug.todo --, explain Debug.todo
, paddingXY 10 5 , paddingXY 10 5
, spacing 10 , spacing 10
, width fill , width fill
] ]
[ namedLink "/" "TODOAPP" [ namedLink "/" "TODOAPP"
, getDebugInfo model , getDebugInfo model
, getCurrentUser model , getCurrentUser model
] ]
-- temp function to get current page url -- temp function to get current page url
-- and links in case shit breaks -- and links in case shit breaks
getDebugInfo : Model -> Element Msg getDebugInfo : Model -> Element Msg
getDebugInfo model = getDebugInfo model =
row row
[ centerX ] [ centerX ]
[ text "Current URL: " [ text "Current URL: "
, bolded (Url.toString model.url) , bolded (Url.toString model.url)
, column [] , column []
[ namedLink "/" "root" [ namedLink "/" "root"
, namedLink "/login" "login" , namedLink "/login" "login"
, namedLink "/signup" "signup" , namedLink "/signup" "signup"
, namedLink "/account" "account" , namedLink "/account" "account"
, namedLink "/about" "about" , namedLink "/about" "about"
] ]
] ]
getCurrentUser : Model -> Element Msg getCurrentUser : Model -> Element Msg
getCurrentUser model = getCurrentUser model =
el [] el []
(case model.user of (case model.user of
Just user -> Just user ->
namedLink "/account" user.email namedLink "/account" user.email
_ -> _ ->
namedLink "/login" "Log In" namedLink "/login" "Log In"
) )

View File

@ -1,66 +1,66 @@
module PageState exposing (getMainContent) module PageState exposing (getMainContent)
import About as AboutPage import About as AboutPage
import Account as AccountPage import Account as AccountPage
import Element exposing (..) import Element exposing (..)
import Home as HomePage import Home as HomePage
import Login as LoginPage import Login as LoginPage
import Model exposing (..) import Model exposing (..)
import Signup as SignupPage import Signup as SignupPage
import Url import Url
import Url.Parser as Parser exposing (..) import Url.Parser as Parser exposing (..)
type Route type Route
= About = About
| Account | Account
| Home | Home
| Login | Login
| Signup | Signup
| NotFound | NotFound
getMainContent : Model -> Element Msg getMainContent : Model -> Element Msg
getMainContent model = getMainContent model =
el [] el []
(case consume (toRoute model.url) of (case consume (toRoute model.url) of
About -> About ->
AboutPage.getPage model AboutPage.getPage model
Account -> Account ->
AccountPage.getPage model AccountPage.getPage model
Home -> Home ->
HomePage.getPage model HomePage.getPage model
Login -> Login ->
LoginPage.getPage model LoginPage.getPage model
Signup -> Signup ->
SignupPage.getPage model SignupPage.getPage model
_ -> _ ->
text "Page not found." text "Page not found."
) )
routeParser : Parser (Route -> a) a routeParser : Parser (Route -> a) a
routeParser = routeParser =
oneOf oneOf
[ Parser.map Home top [ Parser.map Home top
, Parser.map Account (s "account") , Parser.map Account (s "account")
, Parser.map About (s "about") , Parser.map About (s "about")
, Parser.map Login (s "login") , Parser.map Login (s "login")
, Parser.map Signup (s "signup") , Parser.map Signup (s "signup")
] ]
toRoute : Url.Url -> Maybe Route toRoute : Url.Url -> Maybe Route
toRoute url = toRoute url =
{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing } { url | path = Maybe.withDefault "" url.fragment, fragment = Nothing }
|> Parser.parse routeParser |> Parser.parse routeParser
consume : Maybe Route -> Route consume : Maybe Route -> Route
consume route = consume route =
Maybe.withDefault NotFound route Maybe.withDefault NotFound route

View File

@ -1,98 +1,98 @@
module Route exposing (Route(..), fromUrl, href, replaceUrl) module Route exposing (Route(..), fromUrl, href, replaceUrl)
import Browser.Navigation as Nav import Browser.Navigation as Nav
import Html exposing (Attribute) import Html exposing (Attribute)
import Html.Attributes as Attr import Html.Attributes as Attr
import Todo.UUID as UUID import Todo.UUID as UUID
import Url exposing (Url) import Url exposing (Url)
import Url.Parser as Parser exposing ((</>), Parser, oneOf, s, string) import Url.Parser as Parser exposing ((</>), Parser, oneOf, s, string)
import Username exposing (Username) import Username exposing (Username)
-- ROUTING -- ROUTING
type Route type Route
= Home = Home
| Login | Login
| Logout | Logout
| Signup | Signup
| Account | Account
| Todo UUID.UUID | Todo UUID.UUID
| NewTodo | NewTodo
| EditTodo UUID.UUID | EditTodo UUID.UUID
parser : Parser (Route -> a) a parser : Parser (Route -> a) a
parser = parser =
oneOf oneOf
[ Parser.map Home Parser.top [ Parser.map Home Parser.top
, Parser.map Login (s "login") , Parser.map Login (s "login")
, Parser.map Logout (s "logout") , Parser.map Logout (s "logout")
, Parser.map Account (s "account") , Parser.map Account (s "account")
, Parser.map Signup (s "signup") , Parser.map Signup (s "signup")
, Parser.map Todo (s "article" </> UUID.urlParser) , Parser.map Todo (s "article" </> UUID.urlParser)
, Parser.map NewTodo (s "editor") , Parser.map NewTodo (s "editor")
, Parser.map EditTodo (s "editor" </> UUID.urlParser) , Parser.map EditTodo (s "editor" </> UUID.urlParser)
] ]
-- PUBLIC HELPERS -- PUBLIC HELPERS
href : Route -> Attribute msg href : Route -> Attribute msg
href targetRoute = href targetRoute =
Attr.href (routeToString targetRoute) Attr.href (routeToString targetRoute)
replaceUrl : Nav.Key -> Route -> Cmd msg replaceUrl : Nav.Key -> Route -> Cmd msg
replaceUrl key route = replaceUrl key route =
Nav.replaceUrl key (routeToString route) Nav.replaceUrl key (routeToString route)
fromUrl : Url -> Maybe Route fromUrl : Url -> Maybe Route
fromUrl url = fromUrl url =
-- The RealWorld spec treats the fragment like a path. -- The RealWorld spec treats the fragment like a path.
-- This makes it *literally* the path, so we can proceed -- This makes it *literally* the path, so we can proceed
-- with parsing as if it had been a normal path all along. -- with parsing as if it had been a normal path all along.
{ url | path = Maybe.withDefault "" url.fragment, fragment = Nothing } { url | path = Maybe.withDefault "" url.fragment, fragment = Nothing }
|> Parser.parse parser |> Parser.parse parser
-- INTERNAL -- INTERNAL
routeToString : Route -> String routeToString : Route -> String
routeToString page = routeToString page =
"#/" ++ String.join "/" (routeToPieces page) "#/" ++ String.join "/" (routeToPieces page)
routeToPieces : Route -> List String routeToPieces : Route -> List String
routeToPieces page = routeToPieces page =
case page of case page of
Home -> Home ->
[] []
Login -> Login ->
[ "login" ] [ "login" ]
Logout -> Logout ->
[ "logout" ] [ "logout" ]
Signup -> Signup ->
[ "Signup" ] [ "Signup" ]
Account -> Account ->
[ "account" ] [ "account" ]
Todo uuid -> Todo uuid ->
[ "article", UUID.toString uuid ] [ "article", UUID.toString uuid ]
NewTodo -> NewTodo ->
[ "editor" ] [ "editor" ]
EditTodo uuid -> EditTodo uuid ->
[ "editor", UUID.toString uuid ] [ "editor", UUID.toString uuid ]

View File

@ -1,75 +1,75 @@
module Session exposing (Session, changes, cred, fromViewer, navKey, viewer) module Session exposing (Session, changes, cred, fromViewer, navKey, viewer)
import Api exposing (Cred) import Api exposing (Cred)
import Avatar exposing (Avatar) import Avatar exposing (Avatar)
import Browser.Navigation as Nav import Browser.Navigation as Nav
import Json.Decode as Decode exposing (Decoder) import Json.Decode as Decode exposing (Decoder)
import Json.Encode as Encode exposing (Value) import Json.Encode as Encode exposing (Value)
import Profile exposing (Profile) import Profile exposing (Profile)
import Time import Time
import Viewer exposing (Viewer) import Viewer exposing (Viewer)
-- TYPES -- TYPES
type Session type Session
= LoggedIn Nav.Key Viewer = LoggedIn Nav.Key Viewer
| Guest Nav.Key | Guest Nav.Key
-- INFO -- INFO
viewer : Session -> Maybe Viewer viewer : Session -> Maybe Viewer
viewer session = viewer session =
case session of case session of
LoggedIn _ val -> LoggedIn _ val ->
Just val Just val
Guest _ -> Guest _ ->
Nothing Nothing
cred : Session -> Maybe Cred cred : Session -> Maybe Cred
cred session = cred session =
case session of case session of
LoggedIn _ val -> LoggedIn _ val ->
Just (Viewer.cred val) Just (Viewer.cred val)
Guest _ -> Guest _ ->
Nothing Nothing
navKey : Session -> Nav.Key navKey : Session -> Nav.Key
navKey session = navKey session =
case session of case session of
LoggedIn key _ -> LoggedIn key _ ->
key key
Guest key -> Guest key ->
key key
-- CHANGES -- CHANGES
changes : (Session -> msg) -> Nav.Key -> Sub msg changes : (Session -> msg) -> Nav.Key -> Sub msg
changes toMsg key = changes toMsg key =
Api.viewerChanges (\maybeViewer -> toMsg (fromViewer key maybeViewer)) Viewer.decoder Api.viewerChanges (\maybeViewer -> toMsg (fromViewer key maybeViewer)) Viewer.decoder
fromViewer : Nav.Key -> Maybe Viewer -> Session fromViewer : Nav.Key -> Maybe Viewer -> Session
fromViewer key maybeViewer = fromViewer key maybeViewer =
-- It's stored in localStorage as a JSON String; -- It's stored in localStorage as a JSON String;
-- first decode the Value as a String, then -- first decode the Value as a String, then
-- decode that String as JSON. -- decode that String as JSON.
case maybeViewer of case maybeViewer of
Just viewerVal -> Just viewerVal ->
LoggedIn key viewerVal LoggedIn key viewerVal
Nothing -> Nothing ->
Guest key Guest key

View File

@ -1,253 +1,253 @@
module Todo exposing (Full, Preview, Todo, author, body, favorite, favoriteButton, fetch, fromPreview, fullDecoder, mapAuthor, metadata, previewDecoder, unfavorite, unfavoriteButton, uuid) module Todo exposing (Full, Preview, Todo, author, body, favorite, favoriteButton, fetch, fromPreview, fullDecoder, mapAuthor, metadata, previewDecoder, unfavorite, unfavoriteButton, uuid)
{-| The interface to the Todo data structure. {-| The interface to the Todo data structure.
This includes: This includes:
- The Todo type itself - The Todo type itself
- Ways to make HTTP requests to retrieve and modify Todos - Ways to make HTTP requests to retrieve and modify Todos
- Ways to access information about an Todo - Ways to access information about an Todo
- Converting between various types - Converting between various types
-} -}
import Api exposing (Cred) import Api exposing (Cred)
import Api.Endpoint as Endpoint import Api.Endpoint as Endpoint
import Author exposing (Author) import Author exposing (Author)
import Element exposing (..) import Element exposing (..)
import Http import Http
import Iso8601 import Iso8601
import Json.Decode as Decode exposing (Decoder) import Json.Decode as Decode exposing (Decoder)
import Json.Encode as Encode import Json.Encode as Encode
import Markdown import Markdown
import Time import Time
import Todo.Body as Body exposing (Body) import Todo.Body as Body exposing (Body)
import Todo.Tag as Tag exposing (Tag) import Todo.Tag as Tag exposing (Tag)
import Todo.UUID exposing (UUID) import Todo.UUID exposing (UUID)
import Username as Username exposing (Username) import Username as Username exposing (Username)
import Viewer exposing (Viewer) import Viewer exposing (Viewer)
-- TYPES -- TYPES
{-| An Todo, optionally with an Todo body. {-| An Todo, optionally with an Todo body.
To see the difference between { extraInfo : a } and { extraInfo : Maybe Body }, To see the difference between { extraInfo : a } and { extraInfo : Maybe Body },
consider the difference between the "view individual Todo" page (which consider the difference between the "view individual Todo" page (which
renders one Todo, including its body) and the "Todo feed" - renders one Todo, including its body) and the "Todo feed" -
which displays multiple Todos, but without bodies. which displays multiple Todos, but without bodies.
This definition for `Todo` means we can write: This definition for `Todo` means we can write:
viewTodo : Todo Full -> Html msg viewTodo : Todo Full -> Html msg
viewFeed : List (Todo Preview) -> Html msg viewFeed : List (Todo Preview) -> Html msg
This indicates that `viewTodo` requires an Todo _with a `body` present_, This indicates that `viewTodo` requires an Todo _with a `body` present_,
wereas `viewFeed` accepts Todos with no bodies. (We could also have written wereas `viewFeed` accepts Todos with no bodies. (We could also have written
it as `List (Todo a)` to specify that feeds can accept either Todos that it as `List (Todo a)` to specify that feeds can accept either Todos that
have `body` present or not. Either work, given that feeds do not attempt to have `body` present or not. Either work, given that feeds do not attempt to
read the `body` field from Todos.) read the `body` field from Todos.)
This is an important distinction, because in Request.Todo, the `feed` This is an important distinction, because in Request.Todo, the `feed`
function produces `List (Todo Preview)` because the API does not return bodies. function produces `List (Todo Preview)` because the API does not return bodies.
Those Todos are useful to the feed, but not to the individual Todo view. Those Todos are useful to the feed, but not to the individual Todo view.
-} -}
type Todo a type Todo a
= Todo Internals a = Todo Internals a
{-| Metadata about the Todo - its title, description, and so on. {-| Metadata about the Todo - its title, description, and so on.
Importantly, this module's public API exposes a way to read this metadata, but Importantly, this module's public API exposes a way to read this metadata, but
not to alter it. This is read-only information! not to alter it. This is read-only information!
If we find ourselves using any particular piece of metadata often, If we find ourselves using any particular piece of metadata often,
for example `title`, we could expose a convenience function like this: for example `title`, we could expose a convenience function like this:
Todo.title : Todo a -> String Todo.title : Todo a -> String
If you like, it's totally reasonable to expose a function like that for every one If you like, it's totally reasonable to expose a function like that for every one
of these fields! of these fields!
(Okay, to be completely honest, exposing one function per field is how I prefer (Okay, to be completely honest, exposing one function per field is how I prefer
to do it, and that's how I originally wrote this module. However, I'm aware that to do it, and that's how I originally wrote this module. However, I'm aware that
this code base has become a common reference point for beginners, and I think it this code base has become a common reference point for beginners, and I think it
is _extremely important_ that slapping some "getters and setters" on a record is _extremely important_ that slapping some "getters and setters" on a record
does not become a habit for anyone who is getting started with Elm. The whole does not become a habit for anyone who is getting started with Elm. The whole
point of making the Todo type opaque is to create guarantees through point of making the Todo type opaque is to create guarantees through
_selectively choosing boundaries_ around it. If you aren't selective about _selectively choosing boundaries_ around it. If you aren't selective about
where those boundaries are, and instead expose a "getter and setter" for every where those boundaries are, and instead expose a "getter and setter" for every
field in the record, the result is an API with no more guarantees than if you'd field in the record, the result is an API with no more guarantees than if you'd
exposed the entire record directly! It is so important to me that beginners not exposed the entire record directly! It is so important to me that beginners not
fall into the terrible "getters and setters" trap that I've exposed this fall into the terrible "getters and setters" trap that I've exposed this
Metadata record instead of exposing a single function for each of its fields, Metadata record instead of exposing a single function for each of its fields,
as I did originally. This record is not a bad way to do it, by any means, as I did originally. This record is not a bad way to do it, by any means,
but if this seems at odds with <https://youtu.be/x1FU3e0sT1I> - now you know why! but if this seems at odds with <https://youtu.be/x1FU3e0sT1I> - now you know why!
) )
-} -}
type alias Metadata = type alias Metadata =
{ description : String { description : String
, title : String , title : String
, tags : List String , tags : List String
, createdAt : Time.Posix , createdAt : Time.Posix
, favorited : Bool , favorited : Bool
, favoritesCount : Int , favoritesCount : Int
} }
type alias Internals = type alias Internals =
{ uuid : UUID { uuid : UUID
, author : Author , author : Author
, metadata : Metadata , metadata : Metadata
} }
type Preview type Preview
= Preview = Preview
type Full type Full
= Full Body = Full Body
-- INFO -- INFO
author : Todo a -> Author author : Todo a -> Author
author (Todo internals _) = author (Todo internals _) =
internals.author internals.author
metadata : Todo a -> Metadata metadata : Todo a -> Metadata
metadata (Todo internals _) = metadata (Todo internals _) =
internals.metadata internals.metadata
uuid : Todo a -> UUID uuid : Todo a -> UUID
uuid (Todo internals _) = uuid (Todo internals _) =
internals.uuid internals.uuid
body : Todo Full -> Body body : Todo Full -> Body
body (Todo _ (Full extraInfo)) = body (Todo _ (Full extraInfo)) =
extraInfo extraInfo
-- TRANSFORM -- TRANSFORM
{-| This is the only way you can transform an existing Todo: {-| This is the only way you can transform an existing Todo:
you can change its author (e.g. to follow or unfollow them). you can change its author (e.g. to follow or unfollow them).
All other Todo data necessarily comes from the server! All other Todo data necessarily comes from the server!
We can tell this for sure by looking at the types of the exposed functions We can tell this for sure by looking at the types of the exposed functions
in this module. in this module.
-} -}
mapAuthor : (Author -> Author) -> Todo a -> Todo a mapAuthor : (Author -> Author) -> Todo a -> Todo a
mapAuthor transform (Todo info extras) = mapAuthor transform (Todo info extras) =
Todo { info | author = transform info.author } extras Todo { info | author = transform info.author } extras
fromPreview : Body -> Todo Preview -> Todo Full fromPreview : Body -> Todo Preview -> Todo Full
fromPreview newBody (Todo info Preview) = fromPreview newBody (Todo info Preview) =
Todo info (Full newBody) Todo info (Full newBody)
-- SERIALIZATION -- SERIALIZATION
previewDecoder : Maybe Cred -> Decoder (Todo Preview) previewDecoder : Maybe Cred -> Decoder (Todo Preview)
previewDecoder maybeCred = previewDecoder maybeCred =
Decode.succeed Todo Decode.succeed Todo
|> custom (internalsDecoder maybeCred) |> custom (internalsDecoder maybeCred)
|> hardcoded Preview |> hardcoded Preview
fullDecoder : Maybe Cred -> Decoder (Todo Full) fullDecoder : Maybe Cred -> Decoder (Todo Full)
fullDecoder maybeCred = fullDecoder maybeCred =
Decode.succeed Todo Decode.succeed Todo
|> custom (internalsDecoder maybeCred) |> custom (internalsDecoder maybeCred)
|> required "body" (Decode.map Full Body.decoder) |> required "body" (Decode.map Full Body.decoder)
internalsDecoder : Maybe Cred -> Decoder Internals internalsDecoder : Maybe Cred -> Decoder Internals
internalsDecoder maybeCred = internalsDecoder maybeCred =
Decode.succeed Internals Decode.succeed Internals
|> required "uuid" UUID.decoder |> required "uuid" UUID.decoder
|> required "author" (Author.decoder maybeCred) |> required "author" (Author.decoder maybeCred)
|> custom metadataDecoder |> custom metadataDecoder
metadataDecoder : Decoder Metadata metadataDecoder : Decoder Metadata
metadataDecoder = metadataDecoder =
Decode.succeed Metadata Decode.succeed Metadata
|> required "description" (Decode.map (Maybe.withDefault "") (Decode.nullable Decode.string)) |> required "description" (Decode.map (Maybe.withDefault "") (Decode.nullable Decode.string))
|> required "title" Decode.string |> required "title" Decode.string
|> required "tagList" (Decode.list Decode.string) |> required "tagList" (Decode.list Decode.string)
|> required "createdAt" Iso8601.decoder |> required "createdAt" Iso8601.decoder
|> required "favorited" Decode.bool |> required "favorited" Decode.bool
|> required "favoritesCount" Decode.int |> required "favoritesCount" Decode.int
-- SINGLE -- SINGLE
fetch : Maybe Cred -> UUID -> Http.Request (Todo Full) fetch : Maybe Cred -> UUID -> Http.Request (Todo Full)
fetch maybeCred uuid = fetch maybeCred uuid =
Decode.field "Todo" (fullDecoder maybeCred) Decode.field "Todo" (fullDecoder maybeCred)
|> Api.get (Endpoint.Todo uuid) maybeCred |> Api.get (Endpoint.Todo uuid) maybeCred
-- FAVORITE -- FAVORITE
favorite : UUID -> Cred -> Http.Request (Todo Preview) favorite : UUID -> Cred -> Http.Request (Todo Preview)
favorite uuid cred = favorite uuid cred =
Api.post (Endpoint.favorite uuid) (Just cred) Http.emptyBody (faveDecoder cred) Api.post (Endpoint.favorite uuid) (Just cred) Http.emptyBody (faveDecoder cred)
unfavorite : UUID -> Cred -> Http.Request (Todo Preview) unfavorite : UUID -> Cred -> Http.Request (Todo Preview)
unfavorite uuid cred = unfavorite uuid cred =
Api.delete (Endpoint.favorite uuid) cred Http.emptyBody (faveDecoder cred) Api.delete (Endpoint.favorite uuid) cred Http.emptyBody (faveDecoder cred)
faveDecoder : Cred -> Decoder (Todo Preview) faveDecoder : Cred -> Decoder (Todo Preview)
faveDecoder cred = faveDecoder cred =
Decode.field "Todo" (previewDecoder (Just cred)) Decode.field "Todo" (previewDecoder (Just cred))
{-| This is a "build your own element" API. {-| This is a "build your own element" API.
You pass it some configuration, followed by a `List (Attribute msg)` and a You pass it some configuration, followed by a `List (Attribute msg)` and a
`List (Html msg)`, just like any standard Html element. `List (Html msg)`, just like any standard Html element.
-} -}
favoriteButton : favoriteButton :
Cred Cred
-> msg -> msg
-> List (Attribute msg) -> List (Attribute msg)
-> List (Element msg) -> List (Element msg)
-> Element msg -> Element msg
favoriteButton _ msg attrs kids = favoriteButton _ msg attrs kids =
toggleFavoriteButton "btn btn-sm btn-outline-primary" msg attrs kids toggleFavoriteButton "btn btn-sm btn-outline-primary" msg attrs kids
unfavoriteButton : unfavoriteButton :
Cred Cred
-> msg -> msg
-> List (Attribute msg) -> List (Attribute msg)
-> List (Element msg) -> List (Element msg)
-> Element msg -> Element msg
unfavoriteButton _ msg attrs kids = unfavoriteButton _ msg attrs kids =
toggleFavoriteButton "btn btn-sm btn-primary" msg attrs kids toggleFavoriteButton "btn btn-sm btn-primary" msg attrs kids
toggleFavoriteButton : toggleFavoriteButton :
String String
-> msg -> msg
-> List (Attribute msg) -> List (Attribute msg)
-> List (Element msg) -> List (Element msg)
-> Element msg -> Element msg
toggleFavoriteButton classStr msg attrs kids = toggleFavoriteButton classStr msg attrs kids =
Html.button Html.button
(class classStr :: onClickStopPropagation msg :: attrs) (class classStr :: onClickStopPropagation msg :: attrs)
(i [ class "ion-heart" ] [] :: kids) (i [ class "ion-heart" ] [] :: kids)
onClickStopPropagation : msg -> Attribute msg onClickStopPropagation : msg -> Attribute msg
onClickStopPropagation msg = onClickStopPropagation msg =
stopPropagationOn "click" stopPropagationOn "click"
(Decode.succeed ( msg, True )) (Decode.succeed ( msg, True ))

View File

@ -1,35 +1,35 @@
module Todo.UUID exposing (UUID, decoder, toString, urlParser) module Todo.UUID exposing (UUID, decoder, toString, urlParser)
import Json.Decode as Decode exposing (Decoder) import Json.Decode as Decode exposing (Decoder)
import Url.Parser exposing (Parser) import Url.Parser exposing (Parser)
-- TYPES -- TYPES
type UUID type UUID
= UUID String = UUID String
-- CREATE -- CREATE
urlParser : Parser (UUID -> a) a urlParser : Parser (UUID -> a) a
urlParser = urlParser =
Url.Parser.custom "UUID" (\str -> Just (UUID str)) Url.Parser.custom "UUID" (\str -> Just (UUID str))
decoder : Decoder UUID decoder : Decoder UUID
decoder = decoder =
Decode.map UUID Decode.string Decode.map UUID Decode.string
-- TRANSFORM -- TRANSFORM
toString : UUID -> String toString : UUID -> String
toString (UUID str) = toString (UUID str) =
str str

View File

@ -1,47 +1,47 @@
module Username exposing (Username, decoder, encode, toHtml, toString, urlParser) module Username exposing (Username, decoder, encode, toHtml, toString, urlParser)
import Element exposing (..) import Element exposing (..)
import Json.Decode as Decode exposing (Decoder) import Json.Decode as Decode exposing (Decoder)
import Json.Encode as Encode exposing (Value) import Json.Encode as Encode exposing (Value)
import Url.Parser import Url.Parser
-- TYPES -- TYPES
type Username type Username
= Username String = Username String
-- CREATE -- CREATE
decoder : Decoder Username decoder : Decoder Username
decoder = decoder =
Decode.map Username Decode.string Decode.map Username Decode.string
-- TRANSFORM -- TRANSFORM
encode : Username -> Value encode : Username -> Value
encode (Username username) = encode (Username username) =
Encode.string username Encode.string username
toString : Username -> String toString : Username -> String
toString (Username username) = toString (Username username) =
username username
urlParser : Url.Parser.Parser (Username -> a) a urlParser : Url.Parser.Parser (Username -> a) a
urlParser = urlParser =
Url.Parser.custom "USERNAME" (\str -> Just (Username str)) Url.Parser.custom "USERNAME" (\str -> Just (Username str))
toHtml : Username -> Element msg toHtml : Username -> Element msg
toHtml (Username username) = toHtml (Username username) =
text username text username

View File

@ -1,66 +1,66 @@
module Viewer exposing (Viewer, avatar, cred, decoder, minPasswordChars, store, username) module Viewer exposing (Viewer, avatar, cred, decoder, minPasswordChars, store, username)
{-| The logged-in user currently viewing this page. It stores enough data to {-| The logged-in user currently viewing this page. It stores enough data to
be able to render the menu bar (username and avatar), along with Cred so it's be able to render the menu bar (username and avatar), along with Cred so it's
impossible to have a Viewer if you aren't logged in. impossible to have a Viewer if you aren't logged in.
-} -}
import Api exposing (Cred) import Api exposing (Cred)
import Avatar exposing (Avatar) import Avatar exposing (Avatar)
import Email exposing (Email) import Email exposing (Email)
import Json.Decode as Decode exposing (Decoder) import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (custom, required) import Json.Decode.Pipeline exposing (custom, required)
import Json.Encode as Encode exposing (Value) import Json.Encode as Encode exposing (Value)
import Profile exposing (Profile) import Profile exposing (Profile)
import Username exposing (Username) import Username exposing (Username)
-- TYPES -- TYPES
type Viewer type Viewer
= Viewer Avatar Cred = Viewer Avatar Cred
-- INFO -- INFO
cred : Viewer -> Cred cred : Viewer -> Cred
cred (Viewer _ val) = cred (Viewer _ val) =
val val
username : Viewer -> Username username : Viewer -> Username
username (Viewer _ val) = username (Viewer _ val) =
Api.username val Api.username val
avatar : Viewer -> Avatar avatar : Viewer -> Avatar
avatar (Viewer val _) = avatar (Viewer val _) =
val val
{-| Passwords must be at least this many characters long! {-| Passwords must be at least this many characters long!
-} -}
minPasswordChars : Int minPasswordChars : Int
minPasswordChars = minPasswordChars =
6 6
-- SERIALIZATION -- SERIALIZATION
decoder : Decoder (Cred -> Viewer) decoder : Decoder (Cred -> Viewer)
decoder = decoder =
Decode.succeed Viewer Decode.succeed Viewer
|> custom (Decode.field "image" Avatar.decoder) |> custom (Decode.field "image" Avatar.decoder)
store : Viewer -> Cmd msg store : Viewer -> Cmd msg
store (Viewer avatarVal credVal) = store (Viewer avatarVal credVal) =
Api.storeCredWith Api.storeCredWith
credVal credVal
avatarVal avatarVal

30
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local

1
frontend/README.md Normal file
View File

@ -0,0 +1 @@
This is a starter template for [Learn Next.js](https://nextjs.org/learn).

2066
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
frontend/package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "todo",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "^10.0.0",
"react": "17.0.1",
"react-dom": "17.0.1"
}
}

209
frontend/pages/index.js Normal file
View File

@ -0,0 +1,209 @@
import Head from 'next/head'
export default function Home() {
return (
<div className="container">
<Head>
<title>Create Next App</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<h1 className="title">
Welcome to <a href="https://nextjs.org">Next.js!</a>
</h1>
<p className="description">
Get started by editing <code>pages/index.js</code>
</p>
<div className="grid">
<a href="https://nextjs.org/docs" className="card">
<h3>Documentation &rarr;</h3>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a href="https://nextjs.org/learn" className="card">
<h3>Learn &rarr;</h3>
<p>Learn about Next.js in an interactive course with quizzes!</p>
</a>
<a
href="https://github.com/vercel/next.js/tree/master/examples"
className="card"
>
<h3>Examples &rarr;</h3>
<p>Discover and deploy boilerplate example Next.js projects.</p>
</a>
<a
href="https://vercel.com/import?filter=next.js&utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
className="card"
>
<h3>Deploy &rarr;</h3>
<p>
Instantly deploy your Next.js site to a public URL with Vercel.
</p>
</a>
</div>
</main>
<footer>
<a
href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Powered by{' '}
<img src="/vercel.svg" alt="Vercel" className="logo" />
</a>
</footer>
<style jsx>{`
.container {
min-height: 100vh;
padding: 0 0.5rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
main {
padding: 5rem 0;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
footer {
width: 100%;
height: 100px;
border-top: 1px solid #eaeaea;
display: flex;
justify-content: center;
align-items: center;
}
footer img {
margin-left: 0.5rem;
}
footer a {
display: flex;
justify-content: center;
align-items: center;
}
a {
color: inherit;
text-decoration: none;
}
.title a {
color: #0070f3;
text-decoration: none;
}
.title a:hover,
.title a:focus,
.title a:active {
text-decoration: underline;
}
.title {
margin: 0;
line-height: 1.15;
font-size: 4rem;
}
.title,
.description {
text-align: center;
}
.description {
line-height: 1.5;
font-size: 1.5rem;
}
code {
background: #fafafa;
border-radius: 5px;
padding: 0.75rem;
font-size: 1.1rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono,
DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace;
}
.grid {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
max-width: 800px;
margin-top: 3rem;
}
.card {
margin: 1rem;
flex-basis: 45%;
padding: 1.5rem;
text-align: left;
color: inherit;
text-decoration: none;
border: 1px solid #eaeaea;
border-radius: 10px;
transition: color 0.15s ease, border-color 0.15s ease;
}
.card:hover,
.card:focus,
.card:active {
color: #0070f3;
border-color: #0070f3;
}
.card h3 {
margin: 0 0 1rem 0;
font-size: 1.5rem;
}
.card p {
margin: 0;
font-size: 1.25rem;
line-height: 1.5;
}
.logo {
height: 1em;
}
@media (max-width: 600px) {
.grid {
width: 100%;
flex-direction: column;
}
}
`}</style>
<style jsx global>{`
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue,
sans-serif;
}
* {
box-sizing: border-box;
}
`}</style>
</div>
)
}

1889
frontend/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,4 @@
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB