aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md14
-rw-r--r--add.php49
-rw-r--r--config.sample.php14
-rw-r--r--db.php151
-rw-r--r--fetch.php96
-rw-r--r--ical.php67
-rw-r--r--install.sql81
-rw-r--r--list.php91
-rw-r--r--mail.php196
-rw-r--r--nginx.conf49
-rw-r--r--style.css33
12 files changed, 842 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4f4773f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+config.php
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e0a1d3e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,14 @@
+# Agenda
+
+This is the source code for [agenda.hebrewtools.org][], which collects
+information about conferences, workshops, and other events relevant to scholars
+of the near east.
+
+I currently collect calls for papers and other notices of events from the Agade
+mailing list. The events can be filtered by keywords, and such a filtered event
+list can be exported to calendar applications like Google Calendar or Outlook
+in iCal format.
+
+This software is copyright © 2020–present Camil Staps.
+
+[agenda.hebrewtools.org]: https://agenda.hebrewtools.org
diff --git a/add.php b/add.php
new file mode 100644
index 0000000..e538d2e
--- /dev/null
+++ b/add.php
@@ -0,0 +1,49 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Adding new events</title>
+ <link rel="stylesheet" type="text/css" href="style.css"/>
+</head>
+<body>
+<?php
+require_once ('mail.php');
+require_once ('db.php');
+
+$selected_messages=$_POST['messages'];
+if (count ($selected_messages)<1)
+ die ('No events selected.');
+
+$exceptions=[];
+
+$messages=mail_get_unseen_messages();
+foreach ($messages as $msg_id){
+ if (!in_array ($msg_id,$selected_messages))
+ continue;
+
+ try {
+ $msg=mail_fetch_message ($msg_id);
+ $parsed=mail_parse_message ($msg['body'],['source' => $msg['guessed_source']]);
+ $info=$parsed['info'];
+
+ $info['added_by']=$msg['user']['id'];
+ $info['keywords']=array_map ('get_or_add_keyword',$info['keywords']);
+ $info['source']=get_or_add_source ($info['source']);
+
+ $id=add_event ($info);
+ echo 'Added #'.$id.': '.$info['title'].'<br/>';
+
+ mail_mark_seen ($msg_id);
+ } catch (Exception $e){
+ $exceptions[]=$e->getMessage();
+ }
+}
+
+if (count ($exceptions)>0){
+ echo 'Exceptions:<br/>';
+ foreach ($exceptions as $e)
+ echo $e.'<br/>';
+}
+?>
+<p>Back to the <a href="fetch.php">list of new events</a>?</p>
+</body>
+</html>
diff --git a/config.sample.php b/config.sample.php
new file mode 100644
index 0000000..556a306
--- /dev/null
+++ b/config.sample.php
@@ -0,0 +1,14 @@
+<?php
+define ('DB_HOST','localhost');
+define ('DB_NAME','databasename');
+define ('DB_USER','username');
+define ('DB_PASS','password');
+
+define ('IMAP_HOST','mail.example.org');
+define ('IMAP_USER','agenda@example.org');
+define ('IMAP_PASS','password');
+define ('IMAP_CONN','{'.IMAP_HOST.':993/imap/ssl}INBOX');
+
+define ('ICAL_NAME','HebrewTools Events');
+define ('ICAL_VERSION','0.1');
+define ('ICAL_DESC','Conferences, workshops, and other events relevant to scholars of the near east.');
diff --git a/db.php b/db.php
new file mode 100644
index 0000000..0f0c2d7
--- /dev/null
+++ b/db.php
@@ -0,0 +1,151 @@
+<?php
+require_once ('config.php');
+
+$pdo=new PDO ('mysql:host='.DB_HOST.';dbname='.DB_NAME,DB_USER,DB_PASS);
+
+function add_event ($info)
+{
+ global $pdo;
+ $st=$pdo->prepare ('insert into `events` (`title`,`location`,`start_date`,`end_date`,`source`,`added_by`,`description`) values (?,?,?,?,?,?,?)');
+ $ok=$st->execute ([
+ $info['title'],
+ $info['location'],
+ $info['start_date'],
+ $info['end_date'],
+ $info['source'],
+ $info['added_by'],
+ $info['description']
+ ]);
+
+ if (!$ok){
+ $err=$st->errorInfo();
+ throw new Exception ('Error ('.$err[0].', '.$err[1].'): '.$err[2]);
+ }
+
+ $id=$pdo->lastInsertId();
+
+ $st=$pdo->prepare ('insert into `event_keyword` (`event`,`keyword`) values (?,?)');
+ foreach ($info['keywords'] as $keyword_id)
+ $st->execute ([$id,$keyword_id]);
+
+ return $id;
+}
+
+function get_events ($keywords=[])
+{
+ global $pdo;
+
+ if (count ($keywords) > 0){
+ $keywords=$pdo->query (
+ 'select `id` from `keywords` ' .
+ 'where `keyword` in ('.implode (',',array_map ([$pdo,'quote'],$keywords)).')'
+ );
+ $keywords=array_map ('array_shift',$keywords->fetchAll (PDO::FETCH_NUM));
+ }
+
+ $sql='select `events`.*,`sources`.`source` as `source_name` ' .
+ 'from `events` inner join `sources` on `events`.`source`=`sources`.`id` ';
+ if (count ($keywords) > 0){
+ $sql.='where exists (select * from `event_keyword` ' .
+ 'where `event`=`events`.`id` and `keyword` in ('.implode (',',$keywords).')) ';
+ }
+ $sql.='order by `start_date` asc,`end_date` asc,`title` asc';
+
+ $st=$pdo->prepare ($sql);
+ $st->execute();
+ $events=$st->fetchAll (PDO::FETCH_ASSOC);
+
+ $get_keywords_st=$pdo->prepare (
+ 'select `keywords`.`keyword` ' .
+ 'from `event_keyword` inner join `keywords` on `keywords`.`id`=`event_keyword`.`keyword` ' .
+ 'where `event_keyword`.`event`=? ' .
+ 'order by `keywords`.`keyword` asc'
+ );
+ foreach ($events as $i => $event){
+ $get_keywords_st->execute ([$event['id']]);
+ $keywords=$get_keywords_st->fetchAll (PDO::FETCH_NUM);
+ $events[$i]['keywords']=array_map ('array_shift',$keywords);
+ }
+
+ return $events;
+}
+
+function get_keyword ($keyword)
+{
+ global $pdo;
+ $st=$pdo->prepare ('select `id` from `keywords` where `keyword`=?');
+ $st->execute ([$keyword]);
+
+ if ($st->rowCount() > 0){
+ $row=$st->fetch();
+ return $row['id'];
+ }
+
+ return null;
+}
+
+function get_keywords ()
+{
+ global $pdo;
+ $keywords=$pdo->query (
+ 'select `keywords`.`keyword`, count(`event_keyword`.`event`) as `count` ' .
+ 'from `event_keyword` inner join `keywords` on `event_keyword`.`keyword`=`keywords`.`id` ' .
+ 'group by `event_keyword`.`keyword` ' .
+ 'order by `keywords`.`keyword` asc'
+ );
+ return $keywords->fetchAll (PDO::FETCH_ASSOC);
+}
+
+function get_or_add_keyword ($keyword)
+{
+ global $pdo;
+ $id=get_keyword ($keyword);
+
+ if (is_null ($id)){
+ $st=$pdo->prepare ('insert into `keywords` (`keyword`) values (?)');
+ $st->execute ([$keyword]);
+ $id=$pdo->lastInsertId();
+ }
+
+ return $id;
+}
+
+function get_source ($source)
+{
+ global $pdo;
+ $st=$pdo->prepare ('select `id` from `sources` where `source`=?');
+ $st->execute ([$source]);
+
+ if ($st->rowCount() > 0){
+ $row=$st->fetch();
+ return $row['id'];
+ }
+
+ return null;
+}
+
+function get_or_add_source ($source)
+{
+ global $pdo;
+ $id=get_source ($source);
+
+ if (is_null ($id)){
+ $st=$pdo->prepare ('insert into `sources` (`source`) values (?)');
+ $st->execute ([$source]);
+ $id=$pdo->lastInsertId();
+ }
+
+ return $id;
+}
+
+function get_user_by_email ($email)
+{
+ global $pdo;
+ $st=$pdo->prepare ('select * from `users` where `email`=?');
+ $st->execute ([$email]);
+
+ if ($st->rowCount() > 0)
+ return $st->fetch (PDO::FETCH_ASSOC);
+ else
+ throw new Exception ('no user with email '.$email);
+}
diff --git a/fetch.php b/fetch.php
new file mode 100644
index 0000000..1adccc6
--- /dev/null
+++ b/fetch.php
@@ -0,0 +1,96 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Fetching new events</title>
+ <link rel="stylesheet" type="text/css" href="style.css"/>
+</head>
+<body>
+<form action="add.php" method="post">
+<table>
+<tr>
+ <th>Select</th>
+ <th>Info</th>
+ <th>Description</th>
+ <th>Warnings</th>
+</tr>
+<?php
+require_once ('mail.php');
+
+$exceptions=[];
+
+$messages=mail_get_unseen_messages();
+
+foreach ($messages as $msg_id){
+ try {
+ $msg=mail_fetch_message ($msg_id);
+ $parsed=mail_parse_message ($msg['body'],['source' => $msg['guessed_source']]);
+ $info=$parsed['info'];
+ $selected=count ($parsed['warnings'])==0;
+
+ foreach ($info['keywords'] as $i => $keyword){
+ $id=get_keyword ($keyword);
+ if (is_null ($id)){
+ $info['keywords'][$i]='<strong>'.htmlspecialchars ($keyword).'</strong>';
+ $selected=false;
+ } else
+ $info['keywords'][$i]=htmlspecialchars ($keyword);
+ }
+
+ $id=get_source ($info['source']);
+ if (is_null ($id)){
+ $info['source']='<strong>'.htmlspecialchars ($info['source']).'</strong>';
+ $selected=false;
+ } else
+ $info['source']=htmlspecialchars ($info['source']);
+
+ echo '<tr>';
+
+ $checked=$selected ? 'checked="checked"' : '';
+ echo '<td><input type="checkbox" '.$checked.' name="messages[]" value="'.$msg_id.'"/></td>';
+
+ echo '<td>';
+ echo htmlspecialchars ($info['title']).'<br/>';
+ echo htmlspecialchars ($info['location']).'<br/>';
+ if ($info['start_date']==$info['end_date'])
+ echo $info['start_date'].'<br/>';
+ else
+ echo $info['start_date'].' to '.$info['end_date'].'<br/>';
+ echo implode (', ',$info['keywords']).'<br/>';
+ echo $info['source'];
+ echo '</td>';
+
+ $description=nl2br (htmlspecialchars ($info['description']));
+ echo '<td class="description">'.$description.'</td>';
+
+ echo '<td>';
+ if (count ($parsed['warnings'])==0)
+ echo '&mdash;';
+ else
+ foreach ($parsed['warnings'] as $warning)
+ echo htmlspecialchars ($warning).'<br/>';
+ echo '</td>';
+
+ echo '</tr>';
+ } catch (Exception $e){
+ $exceptions[]=$e->getMessage();
+ }
+}
+
+echo '</table>';
+
+if (count ($exceptions)>0){
+ echo 'Exceptions:<br/>';
+ foreach ($exceptions as $e)
+ echo $e.'<br/>';
+}
+
+mail_finish();
+
+if (count ($messages)>0)
+ echo '<input type="submit" value="Add selected events"/>';
+else
+ echo '<tr><td colspan="4">No new events found.</td></tr>';
+?>
+</form>
+</body>
+</html>
diff --git a/ical.php b/ical.php
new file mode 100644
index 0000000..92c7eb8
--- /dev/null
+++ b/ical.php
@@ -0,0 +1,67 @@
+<?php
+if (isset ($_GET['plain'])){
+ header ('Content-Type: text/plain');
+} else {
+ header ('Content-Type: text/calendar');
+ header ('Content-Disposition: attachment; filename=HebrewTools.ical');
+}
+
+require_once ('config.php');
+require_once ('db.php');
+
+function icalspecialchars ($text)
+{
+ $text=str_replace(
+ ["\\", "\n", ";", ","],
+ ["\\\\","\\n","\\;","\\,"],
+ $text
+ );
+ $text=preg_replace ('/[\x00-\x1f\x7f]/','',$text);
+ return $text;
+}
+
+function icalline ($key,$val)
+{
+ $line=$key.':';
+ $val=icalspecialchars ($val);
+
+ if (strlen ($val) + strlen ($line) > 75){
+ $split_at=75 - strlen ($line);
+ $line.=substr ($val,0,$split_at)."\r\n";
+ $val=substr ($val,$split_at);
+ $val_parts=str_split ($val,74);
+ foreach ($val_parts as $val)
+ $line.=' '.$val."\r\n";
+ } else {
+ $line.=$val."\r\n";
+ }
+
+ return $line;
+}
+
+echo "BEGIN:VCALENDAR\r\n";
+echo icalline ('VERSION','2.0');
+echo icalline ('PRODID','-//HebrewTools//NONSGML '.ICAL_NAME.' '.ICAL_VERSION.'//EN');
+echo icalline ('METHOD','PUBLISH');
+echo icalline ('X-WR-CALNAME',ICAL_NAME);
+echo icalline ('X-WR-CALDESC',ICAL_DESC);
+echo icalline ('X-PUBLISHED-TTL','PT12H'); /* update every 12h */
+echo icalline ('URL','https://'.$_SERVER['HTTP_HOST']);
+
+$events=isset($_GET['keywords']) ? get_events (explode (',',$_GET['keywords'])) : get_events();
+foreach ($events as $event){
+ echo "BEGIN:VEVENT\r\n";
+ echo icalline ('UID','event-'.$event['id'].'@agenda.hebrewtools.org');
+ echo icalline ('SUMMARY',$event['title']);
+ echo icalline ('LOCATION',$event['location']);
+ echo icalline ('DTSTART',date ('Ymd',strtotime ($event['start_date'])));
+ if ($event['end_date']!=$event['start_date'])
+ echo icalline ('DTEND',date ('Ymd',strtotime ($event['end_date'])+24*3600));
+ $description=$event['description'];
+ $description.="\n\nKeywords: ".implode (', ',$event['keywords']);
+ $description.="\nSource: ".$event['source_name'];
+ echo icalline ("DESCRIPTION",$description);
+ echo "END:VEVENT\r\n";
+}
+
+echo "END:VCALENDAR\r\n";
diff --git a/install.sql b/install.sql
new file mode 100644
index 0000000..8eb299a
--- /dev/null
+++ b/install.sql
@@ -0,0 +1,81 @@
+CREATE TABLE `events` (
+ `id` int(11) NOT NULL,
+ `title` text NOT NULL,
+ `location` text NOT NULL,
+ `start_date` date NOT NULL,
+ `end_date` date NOT NULL,
+ `source` int(11) NOT NULL,
+ `added_by` int(11) NOT NULL,
+ `description` mediumtext NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE TABLE `event_keyword` (
+ `event` int(11) NOT NULL,
+ `keyword` int(11) NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE TABLE `keywords` (
+ `id` int(11) NOT NULL,
+ `keyword` text NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE TABLE `sources` (
+ `id` int(11) NOT NULL,
+ `source` text NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+CREATE TABLE `users` (
+ `id` int(11) NOT NULL,
+ `name` mediumtext NOT NULL,
+ `email` varchar(255) NOT NULL,
+ `pgp_fingerprint` text NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8;
+
+
+ALTER TABLE `events`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `title_2` (`title`(255),`start_date`),
+ ADD KEY `start_date` (`start_date`),
+ ADD KEY `end_date` (`end_date`),
+ ADD KEY `source` (`source`),
+ ADD KEY `added_by` (`added_by`);
+ALTER TABLE `events` ADD FULLTEXT KEY `location` (`location`);
+ALTER TABLE `events` ADD FULLTEXT KEY `title` (`title`);
+ALTER TABLE `events` ADD FULLTEXT KEY `description` (`description`);
+
+ALTER TABLE `event_keyword`
+ ADD UNIQUE KEY `event_2` (`event`,`keyword`),
+ ADD KEY `event` (`event`),
+ ADD KEY `keyword` (`keyword`);
+
+ALTER TABLE `keywords`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `keyword` (`keyword`(127)) USING BTREE;
+ALTER TABLE `keywords` ADD FULLTEXT KEY `keyword_2` (`keyword`);
+
+ALTER TABLE `sources`
+ ADD PRIMARY KEY (`id`);
+ALTER TABLE `sources` ADD FULLTEXT KEY `source` (`source`);
+
+ALTER TABLE `users`
+ ADD PRIMARY KEY (`id`),
+ ADD UNIQUE KEY `email` (`email`),
+ ADD UNIQUE KEY `name` (`name`(127));
+
+
+ALTER TABLE `events`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
+ALTER TABLE `keywords`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
+ALTER TABLE `sources`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
+ALTER TABLE `users`
+ MODIFY `id` int(11) NOT NULL AUTO_INCREMENT;
+
+ALTER TABLE `events`
+ ADD CONSTRAINT `events_ibfk_1` FOREIGN KEY (`added_by`) REFERENCES `users` (`id`),
+ ADD CONSTRAINT `events_ibfk_2` FOREIGN KEY (`source`) REFERENCES `sources` (`id`);
+
+ALTER TABLE `event_keyword`
+ ADD CONSTRAINT `event_keyword_ibfk_1` FOREIGN KEY (`event`) REFERENCES `events` (`id`),
+ ADD CONSTRAINT `event_keyword_ibfk_2` FOREIGN KEY (`keyword`) REFERENCES `keywords` (`id`);
diff --git a/list.php b/list.php
new file mode 100644
index 0000000..b942fa6
--- /dev/null
+++ b/list.php
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Events</title>
+ <link rel="stylesheet" type="text/css" href="style.css"/>
+</head>
+<body>
+<h1>HebrewTools Events List</h1>
+Select <a href="#" onclick="check_all_keywords(true);">all</a> / <a href="#" onclick="check_all_keywords(false);">no</a> filters.
+<div id="keyword-filters">
+<?php
+require_once ('db.php');
+
+function htmlid ($str)
+{
+ return preg_replace ('/\W/','-',$str);
+}
+
+$selected_keywords=isset ($_GET['keywords']) ? explode(',',$_GET['keywords']) : null;
+
+$keywords=get_keywords();
+foreach ($keywords as $record){
+ $keyword=$record['keyword'];
+ $checked=is_null ($selected_keywords) || in_array($keyword,$selected_keywords);
+ $checked=$checked ? 'checked="checked"' : '';
+ echo '<label for="kw-'.htmlid ($keyword).'">';
+ echo '<input type="checkbox" class="keyword" value="'.$keyword.'" id="kw-'.htmlid ($keyword).'" '.$checked.'/>';
+ echo ' '.$keyword.' ('.$record['count'].')</label><br/>';
+}
+
+$ical_url='https://'.$_SERVER['HTTP_HOST'].'/ical.php';
+if (isset ($_GET['keywords']) && count ($_GET['keywords']) != count ($keywords))
+ $ical_url.='?keywords='.$_GET['keywords'];
+?>
+</div>
+<br/>
+<form action="list.php" method="get">
+<input type="hidden" name="keywords" value=""/>
+<input type="submit" value="Update filters"/>
+</form>
+<p>Export this calendar in <a href="<?=$ical_url?>">iCal</a> format.</p>
+<table>
+<tr>
+ <th>Date(s)</th>
+ <th>Location</th>
+ <th>Title</th>
+ <th>Keywords</th>
+</tr>
+<?php
+$events=is_null ($selected_keywords) ? get_events() : get_events ($selected_keywords);
+foreach ($events as $event){
+ echo '<tr>';
+ echo '<td>'.date ('D j M Y',strtotime ($event['start_date']));
+ if ($event['end_date']!=$event['start_date'])
+ echo ' to '.date ('j M',strtotime ($event['end_date']));
+ echo '</td>';
+ echo '<td>'.htmlspecialchars ($event['location']).'</td>';
+ echo '<td>'.htmlspecialchars ($event['title']).'</td>';
+ foreach ($event['keywords'] as $i => $kw){
+ if (!is_null ($selected_keywords) && !in_array ($kw,$selected_keywords))
+ $event['keywords'][$i]='<span class="filtered-keyword">'.htmlspecialchars ($kw).'</span>';
+ else
+ $event['keywords'][$i]=htmlspecialchars ($kw);
+ }
+ echo '<td>'.implode (', ',$event['keywords']).'</td>';
+ echo '</tr>';
+}
+?>
+</table>
+<script type="text/javascript">
+let checkboxes=Array.from (document.getElementsByClassName ('keyword'));
+
+function update_keywords_field ()
+{
+ var keywords=checkboxes.filter (cb => cb.checked).map (cb => cb.value);
+ var elem=document.getElementsByName ('keywords')[0];
+ elem.value=keywords.join (',');
+}
+
+function check_all_keywords (check)
+{
+ checkboxes.map (cb => cb.checked=check);
+ update_keywords_field();
+ return false;
+}
+
+update_keywords_field();
+checkboxes.map (cb => cb.onchange=update_keywords_field);
+</script>
+</body>
+</html>
diff --git a/mail.php b/mail.php
new file mode 100644
index 0000000..6d2d425
--- /dev/null
+++ b/mail.php
@@ -0,0 +1,196 @@
+<?php
+require_once ('config.php');
+require_once ('db.php');
+
+$imap_options=['DISABLE_AUTHENTICATOR' => 'CRAM_MD5'];
+$imap=imap_open (IMAP_CONN,IMAP_USER,IMAP_PASS,NULL,1,$imap_options);
+
+if (!$imap){
+ echo 'IMAP errors:<br/>';
+ foreach (imap_errors() as $err)
+ echo $err.'<br/>';
+ die();
+}
+
+function verify_signature ($data,$sig)
+{
+ $gnupg=gnupg_init();
+ $sig_details=gnupg_verify ($gnupg,$data,$sig);
+ if (!$sig_details)
+ throw new Exception ('signature verification failed');
+
+ $summary=$sig_details[0]['summary'];
+ if ($summary & 0x0004)
+ throw new Exception ('bad signature');
+ if ($summary & 0x0010)
+ throw new Exception ('key has been revoked');
+ if ($summary & 0x0020)
+ throw new Exception ('key has expired');
+ if ($summary & 0x0040)
+ throw new Exception ('signature has expired');
+ if ($summary & 0x0080)
+ throw new Exception ('can\'t verify: key missing');
+ if ($summary!=3)
+ throw new Exception ('unknown signature verification problem ('.$summary.')');
+
+ return $sig_details[0]['fingerprint'];
+}
+
+function mail_fetch_message ($msg)
+{
+ global $imap;
+
+ $guessed_source=null;
+
+ /* Fetch user */
+ $info=imap_headerinfo ($imap,$msg);
+ $from=$info->from[0]->mailbox.'@'.$info->from[0]->host;
+ $user=get_user_by_email ($from);
+
+ /* Guess source */
+ if (strpos (imap_utf8 ($info->subject),'[agade]')!==false)
+ $guessed_source='Agade';
+
+ /* Fetch the multipart tree */
+ $structure=imap_fetchstructure ($imap,$msg);
+ if ($structure->type!=TYPEMULTIPART)
+ throw new Exception ('not a multipart message');
+ if ($structure->subtype!='SIGNED')
+ throw new Exception ('not a signed message');
+
+ /* Find the body and signature */
+ $body_id=null;
+ $sig_id=null;
+ foreach ($structure->parts as $id => $part){
+ if ($part->type==TYPEAPPLICATION && $part->subtype=='PGP-SIGNATURE')
+ $sig_id=$id+1;
+ else
+ $body_id=$id+1;
+ }
+ if (is_null ($sig_id))
+ throw new Exception ('no signature attachment');
+ if (is_null ($body_id))
+ throw new Exception ('no message body');
+
+ /* Get contents */
+ $headers=imap_fetchmime ($imap,$msg,1,FT_INTERNAL | FT_PEEK);
+ $body=imap_fetchbody ($imap,$msg,$body_id,FT_INTERNAL | FT_PEEK);
+ $sig=imap_fetchbody ($imap,$msg,$sig_id,FT_PEEK);
+
+ /* Verify signature */
+ $fpr=verify_signature ($headers.$body,$sig);
+ if ($fpr!=$user['pgp_fingerprint'])
+ throw new Exception ('illegal key used for signature');
+
+ /* Fetch plain-text body */
+ $body_part=$structure->parts[$body_id-1];
+ $plain_body=null;
+ if ($body_part->type==TYPETEXT && $body_part->subtype=='PLAIN'){
+ $plain_body=$body;
+ } else if ($body_part->type==TYPEMULTIPART){
+ foreach ($body_part->parts as $id => $part)
+ if ($part->type==TYPETEXT && $part->subtype=='PLAIN')
+ $plain_body=imap_fetchbody ($imap,$msg,$body_id.'.'.($id+1),FT_PEEK);
+ } else {
+ throw new Exception ('unknown body part type '.$body_part->type);
+ }
+ if (is_null ($plain_body))
+ throw new Exception ('no plain-text body');
+
+ $plain_body=imap_qprint ($plain_body);
+
+ return [
+ 'user' => $user,
+ 'body' => $plain_body,
+ 'guessed_source' => $guessed_source
+ ];
+}
+
+function mail_get_unseen_messages ()
+{
+ global $imap;
+
+ $unseen_msgs=imap_search ($imap,'UNSEEN');
+ if (!$unseen_msgs)
+ $unseen_msgs=[];
+
+ return $unseen_msgs;
+}
+
+function mail_mark_seen ($msg)
+{
+ global $imap;
+ imap_setflag_full ($imap,$msg,'\Seen');
+}
+
+function mail_finish ()
+{
+ global $imap;
+ imap_close ($imap);
+}
+
+function mail_parse_message ($body,$prefill_info=[])
+{
+ $info=[
+ 'title' => null,
+ 'location' => null,
+ 'start_date' => null,
+ 'end_date' => null,
+ 'keywords' => [],
+ 'source' => null,
+ 'description' => null
+ ];
+ $warnings=[];
+
+ foreach ($prefill_info as $key => $val)
+ $info[$key]=$val;
+
+ $lines=explode ("\n",$body);
+ $i=0;
+ foreach ($lines as $line){
+ $i++;
+ if (trim ($line)=='')
+ break;
+
+ $parts=explode (':',$line,2);
+ $key=trim ($parts[0]);
+ $val=trim ($parts[1]);
+
+ switch ($key){
+ case 'Title':
+ $info['title']=$val;
+ break;
+ case 'Location':
+ $info['location']=$val;
+ break;
+ case 'Source':
+ $info['source']=$val;
+ break;
+ case 'Dates':
+ $parts=explode ('to',$val);
+ if (count ($parts)==2){
+ $info['start_date']=trim ($parts[0]);
+ $info['end_date']=trim ($parts[1]);
+ } else {
+ $info['start_date']=$info['end_date']=$val;
+ }
+ break;
+ case 'Keywords':
+ $info['keywords']=array_map ('trim',explode (';',$val));
+ break;
+ default:
+ $warnings[]='Unknown key '.$key;
+ }
+ }
+
+ $info['description']=trim (implode ("\n",array_slice ($lines,$i)));
+
+ foreach (array_keys ($info) as $key)
+ if (is_null ($info[$key]))
+ $warnings[]='No '.$key.' found';
+
+ return [
+ 'info' => $info,
+ 'warnings' => $warnings
+ ];
+}
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..ac87562
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,49 @@
+server {
+ listen [::]:80;
+
+ server_name agenda.hebrewtools.org www.agenda.hebrewtools.org;
+
+ access_log /var/log/nginx/agenda.hebrewtools.org.access.log;
+ error_log /var/log/nginx/agenda.hebrewtools.org.error.log;
+
+ return 301 https://agenda.hebrewtools.org$request_uri;
+}
+
+server {
+ listen [::]:443;
+
+ root /var/www/agenda.hebrewtools.org;
+ index list.php;
+ charset utf-8;
+
+ server_name agenda.hebrewtools.org;
+
+ access_log /var/log/nginx/agenda.hebrewtools.org.access.log;
+ error_log /var/log/nginx/agenda.hebrewtools.org.error.log;
+
+ include /etc/nginx/confsnippets/ssl.conf;
+ ssl_certificate /etc/letsencrypt/live/agenda.hebrewtools.org/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/agenda.hebrewtools.org/privkey.pem;
+
+ location / {
+ autoindex off;
+ }
+
+ location ~ (add|fetch)\.php {
+ auth_basic "Agenda administration is only available after login:";
+ auth_basic_user_file /etc/nginx/htpasswds/agenda.hebrewtools.org;
+ include /etc/nginx/confsnippets/fastcgi.conf;
+ }
+
+ location ~ \.php$ {
+ include /etc/nginx/confsnippets/fastcgi.conf;
+ }
+
+ include /etc/nginx/confsnippets/letsencrypt.conf;
+
+ location ~ /\. {
+ deny all;
+ }
+
+ include /etc/nginx/confsnippets/expires.conf;
+}
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..0566182
--- /dev/null
+++ b/style.css
@@ -0,0 +1,33 @@
+body {
+ font-family: sans-serif;
+}
+
+a {
+ color: blue;
+}
+
+table {
+ border-collapse: collapse;
+}
+
+tr:first-child {
+ border-bottom: 1px solid black;
+}
+
+th, td {
+ padding-right: 1em;
+ text-align: left;
+ vertical-align: top;
+}
+
+td.description {
+ font-size: 85%;
+}
+
+#keyword-filters {
+ column-width: 20em;
+}
+
+.filtered-keyword {
+ color: gray;
+}