1. Twitter's Chronic Anti-Pattern Problem

    This morning, via a colleague, John, I stumbled on a service called gdzlla that allows you to use Flickr as an alternative to the other de facto Twitter media posting services (twitpic, yfrog, etc.), from Tweetie on the iPhone. The idea is great, but unfortunately, the implementation is dangerous.

    Intrigued by an integrated media-posting solution, I started browsing the gdzlla site, and one of the first pages I saw grabbed my attention... in the wrong way.

    Screen shot of gdzlla login page

    The idea of random web sites asking for credentials is hardly a new concept—especially when it comes to Twitter. Almost a year ago, news broke about a now-defunct site called Twitterank that was created by @brianoberkirch to illustrate the danger of carelessly sharing Twitter credentials with third parties. Since then, Twitter has implemented OAuth to avoid this exact scenario, but uptake has been slow: many third parties who provide a Twitter-related service still require users to submit their Twitter credentials to authenticate.

    What struck me about gdzlla's login page was the text at the bottom of the form: "(Your password gets hashed, we won't ever know it)." Thinking about ways to implement this (the password could be hashed in JavaScript, before the form is submitted, for example), I turned on Firebug, and discovered that the value is actually submitted with the form, in plaintext:

    Screen shot of Firebug showing plaintext submission to gdzlla

    I suspected that the gdzlla guys were not actually being malicious here, and would actually hash the value prior to storage on their side, but the text was misleading at best, so I tweeted about it:

    John noticed that I linked to the form processor page, which didn't work properly, so be brought that part to gdzlla's attention:

    This kicked off a conversation with @gdzl_la:

    Their reply shed some light on exactly how they're integrating with Tweetie. The iPhone app allows users to supply their own custom image service URL. When submitting media, if this value is filled in, Tweetie sends the raw image data (and other information, see below) to the third-party URL and expects to receive a URL where the media is hosted, in return.

    This type of integration is actually a really great idea. More apps should allow customization of third-party services. It's exactly how web services should be used.

    Unfortunately, as @gdzl_la pointed out in our conversation, Tweetie's actual implementation of this feature is horribly insecure, and prevents gdzlla from using OAuth—gdzlla doesn't even use your Twitter credentials to post to Twitter, that's Tweetie's job (as indicated in their instructions).

    So, why does gdzlla require users to submit their Twitter credentials if they're immediately transforming your password into a hashed form that would prevent them from actually using it to access the Twitter API? The simple answer is that this is the only way for them to integrate with Tweetie's poor implementation of a great feature.

    gdzlla presumably collects your Twitter credentials and then has you authenticate against the Flickr API. It then links the accounts to associate your Twitter and Flickr accounts, on the gdzlla side.

    The tragic flaw in all of this is that Tweetie uniformly sends the user's Twitter credentials to the custom image URL as part of the image hosting request. There's no other way for gdzlla to associate the incoming data with a particular Flickr account.

    Tweetie's instruction page says that it will send the following as POST data:

    • username - Twitter username
    • password - Twitter password (plain text, thus HTTPS is strongly recommended, and may be required by future versions of Tweetie)
    • (other information such as the data for the media)

    There's really no good reason for Tweetie to do this. They could just as easily ask the user to supply credentials for the third-party media hosting service. In fact, they absolutely should ask the user to supply this information on the setup page. Providing a user's Twitter credentials to third-parties is irresponsible at the very least, and leaves legitimate third parties in a pinch because there's currently no good way to implement authentication in this system—not even OAuth will save the day. (This also leads to non-security usability problems with services like gdzlla—handling password changes must be a huge headache for them.)

    Hopefully the Tweetie developers will recognize this problem and fix it. In the meantime, my suggestion is to avoid using any service that implements the Password Anti-Pattern, even if you trust them.

  2. The Problem with AIR

    I have a love-hate relationship with Adobe AIR.

    On the positive side, AIR allows developers who are primarily experienced in web technologies (such as myself) to apply existing skills to the creation of GUI applications with a minimum of additional deployment-specific competence, and to release those apps on several platforms, in parallel.

    This shallow learning curve has facilitated the creation of GUI apps that would never have otherwise graduated beyond a passing thought by their creators.

    A good example of this is Spaz, my currently-preferred interface to the Twitter. Ed, its author, and my friend, is well-skilled in web technologies and I suspect that both the application of HTML and JavaScript to GUI deployment, and platform independence, were key factors in choosing AIR as Spaz's platform.

    Is platform independence and portability really a good thing? I do think so, but I also think that special care must be taken to conform to the target platform's established conventions. This is where AIR fails (but where other similar—but not the same—platforms such as RealBasic, XUL and (dare I say it?) yes, even Java do a better job).

    I've been sitting on this rant for a long time, and it's come up with several people in the past few weeks, so once again, I'm blogging about it as time allows. Sorry if these thoughts seem incomplete. Truth is that some of them are, but I want to get something written down.

    Widgets, Controls and Placement

    One of the first things you'll notice if you run several AIR apps concurrently is that they all look different. Take a peek at this article on "8 AIR apps that don't suck", for screen shots. All eight of these apps are visually appealing in their own way (this is subjective, of course), but that's the key: in their own way.

    A lot of care and money has been spent on research and development of the major GUI interfaces, especially by Microsoft and Apple. With few exceptions where the AIR author has opted to adopt the system's native GUI, at least for the basic window chrome, these applications have reinvented the wheel.

    I've read that AIR makes it very hard to emulate the system look and feel for standardized UI widgets. It is especially difficult in HTML-based apps, because the version of webkit they ship will not allow you to modify the look and feel of some form widgets (selects, radio buttons) or the scroll bars. You have to roll your own widgets entirely if you want to change the look of these. Adobe allegedly does this is on purpose. They want apps to look the same on their platform—the Adobe Flash platform—and to look and behave identically on all OSes.

    As a user, this is confusing. Not confusing to the point where I don't know how to use the 7 different types of scrollbars displayed in these 8 applications (hint: WebDrive's screenshot doesn't display a scrollbar), but the lack of established convention is visually distracting at the very least.

    Buttons, menus (I didn't know that the "Spaz >>" button was actually a button for the first few months I used the app; maybe I'm just an idiot), scroll bars, handles, "grippies", toolbars: these controls have been well-defined by our window managers and operating systems. Is it really worth the inconsistency just so you can be more visually appealing (and often fail at this)? I don't think it is.

    (I wrote a short piece on this a while back, and many of the same assertions apply.)

    Inter-application Consistency, Established Conventions

    The previous point leads directly into this one: AIR apps are generally terribly inconsistent, not only between each other but also with the native toolkit.

    Here are some conventions that apply to (almost) every application I currently have open on my Mac, but rarely apply to AIR apps:

    • Window close button is at the top left corner of the window
    • Toolbar at top of window (if applicable); button at top right of window hides this toolbar
    • Scroll bars are clickable outside of the control bar, buttons to increase/decrease scroll are both at the bottom of the scroll bar
    • Pressing cmd-, opens the application's preferences dialog
    • Double-clicking the application's title bar "minimizes" the application to my dock (I actually dislike this, but at least it's consistent in native apps)
    • Pressing cmd-z causes the "undo" event to be fired; this is built in to the toolkit for controls like text boxes

    With the exception of cmd-, (which the author has explicitly definied in the code), Spaz does not conform to any of these conventions. Do I think this is Ed Finkler's fault? No, I don't. At least not entirely his fault...

    Adobe seems to have adopted a different consistency regime than what I believe to be the right solution. It appears that they're more concerned about AIR apps looking exactly the same on each platform, than for those apps to conform to their platform.

    Operating System Conventions

    Admittedly, the convention I'm about to mention is only a de facto standard; not officially endorsed by Apple.

    I love Growl. It works well, and adds much needed consistency to application notifications. I even use it to tell me the caller ID when my home phone rings. With the possible exception of a recent AS3 Growl library, AIR apps have been painfully unable to easily generate Growl notifications (due to improper application sandboxing, in my opinion), and I know this has been a major point of contention for Spaz's author (we've discussed it several times, and I think I was even tasked with solving it, last summer, but no time... no time).

    Worse yet, Adobe has "conveniently" built Notification support into the AIR platform. This sounds good, until one discovers that the notification support has been created from the ground up, and doesn't hook existing conventions. I suppose this was necessary on platforms that don't have a widespread system like Growl, but for us Mac users, it's outright annoying.

    Applescript and Accessibility

    On to the final point of my rant...

    Last weekend, I attempted (and failed for several reasons) to write some AppleScript that would allow automated repsositioning of most of my applications when I change display configurations from laptop to desktop.

    I was not surprised to find that Spaz didn't have an AppleScript dictionary (is AppleScript dying? I'm starting to think so...), but worse, it didn't respond to a standard request: tell application "Spaz" to get the bounds of the first window (results in an error). I found a workaround (sort of), but this just illustrates AIR's neglect when it comes to abiding by system conventions.

    I can only imagine how badly these things must play with accessibility software. Are visually impaired users able to use screen reading software with AIR apps? Spaz certainly doesn't play well with VoiceOver. Perhaps my colleague and friend Jon Gibbins can shed some light on the accessibility issue.

    All this to say: I'm quite fed up with AIR apps. The lack of convention with my regular workflow has gone from annoying to downright disruptive, and I'm on the verge of abandoning them entirely, if something isn't done to promote platform conformance... and I suspect I'm not the only one.

    Thanks to Ed Finkler for giving me some feedback on this rant. I greatly respect his opinion in this area, and he gave me some excellent additional points that I need to think about, especially why I think it's OK for web sites to have a more freeform canvas than desktop apps (though I do think that it's even more evil for web sites to reinvent their toolkits). Some thoughts published, yet more filling my head...

  3. Recent Happenings

    I've got a bunch of stuff that I haven't found/made time to blog about, so just dropping some quick notes here:

    • I've been invited to speak at PHP Quebec 2009. I've been to this conference a few times (but not for a couple years, now), and I'm really looking forward to getting back into the conference circuit (as a speaker, not an organizer... think of all the free time I'll have! (-; Anyway, I'll be giving a talk entitled "Stupid Browser Tricks" in which I'll talk (at a high level) about Firebug, and Selenium IDE, and possibly a few other things like granular browser security, komodo macros/extensions (like a browser!) and maybe greasemonkey.
    • This year, I was once again invited back to the Microsoft Web Developers Summit (couldn't think of a better URL). This is a yearly event where Microsoft selects members of the PHP community to Redmond to have a discussion on PHP and Microsoft's offerings. This year was definitely the best one yet, as it was better organized, and it felt much less like they were trying to sell us things. Their candor was especially appreciated this year, as I think many of the attendees felt like Microsoft was asking us for our opinions instead of trying to give them to us. I wrote about this last year, and I think what I wrote still rings true, today. Thanks to the organizers... we got some great information, made our opinions clear, and had a LOT of fun (great people!).
    • I tweeted about this, but never posted it on my blog. My colleague Luke Welling is a funny guy.
    • Over the holiday weekend (I got days off, but in Canadia, we celebrate Thanksgiving in October), I found some time to work on a bunch of pet projects, including fale.ca, which is nothing special, but kind of fun. See?
    • Today, I was extended an invitation to join the Habari Cabal, which I quickly accepted. So, if you use Habari and your blog breaks in the future, it's probably my fault.
    • ... and last, but not least, Chris and I—with the help of many other people—managed to almost get the 2008 PHP Advent calendar launched in time. Word on the street is that Jon Tan is going to show the design some love, and we have a feed. The 2007 edition was a success, but was a lot of work, so I offered to pitch in this year. Thanks to everyone who's already submitted... and the rest of you slackers: get to it! (-;
    • S
  4. PHP-Aware Diff

    UPDATED (and intentionally reinserted into the feed):

    I've made a bunch of changes to this code, and updated it.

    It's quite a bit slower, but I really don't care (-:

    It uses my new pet project, the tokalizer.

    You'll probably want to grab the newly-compiled diff-php as this is the one I'll be "maintaining" (ie, when someone complains, or when it breaks for me).

    (end update)

    I've told a few people that I'd blog about this "soon" and that was a while ago, so I figured I'd better get on the ball.

    I tweeted this almost two weeks ago:

    Derick responded saying that diff -p does this for C. I tried it with PHP, and it gave me the outermost block where the change occurred (ie, the class, not the function). The interesting thing, though, is that it changed the @@ line:

    @@ -32,7 +32,7 @@ class Foo2 {

    Almost what I was looking for, not not quite. I really wanted a php-aware diff that could tell me context.

    So, what's a developer with almost no spare time on his hands (but an idea of how to actually accomplish this pet project) to do? Write it himself, of course! (-:

    So, I did. Here's an example of the output:

    --- tmp/left.php
    +++ tmp/right.php
    @@ -1,7 +1,7 @@ (root)
     <?php
     class Foo {
         function bar() {
    -        // baz!
    +        // bax!
         }
     }
     
    @@ -32,7 +32,7 @@ (root):Foo2(class)
     // k
     // l
         function bar2() {
    -        // baz2!
    +        // bax2!
         }
     }
     
    @@ -63,7 +63,7 @@ (root):Foo3(class):bar3(function)
     // k
     // l
             $test = "foo {$test}";
    -        // baz2!
    +        // bax2!
         }
     
         function bar4() {
    @@ -93,7 +93,7 @@ (root):Foo3(class):bar4(function):bar5(function)
     // k
     // l
                 $test = "foo {$test}";
    -            //baz5
    +            //bax5
     // a
     // b
     // c

    Here's the code for my php-aware diff. I use it as my default svn diff command now (see comments). Hope you find it useful, I sure do.

    #!/usr/bin/php
    <?php
    /// PHP-Aware diff
     
    /// Copyright 2008, Sean Coates
    ///   Usage of the works is permitted provided that this instrument is retained
    ///   with the works, so that any entity that uses the works is notified of this
    ///   instrument.
    ///   DISCLAIMER: THE WORKS ARE WITHOUT WARRANTY.
    /// (Fair License - http://www.opensource.org/licenses/fair.php )
    /// Short license: do whatever you like with this.
     
     
    //// save this file as diff-php
    ////    and make sure /path/to/diff-php is chmod +x
     
    //// TO USE from cli:
    ////    /path/to/diff-php leftfile rightfile   # (compares files, as diff does)
     
    ////
    //// TO USE from svn:
    ////    in ~/.subversion/config, add: diff-cmd = /path/to/diff-php
     
    //// You might need to adjust DIFF_PATH, below
     
    // the tokenizer scares me a bit (-:
     
    class DiffPHP {
     
        const DEBUG_SYNTAX = false; // set to true to get syntax error data (== broken diffs)
     
        const DIFF_PATH = '/usr/bin/diff';
        const DIFF_OPTS = '-u';
     
        /**
         * The "left" file, as passed by svn (or cli)
         */
        protected $left;
     
        /**
         * The "right" file, as passed by svn (or cli)
         */
        protected $right;
     
        /**
         * A "nice" version of the left file.
         *
         * Instead of foo/bar/.svn/base/whatever.php, it would just be whatever.php
         */
        protected $niceLeft;
     
        /**
         * A "nice" version of the right file.
         *
         * Instead of foo/bar/.svn/base/whatever.php, it would just be whatever.php
         */
        protected $niceRight;
     
        /**
         * Captured file contents (prevents reading the file twice + diff)
         */
        protected $fileContents;
     
        /**
         * The output from the diff executable
         */
        protected $diff;
     
        /**
         * Each chunk of the diff goes in here (begins with a @@ identifier line)
         */
        protected $chunks;
     
        /**
         * Array of tokens from the Left file
         */
        protected $tokens;
     
        /**
         * Mapping of source lines to source class/functions
         */
        protected $lineMap;
     
        /**
         * Current context (used to construct line map)
         */
        protected $context;
     
        /**
         * Brace depth (used to determine if we're still in the current context)
         */
        protected $braceDepth;
     
        /**
         * Bool flag to indicate that syntax is somehow broken
         */
        protected $isBroken;
     
        /**
         * Object-wide index to keep track of the current token number
         */
        protected $tokenIndex;
     
        /**
         * Currently parsing token value
         */
        protected $currentValue;
     
        /**
         * Constructor. The magic happens here. Once instantiated, the entire
         * process runs
         */
        public function __construct() {
            $this->parseArgs();
     
            $this->fileContents = file_get_contents($this->left);
     
            $this->doDiff();
     
            // subject (probably) IS a PHP file:
            if (!isset($_ENV['NODIFFPHP']) && stripos($this->fileContents, '<?') !== false) {
                $this->splitDiff();
                $this->determineHierarchy();
                $this->reconstructDiff();
            } else {
                // not a PHP file; return regular diff:
                echo $this->diff;
            }
        }
     
        /**
         * Parses the passed arguments.
         *
         * Determines if it's svn (7 args) or cli (2 args), and stores the parsed
         * arguments.
         */
        protected function parseArgs() {
            // if this is being called from svn, we'll get 4 arguments
            //   (8th is argv 0 == this script)
            if (8 == $_SERVER['argc']) {
                $this->niceLeft = $_SERVER['argv'][3];
                $this->niceRight = $_SERVER['argv'][5];
                $this->left = $_SERVER['argv'][6];
                $this->right = $_SERVER['argv'][7];
            } else if (3 == $_SERVER['argc']) {
                // 2 arguments means a regular diff
                $this->niceLeft = $_SERVER['argv'][1];
                $this->niceRight = $_SERVER['argv'][2];
                $this->left = $this->niceLeft;
                $this->right = $this->niceRight;
            } else {
                die("See " . __FILE__ . " for details on how to use this script\n");
            }
        }
     
        /**
         * Calls the external diff program to get the base diff
         */
        protected function doDiff() {
            if (is_readable($this->left) && is_readable($this->right)) {
                $diffCmd = self::DIFF_PATH . ' ' . self::DIFF_OPTS . " {$this->left} {$this->right}";
                $this->diff = `$diffCmd`;
            } else {
                die("{$this->left} or {$this->right} is not readable\n");
            }
        }
     
        /**
         * Takes an identifier line (looks like: @@ -30,23 +30,79 @@) and returns
         * the begin line number
         */
        protected function parseLineNum($identifier) {
            list(,$from) = explode(" ", $identifier);
            list($from) = explode(',', $from);
            return (int) substr($from, 1);
        }
     
        /**
         * Sanitizes CRLF or CR into just LF
         */
        protected function sanitizeLineEndings($data) {
            // first, sanitize line endings:
            $data = str_replace("\r\n", "\n", $data);
            $data = str_replace("\r",   "\n", $data);
            return $data;
        }    
     
        /**
         * Actually splits the diff into chunks and stores chunks + line numbers
         */
        protected function splitDiff() {
            // now split:
            $this->diff = explode("\n", $this->sanitizeLineEndings($this->diff));
     
            // array to return:
            $this->chunks = array();
     
            // line counter
            $line = 0;
     
            // outer loop: file(s)
            $maxLine = count($this->diff);
     
            // skip first 2 lines as left, right files
            $line += 2;
     
            // descend into data chunks
            while ($line < $maxLine) {
                // next line is the chunk identifier
                $dataChunk = array();
                $dataChunk['identifier'] = $this->diff[$line++];
                $dataChunk['line'] = $this->parseLineNum($dataChunk['identifier']);
                $dataChunk['data'] = array();
                while ($line < $maxLine && !(substr($this->diff[$line], 0, 2) == '@@' && substr($this->diff[$line], -2) == '@@')) {
                    $dataChunk['data'][] = $this->diff[$line++];
                }
                $this->chunks[] = $dataChunk;
            }
        }
     
        /**
         * Reconstructs the diff (with adjusted identifier lines, and outputs the
         * result)
         */
        protected function reconstructDiff() {
            $out = "--- {$this->niceLeft}\n+++ {$this->niceRight}\n";
            foreach ($this->chunks as $chunk) {
                $out .= $chunk['identifier'] . "\n";
                $out .= implode("\n", $chunk['data']) ."\n";
            }
            echo $out;
        }
     
        /**
         * Descends into a deeper context
         *
         * @param string $type friendly name, either class or function
         */
        protected function enterContext($type) {
            // next comes whitespace:
            if (is_array($this->tokens[++$this->tokenIndex])) {
                list($token, $this->currentValue) = $this->tokens[$this->tokenIndex];
            } else {
                $token = null;
                $this->currentValue = $this->tokens[$this->tokenIndex];
            }
            if ($token != T_WHITESPACE) {
                // syntax is broken, let's get out of here
                if (self::DEBUG_SYNTAX) {
                    die("Syntax broken in whitespace assertion, " . $this->context[count($this->context) - 1] . "\n");
                }
                $this->isBroken = true;
                break;
            }
            $this->checkLineBreak();
     
            // next comes the name:
            if (is_array($this->tokens[++$this->tokenIndex])) {
                list($token, $this->currentValue) = $this->tokens[$this->tokenIndex];
            } else {
                $token = null;
                $this->currentValue = $this->tokens[$this->tokenIndex];
            }
            $this->context[] = $this->currentValue . "({$type})";
     
            // chew through the next few tokens until we get a "{"
            while ($this->currentValue != '{' && $this->tokenIndex < count($this->tokens)) {
                if (is_array($this->tokens[++$this->tokenIndex])) {
                    list($token, $this->currentValue) = $this->tokens[$this->tokenIndex];
                } else {
                    $token = null;
                    $this->currentValue = $this->tokens[$this->tokenIndex];
                }
                $this->checkLineBreak();
                switch ($token) {
                    // these are all valid before the brace:
                    case null:
                    case T_WHITESPACE:
                    case T_VARIABLE:
                    case T_EXTENDS:
                    case T_IMPLEMENTS:
                    case T_STRING:
                    case T_ARRAY:
                    case T_CONSTANT_ENCAPSED_STRING:
                    case T_LNUMBER:
                    case '=':
                        break;
     
                    // if another token is found, then there's a syntax error
                    // (this was added to prevent really deep looping)
                    default:
                        if (self::DEBUG_SYNTAX) {
                            die("Syntax broken in token assertion, " . $this->context[count($this->context) - 1] . "," . token_name($token) . "\n");
                        }
                        $this->isBroken = true;
                        return;
                }
            }
     
            // found the starting brace
            $this->braceDepth[count($this->context) - 1] = 1;
        }    
     
        /**
         * Tokenizes the code and creates a line map
         */
        protected function tokenizeHierarchy() {
            $this->context = array('(root)');
            $this->lineMap = array('');
            $this->tokens = token_get_all($this->sanitizeLineEndings($this->fileContents));
            $this->isBroken = false;
            for ($this->tokenIndex=0; $this->tokenIndex<count($this->tokens); $this->tokenIndex++) {
                if ($this->isBroken) {
                    // syntax is somehow broken; return progress, but don't go further
                    return;
                }
                if (is_array($this->tokens[$this->tokenIndex])) {
                    list($token, $this->currentValue) = $this->tokens[$this->tokenIndex];
                } else {
                    $token = null;
                    $this->currentValue = $this->tokens[$this->tokenIndex];
                    //change here
                }
     
                switch ($token) {
                    // check for class
                    case T_CLASS:
                        // found "class"
                        $this->enterContext('class');
                        break;
     
                    case T_FUNCTION:
                        // found "function"
                        $this->enterContext('function');
                        break;
     
                    default:
                        $idx = count($this->context) - 1;
                        switch ($this->currentValue) {
                            case '{':
                            case T_CURLY_OPEN:
                            case T_DOLLAR_OPEN_CURLY_BRACES:
                                ++$this->braceDepth[$idx];
                                break;
     
                            case '}':
                                --$this->braceDepth[$idx];
                                if ($this->braceDepth[$idx] == 0) {
                                    // we're out of this context
                                    array_pop($this->context);
                                } else if ($this->braceDepth[$idx] < 0) {
                                    // bad stuff!
                                    if (self::DEBUG_SYNTAX) {
                                        die("Syntax broken in brace close assertion, " . $this->context[count($this->context) - 1] . "\n");
                                    }
                                    $this->isBroken = true;
                                }
                                break;
     
                            default:
                                $this->checkLineBreak();
                        }
                }
            }
        }
     
        /**
         * Determines if the currently processing token contains line breaks, and
         * if so, adjusts the lineMap accordingly
         */
        protected function checkLineBreak() {
            // check for new line:
            if (strpos($this->currentValue, "\n") !== false) {
                for ($j=1; $j<=substr_count($this->currentValue, "\n"); $j++) {
                    $this->lineMap[] = implode(':', $this->context);
                }
            }
        }
     
        /**
         * Matches the chunk map to the line map
         */
        protected function determineHierarchy() {
            $this->tokenizeHierarchy();
            for ($chunknum=0; $chunknum < count($this->chunks); $chunknum++) {
                $this->chunks[$chunknum]['identifier'] .= ' ' . $this->lineMap[$this->chunks[$chunknum]['line']];
            }
        }
    }
     
    new DiffPhp;
     
    // komode: le=unix language=php codepage=utf8 tab=4 notabs indent=4

    The most up-to-date version of this file can also be found in my personal svn repostory: https://svn.caedmon.net/svn/public/diff-php/diff-php.

    Please let me know if you run into any bugs.. I'm sure there are a few, but it works pretty well for me.