+csv dep for PG'OCaml.
[cocanwiki.git] / scripts / lib / cocanwiki_func_rss.ml
1 (* An RSS feed reader function.
2  * $Id: cocanwiki_func_rss.ml,v 1.3 2006/12/08 14:10:37 rich Exp $
3  *)
4
5 open Printf
6 open ExtString
7 open ExtList
8
9 open Rss
10
11 open Cocanwiki_extensions
12 open Cocanwiki_template
13
14 (* Argument is a whitespace-separated list of key=value pairs.
15  *
16  * Example:
17  *   {{rss:url=http://blog.merjis.com/feed/ max_items=3 refresh=daily}}
18  *
19  * The keys are:
20  *   url       The URL of the RSS feed (required).
21  *   show      What to show.  A comma-separated list of:
22  *                 title            Blog title
23  *                 link             Blog link
24  *                 description      Blog description
25  *                 items            Items in the feed
26  *                 item_title       Title of items
27  *                 item_date        Date and time of items
28  *                 item_link        Link to items
29  *                 item_description Description of items
30  *               Default is everything.
31  *   max_items Maximum number of items to show.
32  *   refresh   How often to recheck feed: hourly, daily, weekly,
33  *               <nn>h, or <nn>d.
34  *               Default is daily.  Warning: we only check the
35  *               feed when the page is loaded, so making this
36  *               too frequent will slow page loading.
37  *)
38 let ws_re = Pcre.regexp "\\s+"
39 let key_re = Pcre.regexp "^(\\w+)=(.*)$"
40 let refresh_re = Pcre.regexp "^(\\d+)(h|d)$"
41
42 (* This exception is thrown if there is an error during processing.  The
43  * string is an error message.
44  *)
45 exception Error of string
46
47 type show = Title | Link | Description
48           | Items | Item_title | Item_date | Item_link | Item_description
49 let default_show = [ Title; Link; Description;
50                      Items; Item_title; Item_date;
51                      Item_link; Item_description ]
52
53 let event_system = Unixqueue.create_unix_event_system ()
54 let connection =
55   let connection = new Http_client.pipeline in
56   connection#set_event_system event_system;
57   connection
58
59 let fetch_url url =
60   prerr_endline ("fetching URL: " ^ url);
61   try
62     let msg = new Http_client.get url in
63     connection#add msg;
64     connection#run ();
65     msg#get_resp_body ()
66   with
67   | Http_client.Http_protocol exn
68   | exn ->
69       (* Log the real exception in the logfile, for security. *)
70       prerr_endline (Printexc.to_string exn);
71       raise (Error ("error fetching url; " ^
72                       "note that for security reasons " ^
73                       "the real error is only written to error_log"))
74
75 (* OCamlRSS uses its own date format. *)
76 let printable_date { year = year; month = month; day = day;
77                      hour = hour; minute = minute; second = second } =
78   let date = Calendar.lmake ~year ~month ~day ~hour ~minute ~second () in
79   Cocanwiki_date.printable_date_time date
80
81 let rss r dbh hostid arg =
82   let template = _get_template "cocanwiki_func_rss.html" in
83
84   try
85     (* Is RSS enabled for this host?  This is a safety feature to ensure
86      * that people only turn this feature on if they really mean to.
87      *)
88     let rows =
89       PGSQL(dbh) "select enable_rss_func from hosts where id = $hostid" in
90     if rows <> [true] then
91       raise (Error "RSS is disabled on this host.  You have to enable it manually by setting enable_rss_func for the relevant row in the hosts table.  RSS introduces a potential security hole (cross-site scripting) and should not be used where you have untrusted editors or with untrusted RSS feeds.");
92
93     let arg = match arg with
94       | None -> raise (Error ("missing url: try {{rss:url=http://....}}"))
95       | Some arg -> arg in
96
97     (* Parse the argument. *)
98     let args = Pcre.split ~rex:ws_re arg in
99     let args = List.map (
100       fun arg ->
101         try
102           let subs = Pcre.exec ~rex:key_re arg in
103           let key = Pcre.get_substring subs 1 in
104           let value = Pcre.get_substring subs 2 in
105           key, value
106         with
107           Not_found ->
108             raise (Error ("invalid argument: " ^ arg))
109     ) args in
110
111     (* Get known arguments or their defaults. *)
112     let url =
113       try List.assoc "url" args
114       with Not_found ->
115         raise (Error "missing url: try {{rss:url=http://....}}") in
116
117     let max_items =
118       try
119         let n = List.assoc "max_items" args in
120         let n = int_of_string n in
121         Some n
122       with
123       | Not_found -> None
124       | Failure "int_of_string" ->
125           raise (Error "max_items is not a number") in
126
127     let refresh =
128       try
129         let p = List.assoc "refresh" args in
130         match p with
131         | "hourly" -> Calendar.Period.hour 1
132         | "daily" -> Calendar.Period.day 1
133         | "weekly" -> Calendar.Period.week 1
134         | str ->
135             try
136               let subs = Pcre.exec ~rex:refresh_re str in
137               let n = int_of_string (Pcre.get_substring subs 1) in
138               match Pcre.get_substring subs 2 with
139               | "h" -> Calendar.Period.hour n
140               | "d" -> Calendar.Period.day n
141               | _ -> assert false
142             with
143               Not_found ->
144                 raise (Error "refresh is hourly|daily|weekly|<nn>h|<nn>d")
145       with
146         Not_found ->
147           Calendar.Period.day 1 in
148
149     let show =
150       try
151         let xs = List.assoc "show" args in
152         let xs = String.nsplit xs "," in
153         let xs = List.map (
154           function
155           | "title" -> Title
156           | "link" -> Link
157           | "description" -> Description
158           | "items" -> Items
159           | "item_title" -> Item_title
160           | "item_date" -> Item_date
161           | "item_link" -> Item_link
162           | "item_description" -> Item_description
163           | str ->
164               raise (Error ("show: invalid item: " ^ str))
165         ) xs in
166         xs
167       with
168         Not_found -> default_show in
169
170     (* Process the show parameter into template conditionals. *)
171     template#conditional "show_title" false;
172     template#conditional "show_link" false;
173     template#conditional "show_description" false;
174     template#conditional "show_items" false;
175     template#conditional "show_item_title" false;
176     template#conditional "show_item_date" false;
177     template#conditional "show_item_link" false;
178     template#conditional "show_item_description" false;
179     List.iter (
180       function
181       | Title -> template#conditional "show_title" true
182       | Link -> template#conditional "show_link" true
183       | Description -> template#conditional "show_description" true
184       | Items -> template#conditional "show_items" true
185       | Item_title -> template#conditional "show_item_title" true
186       | Item_date -> template#conditional "show_item_date" true
187       | Item_link -> template#conditional "show_item_link" true
188       | Item_description -> template#conditional "show_item_description" true
189     ) show;
190
191     (* Grab the RSS from our database cache.  If it's too old
192      * or we don't have it cached, grab it from the server.
193      *)
194     let rss =
195       let rows =
196         PGSQL(dbh)
197           "select t, rss from rss_cache where url = $url" in
198       let need_to_fetch, rss =
199         match rows with
200         | [] -> true, None (* not in cache, so have to fetch URL *)
201         | (t, rss) :: _ ->
202             if Calendar.compare
203               (Calendar.add t refresh) (Calendar.now ()) < 0 then
204               true, Some rss
205             else
206               false, Some rss in
207       if need_to_fetch then (
208         (* If the fetch fails, keep using the old cached RSS.
209          * fetch_url prints the error in the error log in this case.
210          *)
211         let rss =
212           try fetch_url url
213           with Error _ as exn ->
214             match rss with
215             | Some rss -> rss
216             | None -> raise exn in
217
218         (* This lock is OK - refetches are supposed to be infrequent. *)
219         PGSQL(dbh) "lock table rss_cache in share mode";
220         PGSQL(dbh) "delete from rss_cache where url = $url";
221         PGSQL(dbh) "insert into rss_cache (url, rss) values ($url, $rss)";
222         PGOCaml.commit dbh; (* XXX How to avoid? *)
223         PGOCaml.begin_work dbh;
224         rss
225       ) else (
226         match rss with
227         | Some rss -> rss
228         | None -> assert false
229       ) in
230
231     (* Try to parse the RSS. *)
232     let channel =
233       try
234         channel_of_string rss
235       with
236       | Xml.Error err ->
237           raise (Error ("error parsing feed: XML: " ^ Xml.error err))
238       | Failure err ->
239           raise (Error ("error parsing feed: RSS: " ^ err)) in
240
241     (* Generate some XML. *)
242     template#set "title" channel.ch_title;
243     template#set "link" channel.ch_link;
244     template#set "description" channel.ch_desc;
245
246     let items = channel.ch_items in
247     let items =
248       match max_items with
249       | None -> items
250       | Some n -> List.take n items in
251     let items = List.map (
252       fun item ->
253         [ "title", Template.VarString (Option.default "" item.item_title);
254           "link", Template.VarString (Option.default "" item.item_link);
255           "description",
256             Template.VarString (Option.default "" item.item_desc);
257           "date",
258             Template.VarString (Option.map_default
259                                   printable_date "" item.item_pubdate); ]
260     ) items in
261     template#table "items" items;
262
263     template#to_string
264   with
265     Error msg ->
266       "rss: " ^ Cgi_escape.escape_html msg
267
268 (* Register function. *)
269 let () =
270   external_functions := ("rss", rss) :: !external_functions