site/api/preferences.lua (312 lines of code) (raw):
--[[
 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)