Skip to content

Commit cdaff9b

Browse files
authored
Merge pull request #919 from solid/feature/blacklist-option
Invalidusernames command
2 parents 066203a + db73c6d commit cdaff9b

File tree

13 files changed

+305
-22
lines changed

13 files changed

+305
-22
lines changed

bin/lib/cli.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const program = require('commander')
22
const loadInit = require('./init')
33
const loadStart = require('./start')
4+
const loadInvalidUsernames = require('./invalidUsernames')
45
const { spawnSync } = require('child_process')
56
const path = require('path')
67

@@ -9,6 +10,7 @@ module.exports = function startCli (server) {
910

1011
loadInit(program)
1112
loadStart(program, server)
13+
loadInvalidUsernames(program)
1214

1315
program.parse(process.argv)
1416
if (program.args.length === 0) program.help()

bin/lib/common.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const fs = require('fs')
2+
const extend = require('extend')
3+
const { cyan, bold } = require('colorette')
4+
const util = require('util')
5+
6+
module.exports = {}
7+
module.exports.loadConfig = loadConfig
8+
9+
async function loadConfig (program, options) {
10+
let argv = extend({}, options, { version: program.version() })
11+
let configFile = argv['configFile'] || './config.json'
12+
13+
try {
14+
const file = await util.promisify(fs.readFile)(configFile)
15+
16+
// Use flags with priority over config file
17+
const config = JSON.parse(file)
18+
Object.keys(config).forEach((option) => {
19+
argv[option] = argv[option] || config[option]
20+
})
21+
} catch (err) {
22+
// No file exists, not a problem
23+
console.log(cyan(bold('TIP')), 'create a config.json: `$ solid init`')
24+
}
25+
26+
return argv
27+
}

bin/lib/invalidUsernames.js

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
const fs = require('fs-extra')
2+
const Handlebars = require('handlebars')
3+
const path = require('path')
4+
const { URL } = require('url')
5+
const util = require('util')
6+
7+
const { loadConfig } = require('./common')
8+
const { isValidUsername } = require('../../lib/common/user-utils')
9+
const blacklistService = require('../../lib/services/blacklist-service')
10+
const { initConfigDir, initTemplateDirs } = require('../../lib/server-config')
11+
const { fromServerConfig } = require('../../lib/models/oidc-manager')
12+
13+
const AccountManager = require('../../lib/models/account-manager')
14+
const EmailService = require('../../lib/services/email-service')
15+
const LDP = require('../../lib/ldp')
16+
const SolidHost = require('../../lib/models/solid-host')
17+
18+
const fileExists = util.promisify(fs.exists)
19+
const fileRename = util.promisify(fs.rename)
20+
21+
module.exports = function (program) {
22+
program
23+
.command('invalidusernames')
24+
.option('--notify', 'Will notify users with usernames that are invalid')
25+
.option('--delete', 'Will delete users with usernames that are invalid')
26+
.description('Manage usernames that are invalid')
27+
.action(async (options) => {
28+
const config = await loadConfig(program, options)
29+
if (!config.multiuser) {
30+
return console.error('You are running a single user server, no need to check for invalid usernames')
31+
}
32+
33+
const invalidUsernames = await getInvalidUsernames(config)
34+
const host = SolidHost.from({ port: config.port, serverUri: config.serverUri })
35+
const accountManager = getAccountManager(config, host)
36+
37+
if (options.notify) {
38+
return notifyUsers(invalidUsernames, accountManager, config)
39+
}
40+
41+
if (options.delete) {
42+
return deleteUsers(invalidUsernames, accountManager, config, host)
43+
}
44+
45+
listUsernames(listUsernames)
46+
})
47+
}
48+
49+
async function createNewIndexFile (username, accountManager, invalidUsernameTemplate, dateOfRemoval, supportEmail, fileOptions) {
50+
const userDirectory = accountManager.accountDirFor(username)
51+
const currentIndex = path.join(userDirectory, 'index.html')
52+
const currentIndexExists = await fileExists(currentIndex)
53+
const backupIndex = path.join(userDirectory, 'index.backup.html')
54+
const backupIndexExists = await fileExists(backupIndex)
55+
if (currentIndexExists && !backupIndexExists) {
56+
await fileRename(currentIndex, backupIndex)
57+
const newIndexSource = invalidUsernameTemplate({
58+
username,
59+
dateOfRemoval,
60+
supportEmail
61+
})
62+
fs.writeFileSync(currentIndex, newIndexSource, fileOptions)
63+
console.info(`index.html updated for user ${username}`)
64+
}
65+
}
66+
67+
async function deleteUsers (usernames, accountManager, config, host) {
68+
const oidcManager = fromServerConfig({
69+
...config,
70+
host
71+
})
72+
const deletingUsers = usernames
73+
.map(async username => {
74+
try {
75+
const user = accountManager.userAccountFrom({ username })
76+
await oidcManager.users.deleteUser(user)
77+
} catch (error) {
78+
if (error.message !== 'No email given') {
79+
// 'No email given' is an expected error that we want to ignore
80+
throw error
81+
}
82+
}
83+
const userDirectory = accountManager.accountDirFor(username)
84+
await fs.remove(userDirectory)
85+
})
86+
await Promise.all(deletingUsers)
87+
console.info(`Deleted ${deletingUsers.length} users succeeded`)
88+
}
89+
90+
function getAccountManager (config, host) {
91+
const ldp = new LDP(config)
92+
return AccountManager.from({
93+
host,
94+
store: ldp,
95+
multiuser: config.multiuser
96+
})
97+
}
98+
99+
async function getInvalidUsernames (config) {
100+
const files = await util.promisify(fs.readdir)(config.root)
101+
const hostname = new URL(config.serverUri).hostname
102+
const isUserDirectory = new RegExp(`.${hostname}$`)
103+
return files
104+
.filter(file => isUserDirectory.test(file))
105+
.map(userDirectory => userDirectory.substr(0, userDirectory.length - hostname.length - 1))
106+
.filter(username => !isValidUsername(username) || !blacklistService.validate(username))
107+
}
108+
109+
function listUsernames (usernames) {
110+
if (usernames.length === 0) {
111+
console.info('No invalid usernames was found')
112+
}
113+
console.info(`${usernames.length} invalid usernames were found:${usernames.map(username => `\n- ${username}`)}`)
114+
}
115+
116+
async function notifyUsers (usernames, accountManager, config) {
117+
const twoWeeksFromNow = Date.now() + 14 * 24 * 60 * 60 * 1000
118+
const dateOfRemoval = (new Date(twoWeeksFromNow)).toLocaleDateString()
119+
const { supportEmail } = config
120+
121+
await updateIndexFiles(usernames, accountManager, dateOfRemoval, supportEmail)
122+
await sendEmails(config, usernames, accountManager, dateOfRemoval, supportEmail)
123+
}
124+
125+
async function sendEmails (config, usernames, accountManager, dateOfRemoval, supportEmail) {
126+
if (config.email && config.email.host) {
127+
const configPath = initConfigDir(config)
128+
const templates = initTemplateDirs(configPath)
129+
const users = await Promise.all(await usernames.map(async username => {
130+
const emailAddress = await accountManager.loadAccountRecoveryEmail({ username })
131+
const accountUri = accountManager.accountUriFor(username)
132+
return { username, emailAddress, accountUri }
133+
}))
134+
const emailService = new EmailService(templates.email, config.email)
135+
const sendingEmails = await users
136+
.filter(user => !!user.emailAddress)
137+
.map(async user => await emailService.sendWithTemplate('invalid-username', {
138+
to: user.emailAddress,
139+
accountUri: user.accountUri,
140+
dateOfRemoval,
141+
supportEmail
142+
}))
143+
const emailsSent = await Promise.all(sendingEmails)
144+
console.info(`${emailsSent.length} emails sent to users with invalid usernames`)
145+
return
146+
}
147+
console.info('You have not configured an email service.')
148+
console.info('Please set it up to send users email about their accounts')
149+
}
150+
151+
async function updateIndexFiles (usernames, accountManager, dateOfRemoval, supportEmail) {
152+
const invalidUsernameFilePath = path.join(process.cwd(), 'default-views/account/invalid-username.hbs')
153+
const fileOptions = {
154+
encoding: 'utf-8'
155+
}
156+
const source = fs.readFileSync(invalidUsernameFilePath, fileOptions)
157+
const invalidUsernameTemplate = Handlebars.compile(source)
158+
const updatingFiles = usernames.map(username => createNewIndexFile(username, accountManager, invalidUsernameTemplate, dateOfRemoval, supportEmail, fileOptions))
159+
return Promise.all(updatingFiles)
160+
}

bin/lib/options.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const fs = require('fs')
22
const path = require('path')
33
const validUrl = require('valid-url')
44
const { URL } = require('url')
5+
const { isEmail } = require('validator')
56

67
module.exports = [
78
// {
@@ -349,6 +350,18 @@ module.exports = [
349350
prompt: true,
350351
validate: validUri,
351352
when: answers => answers.enforceToc
353+
},
354+
{
355+
name: 'support-email',
356+
help: 'The support email you provide for your users (not required)',
357+
prompt: true,
358+
validate: (value) => {
359+
if (value && !isEmail(value)) {
360+
return 'Must be a valid email'
361+
}
362+
return true
363+
},
364+
when: answers => answers.multiuser
352365
}
353366
]
354367

bin/lib/start.js

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
const options = require('./options')
44
const fs = require('fs')
5-
const extend = require('extend')
6-
const { cyan, red, bold } = require('colorette')
5+
const { loadConfig } = require('./common')
6+
const { red, bold } = require('colorette')
77

88
module.exports = function (program, server) {
99
const start = program
@@ -34,24 +34,9 @@ module.exports = function (program, server) {
3434

3535
start.option('-v, --verbose', 'Print the logs to console')
3636

37-
start.action((opts) => {
38-
let argv = extend({}, opts, { version: program.version() })
39-
let configFile = argv['configFile'] || './config.json'
40-
41-
fs.readFile(configFile, (err, file) => {
42-
// No file exists, not a problem
43-
if (err) {
44-
console.log(cyan(bold('TIP')), 'create a config.json: `$ solid init`')
45-
} else {
46-
// Use flags with priority over config file
47-
const config = JSON.parse(file)
48-
Object.keys(config).forEach((option) => {
49-
argv[option] = argv[option] || config[option]
50-
})
51-
}
52-
53-
bin(argv, server)
54-
})
37+
start.action(async (options) => {
38+
const config = await loadConfig(program, options)
39+
bin(config, server)
5540
})
5641
}
5742

config.json-default

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,6 @@
1616
"logo": ""
1717
},
1818
"enforceToc": true,
19-
"tocUri": "https://your-toc"
19+
"tocUri": "https://your-toc",
20+
"supportEmail": "Your support email address"
2021
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
module.exports.render = render
2+
3+
function render (data) {
4+
return {
5+
subject: `Invalid username for account ${data.accountUri}`,
6+
7+
/**
8+
* Text version
9+
*/
10+
text: `Hi,
11+
12+
We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy.
13+
14+
This account has been set to be deleted at ${data.dateOfRemoval}.
15+
16+
${data.supportEmail ? `Please contact ${data.supportEmail} if you want to move your account.` : ''}`,
17+
18+
/**
19+
* HTML version
20+
*/
21+
html: `<p>Hi,</p>
22+
23+
<p>We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy.</p>
24+
25+
<p>This account has been set to be deleted at ${data.dateOfRemoval}.</p>
26+
27+
${data.supportEmail ? `<p>Please contact ${data.supportEmail} if you want to move your account.</p>` : ''}
28+
`
29+
}
30+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1">
6+
<title>Invalid username</title>
7+
<link rel="stylesheet" href="/common/css/bootstrap.min.css">
8+
<script></script>
9+
</head>
10+
<body>
11+
<div class="container">
12+
<h4>Invalid username</h4>
13+
</div>
14+
<div class="container">
15+
<p>We're sorry to inform you that this account's username ({{username}}) is not allowed after changes to username policy.</p>
16+
<p>This account has been set to be deleted at {{dateOfRemoval}}.</p>
17+
{{#if supportEmail}}
18+
<p>Please contact {{supportEmail}} if you want to move your account.</p>
19+
{{/if}}
20+
<p>If you had an email address connected to this account, you should have received an email about this.</p>
21+
</div>
22+
</body>
23+
</html>

lib/common/user-utils.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module.exports.isValidUsername = isValidUsername
2+
3+
function isValidUsername (username) {
4+
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(username)
5+
}

lib/requests/create-account-request.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const AuthRequest = require('./auth-request')
44
const WebIdTlsCertificate = require('../models/webid-tls-certificate')
55
const debug = require('../debug').accounts
66
const blacklistService = require('../services/blacklist-service')
7+
const { isValidUsername } = require('../common/user-utils')
78

89
/**
910
* Represents a 'create new user account' http request (either a POST to the
@@ -208,7 +209,7 @@ class CreateAccountRequest extends AuthRequest {
208209
* @return {UserAccount} Chainable
209210
*/
210211
cancelIfUsernameInvalid (userAccount) {
211-
if (!userAccount.username || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(userAccount.username)) {
212+
if (!userAccount.username || !isValidUsername(userAccount.username)) {
212213
debug('Invalid username ' + userAccount.username)
213214
const error = new Error('Invalid username (contains invalid characters)')
214215
error.status = 400

0 commit comments

Comments
 (0)