| --[[ |
| Licensed to the Apache Software Foundation (ASF) under one or more |
| contributor license agreements. See the NOTICE file distributed with |
| this work for additional information regarding copyright ownership. |
| The ASF licenses this file to You under the Apache License, Version 2.0 |
| (the "License"); you may not use this file except in compliance with |
| the License. You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, software |
| distributed under the License is distributed on an "AS IS" BASIS, |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| See the License for the specific language governing permissions and |
| limitations under the License. |
| ]]-- |
| |
| -- This is preferences.lua - an account info agent |
| |
| local JSON = require 'cjson' |
| local elastic = require 'lib/elastic' |
| local user = require 'lib/user' |
| local cross = require 'lib/cross' |
| local smtp = require 'socket.smtp' |
| local config = require 'lib/config' |
| local aaa = require 'lib/aaa' |
| local utils = require 'lib/utils' |
| |
| --[[ |
| Remove nulls values from a table |
| This is for use in tidying up account.credentials.altemail |
| which may contain null entries. |
| Rather than continually check for them, remove them from |
| the input before use. |
| ]] |
| local function filtertable(input) |
| -- table.remove can affect pairs() |
| -- so repeat until no more to do |
| repeat |
| local isClean = true |
| for k, v in pairs(input) do |
| if not v or v == JSON.null then |
| table.remove(input, k) |
| isClean = false |
| break |
| end |
| end |
| until isClean |
| end |
| |
| --[[ |
| Get login details (if logged in), mail list counts and descriptions |
| |
| Parameters: (cookie required) |
| - logout: Whether to log out of the system (optional) |
| - associate=$email - associate the account with the $email address |
| - verify&hash=$hash - verify an association request $hash |
| - removealt=$email - remove an alternate $email address |
| - save - save preferences as specified (does not merge) |
| - addfav=$list - add a favourite $list |
| - remfav=$list - remove a favourite $list |
| ]]-- |
| function handle(r) |
| cross.contentType(r, "application/json") |
| local DEBUG = config.debug or false |
| local START = DEBUG and r:clock() or nil |
| local get = r:parseargs() |
| |
| |
| local login = { |
| loggedIn = false |
| } |
| |
| local prefs = nil -- Default to JS prefs if not logged in |
| |
| -- prefs? |
| local account = user.get(r) |
| |
| -- while we're here, are you logging out? |
| if get.logout and account then |
| user.logout(r, account) |
| r:puts[[{"logout": true}]] |
| return cross.OK |
| end |
| |
| -- associating an email address?? |
| if get.associate and account and get.associate:match("^%S+@%S+$") then |
| local fp, lp = get.associate:match("([^@]+)@([^@]+)") |
| if config.no_association then |
| for k, v in pairs(config.no_association) do |
| if r.strcmp_match(lp:lower(), v) or v == "*" then |
| r:puts(JSON.encode{error="You cannot associate email addresses from this domain"}) |
| return cross.OK |
| end |
| end |
| end |
| |
| if get.associate == account.credentials.email then |
| r:puts(JSON.encode{error="The primary mail address cannot be added as an alternate"}) |
| return cross.OK |
| end |
| |
| |
| account.credentials.altemail = account.credentials.altemail or {} |
| filtertable(account.credentials.altemail) |
| local duplicateRequest = false |
| for k, v in pairs(account.credentials.altemail) do |
| if v.email == get.associate then -- duplicate request |
| if v.verified then -- already exists |
| r:puts(JSON.encode{error="That email is already defined as an alternate"}) |
| -- OK to return here as we don't need to update anything |
| return cross.OK |
| else -- pending verification, update the hash |
| v.hash = hash -- update all pending requests to the new hash |
| -- cannot return here in case there are multiple entries |
| -- also we need to mail the new hash to the user |
| duplicateRequest = true |
| end |
| end |
| end |
| |
| local hash = r:md5(math.random(1,999999) .. os.time() .. account.cid) |
| local scheme = "https" |
| if r.port == 80 then |
| scheme = "http" |
| end |
| local domain = ("%s://%s:%u/"):format(scheme, r.hostname, r.port) |
| if r.headers_in['Referer'] and r.headers_in['Referer']:match("merge%.html") then |
| domain = r.headers_in['Referer']:gsub("/merge%.html", "/") |
| end |
| local vURL = ("%sapi/preferences.lua?verify=true&hash=%s"):format(domain, hash) |
| |
| local mldom = r.headers_in['Referer'] and r.headers_in['Referer']:match("https?://([^/:]+)") or r.hostname |
| if not mldom then mldom = r.hostname end |
| |
| -- send email |
| local source = smtp.message{ |
| headers = { |
| subject = "Confirm email address association in Pony Mail", |
| to = get.associate, |
| from = ("\"Pony Mail\"<no-reply@%s>"):format(mldom) |
| }, |
| body = ([[ |
| You (or someone else) has requested to associate the email address '%s' with the account '%s' in Pony Mail. |
| If you wish to complete this association, please visit |
| %s |
| whilst logged in to Pony Mail. |
| Note: if you have repeated the association request, only the last URL will work. |
| ...Or if you didn't request this, just ignore this email. |
| |
| With regards, |
| Pony Mail - Email for Ponies and People. |
| ]]):format(get.associate, account.credentials.email, vURL) |
| } |
| |
| -- send email! |
| local rv, er = smtp.send{ |
| from = ("\"Pony Mail\"<no-reply@%s>"):format(r.hostname), |
| rcpt = get.associate, |
| source = source, |
| server = config.mailserver, |
| port = config.mailport or nil -- if not specified, use the default |
| } |
| -- only update the account if the mail was sent OK |
| if rv then |
| if not duplicateRequest then |
| table.insert(account.credentials.altemail, { email = get.associate, hash = hash, verified = false}) |
| end |
| user.save(r, account, true) |
| end |
| r:puts(JSON.encode{requested = rv or er}) |
| return cross.OK |
| end |
| |
| -- verify alt email? |
| if get.verify and get.hash and account and account.credentials.altemail then |
| filtertable(account.credentials.altemail) |
| local verified = false |
| for k, v in pairs(account.credentials.altemail) do |
| if v.hash == get.hash then |
| account.credentials.altemail[k].verified = true |
| account.credentials.altemail[k].hash = nil |
| verified = true |
| -- fix all the matches |
| end |
| end |
| user.save(r, account, true) |
| -- response goes back to the browser direct |
| cross.contentType(r, "text/plain") |
| if verified then |
| r:puts("Email address verified! Thanks for shopping at Pony Mail!\n") |
| else |
| r:puts("Either you supplied an invalid hash or something else went wrong.\n") |
| end |
| return cross.OK |
| end |
| |
| -- remove alt email? |
| if get.removealt and account and account.credentials.altemail then |
| filtertable(account.credentials.altemail) |
| for k, v in pairs(account.credentials.altemail) do |
| if v.email == get.removealt then |
| table.remove(account.credentials.altemail, k) |
| break |
| end |
| end |
| user.save(r, account, true) |
| r:puts(JSON.encode{removed = true}) |
| return cross.OK |
| end |
| |
| -- Or are you saving your preferences? |
| if get.save and account then |
| prefs = {} |
| for k, v in pairs(get) do |
| if k ~= 'save' then |
| prefs[k] = v |
| end |
| end |
| account.preferences = prefs |
| user.save(r, account) |
| r:puts[[{"saved": true}]] |
| return cross.OK |
| end |
| |
| -- Adding a favorite list |
| if get.addfav and account then |
| local add = get.addfav |
| local favs = account.favorites or {} |
| local found = false |
| -- ensure it's not already there.... |
| for k, v in pairs(favs) do |
| if v == add then |
| found = true |
| break |
| end |
| end |
| -- if not found, add it |
| if not found then |
| table.insert(favs, add) |
| end |
| -- save prefs |
| account.favorites = favs |
| user.favs(r, account) |
| r:puts[[{"saved": true}]] |
| return cross.OK |
| end |
| |
| -- Removing a favorite list |
| if get.remfav and account then |
| local rem = get.remfav |
| local favs = account.favorites or {} |
| -- ensure it's here.... |
| for k, v in pairs(favs) do |
| if v == rem then |
| table.remove(favs, k) |
| break |
| end |
| end |
| -- save prefs |
| account.favorites = favs |
| user.favs(r, account) |
| r:puts[[{"saved": true}]] |
| return cross.OK |
| end |
| |
| -- don't allow failed options to drop-thru |
| for _, v in pairs({'associate', 'verify', 'removealt', 'save', 'addfav', 'remfav'}) do |
| if get[v] then |
| if not account then |
| r:puts(JSON.encode{error="Not logged in"}) |
| else |
| r:puts(JSON.encode{error="Missing or invalid parameter(s)"}) |
| end |
| return cross.OK |
| end |
| end |
| |
| -- Get list counts (cached if possible) |
| local NOWISH = math.floor(os.time() / 600) |
| local PM_LISTS_KEY = "pm_lists_counts_" .. r.hostname .. "-" .. NOWISH |
| local cache = r:ivm_get(PM_LISTS_KEY) |
| local listcounts = {} -- summary of aggregated data for cache |
| if cache then |
| listcounts = JSON.decode(cache) |
| else |
| -- aggregate the documents by listname, privacy flag, recent docs |
| |
| local alldocs = elastic.raw{ |
| size = 0, -- we don't need the hits themselves |
| aggs = { |
| listnames = { |
| terms = { |
| field = "list_raw", |
| size = utils.MAX_LIST_COUNT |
| }, |
| aggs = { |
| -- split list into public and private buckets |
| privacy = { |
| terms = { |
| field = "private" |
| }, |
| aggs = { |
| -- Create a single bucket of recent mails |
| recent = { |
| range = { |
| field = "date", |
| ranges = { {from = "now-90d"} } |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| } |
| -- squash the output for caching (it's quite verbose otherwise) |
| for _, entry in pairs (alldocs.aggregations.listnames.buckets) do |
| local listname = entry.key:lower() |
| listcounts[listname] = {} |
| -- the same list may have both private and public docs |
| for _, privacy in pairs(entry.privacy.buckets) do |
| listcounts[listname][privacy.key_as_string] = privacy.recent.buckets[1].doc_count |
| end |
| end |
| -- save the squashed counts in cache |
| r:ivm_set(PM_LISTS_KEY, JSON.encode(listcounts)) |
| end |
| |
| -- Now count the docs and lists that are visible to the current user |
| local lists = {} |
| for listname, entry in pairs(listcounts) do |
| local _, list, domain = aaa.parseLid(listname) |
| -- Note: the default implementation ensures that list and domain are non-empty |
| -- Check lengths just in case a local version does not do so |
| if list and domain and #list > 0 and #domain > 0 then |
| -- there may be both private and public docs in the list |
| for privacy, recent_count in pairs(entry) do |
| local isPublic = privacy == 'false' |
| -- does the user have access to this list? |
| if isPublic or aaa.canAccessList(r, listname, account) then |
| -- create the domain entry if necessary |
| lists[domain] = lists[domain] or {} |
| -- check if we have a list entry yet |
| if lists[domain][list] then |
| lists[domain][list] = lists[domain][list] + recent_count |
| else |
| lists[domain][list] = recent_count -- init the entry |
| end |
| end |
| end |
| end |
| end |
| |
| -- do we need to remove junk? |
| if config.listsDisplay then |
| for k, v in pairs(lists) do |
| if not k:match(config.listsDisplay) then |
| lists[k] = nil |
| end |
| end |
| end |
| |
| |
| -- Get notifs |
| local notifications = 0 |
| if account then |
| local _, notifs = pcall(function() return elastic.find("seen:0 AND recipient:" .. r:sha1(account.cid), 10, "notifications") end) |
| if notifs and #notifs > 0 then |
| notifications = #notifs |
| end |
| end |
| |
| account = account or {} |
| local stat, descs = pcall(function() return elastic.find("*", 9999, "mailinglists", "name") end) |
| if not stat or not descs then |
| descs = {} -- ensure descs is valid |
| end |
| -- try to extrapolate foo@bar.tld here |
| for k, v in pairs(descs) do |
| local _, l, d = aaa.parseLid(v.list:lower()) |
| if l and d then |
| descs[k].lid = ("%s@%s"):format(l, d) |
| else |
| descs[k].lid = v.list |
| end |
| end |
| |
| local alts = {} |
| if account and account.credentials and type(account.credentials.altemail) == "table" then |
| filtertable(account.credentials.altemail) |
| for k, v in pairs(account.credentials.altemail) do |
| if v.verified then |
| table.insert(alts, v.email) |
| end |
| end |
| end |
| r:puts(JSON.encode{ |
| lists = lists, |
| descriptions = descs, |
| preferences = account.preferences, |
| login = { |
| favorites = account.favorites, |
| credentials = account.credentials, |
| notifications = notifications, |
| alternates = alts |
| }, |
| took = DEBUG and (r:clock() - START) or nil |
| }) |
| |
| return cross.OK |
| end |
| |
| cross.start(handle) |