001package votorola.a.voter; // Copyright 2009, 2011, 2013, Michael Allan.  Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Votorola Software"), to deal in the Votorola Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of the Votorola Software, and to permit persons to whom the Votorola Software is furnished to do so, subject to the following conditions: The preceding copyright notice and this permission notice shall be included in all copies or substantial portions of the Votorola Software. THE VOTOROLA SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE VOTOROLA SOFTWARE OR THE USE OR OTHER DEALINGS IN THE VOTOROLA SOFTWARE.
002
003import java.util.*;
004import javax.mail.internet.*;
005import votorola.g.*;
006import votorola.g.lang.*;
007import votorola.g.mail.*;
008
009
010/** A user identifier in two forms: email address and mailish username.  Both are globally
011  * unique and map 1:1.
012  */
013  @SuppressWarnings("overrides")/* overrides equals, but not hashCode*/
014public @ThreadSafe class IDPair implements java.io.Serializable
015{
016
017    private static final long serialVersionUID = 1L;
018
019
020
021    /** Constructs an IDPair.  The username will automatically be translated to normal
022      * form.
023      *
024      *     @see #email()
025      *     @see #username()
026      *     @see #isFromEmail()
027      */
028    public IDPair( String _email, String _username, boolean _isFromEmail )
029    {
030        email = _email;
031        username = normalUsername( _username );
032        isFromEmail = _isFromEmail;
033    }
034
035
036
037    /** Constructs an IDPair by copying from another.
038      */
039    protected IDPair( final IDPair other )
040    {
041        email = other.email;
042        username = other.username;
043        isFromEmail = other.isFromEmail;
044    }
045
046
047
048    /** Constructs an IDPair from a canonical email address.
049      *
050      *     @see #email()
051      *
052      *     @return a newly constructed IDPair, or NOBODY if the email address is null.
053      *
054      *     @see votorola.g.mail.InternetAddressX#canonicalAddress(String)
055      */
056    public static IDPair fromEmail( final String email )
057    {
058        return email == null? NOBODY:
059          new IDPair( email, toUsername(email), /*isFromEmail*/true );
060    }
061
062
063
064    /** Constructs an IDPair from a username, which will automatically be translated to
065      * normal form.
066      *
067      *     @see #username()
068      *
069      *     @return a newly constructed IDPair, or NOBODY if the username is null.
070      */
071    public static IDPair fromUsername( final String username ) throws AddressException
072    {
073        return username == null? NOBODY:
074          new IDPair( toInternetAddress(username).getAddress(), username, /*isFromEmail*/false );
075    }
076
077
078
079   // --------------------------------------------------------------------------------
080
081
082    /** Translates an email address to a mailish username, and appends the result to the
083      * specified string builder.<pre>
084      *
085      *             Email address  Mailish username
086      *   -----------------------  ------------------------------
087      *            Smith@acme.com  Smith AcmeCom
088      *            smith@acme.com  Smith-AcmeCom
089      *   ThomasvonderElbe@gmx.de  ThomasvonderElbe GmxDe
090      *            mike@zelea.com  Mike-ZeleaCom</pre>
091      *
092      * <p>Note that MediaWiki ignores the case of the first letter of the username,
093      * forcing all names to begin with an uppercase letter, wheras the local part of an
094      * email address (before the @) is case sensitive.  We therefore encode the fact of
095      * lowercase by mapping the '@' symbol to a dash '-' surrogate, and of uppercase by
096      * mapping it to a space ' ' surrogate.  We might have tried using $wgCapitalLinks in
097      * MediaWiki, but, despite that, calls to ucfirst() in includes/User.php methods
098      * isValidUserName() and getCanonicalName() (and maybe elsewhere) block the creation
099      * of any username having a lowercase first character.</p>
100      *
101      * <p>This translation should work with all email addresses in common use today.
102      * Here are the strict requirements:</p><pre>
103      *
104      *  a) No '/' in address
105      *      - invalid in MediaWiki username
106      *  b) No ' ' in address
107      *      - MediaWiki cannot distinguish '_' and ' ' in page names
108      *  c) Only ASCII characters in domain name
109      *      - only ASCII domain names are considered case insensitive
110      *        and we use lower-to-uppercase boundaries in place of '.'
111      *        to separate the component labels of the name
112      *  d) No '-' or digit (or anything else non-lowercase) at start of a domain label
113      *      - we encode label with leading uppercase
114      *  e) No '-' at end of a domain label
115      *      - next label is encoded with leading uppercase, so we have internal
116      *        sequence '-U', where U is an uppercase letter; but we use '-U'
117      *        to encode '@' (though only for email addresses that begin with
118      *        lowercase)
119      *  f) No '_' in domain name
120      *      - we use it (or equivalently ' ') in place of '@' (though only for email
121      *        addresses that begin with uppercase)</pre>
122      *
123      *  <pre>References:
124      *      - http://tools.ietf.org/html/rfc1035
125      *      - http://tools.ietf.org/html/rfc2181#section-11
126      *          - no chars *abolutely* restricted in components (labels) of a domain name
127      *      - http://tools.ietf.org/html/rfc1035#section-2.3.1
128      *          - but mail hosts *ought* to follow the preferred name syntax
129      *      - http://tools.ietf.org/html/rfc4343#section-3.2
130      *          - case-insensitivity applies only to ASCII labels</pre>
131      *
132      *     @param email a canonical email address.
133      *
134      *     @return the same string builder b.
135      *
136      *     @see #toUsername(String)
137      *     @see votorola.g.mail.InternetAddressX#canonicalAddress(String)
138      */
139    public static StringBuilder appendUsername( final String email, final StringBuilder b )
140    {
141        // cf. emailToUsername($email) http://reluk.ca/project/_/mailish/MailishUsername.php
142        //     emailToUsername($) http://reluk.ca/system/host/havoc/usr/local/libexec/relay-to-minder
143        //
144        // Using plain 16 bit Java chars for case conversions.  32 bit code-points are
145        // poorly documented.  What's the code-point for '@'?  Is it just (int)'@'?  No
146        // worry, because email addresses are unlikely to use the extended characters.
147
148        final int cN = email.length();
149
150      // First character
151      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
152        int c = 0;
153        final char atSurrogateChar;
154        {
155            final char ch = email.charAt( c++ );
156            final char chUp = Character.toUpperCase( ch );
157            b.append( chUp );
158
159            if( ch == chUp ) atSurrogateChar = ' ';
160            else atSurrogateChar = '-';
161        }
162
163      // Remainder of local part and at-surrogate
164      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
165        for( ;; )
166        {
167            char ch = email.charAt( c++ );
168            if( ch == '@' ) break;
169            else if( ch == '_' ) ch = ' '; // normalize
170
171            b.append( ch );
172        }
173
174        b.append( atSurrogateChar );
175
176      // Domain name
177      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
178        b.append( Character.toUpperCase( email.charAt( c++ )));
179        for(; c < cN; )
180        {
181            char ch = email.charAt( c++ );
182            if( ch == '.' ) ch = Character.toUpperCase( email.charAt( c++ )); // collapse '.'
183
184            b.append( ch );
185        }
186
187        return b;
188    }
189
190
191
192    /** Appends a short abbreviation of the username to the string builder.
193      */
194    public static StringBuilder buildUserMnemonic( final String username, final StringBuilder sB )
195    {
196        // changing?  change also in a/web/gwt/super/
197
198        int n = username.length();
199        if( username.startsWith("Test-") && username.endsWith("-ZeleaCom") && n > 14 )
200        {
201            n -= 14;
202            sB.append( Character.toUpperCase( username.charAt( 5 )));
203            if( n > 1 )
204            {
205                final char ch = username.charAt( 6 );
206                if( Character.isLetterOrDigit( ch )) sB.append( ch );
207            }
208            else sB.append( 'z' ); // fill out single-letter names, which are common and unnatural
209        }
210        else
211        {
212            sB.append( username.charAt( 0 ));
213            if( n > 1 )
214            {
215                final char ch = username.charAt( 1 );
216                if( Character.isLetterOrDigit( ch )) sB.append( ch );
217            }
218        }
219        sB.append( '.' );
220        return sB;
221    }
222
223
224
225    /** Identifies the user by canonical email address.  This is the primary form of
226      * identifier for purposes of persistent back-end storage.
227      *
228      *     @see votorola.g.mail.InternetAddressX#canonicalAddress(String)
229      */
230    public final String email() { return email; }
231
232
233        private final String email;
234
235
236
237    /** Returns the same email address; or, if it is null or {@linkplain #NOBODY NOBODY},
238      * the localized string for "nobody".
239      *
240      *     @param resA a resource bundle of type application (A)
241      */
242    public static String emailOrNobody( final String email, final ResourceBundle resA )
243    {
244        return email == null || email.equals(NOBODY.email())?
245          resA.getString("a.count.nobodyEmailPlaceholder"): email;
246    }
247
248
249
250    /** Returns true iff o is an ID pair with the same email address.  This method is
251      * provided for backward compatibility and will soon be deprecated; use plain
252      * equals() instead.
253      *
254      *     @see #equals(Object)
255      */
256    public final boolean equalsEmail( Object o ) { return equals( o ); }
257 // {
258 //     if( !( o instanceof IDPair )) return false;
259 //
260 //     final IDPair other = (IDPair)o;
261 //     return email.equals( other.email );
262 // }
263
264
265
266    /** Whether this ID pair was created from an email address as opposed to a username.
267      *
268      *   @deprecated as an unecessary complexity and source of potential bugs.
269      */
270    public @Deprecated final boolean isFromEmail() { return isFromEmail; }
271
272
273        private final boolean isFromEmail;
274
275
276
277    /** An ID pair for a non-existent user.
278      */
279    public static IDPair NOBODY = IDPair.fromEmail( "nobody@zelea.com" );
280
281
282
283    /** Translates the mailish username to normal form by shifting the first letter to
284      * uppercase and substituting spaces for underscores.  The normal name is globally
285      * unique and maps 1:1 with the canonical email address.  Examples are
286      * "Smith-AcmeCom" for smith@acme.com, or "Smith AcmeCom" for Smith@acme.com.
287      *
288      *     @return the translated name, which may be the same name; or null if the name
289      *       is null.
290      *
291      *     @see #username()
292      *     @see MediaWiki#normalUsername(String)
293      */
294    public static String normalUsername( String name ) { return MediaWiki.normalUsername( name ); }
295        // changing?  change also in a/web/gwt/super/
296
297
298
299    /** Translates a username to a canonical email address.
300      *
301      *     @param username the username encoded with either a space (or equivalently
302      *       underscore) or dash at-surrogate.
303      *
304      *     @see #username()
305      *     @see votorola.g.mail.InternetAddressX#canonicalAddress(String)
306      */
307    public static InternetAddress toInternetAddress( final String username ) throws AddressException
308    {
309        // cf. usernameToEmail($) http://reluk.ca/system/host/havoc/usr/local/libexec/relay-to-minder
310        final int cN = username.length();
311        final int cAtSurrogate;
312        {
313            final int cDash;
314            {
315                int c = cN - 1;
316                boolean isLastUppercase = false;
317                for(; c >= 0; c-- )
318                {
319                    final char ch = username.charAt( c );
320                    if( ch == '-' && isLastUppercase ) break;
321                      // dash may occur in domain-name part, but never followed by uppercase
322
323                    isLastUppercase = Character.isUpperCase( ch );
324                }
325                cDash = c;
326            }
327            int cSpace = username.lastIndexOf( ' ' );
328            if( cSpace == -1 ) cSpace = username.lastIndexOf( '_' ); // then at-surrogate must be '_'
329            cAtSurrogate = Math.max( cDash, cSpace ); // whichever is closest to the end
330            if( cAtSurrogate < 0 )
331            {
332                throw new AddressException(
333                  "username has neither space nor underscore, nor a dash followed by an uppercase letter" );
334            }
335        }
336
337        final StringBuilder b = new StringBuilder( cN + 1/*dot*/ + 3/*extra*/ );
338
339      // First character
340      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
341        int c = 0;
342        {
343            char ch = username.charAt( c++ );
344            final char atSurrogate = username.charAt( cAtSurrogate );
345            if( atSurrogate == '-' ) ch = Character.toLowerCase( ch );
346            b.append(  ch );
347        }
348
349      // Remainder of local part
350      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
351        for(; c < cAtSurrogate; ++c )
352        {
353            char ch = username.charAt( c );
354            if( ch == ' ' ) ch = '_'; // equivalent because we disallow space in address
355            b.append( ch );
356        }
357
358      // @
359      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
360        b.append( '@' ); c++;
361
362      // Domain name
363      // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
364        b.append( Character.toLowerCase( username.charAt( c++ )));
365        for(; c < cN; )
366        {
367            final char ch = username.charAt( c++ );
368            final char chLow = Character.toLowerCase( ch );
369            if( ch != chLow ) b.append( '.' ); // uncollapse '.'
370            b.append( chLow );
371        }
372        final InternetAddress iAddress = new InternetAddress( b.toString(), /*strict*/true );
373        if( !InternetAddressX.VALID_PATTERN_BARE_DOMNAME.matcher( // stricter checks
374          iAddress.getAddress() ).matches() )
375        {
376            throw new AddressException( "translation yields malformed email address \""
377              + iAddress.getAddress() + "\"" );
378        }
379
380        return iAddress;
381    }
382
383
384
385    /** Translates a canonical email address to a mailish username in normal form.
386      *
387      *     @param email the canonical email address of the user.
388      *
389      *     @see IDPair#username()
390      *     @see #appendUsername(String,StringBuilder)
391      *     @see votorola.g.mail.InternetAddressX#canonicalAddress(String)
392      */
393    public static String toUsername( final String email )
394    {
395        final StringBuilder b = new StringBuilder( email.length() );
396        appendUsername( email, b );
397        return b.toString();
398    }
399
400
401
402    /** Identifies the user by mailish username.  This is a front-end identifier for use
403      * in the pollwiki and other UIs where it offers a degree of protection from the
404      * address harvesting practices of spammers.  Another advantage is its lack of
405      * punctuation characters ('@' and '.') which makes the UI a little easier to read.
406      *
407      *     @see #normalUsername(String)
408      *     @see #toUsername(String)
409      *     @see <a href='http://reluk.ca/project/_/mailish/MailishUsername.xht'>MailishUsername extension for MediaWiki</a>
410      */
411    public final String username() { return username; }
412
413
414        private final String username;
415
416
417
418   // - O b j e c t ------------------------------------------------------------------
419
420
421 // /** Returns true iff o is an ID pair constructed from the same (equals) identifier.
422 //   * If two ID pairs are equal, then they identify the same user; but the reverse is
423 //   * not true when the ID pairs were created from different source identifiers (email
424 //   * and username).
425 //   *
426 //   *     @see #isFromEmail()
427 //   *     @see #equalsEmail(Object)
428 //   */
429 // public @Override boolean equals( Object o )
430 // {
431 //     if( !( o instanceof IDPair )) return false;
432 //
433 //     final IDPair other = (IDPair)o;
434 //     return isFromEmail == other.isFromEmail && email.equals( other.email );
435 //
436 // }
437 //// will cause bugs, so revert (hopefully without also causing bugs):
438
439    /** True iff o is an ID pair constructed from the same (equals) email address.
440      */
441    public @Override final boolean equals( Object o )
442    {
443        if( !( o instanceof IDPair )) return false;
444
445        final IDPair other = (IDPair)o;
446        return email.equals( other.email );
447
448    }
449
450
451
452    /** Returns the same identifier from which the ID pair was constructed.
453      *
454      *     @see #isFromEmail()
455      */
456    public @Override final String toString() { return isFromEmail? email(): username(); }
457
458
459
460}