source: trunk/libtransmission/rpc-server.c @ 8199

Last change on this file since 8199 was 8199, checked in by charles, 13 years ago

(trunk libT) better filtering of maliciously-crafted URLs when serving web interface files

  • Property svn:keywords set to Date Rev Author Id
File size: 22.6 KB
Line 
1/*
2 * This file Copyright (C) 2008-2009 Charles Kerr <charles@transmissionbt.com>
3 *
4 * This file is licensed by the GPL version 2.  Works owned by the
5 * Transmission project are granted a special exemption to clause 2(b)
6 * so that the bulk of its code can remain under the MIT license.
7 * This exemption does not extend to derived works not owned by
8 * the Transmission project.
9 *
10 * $Id: rpc-server.c 8199 2009-04-10 15:09:31Z charles $
11 */
12
13#include <assert.h>
14#include <errno.h>
15#include <string.h> /* memcpy */
16#include <limits.h> /* INT_MAX */
17
18#include <sys/types.h> /* open */
19#include <sys/stat.h>  /* open */
20#include <fcntl.h>     /* open */
21#include <unistd.h>    /* close */
22
23#ifdef HAVE_ZLIB
24 #include <zlib.h>
25#endif
26
27#include <libevent/event.h>
28#include <libevent/evhttp.h>
29
30#include "transmission.h"
31#include "bencode.h"
32#include "crypto.h"
33#include "list.h"
34#include "platform.h"
35#include "rpcimpl.h"
36#include "rpc-server.h"
37#include "trevent.h"
38#include "utils.h"
39#include "web.h"
40
41#define MY_NAME "RPC Server"
42#define MY_REALM "Transmission"
43#define TR_N_ELEMENTS( ary ) ( sizeof( ary ) / sizeof( *ary ) )
44
45#ifdef WIN32
46#define strncasecmp _strnicmp
47#endif
48
49struct tr_rpc_server
50{
51    tr_bool            isEnabled;
52    tr_bool            isPasswordEnabled;
53    tr_bool            isWhitelistEnabled;
54    tr_port            port;
55    struct evhttp *    httpd;
56    tr_session *       session;
57    char *             username;
58    char *             password;
59    char *             whitelistStr;
60    tr_list *          whitelist;
61
62#ifdef HAVE_ZLIB
63    z_stream           stream;
64#endif
65};
66
67#define dbgmsg( ... ) \
68    do { \
69        if( tr_deepLoggingIsActive( ) ) \
70            tr_deepLog( __FILE__, __LINE__, MY_NAME, __VA_ARGS__ ); \
71    } while( 0 )
72
73
74/**
75***
76**/
77
78static void
79send_simple_response( struct evhttp_request * req,
80                      int                     code,
81                      const char *            text )
82{
83    const char *      code_text = tr_webGetResponseStr( code );
84    struct evbuffer * body = tr_getBuffer( );
85
86    evbuffer_add_printf( body, "<h1>%d: %s</h1>", code, code_text );
87    if( text )
88        evbuffer_add_printf( body, "%s", text );
89    evhttp_send_reply( req, code, code_text, body );
90
91    tr_releaseBuffer( body );
92}
93
94static const char*
95tr_memmem( const char * s1,
96           size_t       l1,
97           const char * s2,
98           size_t       l2 )
99{
100    if( !l2 ) return s1;
101    while( l1 >= l2 )
102    {
103        l1--;
104        if( !memcmp( s1, s2, l2 ) )
105            return s1;
106        s1++;
107    }
108
109    return NULL;
110}
111
112static void
113handle_upload( struct evhttp_request * req,
114               struct tr_rpc_server *  server )
115{
116    if( req->type != EVHTTP_REQ_POST )
117    {
118        send_simple_response( req, 405, NULL );
119    }
120    else
121    {
122        const char * content_type = evhttp_find_header( req->input_headers,
123                                                        "Content-Type" );
124
125        const char * query = strchr( req->uri, '?' );
126        const int    paused = query && strstr( query + 1, "paused=true" );
127
128        const char * in = (const char *) EVBUFFER_DATA( req->input_buffer );
129        size_t       inlen = EVBUFFER_LENGTH( req->input_buffer );
130
131        const char * boundary_key = "boundary=";
132        const char * boundary_key_begin = strstr( content_type,
133                                                  boundary_key );
134        const char * boundary_val =
135            boundary_key_begin ? boundary_key_begin +
136            strlen( boundary_key ) : "arglebargle";
137
138        char *       boundary = tr_strdup_printf( "--%s", boundary_val );
139        const size_t boundary_len = strlen( boundary );
140
141        const char * delim = tr_memmem( in, inlen, boundary, boundary_len );
142        while( delim )
143        {
144            size_t       part_len;
145            const char * part = delim + boundary_len;
146            inlen -= ( part - in );
147            in = part;
148            delim = tr_memmem( in, inlen, boundary, boundary_len );
149            part_len = delim ? (size_t)( delim - part ) : inlen;
150
151            if( part_len )
152            {
153                char * text = tr_strndup( part, part_len );
154                if( strstr( text, "filename=\"" ) )
155                {
156                    const char * body = strstr( text, "\r\n\r\n" );
157                    if( body )
158                    {
159                        char * b64;
160                        size_t  body_len;
161                        tr_benc top, *args;
162                        struct evbuffer * json = tr_getBuffer( );
163
164                        body += 4; /* walk past the \r\n\r\n */
165                        body_len = part_len - ( body - text );
166                        if( body_len >= 2
167                          && !memcmp( &body[body_len - 2], "\r\n", 2 ) )
168                            body_len -= 2;
169
170                        tr_bencInitDict( &top, 2 );
171                        args = tr_bencDictAddDict( &top, "arguments", 2 );
172                        tr_bencDictAddStr( &top, "method", "torrent-add" );
173                        b64 = tr_base64_encode( body, body_len, NULL );
174                        tr_bencDictAddStr( args, "metainfo", b64 );
175                        tr_bencDictAddBool( args, "paused", paused );
176                        tr_bencSaveAsJSON( &top, json );
177                        tr_rpc_request_exec_json( server->session,
178                                                  EVBUFFER_DATA( json ),
179                                                  EVBUFFER_LENGTH( json ),
180                                                  NULL, NULL );
181
182                        tr_releaseBuffer( json );
183                        tr_free( b64 );
184                        tr_bencFree( &top );
185                    }
186                }
187                tr_free( text );
188            }
189        }
190
191        tr_free( boundary );
192
193        /* use xml here because json responses to file uploads is trouble.
194         * see http://www.malsup.com/jquery/form/#sample7 for details */
195        evhttp_add_header( req->output_headers, "Content-Type",
196                           "text/xml; charset=UTF-8" );
197        send_simple_response( req, HTTP_OK, NULL );
198    }
199}
200
201static const char*
202mimetype_guess( const char * path )
203{
204    unsigned int i;
205
206    const struct
207    {
208        const char *    suffix;
209        const char *    mime_type;
210    } types[] = {
211        /* these are just the ones we need for serving clutch... */
212        { "css",  "text/css"                  },
213        { "gif",  "image/gif"                 },
214        { "html", "text/html"                 },
215        { "ico",  "image/vnd.microsoft.icon"  },
216        { "js",   "application/javascript"    },
217        { "png",  "image/png"                 }
218    };
219    const char * dot = strrchr( path, '.' );
220
221    for( i = 0; dot && i < TR_N_ELEMENTS( types ); ++i )
222        if( !strcmp( dot + 1, types[i].suffix ) )
223            return types[i].mime_type;
224
225    return "application/octet-stream";
226}
227
228static void
229add_response( struct evhttp_request * req,
230              struct tr_rpc_server *  server,
231              struct evbuffer *       out,
232              const void *            content,
233              size_t                  content_len )
234{
235#ifndef HAVE_ZLIB
236    evbuffer_add( out, content, content_len );
237#else
238    const char * key = "Accept-Encoding";
239    const char * encoding = evhttp_find_header( req->input_headers, key );
240    const int do_deflate = encoding && strstr( encoding, "deflate" );
241
242    if( !do_deflate )
243    {
244        evbuffer_add( out, content, content_len );
245    }
246    else
247    {
248        int state;
249
250        server->stream.next_in = (Bytef*) content;
251        server->stream.avail_in = content_len;
252
253        /* allocate space for the raw data and call deflate() just once --
254         * we won't use the deflated data if it's longer than the raw data,
255         * so it's okay to let deflate() run out of output buffer space */
256        evbuffer_expand( out, content_len );
257        server->stream.next_out = EVBUFFER_DATA( out );
258        server->stream.avail_out = content_len;
259
260        state = deflate( &server->stream, Z_FINISH );
261
262        if( state == Z_STREAM_END )
263        {
264            EVBUFFER_LENGTH( out ) = content_len - server->stream.avail_out;
265
266            /* http://carsten.codimi.de/gzip.yaws/
267               It turns out that some browsers expect deflated data without
268               the first two bytes (a kind of header) and and the last four
269               bytes (an ADLER32 checksum). This format can of course
270               be produced by simply stripping these off. */
271            if( EVBUFFER_LENGTH( out ) >= 6 ) {
272                EVBUFFER_LENGTH( out ) -= 4;
273                evbuffer_drain( out, 2 );
274            }
275
276#if 0
277            tr_ninf( MY_NAME, _( "Deflated response from %zu bytes to %zu" ),
278                              content_len,
279                              EVBUFFER_LENGTH( out ) );
280#endif
281            evhttp_add_header( req->output_headers,
282                               "Content-Encoding", "deflate" );
283        }
284        else
285        {
286            evbuffer_drain( out, EVBUFFER_LENGTH( out ) );
287            evbuffer_add( out, content, content_len );
288        }
289
290        deflateReset( &server->stream );
291    }
292#endif
293}
294
295static void
296serve_file( struct evhttp_request * req,
297            struct tr_rpc_server *  server,
298            const char *            filename )
299{
300    if( req->type != EVHTTP_REQ_GET )
301    {
302        evhttp_add_header( req->output_headers, "Allow", "GET" );
303        send_simple_response( req, 405, NULL );
304    }
305    else
306    {
307        size_t content_len;
308        uint8_t * content;
309        const int error = errno;
310
311        errno = 0;
312        content_len = 0;
313        content = tr_loadFile( filename, &content_len );
314
315        if( errno )
316        {
317            send_simple_response( req, HTTP_NOTFOUND, filename );
318        }
319        else
320        {
321            struct evbuffer * out;
322
323            errno = error;
324            out = tr_getBuffer( );
325            evhttp_add_header( req->output_headers, "Content-Type",
326                               mimetype_guess( filename ) );
327            add_response( req, server, out, content, content_len );
328            evhttp_send_reply( req, HTTP_OK, "OK", out );
329
330            tr_releaseBuffer( out );
331            tr_free( content );
332        }
333    }
334}
335
336static void
337handle_clutch( struct evhttp_request * req,
338               struct tr_rpc_server *  server )
339{
340    const char * clutchDir = tr_getClutchDir( server->session );
341
342    assert( !strncmp( req->uri, "/transmission/web/", 18 ) );
343
344    if( !clutchDir || !*clutchDir )
345    {
346        send_simple_response( req, HTTP_NOTFOUND,
347            "<p>Couldn't find Transmission's web interface files!</p>"
348            "<p>Users: to tell Transmission where to look, "
349            "set the TRANSMISSION_WEB_HOME environmental "
350            "variable to the folder where the web interface's "
351            "index.html is located.</p>"
352            "<p>Package Builders: to set a custom default at compile time, "
353            "#define PACKAGE_DATA_DIR in libtransmission/platform.c "
354            "or tweak tr_getClutchDir() by hand.</p>" );
355    }
356    else
357    {
358        char * pch;
359        char * subpath;
360
361        subpath = tr_strdup( req->uri + 18 );
362        if(( pch = strchr( subpath, '?' )))
363            *pch = '\0';
364
365        if( strstr( subpath, ".." ) )
366        {
367            send_simple_response( req, HTTP_NOTFOUND, "<p>Tsk, tsk.</p>" );
368        }
369        else
370        {
371            char * filename = tr_strdup_printf( "%s%s%s",
372                                 clutchDir,
373                                 TR_PATH_DELIMITER_STR,
374                                 subpath && *subpath ? subpath : "index.html" );
375            serve_file( req, server, filename );
376            tr_free( filename );
377        }
378
379        tr_free( subpath );
380    }
381}
382
383struct rpc_response_data
384{
385    struct evhttp_request * req;
386    struct tr_rpc_server  * server;
387};
388
389static void
390rpc_response_func( tr_session      * session UNUSED,
391                   const char      * response,
392                   size_t            response_len,
393                   void            * user_data )
394{
395    struct rpc_response_data * data = user_data;
396    struct evbuffer * buf = tr_getBuffer( );
397
398    add_response( data->req, data->server, buf, response, response_len );
399    evhttp_add_header( data->req->output_headers,
400                           "Content-Type", "application/json; charset=UTF-8" );
401    evhttp_send_reply( data->req, HTTP_OK, "OK", buf );
402
403    tr_releaseBuffer( buf );
404    tr_free( data );
405}
406
407
408static void
409handle_rpc( struct evhttp_request * req,
410            struct tr_rpc_server  * server )
411{
412    struct rpc_response_data * data = tr_new0( struct rpc_response_data, 1 );
413
414    data->req = req;
415    data->server = server;
416   
417    if( req->type == EVHTTP_REQ_GET )
418    {
419        const char * q;
420        if( ( q = strchr( req->uri, '?' ) ) )
421            tr_rpc_request_exec_uri( server->session, q+1, -1, rpc_response_func, data );
422    }
423    else if( req->type == EVHTTP_REQ_POST )
424    {
425        tr_rpc_request_exec_json( server->session,
426                                  EVBUFFER_DATA( req->input_buffer ),
427                                  EVBUFFER_LENGTH( req->input_buffer ),
428                                  rpc_response_func, data );
429    }
430
431}
432
433static tr_bool
434isAddressAllowed( const tr_rpc_server * server,
435                  const char *          address )
436{
437    tr_list * l;
438
439    if( !server->isWhitelistEnabled )
440        return TRUE;
441
442    for( l=server->whitelist; l!=NULL; l=l->next )
443        if( tr_wildmat( address, l->data ) )
444            return TRUE;
445
446    return FALSE;
447}
448
449static void
450handle_request( struct evhttp_request * req,
451                void *                  arg )
452{
453    struct tr_rpc_server * server = arg;
454
455    if( req && req->evcon )
456    {
457        const char * auth;
458        char *       user = NULL;
459        char *       pass = NULL;
460
461        evhttp_add_header( req->output_headers, "Server", MY_REALM );
462
463        auth = evhttp_find_header( req->input_headers, "Authorization" );
464
465        if( auth && !strncasecmp( auth, "basic ", 6 ) )
466        {
467            int    plen;
468            char * p = tr_base64_decode( auth + 6, 0, &plen );
469            if( p && plen && ( ( pass = strchr( p, ':' ) ) ) )
470            {
471                user = p;
472                *pass++ = '\0';
473            }
474        }
475
476        if( !isAddressAllowed( server, req->remote_host ) )
477        {
478            send_simple_response( req, 401,
479                "<p>Unauthorized IP Address.</p>"
480                "<p>Either disable the IP address whitelist or add your address to it.</p>"
481                "<p>If you're editing settings.json, see the 'rpc-whitelist' and 'rpc-whitelist-enabled' entries.</p>"
482                "<p>If you're still using ACLs, use a whitelist instead.  See the transmission-daemon manpage for details.</p>" );
483        }
484        else if( server->isPasswordEnabled
485                 && ( !pass || !user || strcmp( server->username, user )
486                                     || !tr_ssha1_matches( server->password,
487                                                           pass ) ) )
488        {
489            evhttp_add_header( req->output_headers,
490                               "WWW-Authenticate",
491                               "Basic realm=\"" MY_REALM "\"" );
492            send_simple_response( req, 401, "Unauthorized User" );
493        }
494        else if( !strcmp( req->uri, "/transmission/web" )
495               || !strcmp( req->uri, "/transmission/clutch" )
496               || !strcmp( req->uri, "/" ) )
497        {
498            const char * protocol = "http";
499            const char * host = evhttp_find_header( req->input_headers, "Host" );
500            const char * uri = "transmission/web/";
501            char * location = tr_strdup_printf( "%s://%s/%s", protocol, host, uri );
502            evhttp_add_header( req->output_headers, "Location", location );
503            send_simple_response( req, HTTP_MOVEPERM, NULL );
504            tr_free( location );
505        }
506        else if( !strncmp( req->uri, "/transmission/web/", 18 ) )
507        {
508            handle_clutch( req, server );
509        }
510        else if( !strncmp( req->uri, "/transmission/rpc", 17 ) )
511        {
512            handle_rpc( req, server );
513        }
514        else if( !strncmp( req->uri, "/transmission/upload", 20 ) )
515        {
516            handle_upload( req, server );
517        }
518        else
519        {
520            send_simple_response( req, HTTP_NOTFOUND, req->uri );
521        }
522
523        tr_free( user );
524    }
525}
526
527static void
528startServer( void * vserver )
529{
530    tr_rpc_server * server  = vserver;
531
532    if( !server->httpd )
533    {
534        server->httpd = evhttp_new( tr_eventGetBase( server->session ) );
535        evhttp_bind_socket( server->httpd, "0.0.0.0", server->port );
536        evhttp_set_gencb( server->httpd, handle_request, server );
537
538    }
539}
540
541static void
542stopServer( tr_rpc_server * server )
543{
544    if( server->httpd )
545    {
546        evhttp_free( server->httpd );
547        server->httpd = NULL;
548    }
549}
550
551static void
552onEnabledChanged( void * vserver )
553{
554    tr_rpc_server * server = vserver;
555
556    if( !server->isEnabled )
557        stopServer( server );
558    else
559        startServer( server );
560}
561
562void
563tr_rpcSetEnabled( tr_rpc_server * server,
564                  tr_bool         isEnabled )
565{
566    server->isEnabled = isEnabled;
567
568    tr_runInEventThread( server->session, onEnabledChanged, server );
569}
570
571tr_bool
572tr_rpcIsEnabled( const tr_rpc_server * server )
573{
574    return server->isEnabled;
575}
576
577static void
578restartServer( void * vserver )
579{
580    tr_rpc_server * server = vserver;
581
582    if( server->isEnabled )
583    {
584        stopServer( server );
585        startServer( server );
586    }
587}
588
589void
590tr_rpcSetPort( tr_rpc_server * server,
591               tr_port         port )
592{
593    if( server->port != port )
594    {
595        server->port = port;
596
597        if( server->isEnabled )
598            tr_runInEventThread( server->session, restartServer, server );
599    }
600}
601
602tr_port
603tr_rpcGetPort( const tr_rpc_server * server )
604{
605    return server->port;
606}
607
608void
609tr_rpcSetWhitelist( tr_rpc_server * server,
610                    const char    * whitelistStr )
611{
612    void * tmp;
613    const char * walk;
614
615    /* keep the string */
616    tr_free( server->whitelistStr );
617    server->whitelistStr = tr_strdup( whitelistStr );
618
619    /* clear out the old whitelist entries */
620    while(( tmp = tr_list_pop_front( &server->whitelist )))
621        tr_free( tmp );
622
623    /* build the new whitelist entries */
624    for( walk=whitelistStr; walk && *walk; ) {
625        const char * delimiters = " ,;";
626        const size_t len = strcspn( walk, delimiters );
627        char * token = tr_strndup( walk, len );
628        tr_list_append( &server->whitelist, token );
629        if( strcspn( token, "+-" ) < len )
630            tr_ninf( MY_NAME, "Adding address to whitelist: %s (And it has a '+' or '-'!  Are you using an old ACL by mistake?)", token );
631        else
632            tr_ninf( MY_NAME, "Adding address to whitelist: %s", token );
633       
634        if( walk[len]=='\0' )
635            break;
636        walk += len + 1;
637    }
638}
639
640char*
641tr_rpcGetWhitelist( const tr_rpc_server * server )
642{
643    return tr_strdup( server->whitelistStr ? server->whitelistStr : "" );
644}
645
646void
647tr_rpcSetWhitelistEnabled( tr_rpc_server  * server,
648                           tr_bool          isEnabled )
649{
650    server->isWhitelistEnabled = isEnabled != 0;
651}
652
653tr_bool
654tr_rpcGetWhitelistEnabled( const tr_rpc_server * server )
655{
656    return server->isWhitelistEnabled;
657}
658
659/****
660*****  PASSWORD
661****/
662
663void
664tr_rpcSetUsername( tr_rpc_server * server,
665                   const char *    username )
666{
667    tr_free( server->username );
668    server->username = tr_strdup( username );
669    dbgmsg( "setting our Username to [%s]", server->username );
670}
671
672char*
673tr_rpcGetUsername( const tr_rpc_server * server )
674{
675    return tr_strdup( server->username ? server->username : "" );
676}
677
678void
679tr_rpcSetPassword( tr_rpc_server * server,
680                   const char *    password )
681{
682    tr_free( server->password );
683    if( *password != '{' )
684        server->password = tr_ssha1( password );
685    else
686        server->password = strdup( password );
687    dbgmsg( "setting our Password to [%s]", server->password );
688}
689
690char*
691tr_rpcGetPassword( const tr_rpc_server * server )
692{
693    return tr_strdup( server->password ? server->password : "" );
694}
695
696void
697tr_rpcSetPasswordEnabled( tr_rpc_server * server,
698                          tr_bool          isEnabled )
699{
700    server->isPasswordEnabled = isEnabled;
701    dbgmsg( "setting 'password enabled' to %d", (int)isEnabled );
702}
703
704tr_bool
705tr_rpcIsPasswordEnabled( const tr_rpc_server * server )
706{
707    return server->isPasswordEnabled;
708}
709
710/****
711*****  LIFE CYCLE
712****/
713
714static void
715closeServer( void * vserver )
716{
717    void * tmp;
718    tr_rpc_server * s = vserver;
719
720    stopServer( s );
721    while(( tmp = tr_list_pop_front( &s->whitelist )))
722        tr_free( tmp );
723#ifdef HAVE_ZLIB
724    deflateEnd( &s->stream );
725#endif
726    tr_free( s->whitelistStr );
727    tr_free( s->username );
728    tr_free( s->password );
729    tr_free( s );
730}
731
732void
733tr_rpcClose( tr_rpc_server ** ps )
734{
735    tr_runInEventThread( ( *ps )->session, closeServer, *ps );
736    *ps = NULL;
737}
738
739tr_rpc_server *
740tr_rpcInit( tr_session  * session,
741            tr_benc * settings )
742{
743    tr_rpc_server * s;
744    tr_bool found;
745    tr_bool boolVal;
746    int64_t i;
747    const char *str;
748
749    s = tr_new0( tr_rpc_server, 1 );
750    s->session = session;
751
752    found = tr_bencDictFindBool( settings, TR_PREFS_KEY_RPC_ENABLED, &boolVal );
753    assert( found );
754    s->isEnabled = boolVal;
755
756    found = tr_bencDictFindInt( settings, TR_PREFS_KEY_RPC_PORT, &i );
757    assert( found );
758    s->port = i;
759
760    found = tr_bencDictFindBool( settings, TR_PREFS_KEY_RPC_WHITELIST_ENABLED, &boolVal );
761    assert( found );
762    s->isWhitelistEnabled = boolVal;
763
764    found = tr_bencDictFindBool( settings, TR_PREFS_KEY_RPC_AUTH_REQUIRED, &boolVal );
765    assert( found );
766    s->isPasswordEnabled = boolVal;
767
768    found = tr_bencDictFindStr( settings, TR_PREFS_KEY_RPC_WHITELIST, &str );
769    assert( found );
770    tr_rpcSetWhitelist( s, str ? str : "127.0.0.1" );
771
772    found = tr_bencDictFindStr( settings, TR_PREFS_KEY_RPC_USERNAME, &str );
773    assert( found );
774    s->username = tr_strdup( str );
775
776    found = tr_bencDictFindStr( settings, TR_PREFS_KEY_RPC_PASSWORD, &str );
777    assert( found );
778    if( *str != '{' )
779        s->password = tr_ssha1( str );
780    else
781        s->password = strdup( str );
782
783#ifdef HAVE_ZLIB
784    s->stream.zalloc = (alloc_func) Z_NULL;
785    s->stream.zfree = (free_func) Z_NULL;
786    s->stream.opaque = (voidpf) Z_NULL;
787    deflateInit( &s->stream, Z_BEST_COMPRESSION );
788#endif
789
790    if( s->isEnabled )
791    {
792        tr_ninf( MY_NAME, _( "Serving RPC and Web requests on port %d" ), (int) s->port );
793        tr_runInEventThread( session, startServer, s );
794
795        if( s->isWhitelistEnabled )
796            tr_ninf( MY_NAME, _( "Whitelist enabled" ) );
797
798        if( s->isPasswordEnabled )
799            tr_ninf( MY_NAME, _( "Password required" ) );
800    }
801
802    return s;
803}
Note: See TracBrowser for help on using the repository browser.