diff --git a/TODO b/TODO new file mode 100644 index 0000000000..08ac0a5eb2 --- /dev/null +++ b/TODO @@ -0,0 +1,62 @@ ++ login ++ register +- settings ++ disallow login if user is logged in ++ disallow register if user is logged in ++ common_current_user() ++ common_logged_in() ++ session variable for login ++ post notice ++ logout ++ subscribe ++ unsubscribe ++ subscribe links on profile +- licenses +- header menu +- footer menu +- disallow direct to PHP files +- common_local_url() +- configuration system ($config) +- RSS 1.0 feeds of a user's notices +- RSS 1.0 dump of a user's notices +- RSS 1.0 feed of all public notices +- RDF dump of entire site +- FOAF dump for user +- delete a notice +- make sure canonical usernames are unique +- upload avatar +- design from Open Source Web Designs +- release 0.1 +- gettext +- subscribe remote +- add subscriber remote +- send remote notice +- receive remote notice +- confirmation email +- tinyurl-ification of URLs +- jQuery for as much as possible +- themes +- release 0.2 +- @ messages +- # tags +- L: location +- stay logged in between sessions +- use RSS as a subscription +- URL notices +- image notices +- video notices +- audio notices +- release 0.3 +- forward notices to Jabber +- forward notices to other IM +- forward notices to mobile phone +- machine tags +- release 0.4 +- include twitter subscriptions +- include Pownce subscriptions +- privacy +- Wrap DB_DataObject with memcached caching layer +- login throttle to prevent brute-force attacks +- form token in login to prevent XSS +- release 1.0 +- Atom Publishing Protocol diff --git a/URLS.txt b/URLS.txt index b544bd0369..bcbad40da2 100644 --- a/URLS.txt +++ b/URLS.txt @@ -11,6 +11,7 @@ /main/login login to site /main/register register to site +/main/settings change account settings /main/recover recover password /doc/ documentation about about this site diff --git a/actions/login.php b/actions/login.php index a95dc9e3a3..b939362973 100644 --- a/actions/login.php +++ b/actions/login.php @@ -1,25 +1,59 @@ arg('METHOD') == 'POST') { + $this->check_login(); } else { - } - } else { - if (user_logged_in()) { - } else { - login_show_form(); + $this->show_form(); } } + + function check_login() { + # XXX: form token in $_SESSION to prevent XSS + # XXX: login throttle + $nickname = $this->arg('nickname'); + $password = $this->arg('password'); + if (common_check_user($nickname, $password)) { + common_set_user($nickname); + common_redirect(common_local_url('all', + array('nickname' => + $nickname))); + } else { + $this->show_form(_t('Incorrect username or password.')); + } + } + + function show_form($error=NULL) { + + common_show_header(_t('Login')); + if (!is_null($error)) { + common_element('div', array('class' => 'error'), $msg); + } + common_start_element('form', array('method' => 'POST', + 'id' => 'login', + 'action' => common_local_url('login'))); + common_element('label', array('for' => 'username'), + _t('Name')); + common_element('input', array('name' => 'username', + 'type' => 'text', + 'id' => 'username')); + common_element('label', array('for' => 'password'), + _t('Password')); + common_element('input', array('name' => 'password', + 'type' => 'password', + 'id' => 'password')); + common_element('input', array('name' => 'submit', + 'type' => 'submit', + 'id' => 'submit'), + _t('Login')); + common_element('input', array('name' => 'cancel', + 'type' => 'button', + 'id' => 'cancel'), + _t('Cancel')); + } } - -function login_show_form() { - html_start(); - html_head("Login"); - html_body(); -} - -function login_check_user($username, $password) { - -} \ No newline at end of file diff --git a/actions/logout.php b/actions/logout.php new file mode 100644 index 0000000000..a40400e7ee --- /dev/null +++ b/actions/logout.php @@ -0,0 +1,13 @@ +arg('METHOD') == 'POST') { + if ($this->save_new_notice()) { + # XXX: smarter redirects + $user = common_current_user(); + assert(!is_null($user)); # see if... above + # XXX: redirect to source + # XXX: use Ajax instead of a redirect + common_redirect(common_local_url('all', + array('nickname' => + $user->nickname))); + } else { + common_server_error(_t('Problem saving notice.')); + } + } else { + $this->show_form(); + } + } + + function save_new_notice() { + $user = common_current_user(); + assert($user); # XXX: maybe an error instead... + $notice = DB_DataObject::factory('notice'); + assert($notice); + $notice->profile_id = $user->id; # user id *is* profile id + $notice->content = $this->arg('content'); + $notice->created = time(); + return $notice->insert(); + } + + function show_form() { + common_start_element('form', array('id' => 'newnotice', 'method' => 'POST', + 'action' => common_local_url('newnotice'))); + common_element('span', 'nickname', $profile->nickname); + common_element('textarea', array('rows' => 4, 'cols' => 80, 'id' => 'content')); + common_element('input', array('type' => 'submit'), 'Send'); + common_end_element('form'); + } +} \ No newline at end of file diff --git a/actions/register.php b/actions/register.php new file mode 100644 index 0000000000..5972d58388 --- /dev/null +++ b/actions/register.php @@ -0,0 +1,115 @@ +arg('METHOD') == 'POST') { + $this->try_register(); + } else { + $this->show_form(); + } + } + + function try_register() { + $nickname = $this->arg('nickname'); + $password = $this->arg('password'); + $confirm = $this->arg('confirm'); + $email = $this->arg('email'); + + # Input scrubbing + + $nickname = common_canonical_nickname($nickname); + $email = common_canonical_email($email); + + if ($this->nickname_exists($nickname)) { + $this->show_form(_t('Username already exists.')); + } else if ($this->email_exists($email)) { + $this->show_form(_t('Email address already exists.')); + } else if ($password != $confirm) { + $this->show_form(_t('Passwords don\'t match.')); + } else if ($this->register_user($nickname, $password, $email)) { + common_set_user($nickname); + common_redirect(common_local_url('settings')); + } else { + $this->show_form(_t('Invalid username or password.')); + } + } + + # checks if *CANONICAL* nickname exists + + function nickname_exists($nickname) { + $user = User::staticGet('nickname', $nickname); + return ($user !== false); + } + + # checks if *CANONICAL* email exists + + function email_exists($email) { + $email = common_canonicalize_email($email); + $user = User::staticGet('email', $email); + return ($user !== false); + } + + function register_user($nickname, $password, $email) { + # TODO: wrap this in a transaction! + $profile = new Profile(); + $profile->nickname = $nickname; + $profile->created = time(); + $id = $profile->insert(); + if (!$id) { + return FALSE; + } + $user = new User(); + $user->id = $id; + $user->nickname = $nickname; + $user->password = common_munge_password($password, $id); + $user->email = $email; + $user->created = time(); + $result = $user->insert(); + if (!$result) { + # Try to clean up... + $profile->delete(); + } + return $result; + } + + function show_form($error=NULL) { + + common_show_header(_t('Login')); + common_start_element('form', array('method' => 'POST', + 'id' => 'login', + 'action' => common_local_url('login'))); + common_element('label', array('for' => 'username'), + _t('Name')); + common_element('input', array('name' => 'username', + 'type' => 'text', + 'id' => 'username')); + common_element('label', array('for' => 'password'), + _t('Password')); + common_element('input', array('name' => 'password', + 'type' => 'password', + 'id' => 'password')); + common_element('label', array('for' => 'confirm'), + _t('Confirm')); + common_element('input', array('name' => 'confirm', + 'type' => 'password', + 'id' => 'confirm')); + common_element('label', array('for' => 'email'), + _t('Email')); + common_element('input', array('name' => 'email', + 'type' => 'text', + 'id' => 'email')); + common_element('input', array('name' => 'submit', + 'type' => 'submit', + 'id' => 'submit'), + _t('Login')); + common_element('input', array('name' => 'cancel', + 'type' => 'button', + 'id' => 'cancel'), + _t('Cancel')); + } +} diff --git a/actions/settings.php b/actions/settings.php new file mode 100644 index 0000000000..826770ad7b --- /dev/null +++ b/actions/settings.php @@ -0,0 +1,34 @@ +arg('METHOD') == 'POST') { + $nickname = $this->arg('nickname'); + $fullname = $this->arg('fullname'); + $email = $this->arg('email'); + $homepage = $this->arg('homepage'); + $bio = $this->arg('bio'); + $location = $this->arg('location'); + $oldpass = $this->arg('oldpass'); + $password = $this->arg('password'); + $confirm = $this->arg('confirm'); + + if ($password) { + if ($password != $confirm) { + $this->show_form(_t('Passwords don\'t match.')); + } + } else if ( + + $error = $this->save_settings($nickname, $fullname, $email, $homepage, + $bio, $location, $password); + if (!$error) { + $this->show_form(_t('Settings saved.'), TRUE); + } else { + $this->show_form($error); + } + } else { + $this->show_form(); + } + \ No newline at end of file diff --git a/actions/shownotice.php b/actions/shownotice.php index 4d4876122c..b3204d0634 100644 --- a/actions/shownotice.php +++ b/actions/shownotice.php @@ -37,7 +37,8 @@ class ShownoticeAction extends Action { 'class' => 'nickname'), $profile->nickname); # FIXME: URL, image, video, audio - common_element('span', array('class' => 'content'), $notice->content); + common_element('span', array('class' => 'content'), + $notice->content); common_element('span', array('class' => 'date'), common_date_string($notice->created)); common_end_element('div'); diff --git a/actions/showstream.php b/actions/showstream.php index 1eb060fdc9..5950a4ead0 100644 --- a/actions/showstream.php +++ b/actions/showstream.php @@ -9,34 +9,43 @@ class ShowstreamAction extends StreamAction { parent::handle($args); - $nickname = $this->arg('profile'); - $profile = Profile::staticGet('nickname', strtolower($nickname)); - - if (!$profile) { + $nickname = common_canonicalize_nickname($this->arg('profile')); + $user = User::staticGet('nickname', $nickname); + + if (!$user) { $this->no_such_user(); } - - $user = User::staticGet($profile->id); - - if (!$user) { - // remote profile - $this->no_such_user(); + + $profile = $user->getProfile(); + + if (!$profile) { + common_server_error(_t('User record exists without profile.')); } # Looks like we're good; show the header common_show_header($profile->nickname); + + $cur = common_current_user(); - if ($profile->id == current_user()->id) { + if ($cur && $profile->id == $cur->id) { $this->notice_form(); } $this->show_profile($profile); $this->show_last_notice($profile); + + if ($cur) { + if ($cur->isSubscribed($profile)) { + $this->show_unsubscribe_form($profile); + } else { + $this->show_subscribe_form($profile); + } + } $this->show_statistics($profile); - + $this->show_subscriptions($profile); $this->show_notices($profile); @@ -75,13 +84,33 @@ class ShowstreamAction extends StreamAction { common_element('div', 'bio', $profile->bio); } } + + function show_subscribe_form($profile) { + common_start_element('form', array('id' => 'subscribe', 'method' => 'POST', + 'action' => common_local_url('subscribe'))); + common_element('input', array('id' => 'subscribeto', + 'name' => 'subscribeto', + 'type' => 'hidden', + 'value' => $profile->nickname)); + common_element('input', array('type' => 'submit'), _t('subscribe')); + common_end_element('form'); + } + + function show_unsubscribe_form($profile) { + common_start_element('form', array('id' => 'unsubscribe', 'method' => 'POST', + 'action' => common_local_url('unsubscribe'))); + common_element('input', array('id' => 'unsubscribeto', + 'name' => 'unsubscribeto', + 'type' => 'hidden', + 'value' => $profile->nickname)); + common_element('input', array('type' => 'submit'), _t('unsubscribe')); + common_end_element('form'); + } function show_subscriptions($profile) { - - # XXX: add a limit + # XXX: add a limit $subs = $profile->getLink('id', 'subscription', 'subscriber'); - common_start_element('div', 'subscriptions'); $cnt = 0; @@ -113,7 +142,7 @@ class ShowstreamAction extends StreamAction { array('profile' => $profile->nickname)) 'class' => 'moresubscriptions'), _t('All subscriptions')); - + common_end_element('div'); } @@ -174,7 +203,8 @@ class ShowstreamAction extends StreamAction { while ($notice->fetch()) { # FIXME: URL, image, video, audio - common_element('span', array('class' => 'content'), $notice->content); + common_element('span', array('class' => 'content'), + $notice->content); common_element('span', array('class' => 'date'), common_date_string($notice->created)); } diff --git a/actions/subscribe.php b/actions/subscribe.php new file mode 100644 index 0000000000..35961d0517 --- /dev/null +++ b/actions/subscribe.php @@ -0,0 +1,42 @@ +arg('subscribeto'); + + $other = User::staticGet('nickname', $other_nickname); + + if (!$other) { + common_user_error(_t('No such user.')); + return; + } + + $user = common_current_user(); + + if ($user->isSubscribed($other)) { + common_user_error(_t('Already subscribed!.')); + return; + } + + $sub = new Subscription(); + $sub->subscriber = $user->id; + $sub->subscribed = $other->id; + + $sub->created = time(); + + if (!$sub->insert()) { + common_server_error(_t('Couldn\'t create subscription.')); + return; + } + + common_redirect(common_local_url('all', array('nickname' => + $user->nickname))); + } +} \ No newline at end of file diff --git a/actions/unsubscribe.php b/actions/unsubscribe.php new file mode 100644 index 0000000000..c4e6b98917 --- /dev/null +++ b/actions/unsubscribe.php @@ -0,0 +1,35 @@ +arg('unsubscribeto'); + $other = User::staticGet('nickname', $other_nickname); + if (!$other) { + common_user_error(_t('No such user.')); + return; + } + + $user = common_current_user(); + + if (!$user->isSubscribed($other)) { + common_server_error(_t('Not subscribed!.')); + } + + $sub = new Subscription(); + $sub->subscriber = $user->id; + $sub->subscribed = $other->id; + + if (!$sub->delete()) { + common_server_error(_t('Couldn\'t delete subscription.')); + return; + } + + common_redirect(common_local_url('all', array('nickname' => + $user->nickname))); + } +} diff --git a/classes/User.php b/classes/User.php index 4ed6003dcd..8234e07848 100644 --- a/classes/User.php +++ b/classes/User.php @@ -21,4 +21,16 @@ class User extends DB_DataObject /* the code above is auto generated do not remove the tag below */ ###END_AUTOCODE + + function getProfile() { + return Profile::staticGet($this->$id); + } + + function isSubscribed($other) { + assert(!is_null($other)); + $sub = DB_DataObject::factory('subscription'); + $sub->subscriber = $this->id; + $sub->subscribed = $other->id; + return $sub->find(); + } } diff --git a/common.php b/common.php index a6061920d4..de0529a2e8 100644 --- a/common.php +++ b/common.php @@ -5,7 +5,7 @@ // default configuration, overwritten in config.php $config = - array('site' => + array('site' => array('name' => 'Just another µB'), 'dsn' => array('phptype' => 'mysql', @@ -20,20 +20,7 @@ $config = require_once(INSTALLDIR . '/config.php'); require_once('DB.php'); -function common_database() { - global $config; - $db =& DB::connect($config['dsn'], $config['dboptions']); - if (PEAR::isError($db)) { - common_server_error($db->getMessage()); - } else { - return $db; - } -} - -function common_read_database() { - // XXX: read from slave server - return common_database(); -} +# Show a server error function common_server_error($msg) { header('Status: 500 Server Error'); @@ -43,12 +30,14 @@ function common_server_error($msg) { exit(); } -function common_user_error($msg) { +# Show a user error +function common_user_error($msg, $code=200) { common_show_header('Error'); common_element('div', array('class' => 'error'), $msg); common_show_footer(); } +# Start an HTML element function common_element_start($tag, $attrs=NULL) { print "<$tag"; if (is_array($attrs)) { @@ -67,7 +56,7 @@ function common_element_end($tag) { function common_element($tag, $attrs=NULL, $content=NULL) { common_element_start($tag, $attrs); - if ($content) print $content; + if ($content) print htmlspecialchars($content); common_element_end($tag); } @@ -75,7 +64,8 @@ function common_show_header($pagetitle) { global $config; common_element_start('html'); common_element_start('head'); - common_element('title', NULL, $pagetitle . " - " . $config['site']['name']); + common_element('title', NULL, + $pagetitle . " - " . $config['site']['name']); common_element_end('head'); common_element_start('body'); } @@ -85,6 +75,82 @@ function common_show_footer() { common_element_end('html'); } -// TODO: set up gettext +# salted, hashed passwords are stored in the DB + +function common_munge_password($id, $password) { + return md5($id . $password); +} + +# check if a username exists and has matching password +function common_check_user($nickname, $password) { + $user = User::staticGet('nickname', $nickname); + if (is_null($user)) { + return false; + } else { + return (0 == strcmp(common_munge_password($password, $user->id), + $user->password)); + } +} + +# is the current user logged in? +function common_logged_in() { + return (!is_null(common_current_user())); +} + +function common_have_session() { + return (0 != strcmp(session_id(), '')); +} + +function common_ensure_session() { + if (!common_have_session()) { + @session_start(); + } +} + +function common_set_user($nickname) { + if (is_null($nickname) && common_have_session()) { + unset($_SESSION['userid']); + return true; + } else { + $user = User::staticGet('nickname', $nickname); + if ($user) { + common_ensure_session(); + $_SESSION['userid'] = $user->id; + return true; + } else { + return false; + } + } + return false; +} + +# who is the current user? +function common_current_user() { + static $user = NULL; # FIXME: global memcached + if (is_null($user)) { + if (common_have_session()) { + $id = $_SESSION['userid']; + if ($id) { + $user = User::staticGet($id); + } + } + } + return $user; +} + +# get canonical version of nickname for comparison +function common_canonical_nickname($nickname) { + # XXX: UTF-8 canonicalization (like combining chars) + return strtolower($nickname); +} + +function common_render_content($text) { + # XXX: @ messages + # XXX: # tags + # XXX: machine tags + return htmlspecialchars($text); +} + +// XXX: set up gettext function _t($str) { $str } diff --git a/index.php b/index.php index d63d09edf5..7237c08bc0 100644 --- a/index.php +++ b/index.php @@ -9,7 +9,7 @@ $actionfile = INSTALLDIR."/actions/$action.php"; if (file_exists($actionfile)) { require_once($actionfile); - $action_function = 'handle_' . $action; + $action_class = ucfirst($action) . "Action"; if (function_exists($action_function)) { call_user_func($action_function); } else { diff --git a/stoica.sql b/stoica.sql index f7f32ad76f..28e8f7662c 100644 --- a/stoica.sql +++ b/stoica.sql @@ -18,6 +18,7 @@ create table profile ( create table user ( id integer primary key comment 'foreign key to profile table' references profile (id), + nickname varchar(64) unique key comment 'nickname or username, duped in profile', password varchar(255) comment 'salted password, can be null for OpenID users', email varchar(255) unique key comment 'email address for password recovery etc.', created datetime not null comment 'date this record was created', @@ -49,7 +50,7 @@ create table notice ( id integer auto_increment primary key comment 'unique identifier', profile_id integer not null comment 'who made the update' references profile (id), content varchar(140) comment 'update content', - rendered varchar(140) comment 'pre-rendered content', + /* XXX: cache rendered content. */ url varchar(255) comment 'URL of any attachment (image, video, bookmark, whatever)', created datetime not null comment 'date this record was created', modified timestamp comment 'date this record was modified',