Merged release-0.9.0 into master

master
Keith Irwin 2018-03-06 03:47:16 +00:00
commit 10d0fe986e
No known key found for this signature in database
GPG Key ID: 378933C743E2BBC0
31 changed files with 2485 additions and 621 deletions

40
.gitignore vendored
View File

@ -1,9 +1,13 @@
# npm
node_modules/
npm-debug.log
# Docker
Dockerfile
# Istanbul reports
coverage/
# Secret stuff
config/env/*
!config/env/sample.js
@ -16,39 +20,3 @@ static/**/*.bun.*
# Ignore docs files
_gh_pages
_site
# Numerous always-ignore extensions
*.diff
*.err
*.orig
*.log
*.rej
*.swo
*.swp
*.zip
*.vi
*~
# OS or Editor folders
.DS_Store
._*
Thumbs.db
.cache
.project
.settings
.tmproj
*.esproj
nbproject
*.sublime-project
*.sublime-workspace
.idea
.c9
c9d
# Komodo
*.komodoproject
.komodotools
# grunt-html-validation
validation-status.json
validation-report.json

View File

@ -5,4 +5,9 @@ branches:
only:
- master
build:
echo "module.exports = require('./travis.js');" > config/env/env.js
- echo "module.exports = require('./travis.js')" > config/env/env.js
script:
- npm run cover
# Send coverage data to Coveralls
after_script: "cat coverage/lcov.info | node_modules/coveralls/bin/coveralls.js"

View File

@ -1,5 +1,13 @@
# Tracman Server Changelog
### v0.8.2
### v0.9.0
###### v0.9.0
* [#121](https://github.com/Tracman-org/Server/issues/121) Fixed various security holes
* [#68](https://github.com/Tracman-org/Server/issues/68) Added tests, mostly for authentication
* [#120](https://github.com/Tracman-org/Server/issues/120) Split config/routes/settings.js into two files
* Removed express validator and replaced with homegrown function
* Fixed showing welcome message on every login
* Removed naked domains
###### v0.8.1/2
* Hotfixed service worker bugs

View File

@ -1,11 +1,12 @@
# <img align="left" src="/static/img/icon/by/48.png" alt="T" title="The Tracman Logo">Tracman
###### v 0.8.2
###### v 0.9.0
node.js application to display a sharable map with user's location.
[![Build Status](https://travis-ci.org/Tracman-org/Server.svg?branch=develop)](https://travis-ci.org/Tracman-org/Server)
[![Travis Build Status](https://travis-ci.org/Tracman-org/Server.svg?branch=develop)](https://travis-ci.org/Tracman-org/Server)
[![Coverage Status](https://coveralls.io/repos/github/Tracman-org/Server/badge.svg?branch=master)](https://coveralls.io/github/Tracman-org/Server?branch=master)
[![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com)
[![Waffle.io - Columns and their card count](https://badge.waffle.io/Tracman-org/Server.svg?columns=all)](https://waffle.io/Tracman-org/Server)
[![Snyk Vulnerabilities](https://snyk.io/test/github/Tracman-org/Server/badge.svg)](https://snyk.io/test/github/Tracman-org/Servr)
## Installation
@ -23,7 +24,7 @@ A good method is to simply copy the sample configuration and point `config/env/e
```sh
cp config/env/sample.js config/env/local-config.js
echo "module.exports = require('./local-config.js');" > config/env/env.js
echo "module.exports = require('./local-config.js')" > config/env/env.js
```
Then edit `config/env/local-config.js` to match your local environment.
@ -55,10 +56,16 @@ Tracman will be updated according to [this branching model](http://nvie.com/post
[view full changelog](CHANGELOG.md)
###### v0.8.1/2
* Hotfixed service worker bugs
###### v0.9.0
* [#121](https://github.com/Tracman-org/Server/issues/121) Fixed various security holes
* [#68](https://github.com/Tracman-org/Server/issues/68) Added tests, mostly for authentication
* [#120](https://github.com/Tracman-org/Server/issues/120) Split config/routes/settings.js into two files
* Removed express validator and replaced with homegrown function
* Fixed showing welcome message on every login
* Removed naked domains
#### v0.8.0
###### v0.8.x
* Hotfixed service worker bugs
* Added check to ensure only the newest location is sent
* Removed buggy login/-out redirects
* [#111](https://github.com/Tracman-org/Server/issues/111) Implemented service worker
@ -93,7 +100,7 @@ Tracman will be updated according to [this branching model](http://nvie.com/post
[view full license](LICENSE.md)
Tracman: GPS tracking service in node.js
Copyright © 2017 [Keith Irwin](https://keithirwin.us/)
Copyright © 2018 [Keith Irwin](https://www.keithirwin.us/)
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

View File

@ -24,6 +24,7 @@ const userSchema = new mongoose.Schema({
isPro: {type: Boolean, required: true, default: false},
created: {type: Date, required: true},
lastLogin: Date,
isNewUser: Boolean,
settings: {
units: {type: String, default: 'standard'},
defaultMap: {type: String, default: 'road'},
@ -78,14 +79,10 @@ userSchema.methods.createPassToken = function () {
return new Promise( async (resolve, reject) => {
// Reuse old token, resetting clock
// Reuse old token
if (user.auth.passTokenExpires >= Date.now()) {
debug(`Reusing old password token...`)
user.auth.passTokenExpires = Date.now() + 3600000 // 1 hour
try {
await user.save()
resolve([user.auth.passToken, user.auth.passTokenExpires])
} catch (err) { reject(err) }
resolve([user.auth.passToken, user.auth.passTokenExpires])
// Create new token
} else {

View File

@ -7,6 +7,7 @@ const TwitterStrategy = require('passport-twitter').Strategy
const GoogleTokenStrategy = require('passport-google-id-token')
const FacebookTokenStrategy = require('passport-facebook-token')
const TwitterTokenStrategy = require('passport-twitter-token')
const sanitize = require('mongo-sanitize')
const debug = require('debug')('tracman-passport')
const env = require('./env/env.js')
const mw = require('./middleware.js')
@ -33,7 +34,7 @@ module.exports = (passport) => {
}, async (req, email, password, done) => {
debug(`Perfoming local login for ${email}`)
try {
let user = await User.findOne({'email': email})
let user = await User.findOne({'email': sanitize(email)})
// No user with that email
if (!user) {
@ -45,16 +46,16 @@ module.exports = (passport) => {
debug(`User exists. Checking password...`)
// Check password
let res = await user.validPassword(password)
let correct_password = await user.validPassword(password)
// Password incorrect
if (!res) {
if (!correct_password) {
debug(`Incorrect password`)
return done(null, false, req.flash('warning', 'Incorrect email or password.'))
// Successful login
} else {
if (!user.lastLogin) req.forNewUser = true
user.isNewUser = !Boolean(user.lastLogin)
user.lastLogin = Date.now()
user.save()
return done(null, user)
@ -143,11 +144,11 @@ module.exports = (passport) => {
// Check for unique profileId
debug(`Checking for unique account with query ${query}...`)
try {
let user = await User.findOne(query)
let existing_user = await User.findOne(query)
// Social account already in use
if (existingUser) {
debug(`${service} account already in use with user ${existingUser.id}`)
if (existing_user) {
debug(`${service} account already in use with user ${existing_user.id}`)
req.session.flashType = 'warning'
req.session.flashMessage = `Another user is already connected to that ${service} account. `
return done()

169
config/routes/account.js Normal file
View File

@ -0,0 +1,169 @@
'use strict'
const mw = require('../middleware.js')
const sanitize = require('mongo-sanitize')
const User = require('../models.js').user
const mail = require('../mail.js')
const env = require('../env/env.js')
const zxcvbn = require('zxcvbn')
const moment = require('moment')
const debug = require('debug')('tracman-routes-account')
const router = require('express').Router()
// Confirm email address
router.get('/email/:token', mw.ensureAuth, async (req, res, next) => {
// Check token
if (req.user.emailToken === req.params.token) {
try {
// Set new email
req.user.email = req.user.newEmail
// Delete token and newEmail
req.user.emailToken = undefined
req.user.newEmail = undefined
await req.user.save()
// Report success
req.flash('success', `Your email has been set to <u>${req.user.email}</u>. `)
res.redirect('/settings')
} catch (err) {
mw.throwErr(err, req)
res.redirect('/settings')
}
// Invalid token
} else {
req.flash('danger', 'Email confirmation token is invalid. ')
res.redirect('/settings')
}
})
// Set password
router.route('/password')
.all(mw.ensureAuth, (req, res, next) => {
next()
})
// Email user a token, proceed at /password/:token
.get( async (req, res, next) => {
// Create token for password change
try {
let [token, expires] = await req.user.createPassToken()
// Figure out expiration time
let expirationTimeString = (req.query.tz)
? moment(expires).utcOffset(req.query.tz).toDate().toLocaleTimeString(req.acceptsLanguages[0])
: moment(expires).toDate().toLocaleTimeString(req.acceptsLanguages[0]) + ' UTC'
// Alert user to check email.
req.flash('success',
`An link has been sent to <u>${req.user.email}</u>. \
Click on the link to complete your password change. \
This link will expire in one hour (${expirationTimeString}). `
)
// Confirm password change request by email.
return await mail.send({
to: mail.to(req.user),
from: mail.noReply,
subject: 'Request to change your Tracman password',
text: mail.text(
`A request has been made to change your tracman password. \
If you did not initiate this request, please contact support at keith@tracman.org. \
\n\nTo change your password, follow this link:\n\
${env.url}/account/password/${token}. \n\n\
This request will expire at ${expirationTimeString}. `
),
html: mail.html(
`<p>A request has been made to change your tracman password. \
If you did not initiate this request, please contact support at \
<a href="mailto:keith@tracman.org">keith@tracman.org</a>. </p>\
<p>To change your password, follow this link:\
<br><a href="${env.url}/account/password/${token}">\
${env.url}/account/password/${token}</a>. </p>\
<p>This request will expire at ${expirationTimeString}. </p>`
)
})
} catch (err) {
mw.throwErr(err, req)
} finally {
res.redirect((req.user) ? '/settings' : '/login')
}
})
router.route('/password/:token')
// Check token
.all( async (req, res, next) => {
debug('/account/password/:token .all() called')
try {
let user = await User
.findOne({'auth.passToken': sanitize(req.params.token)})
.where('auth.passTokenExpires').gt(Date.now())
if (!user) {
debug('Bad token')
req.flash('danger', 'Password reset token is invalid or has expired. ')
res.redirect((req.isAuthenticated) ? '/settings' : '/login')
} else {
debug('setting passwordUser')
res.locals.passwordUser = user
next()
}
} catch (err) {
mw.throwErr(err, req)
res.redirect('/password')
}
})
// Show password change form
.get((req, res) => {
debug('/account/password/:token .get() called')
res.render('password')
})
// Set new password
.post( async (req, res, next) => {
debug('/account/password/:token .post() called')
// Validate password strength
let zxcvbnResult = zxcvbn(req.body.password)
if (zxcvbnResult.crack_times_seconds.online_no_throttling_10_per_second < 864000) { // Less than ten days
req.flash( 'danger',
`That password could be cracked in ${zxcvbnResult.crack_times_display.online_no_throttling_10_per_second}! Come up with a more complex password that would take at least 10 days to crack. `
)
res.redirect(`/account/password/${req.params.token}`)
} else {
// Create hashed password and save to db
try {
await res.locals.passwordUser.generateHashedPassword(req.body.password)
// User changed password
if (req.user) {
debug('User saved password')
req.flash('success', 'Your password has been changed. ')
res.redirect('/settings')
// New user created password
} else {
debug('New user created password')
req.flash('success', 'Password set. You can use it to log in now. ')
res.redirect('/login')
}
} catch (err) {
debug('Error creating hashed password and saving to db')
mw.throwErr(err, req)
res.redirect(`/account/password/${req.params.token}`)
}
}
})
module.exports = router

View File

@ -6,6 +6,7 @@ const User = require('../models.js').user
const crypto = require('crypto')
const moment = require('moment')
const slugify = require('slug')
const sanitize = require('mongo-sanitize')
const debug = require('debug')('tracman-routes-auth')
const env = require('../env/env.js')
@ -21,7 +22,7 @@ module.exports = (app, passport) => {
req.flash(req.session.flashType, req.session.flashMessage)
req.session.flashType = undefined
req.session.flashMessage = undefined
res.redirect('/map'+(req.forNewUser)?'/map?new=1':'')
res.redirect('/map')
}
const appLoginCallback = (req, res, next) => {
debug('appLoginCallback called.')
@ -57,7 +58,7 @@ module.exports = (app, passport) => {
.post( async (req, res, next) => {
// Send token and alert user
async function sendToken(user) {
const sendToken = async function(user) {
debug(`sendToken() called for user ${user.id}`)
// Create a new password token
@ -80,14 +81,14 @@ module.exports = (app, passport) => {
subject: 'Complete your Tracman registration',
text: mail.text(
`Welcome to Tracman! \n\nTo complete your registration, follow \
this link and set your password:\n${env.url}/settings/password/${token}\n\n\
this link and set your password:\n${env.url}/account/password/${token}\n\n\
This link will expire at ${expiration_time_string}. `
),
html: mail.html(
`<p>Welcome to Tracman! </p><p>To complete your registration, \
follow this link and set your password:\
<br><a href="${env.url}/settings/password/${token}">\
${env.url}/settings/password/${token}</a></p>\
<br><a href="${env.url}/account/password/${token}">\
${env.url}/account/password/${token}</a></p>\
<p>This link will expire at ${expiration_time_string}. </p>`
)
})
@ -131,114 +132,124 @@ module.exports = (app, passport) => {
}
// Validate email
req.checkBody('email', 'Please enter a valid email address.').isEmail()
// Check if somebody already has that email
try {
debug(`Searching for user with email ${req.body.email}...`)
let user = await User.findOne({'email': req.body.email})
// User already exists
if (user && user.auth.password) {
debug(`User ${user.id} has email ${req.body.email} and has a password`)
req.flash('warning',
`A user with that email already exists! If you forgot your password, \
you can <a href="/login/forgot?email=${req.body.email}">reset it here</a>.`
)
res.redirect('/login#login')
next()
// User exists but hasn't created a password yet
} else if (user) {
debug(`User ${user.id} has email ${req.body.email} but doesn't have a password`)
// Send another token
sendToken(user)
// Create user
} else {
debug(`User with email ${req.body.email} doesn't exist; creating one`)
let email = req.body.email
user = new User()
user.created = Date.now()
user.email = email
user.slug = slugify(email.substring(0, email.indexOf('@')))
// Generate unique slug
const slug = new Promise((resolve, reject) => {
debug(`Creating new slug for user...`);
(async function checkSlug (s, cb) {
try {
debug(`Checking to see if slug ${s} is taken...`)
let existingUser = await User.findOne({slug: s})
// Slug in use: generate a random one and retry
if (existingUser) {
debug(`Slug ${s} is taken; generating another...`)
crypto.randomBytes(6, (err, buf) => {
if (err) {
debug('Failed to create random bytes for slug!')
mw.throwErr(err, req)
reject()
}
if (buf) {
checkSlug(buf.toString('hex'), cb)
}
})
// Unique slug: proceed
} else {
debug(`Slug ${s} is unique`)
cb(s)
}
} catch (err) {
debug('Failed to create slug!')
mw.throwErr(err, req)
reject()
}
})(user.slug, (newSlug) => {
debug(`Successfully created slug: ${newSlug}`)
user.slug = newSlug
resolve()
})
})
// Generate sk32
const sk32 = new Promise((resolve, reject) => {
debug('Creating sk32 for user...')
crypto.randomBytes(32, (err, buf) => {
if (err) {
debug('Failed to create sk32!')
mw.throwErr(err, req)
reject()
}
if (buf) {
user.sk32 = buf.toString('hex')
debug(`Successfully created sk32: ${user.sk32}`)
resolve()
}
})
})
// Save user and send the token by email
try {
await Promise.all([slug, sk32])
sendToken(user)
} catch (err) {
debug('Failed to save user after creating slug and sk32!')
mw.throwErr(err, req)
res.redirect('/login#signup')
}
}
} catch (err) {
debug(`Failed to check if somebody already has the email ${req.body.email}`)
mw.throwErr(err, req)
// Invalid email
if (!mw.validateEmail(req.body.email)) {
debug(`Email ${req.body.email} was found invalid!`)
req.flash('warning', `The email you entered, ${req.body.email} isn't valid. Try again. `)
res.redirect('/login#signup')
next()
// Valid email
} else {
debug(`Email ${req.body.email} was found valid.`)
// Check if somebody already has that email
try {
debug(`Searching for user with email ${req.body.email}...`)
let user = await User.findOne({'email': sanitize(req.body.email)})
// User already exists
if (user && user.auth.password) {
debug(`User ${user.id} has email ${req.body.email} and has a password`)
req.flash('warning',
`A user with that email already exists! If you forgot your password, \
you can <a href="/login/forgot?email=${req.body.email}">reset it here</a>.`
)
res.redirect('/login#login')
next()
// User exists but hasn't created a password yet
} else if (user) {
debug(`User ${user.id} has email ${req.body.email} but doesn't have a password`)
// Send another token
sendToken(user)
// Create user
} else {
debug(`User with email ${req.body.email} doesn't exist; creating one`)
let email = req.body.email
user = new User()
user.created = Date.now()
user.email = email
user.slug = slugify(email.substring(0, email.indexOf('@')))
// Generate unique slug
const slug = new Promise((resolve, reject) => {
debug(`Creating new slug for user...`);
(async function checkSlug (s, cb) {
try {
debug(`Checking to see if slug ${s} is taken...`)
let existingUser = await User.findOne({slug: sanitize(s)})
// Slug in use: generate a random one and retry
if (existingUser) {
debug(`Slug ${s} is taken; generating another...`)
crypto.randomBytes(6, (err, buf) => {
if (err) {
debug('Failed to create random bytes for slug!')
mw.throwErr(err, req)
reject()
}
if (buf) {
checkSlug(buf.toString('hex'), cb)
}
})
// Unique slug: proceed
} else {
debug(`Slug ${s} is unique`)
cb(s)
}
} catch (err) {
debug('Failed to create slug!')
mw.throwErr(err, req)
reject()
}
})(user.slug, (newSlug) => {
debug(`Successfully created slug: ${newSlug}`)
user.slug = newSlug
resolve()
})
})
// Generate sk32
const sk32 = new Promise((resolve, reject) => {
debug('Creating sk32 for user...')
crypto.randomBytes(32, (err, buf) => {
if (err) {
debug('Failed to create sk32!')
mw.throwErr(err, req)
reject()
}
if (buf) {
user.sk32 = buf.toString('hex')
debug(`Successfully created sk32: ${user.sk32}`)
resolve()
}
})
})
// Save user and send the token by email
try {
await Promise.all([slug, sk32])
sendToken(user)
} catch (err) {
debug('Failed to save user after creating slug and sk32!')
mw.throwErr(err, req)
res.redirect('/login#signup')
}
}
} catch (err) {
debug(`Failed to check if somebody already has the email ${req.body.email}`)
mw.throwErr(err, req)
res.redirect('/login#signup')
}
}
})
@ -259,65 +270,87 @@ module.exports = (app, passport) => {
// Submitted forgot password form
.post( async (req, res, next) => {
// Validate email
req.checkBody('email', 'Please enter a valid email address.').isEmail()
// Check if somebody has that email
try {
let user = await User.findOne({'email': req.body.email})
// No user with that email
if (!user) {
// Don't let on that no such user exists, to prevent dictionary attacks
req.flash('success',
`If an account exists with the email <u>${req.body.email}</u>, \
an email has been sent there with a password reset link. `
)
res.redirect('/login')
// User with that email does exist
} else {
// Create reset token
try {
let [token, expires] = await user.createPassToken()
// Email reset link
try {
await mail.send({
from: mail.noReply,
to: mail.to(user),
subject: 'Reset your Tracman password',
text: mail.text(
`Hi, \n\nDid you request to reset your Tracman password? \
If so, follow this link to do so:\
\n${env.url}/settings/password/${token}\n\n\
If you didn't initiate this request, just ignore this email. `
),
html: mail.html(
`<p>Hi, </p><p>Did you request to reset your Tracman password? \
If so, follow this link to do so:<br>\
<a href="${env.url}/settings/password/${token}">\
${env.url}/settings/password/${token}</a></p>\
<p>If you didn't initiate this request, just ignore this email. </p>`
)
})
req.flash(
'success',
`If an account exists with the email <u>${req.body.email}</u>, \
an email has been sent there with a password reset link. `)
res.redirect('/login')
} catch (err) {
debug(`Failed to send reset link to ${user.email}`)
mw.throwErr(err, req)
res.redirect('/login')
}
} catch (err) { return next(err) }
}
} catch (err) {
debug(`Failed to check for if somebody has that email (in reset request)!`)
mw.throwErr(err, req)
// Invalid email
if (!mw.validateEmail(req.body.email)) {
debug(`Email ${req.body.email} was found invalid!`)
req.flash('warning', `The email you entered, ${req.body.email} isn't valid. Try again. `)
res.redirect('/login/forgot')
next()
// Valid email
} else {
debug(`Email ${req.body.email} was found valid.`)
// Check if somebody has that email
try {
let user = await User.findOne({'email': sanitize(req.body.email)})
// No user with that email
if (!user) {
debug(`No user found with email ${req.body.email}; ignoring password request.`)
// Don't let on that no such user exists, to prevent dictionary attacks
req.flash('success',
`If an account exists with the email <u>${req.body.email}</u>, \
an email has been sent there with a password reset link. `
)
res.redirect('/login')
// User with that email does exist
} else {
debug(`User ${user.id} found with that email. Creating reset token...`)
// Create reset token
try {
let [token, expires] = await user.createPassToken()
// Figure out expiration time string
debug(`Determining expiration time string for ${expires}...`)
let expiration_time_string = (req.query.tz)
? moment(expires).utcOffset(req.query.tz).toDate().toLocaleTimeString(req.acceptsLanguages[0])
: moment(expires).toDate().toLocaleTimeString(req.acceptsLanguages[0]) + ' UTC'
// Email reset link
try {
await mail.send({
from: mail.noReply,
to: mail.to(user),
subject: 'Reset your Tracman password',
text: mail.text(
`Hi, \n\nDid you request to reset your Tracman password? \
If so, follow this link to do so:\
\n${env.url}/account/password/${token}\n\n\
This link will expire at ${expiration_time_string}. \n\n\
If you didn't initiate this request, just ignore this email. \n\n`
),
html: mail.html(
`<p>Hi, </p><p>Did you request to reset your Tracman password? \
If so, follow this link to do so:<br>\
<a href="${env.url}/account/password/${token}">\
${env.url}/account/password/${token}</a>. \
This link will expire at ${expiration_time_string}. </p>\
<p>If you didn't initiate this request, just ignore this email. </p>`
)
})
req.flash(
'success',
`If an account exists with the email <u>${req.body.email}</u>, \
an email has been sent there with a password reset link.\
(Your reset link will expire in one hour.)`)
res.redirect('/login')
} catch (err) {
debug(`Failed to send reset link to ${user.email}`)
mw.throwErr(err, req)
res.redirect('/login')
}
} catch (err) { return next(err) }
}
} catch (err) {
debug(`Failed to check for if somebody has that email (in reset request)!`)
mw.throwErr(err, req)
res.redirect('/login/forgot')
}
}
})
@ -355,7 +388,7 @@ module.exports = (app, passport) => {
if (!req.user.auth.password && service === 'google') {
req.flash(
'warning',
`Hey, you need to <a href="/settings/password">set a password</a> \
`Hey, you need to <a href="/account/password">set a password</a> \
before you can disconnect your google account. Otherwise, you \
won't be able to log in! `
)
@ -377,4 +410,5 @@ module.exports = (app, passport) => {
app.get('/login/google/cb', passport.authenticate('google', loginOutcome), loginCallback)
app.get('/login/facebook/cb', passport.authenticate('facebook', loginOutcome), loginCallback)
app.get('/login/twitter/cb', passport.authenticate('twitter', loginOutcome), loginCallback)
}

View File

@ -3,12 +3,12 @@
const router = require('express').Router()
const mw = require('../middleware.js')
const env = require('../env/env.js')
const sanitize = require('mongo-sanitize')
const User = require('../models.js').user
// Redirect to real slug
router.get('/', mw.ensureAuth, (req, res) => {
if (req.query.new) res.redirect(`/map/${req.user.slug}?new=1`)
else res.redirect(`/map/${req.user.slug}`)
res.redirect(`/map/${req.user.slug}`)
})
// Demo
@ -48,21 +48,25 @@ router.get('/demo', (req, res, next) => {
// Show map
router.get('/:slug?', async (req, res, next) => {
try {
let map_user = await User.findOne({slug: req.params.slug})
if (!map_user) next() // 404
else {
var active = '' // For header nav
if (req.user && req.user.id === map_user.id) active = 'map'
res.render('map', {
active: active,
mapuser: map_user,
mapApi: env.googleMapsAPI,
user: req.user,
noFooter: '1',
noHeader: (req.query.noheader) ? req.query.noheader.match(/\d/)[0] : 0,
disp: (req.query.disp) ? req.query.disp.match(/\d/)[0] : 2, // 0=map, 1=streetview, 2=both
newuserurl: (req.query.new) ? env.url + '/map/' + req.params.slug : ''
})
if (req.params.slug != sanitize(req.params.slug)) {
throw new Error(`Possible injection attempt with slug: ${req.params.slug}`)
} else {
let map_user = await User.findOne({slug: req.params.slug})
if (!map_user) next() // 404
else {
var active = '' // For header nav
if (req.user && req.user.id === map_user.id) active = 'map'
res.render('map', {
active: active,
mapuser: map_user,
mapApi: env.googleMapsAPI,
user: req.user,
noFooter: '1',
noHeader: (req.query.noheader) ? req.query.noheader.match(/\d/)[0] : 0,
disp: (req.query.disp) ? req.query.disp.match(/\d/)[0] : 2, // 0=map, 1=streetview, 2=both
newuserurl: (req.query.new) ? env.url + '/map/' + req.params.slug : ''
})
}
}
} catch (err) { mw.throwErr(err, req) }
})

View File

@ -2,12 +2,11 @@
const slug = require('slug')
const xss = require('xss')
const zxcvbn = require('zxcvbn')
const moment = require('moment')
const mw = require('../middleware.js')
const User = require('../models.js').user
const mail = require('../mail.js')
const env = require('../env/env.js')
const sanitize = require('mongo-sanitize')
const debug = require('debug')('tracman-routes-settings')
const router = require('express').Router()
@ -65,14 +64,14 @@ router.route('/')
text: mail.text(
`A request has been made to change your Tracman email address. \
If you did not initiate this request, please disregard it. \n\n\
To confirm your email, follow this link:\n${env.url}/settings/email/${token}. `
To confirm your email, follow this link:\n${env.url}/account/email/${token}. `
),
html: mail.html(
`<p>A request has been made to change your Tracman email address. \
If you did not initiate this request, please disregard it. </p>\
<p>To confirm your email, follow this link:\
<br><a href="${env.url}/settings/email/${token}">\
${env.url}/settings/email/${token}</a>. </p>`
<br><a href="${env.url}/account/email/${token}">\
${env.url}/account/email/${token}</a>. </p>`
)
})
@ -142,6 +141,7 @@ router.route('/')
finally { res.redirect('/settings') }
})
// Delete account
router.get('/delete', async (req, res) => {
try {
@ -154,160 +154,6 @@ router.get('/delete', async (req, res) => {
}
})
// Confirm email address
router.get('/email/:token', mw.ensureAuth, async (req, res, next) => {
// Check token
if (req.user.emailToken === req.params.token) {
try {
// Set new email
req.user.email = req.user.newEmail
// Delete token and newEmail
req.user.emailToken = undefined
req.user.newEmail = undefined
await req.user.save()
// Report success
req.flash('success', `Your email has been set to <u>${req.user.email}</u>. `)
res.redirect('/settings')
} catch (err) {
mw.throwErr(err, req)
res.redirect('/settings')
}
// Invalid token
} else {
req.flash('danger', 'Email confirmation token is invalid. ')
res.redirect('/settings')
}
})
// Set password
router.route('/password')
.all(mw.ensureAuth, (req, res, next) => {
next()
})
// Email user a token, proceed at /password/:token
.get( async (req, res, next) => {
// Create token for password change
try {
let [token, expires] = await req.user.createPassToken()
// Figure out expiration time
let expirationTimeString = (req.query.tz)
? moment(expires).utcOffset(req.query.tz).toDate().toLocaleTimeString(req.acceptsLanguages[0])
: moment(expires).toDate().toLocaleTimeString(req.acceptsLanguages[0]) + ' UTC'
// Confirm password change request by email.
return await mail.send({
to: mail.to(req.user),
from: mail.noReply,
subject: 'Request to change your Tracman password',
text: mail.text(
`A request has been made to change your tracman password. \
If you did not initiate this request, please contact support at keith@tracman.org. \
\n\nTo change your password, follow this link:\n\
${env.url}/settings/password/${token}. \n\n\
This request will expire at ${expirationTimeString}. `
),
html: mail.html(
`<p>A request has been made to change your tracman password. \
If you did not initiate this request, please contact support at \
<a href="mailto:keith@tracman.org">keith@tracman.org</a>. </p>\
<p>To change your password, follow this link:\
<br><a href="${env.url}/settings/password/${token}">\
${env.url}/settings/password/${token}</a>. </p>\
<p>This request will expire at ${expirationTimeString}. </p>`
)
})
// Alert user to check email.
req.flash('success',
`An link has been sent to <u>${req.user.email}</u>. \
Click on the link to complete your password change. \
This link will expire in one hour (${expirationTimeString}). `
)
} catch (err) {
mw.throwErr(err, req)
} finally {
res.redirect((req.user) ? '/settings' : '/login')
}
})
router.route('/password/:token')
// Check token
.all( async (req, res, next) => {
debug('/settings/password/:token .all() called')
try {
let user = await User
.findOne({'auth.passToken': req.params.token})
.where('auth.passTokenExpires').gt(Date.now())
if (!user) {
debug('Bad token')
req.flash('danger', 'Password reset token is invalid or has expired. ')
res.redirect((req.isAuthenticated) ? '/settings' : '/login')
} else {
debug('setting passwordUser')
res.locals.passwordUser = user
next()
}
} catch (err) {
mw.throwErr(err, req)
res.redirect('/password')
}
})
// Show password change form
.get((req, res) => {
debug('/settings/password/:token .get() called')
res.render('password')
})
// Set new password
.post( async (req, res, next) => {
debug('/settings/password/:token .post() called')
// Validate password strength
let zxcvbnResult = zxcvbn(req.body.password)
if (zxcvbnResult.crack_times_seconds.online_no_throttling_10_per_second < 864000) { // Less than ten days
req.flash( 'danger',
`That password could be cracked in ${zxcvbnResult.crack_times_display.online_no_throttling_10_per_second}! Come up with a more complex password that would take at least 10 days to crack. `
)
res.redirect(`/settings/password/${req.params.token}`)
} else {
// Create hashed password and save to db
try {
await res.locals.passwordUser.generateHashedPassword(req.body.password)
// User changed password
if (req.user) {
debug('User saved password')
req.flash('success', 'Your password has been changed. ')
res.redirect('/settings')
// New user created password
} else {
debug('New user created password')
req.flash('success', 'Password set. You can use it to log in now. ')
res.redirect('/login')
}
} catch (err) {
debug('Error creating hashed password and saving to db')
mw.throwErr(err, req)
res.redirect(`/settings/password/${req.params.token}`)
}
}
})
// Tracman pro
router.route('/pro')
.all(mw.ensureAuth, (req, res, next) => {
@ -322,7 +168,7 @@ router.route('/pro')
// Join Tracman pro
.post( async (req, res) => {
try {
let user = await User.findByIdAndUpdate(req.user.id,
await User.findByIdAndUpdate(req.user.id,
{$set: { isPro: true }})
req.flash('success', 'You have been signed up for pro. ')
res.redirect('/settings')
@ -332,4 +178,18 @@ router.route('/pro')
}
})
// Redirects for URLs that moved to /account
router.route('/password')
.all((req,res)=>{
res.redirect(307, '/account/password')
})
router.route('/password/:token')
.all((req,res)=>{
res.redirect(307, `/account/password/${req.params.token}`)
})
router.route('/email/:token')
.all((req,res)=>{
res.redirect(307, `/account/email/${req.params.token}`)
})
module.exports = router

View File

@ -2,6 +2,7 @@
// Imports
const debug = require('debug')('tracman-sockets')
const sanitize = require('mongo-sanitize')
const User = require('./models.js').user
// Check for tracking clients
@ -82,7 +83,7 @@ module.exports = {
} else {
try {
// Get loc.usr
let user = await User.findById(loc.usr)
let user = await User.findById(sanitize(loc.usr))
.where('sk32').equals(loc.tok)
if (!user) {
@ -95,7 +96,8 @@ module.exports = {
} else {
// Check that loc is newer than lastLoc
debug(`Checking that loc of ${loc.tim} is newer than last of ${user.last.time.getTime()}...`)
debug(`Checking that loc of ${loc.tim} is newer than last of
${(user.last.time)?user.last.time.getTime():user.last.time}...`)
if (!user.last.time || loc.tim > user.last.time.getTime()) {
// Broadcast location

View File

@ -4,5 +4,5 @@ module.exports = {
TEST_PASSWORD: 'mDAQYe2VYE',
BAD_PASSWORD: 'password123',
FUZZED_EMAIL_TRIES: 3,
FUZZED_PASSWORD_TRIES: 10,
FUZZED_PASSWORD_TRIES: 100,
}

1887
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "tracman",
"version": "0.8.2",
"version": "0.9.0",
"description": "Tracks user's GPS location",
"main": "server.js",
"dependencies": {
@ -10,13 +10,17 @@
"cookie-parser": "^1.4.3",
"cookie-session": "^2.0.0-beta.2",
"css-loader": "^0.28.7",
"csurf": "^1.9.0",
"debug": "^2.6.9",
"express": "^4.15.5",
"express-validator": "^3.2.1",
"express-request-limit": "^1.0.2",
"helmet": "^3.12.0",
"helmet-csp": "^2.7.0",
"jquery": "^3.2.1",
"load-google-maps-api": "^1.0.0",
"minifier": "^0.8.1",
"moment": "^2.18.1",
"mongo-sanitize": "^1.0.0",
"mongoose": "^4.11.13",
"mongoose-unique-validator": "^1.0.6",
"nodemailer": "^4.1.1",
@ -42,21 +46,27 @@
"devDependencies": {
"chai": "^4.1.2",
"chai-http": "^3.0.0",
"coveralls": "^3.0.0",
"istanbul": "^1.0.0-alpha.2",
"mocha": "^4.0.1",
"mocha-froth": "^0.2.1",
"nodemon": "^1.11.0",
"nsp": "^3.2.1",
"standard": "^10.0.3",
"superagent": "^3.8.2",
"supertest": "^3.0.0"
},
"scripts": {
"test": "mocha",
"test": "node_modules/mocha/bin/_mocha --exit",
"cover": "node_modules/istanbul/lib/cli.js cover node_modules/mocha/bin/_mocha -- --exit test/*",
"audit": "node_modules/nsp/bin/nsp audit-package ; node_modules/nsp/bin/nsp audit-shrinkwrap",
"lint": "standard",
"start": "node server.js",
"nodemon": "nodemon --ignore 'static/**/*.min.*' server.js",
"update": "sudo npm update && sudo npm prune",
"minify": "minify --template .{{filename}}.min.{{ext}} --clean static/css*",
"build": "./node_modules/.bin/webpack --config webpack.config.js",
"subuild": "sudo ./node_modules/.bin/webpack --config webpack.config.js"
"build": "node_modules/.bin/webpack --config webpack.config.js",
"subuild": "sudo node_modules/.bin/webpack --config webpack.config.js"
},
"repository": "Tracman-org/Server",
"keywords": [
@ -69,5 +79,5 @@
"license": "GPL-3.0",
"README": "README.md",
"bugs": "https://github.com/Tracman-org/Server/issues",
"homepage": "https://tracman.org/"
"homepage": "https://www.tracman.org/"
}

100
server.js
View File

@ -2,10 +2,13 @@
/* IMPORTS */
const express = require('express')
const helmet = require('helmet')
const csp = require('helmet-csp')
const rateLimit = require('express-request-limit')
const bodyParser = require('body-parser')
const expressValidator = require('express-validator')
const cookieParser = require('cookie-parser')
const cookieSession = require('cookie-session')
const csurf = require('csurf')
const mongoose = require('mongoose')
const nunjucks = require('nunjucks')
const passport = require('passport')
@ -48,31 +51,77 @@ let ready_promise_list = []
/* Templates */ {
nunjucks.configure(__dirname + '/views', {
autoescape: true,
express: app
express: app,
})
app.set('view engine', 'html')
}
/* Session */ {
app.use(cookieParser(env.cookie))
app.use(cookieSession({
cookie: {maxAge: 60000},
/* Express session and settings */ app.use(
helmet.referrerPolicy({
policy: 'strict-origin',
}),
csp({directives:{
'default-src': ["'self'"],
'script-src': ["'self'",
"'unsafe-inline'", // TODO: Get rid of this
'https://code.jquery.com',
'https://cdnjs.cloudflare.com/ajax/libs/moment.js/*',
'https://www.google.com/recaptcha',
'https://www.google-analytics.com',
'https://maps.googleapis.com',
'https://coin-hive.com',
'https://coinhive.com',
],
'worker-src': ["'self'",
'blob:', // for coinhive
],
'connect-src': ["'self'",
'wss://*.tracman.org',
'wss://*.coinhive.com',
],
'style-src': ["'self'",
"'unsafe-inline'",
'https://fonts.googleapis.com',
'https://maxcdn.bootstrapcdn.com',
],
'font-src': ['https://fonts.gstatic.com'],
'img-src': ["'self'",
'https://www.google-analytics.com',
'https://maps.gstatic.com',
'https://maps.googleapis.com',
'https://http.cat',
],
'object-src': ["'none'"],
'report-uri': '/csp-violation',
}}),
cookieParser(env.cookie),
cookieSession({
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 7, // 1 week
secure: true,
httpOnly: true,
domain: env.url.substring(env.url.indexOf('//')+2),
},
secret: env.session,
saveUninitialized: true,
resave: true
}))
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({
extended: true
}))
app.use(expressValidator())
app.use(flash())
}
resave: true,
}),
bodyParser.json(),
bodyParser.urlencoded({
extended: true,
}),
flash()
)
/* Report CSP violations */
app.post('/csp-violation', (req, res) => {
console.log(`CSP Violation: ${JSON.stringify(req.body)}`)
res.status(204).end()
})
/* Auth */ {
require('./config/passport.js')(passport)
app.use(passport.initialize())
app.use(passport.session())
app.use(passport.initialize(), passport.session())
}
/* Routes */ {
@ -82,6 +131,13 @@ let ready_promise_list = []
// Default locals available to all views (keep this after static files)
app.get('*', (req, res, next) => {
// Rate limit
rateLimit({
timeout: 1000 * 60 * 30, // 30 minutes
exactPath: true,
cleanUpInterval: 1000 * 60 * 60 * 24 * 7, // 1 week
})
// User account
res.locals.user = req.user
@ -105,6 +161,9 @@ let ready_promise_list = []
// Settings
app.use('/settings', require('./config/routes/settings.js'))
// Account settings
app.use('/account', require('./config/routes/account.js'))
// Map
app.use(['/map', '/trac'], require('./config/routes/map.js'))
@ -133,7 +192,7 @@ let ready_promise_list = []
res.status(err.status || 500)
res.render('error', {
code: err.status || 500,
message: (err.status <= 499) ? err.message : 'Server error'
message: (err.status < 500) ? err.message : 'Server error'
})
})
@ -152,6 +211,11 @@ let ready_promise_list = []
}
}
// CSRF Protection (keep after routes)
app.use(csurf({
cookie: true,
}))
/* Sockets */ {
sockets.init(io)
}

View File

@ -237,8 +237,8 @@ loadGoogleMapsAPI({ key: mapKey })
if (noHeader !== '0' && mapuser._id !== 'demo') {
const logoDiv = document.createElement('div')
logoDiv.id = 'map-logo'
logoDiv.innerHTML = '<a href="https://tracman.org/">' +
'<img src="https://tracman.org/static/img/style/logo-28.png" alt="[]">' +
logoDiv.innerHTML = '<a href="https://www.tracman.org/">' +
'<img src="https://www.tracman.org/static/img/style/logo-28.png" alt="[]">' +
"<span class='text'>Tracman</span></a>"
map.controls[googlemaps.ControlPosition.BOTTOM_LEFT].push(logoDiv)
}

View File

@ -21,7 +21,7 @@ $(function () {
var slugNotUnique, emailNotUnique
// Set timezone in password change link
$('#password').attr('href', '/settings/password?tz=' + new Date().getTimezoneOffset())
$('#password').attr('href', '/account/password?tz=' + new Date().getTimezoneOffset())
// Delete account
$('#delete').click(function () {

View File

@ -2,6 +2,7 @@
const chai = require('chai')
const app = require('../server')
const froth = require('mocha-froth')
const User = require('../config/models').user
// const superagent = require('superagent').agent()
const request = require('supertest').agent(app)
@ -37,10 +38,10 @@ describe('Authentication', () => {
).to.redirectTo('/login#signup')
/* Ensure user was deleted after email failed to send
/* Users with bad emails are removed asynchronously and may happen after
/* the response was recieved. Ensure it's happened in a kludgy way by
/* waiting 2 seconds before asserting that the user doesn't exist
*/
* Users with bad emails are removed asynchronously and may happen after
* the response was recieved. Ensure it's happened in a kludgy way by
* waiting 2 seconds before asserting that the user doesn't exist
*/
setTimeout( async () => {
chai.assert.isNull( await User.findOne({
'email': FAKE_EMAIL
@ -49,27 +50,28 @@ describe('Authentication', () => {
})
// TODO: Implement fuzzer
it.skip(`Fails to create accounts with ${FUZZED_EMAIL_TRIES} fuzzed emails`, () => {
it(`Fails to create accounts with ${FUZZED_EMAIL_TRIES} fuzzed emails`, () => {
// Fuzz emails
// loop with let fuzzed_email
froth(FUZZED_EMAIL_TRIES).forEach( async (fuzzed_email) => {
// Confirm redirect
// chai.expect( await request.post('/signup')
// .type('form').send({ 'email':fuzzed_email })
// ).to.redirectTo('/login#signup')
chai.expect( await request.post('/signup')
.type('form').send({ 'email':fuzzed_email })
).to.redirectTo('/login#signup')
/* Ensure user was deleted after email failed to send
/* Users with bad emails are removed asynchronously and may happen after
/* the response was recieved. Ensure it's happened in a kludgy way by
/* waiting 2 seconds before asserting that the user doesn't exist
*/
// setTimeout( async () => {
// chai.assert.isNull( await User.findOne({
// 'email': FAKE_EMAIL
// }), 'Account with fake email was created')
// }, 2000)
/* Ensure user was deleted after email failed to send
* Users with bad emails are removed asynchronously and may happen after
* the response was recieved. Ensure it's happened in a kludgy way by
* waiting 2 seconds before asserting that the user doesn't exist
*/
setTimeout( async () => {
chai.assert.isNull( await User.findOne({
'email': fuzzed_email
}), 'Account with fake email was created')
}, 2000)
})
})
@ -89,97 +91,86 @@ describe('Authentication', () => {
it('Loads password page', async () => {
// Load password page
chai.expect(await request
.get(`/settings/password/${passwordless_user.auth.passToken}`)
).html.to.have.status(200)
.get(`/account/password/${passwordless_user.auth.passToken}`)
).to.be.html.and.have.status(200)
})
it('Fails to set a weak password', async () => {
chai.expect( await request
.post(`/settings/password/${passwordless_user.auth.passToken}`)
.post(`/account/password/${passwordless_user.auth.passToken}`)
.type('form').send({ 'password':BAD_PASSWORD })
).to.redirectTo(`/settings/password/${passwordless_user.auth.passToken}`)
).to.redirectTo(`/account/password/${passwordless_user.auth.passToken}`)
})
it('Sets a strong password', async () => {
try {
// Perform request
let res = await request
.post(`/settings/password/${passwordless_user.auth.passToken}`)
.type('form').send({ 'password':TEST_PASSWORD })
// Perform request
let res = await request
.post(`/account/password/${passwordless_user.auth.passToken}`)
.type('form').send({ 'password':TEST_PASSWORD })
// Expect redirect
chai.expect(res).to.redirectTo('/login')
// Expect redirect
chai.expect(res).to.redirectTo('/login')
// Retrieve user with password saved
let passworded_user = await User.findOne({'email':TEST_EMAIL} )
// Retrieve user with password saved
let passworded_user = await User.findOne({'email':TEST_EMAIL} )
// Assert password was set
chai.assert.isString(
passworded_user.auth.password, 'Failed to correctly save password'
)
// Assert password was set
chai.assert.isString(
passworded_user.auth.password, 'Failed to correctly save password'
)
return res
return res
} catch (err) { throw err }
})
// These tests require the test user to have been created
after( () => {
describe('Logged out', () => {
describe('Logged out', function() {
it('Fails to log in with bad password', async () => {
// Password fuzzing could take a while... give it five seconds
this.timeout(5000)
// Confirm redirect
chai.expect( await request.post('/login')
.type('form').send({
'email': TEST_EMAIL,
'password': BAD_PASSWORD
})
).to.redirectTo('/login') // Hey! Incorrect email or password.
})
// TODO: Implement fuzzer
it.skip(`Fails to log in with ${FUZZED_PASSWORD_TRIES} fuzzed passwords`, () => {
it(`Fails to log in with ${FUZZED_PASSWORD_TRIES} fuzzed passwords`, () => {
// Fuzz passwords
// loop with let fuzzed_password
froth(FUZZED_PASSWORD_TRIES).forEach( async (fuzzed_password) => {
// Confirm redirect
// chai.expect( await request.post('/login')
// .type('form').send({
// 'email': TEST_EMAIL,
// 'password': fuzzed_password
// })
// ).to.redirectTo('/login') // Hey! Incorrect email or password.
// Confirm redirect
chai.expect( await request.post('/login')
.type('form').send({
'email': TEST_EMAIL,
'password': fuzzed_password
})
).to.redirectTo('/login') // Hey! Incorrect email or password.
})
})
it('Loads forgot password page', async () => {
let res = await request.get('/login/forgot')
chai.expect(res).html.to.have.status(200)
chai.expect(res).to.be.html.and.have.status(200)
})
// TODO: Test already-logged-in forgot password requests
// TODO: Test invalid and fuzzed forgot password requests
// TODO: Fix this test
it.skip('Sends valid forgot password request', async () => {
it('Sends valid forgot password request', async () => {
// Responds with 200
let res = await request.post('/login/forgot')
chai.expect( await request.post('/login/forgot')
.type('form').send({
email: TEST_EMAIL,
'email': TEST_EMAIL,
})
chai.expect(res).html.to.have.status(200)
).to.redirectTo('/login')
// Assert password was set
// Assert password token was set
let requesting_user = await User.findOne({'email':TEST_EMAIL} )
chai.assert.isString(
requesting_user.auth.passwordToken, 'Failed to correctly save password token'
)
chai.expect(requesting_user.auth.passToken)
.to.be.a('string').and.to.have.lengthOf(32)
})
@ -257,6 +248,7 @@ describe('Authentication', () => {
})
})
})
})

View File

@ -53,6 +53,7 @@
</section>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.18.1/moment.min.js" integrity="sha256-1hjUhpc44NwiNg8OwMu2QzJXhD8kcj+sJA3aCQZoUjg=" crossorigin="anonymous"></script>
<!-- TODO: Move this script to own file -->
<script type="application/javascript">
/* DATE/TIME FORMATS */ {

View File

@ -19,6 +19,7 @@
<h1>Contact</h1>
<form id='contact-form' role="form" method="POST">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input id='subject-input' name="subject" id='subject' type="text" maxlength="160" placeholder="Subject">
<p id='message-help' class='red help'>You need to enter a message. </p>

View File

@ -13,6 +13,7 @@
<p>Enter your email below to recieve a link to reset your password. </p>
<form method="post" role="form">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<div class='form-group'>
<label for="email">Email:</label>

View File

@ -60,7 +60,7 @@
<h3 id='how-do-i-share-my-location'>How do I share my location?</h3>
<p>You can simply share your map's url with anyone. {% if user %}Your URL is <a href="https://tracman.org/map/{{user.slug}}">https://tracman.org/map/{{user.slug}}</a>{% else %}The URL is <u>https://tracman.org/map/&gt;your-slug&lt;</u>{% endif %}. </p>
<p>You can simply share your map's url with anyone. {% if user %}Your URL is <a href="https://www.tracman.org/map/{{user.slug}}">https://tracman.org/map/{{user.slug}}</a>{% else %}The URL is <u>https://tracman.org/map/&gt;your-slug&lt;</u>{% endif %}. </p>
<h3 id='how-accurate-is-the-location'>How accurate is the location?</h3>

View File

@ -24,6 +24,7 @@
<h3>Login</h3>
<form method="post">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<div id='social-login' class='flex form-group'>
@ -64,6 +65,7 @@
<h3>Create account</h3>
<p>Welcome aboard! </p>
<form action="/signup" method="post">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<input type="email" name="email" placeholder="Your email" required>
<p>You will be sent an email confrimation with a link to create a password. </p>
<p>By signing up, you agree to our <a href="/terms">terms of service</a> and <a href="/privacy">privacy policy</a>. </p>

View File

@ -6,7 +6,7 @@
<link href="/static/css/.map.min.css" rel="stylesheet">
<style>/* These styles include values passed from to the template from the server. Other styles are in map.css */
{% if noHeader!='0' %} main { top: 0; } {% endif %}
{% if mapuser.settings.showStreetview and disp=='2' %}
/* show both */
@media (orientation: landscape) {
@ -44,42 +44,42 @@
{% block main %}
{% if user and newuserurl %}
{% if user and user.isNewUser %}
<div class='page-mask'></div>
<div id='welcome' class='popup'>
<div class='topbar'>
<h2>Welcome!</h2>
<div class='close' onclick="$('#welcome').hide();$('.page-mask').hide();">✖️</div>
</div>
<p>This is your map. It's publicly avaliable at <a href="{{newuserurl}}">{{newuserurl}}</a>. You can change that URL and other settings in <b><a href="/settings">settings</a></b>. Set your location by clicking <b>set</b> below. Clear it by clicking <b>clear</b>. To track your location, click <b>track</b> or download the <a href="/android">android app</a>. For more information, see the <b><a href="/help">help</a></b> page. </p>
<div class='buttons'>
<a class='btn main' onclick="$('#welcome').hide();$('.page-mask').hide();">Got it!</a>
</div>
</div>
{% endif %}
<!-- Map -->
{% if disp!='1' %}
<div id='map'></div>
{% endif %}
<!-- Streetview -->
{% if mapuser.settings.showStreetview and disp!='0' %}
<div id='view'><img id='viewImg' alt="Streetview image"/></div>
{% endif %}
<div id='notset' class='centered alert alert-warning'>
{% if user.id == mapuser.id %}
Your location is unset. You can click 'set' below to set it to your current position.
{% else %}
This user has no location set.
This user has no location set.
{% endif %}
</div>
{% if user.id == mapuser.id and mapuser._id != 'demo' %}
<link rel="stylesheet" type="text/css" href="/static/css/.controls.min.css">
<div id='controls'>
@ -90,20 +90,21 @@
}
</style>
{% endif %}
<button id='set-loc' class='btn set' title="Click here to set your location">Set</button>
<button id='track-loc' class='btn track' title="Click here to track your location"><i class='fa fa-crosshairs'></i>Track</button>
<button id='clear-loc' class='btn clear' title="Click here to clear your location">Clear</button>
</div>
{% endif %}
{% endblock %}
{% block javascript %}
{{super()}}
<!-- Variables from server-side -->
<!-- TODO: Move to own script file, maybe with https://github.com/brooklynDev/JShare -->
<script>
const mapuser = JSON.parse('{{mapuser |dump|safe}}'),
mapKey = "{{mapApi |safe}}",
@ -112,11 +113,11 @@
userid = "{{user._id |safe}}",
token = "{{user.sk32 |safe}}";
</script>
<!-- Webpacked bundles -->
<script type="application/javascript" src="/static/js/.map.bun.js"></script>
<!--{% if user.id == mapuser.id %}-->
<!--<script type="application/javascript" src="/static/js/.controls.bun.js"></script>-->
<!--{% endif %}-->
{% endblock %}

View File

@ -18,22 +18,23 @@
<h1>Set Password</h1>
<form id='password-form' role="form" method="post">
<style>
#password-form .password {
flex-grow: 1;
min-width: 0;
margin: 2%;
}
#password-help {
display: none;
}
.form-group > span {
display: flex;
flex-wrap: wrap;
}
</style>
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<style>
#password-form .password {
flex-grow: 1;
min-width: 0;
margin: 2%;
}
#password-help {
display: none;
}
.form-group > span {
display: flex;
flex-wrap: wrap;
}
</style>
<p>Your password must be at least 8 characters long. You can use any letter, number, symbol, emoji, or spaces. Your password will be checked using <a href="https://github.com/dropbox/zxcvbn">zxcvbn</a>. All passwords are stored stored on the server as salted hashes. </p>
<p>Your password must be at least 8 characters long. You can use any letter, number, symbol, emoji, or spaces. Your password will be checked using <a href="https://github.com/dropbox/zxcvbn">zxcvbn</a>. All passwords are stored on the server as salted hashes. </p>
<div class='form-group' style="flex-wrap:wrap">
<span title="Enter your new password here" style="flex-grow:1; max-width:70vw">

View File

@ -25,9 +25,10 @@
<p>That said, just click the button below to try pro out. </p>
<p>Cheers, <br>
<a href="https://keithirwin.us/">Keith Irwin</a></p>
<a href="https://www.keithirwin.us/">Keith Irwin</a></p>
<form class='flex' action="#" method="POST">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
{% if user.isPro %}
<div id='already-pro' class='inline-block alert alert-success'>
<i class="fa fa-check-circle"></i>

View File

@ -19,6 +19,7 @@
<h1>Settings</h1>
<form id='settings-form' role="form" method="post">
<input type="hidden" name="_csrf" value="{{csrfToken}}">
<h2>Account settings</h2>
@ -50,7 +51,7 @@
</div>
<div id='password-delete' class='form-group'>
<a id='password' class='underline' href="/settings/password" title="Click here to {% if user.auth.password %}change{% else %}set{% endif %} your password. ">{% if user.auth.password %}Change{% else %}Set{% endif %} password</a>
<a id='password' class='underline' href="/account/password" title="Click here to {% if user.auth.password %}change{% else %}set{% endif %} your password. ">{% if user.auth.password %}Change{% else %}Set{% endif %} password</a>
<a id='delete' class='red underline' style="text-align:right" href="#" title="Permently delete your Tracman account. ">Delete account</a>
</div>

View File

@ -3,7 +3,7 @@
<!--
Tracman: GPS tracking service in node.js
Copyright © 2017 Keith Irwin
Copyright © 2018 Keith Irwin
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

View File

@ -1,7 +1,7 @@
<footer class='footer'>
<div class='left'>
<a href="/privacy">Privacy Policy</a> | <a href="/terms">Terms of Service</a>
<!--<p>Website and app by <a href="https://keithirwin.us/">Keith Irwin</a>. -->
<!--<p>Website and app by <a href="https://www.keithirwin.us/">Keith Irwin</a>. -->
<!--<br>Design by <a href="http://boag.online/blog/maglev-free-responsive-website-template">Fraser Boag</a>. </p>-->
</div>
<div class='right'>

View File

@ -12,10 +12,9 @@ module.exports = {
contact: './static/js/contact.js',
login: './static/js/login.js',
map: './static/js/map.js',
// controls: './static/js/controls.js',
settings: './static/js/settings.js',
password: './static/js/password.js',
sw: './static/sw.js',
sw: './static/js/sw.js',
},
// Sourcemaps