-(* COCANWIKI scripts.
+(* COCANWIKI - a wiki written in Objective CAML.
* Written by Richard W.M. Jones <rich@merjis.com>.
* Copyright (C) 2004 Merjis Ltd.
- * $Id: edit.ml,v 1.4 2004/09/08 09:54:28 rich Exp $
+ * $Id: edit.ml,v 1.33 2006/08/01 14:50:47 rich Exp $
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; see the file COPYING. If not, write to
+ * the Free Software Foundation, Inc., 59 Temple Place - Suite 330,
+ * Boston, MA 02111-1307, USA.
*)
open Apache
open Cocanwiki_emailnotify
open Cocanwiki_diff
open Cocanwiki_strings
+open Cocanwiki_pages
-let template = get_template "edit.html"
-let template_conflict = get_template "edit_conflict.html"
+let run r (q : cgi) dbh hostid {hostname = hostname} user =
+ let template = get_template dbh hostid "edit.html" in
+ let template_conflict = get_template dbh hostid "edit_conflict.html" in
+ let template_email = get_template dbh hostid "edit_page_email.txt" in
-(* We keep an "internal model" of the page - see build_internal_model ()
- * below.
- *)
-type model_t = {
- id : int; (* Original page ID. *)
- description : string; (* Description. *)
- redirect : string; (* Redirect to ("" = none). *)
- contents : (string * string * string) list;
- (* (sectionname, divname, content)
- * for each section. *)
-}
-
-let run r (q : cgi) (dbh : Dbi.connection) (hostid, hostname, _) _ =
(* Workaround bugs in IE, specifically lack of support for <button>
* elements.
*)
ignore (String.find ua "MSIE"); (* Throws Invalid_string if not found. *)
true
with
- Not_found | String.Invalid_string -> false in
+ Not_found | Invalid_string -> false in
template#conditional "msie" msie;
(* Build the internal model from the parameters passed to the script. *)
let build_internal_model () =
- let id = int_of_string (q#param "id") in
+ let id = Int32.of_string (q#param "id") in
let description = q#param "description" in
let redirect = q#param "redirect" in
+ let redirect =
+ if string_is_whitespace redirect then
+ None else Some redirect in
+ let pt = match q#param "pt_type" with
+ | "page" -> Page (q#param "pt_value")
+ | "title" -> Title (q#param "pt_value")
+ | _ -> failwith "unknown value for pt_type parameter" in
let contents = ref [] in
let i = ref 1 in
while q#param_exists ("content_" ^ string_of_int !i) do
let sectionname = q#param ("sectionname_" ^ string_of_int !i) in
+ let sectionname =
+ if string_is_whitespace sectionname then None else Some sectionname in
let content = q#param ("content_" ^ string_of_int !i) in
let divname = q#param ("divname_" ^ string_of_int !i) in
- contents := (sectionname, divname, content) :: !contents;
+ let divname =
+ if string_is_whitespace divname then None else Some divname in
+ let jsgo = q#param ("jsgo_" ^ string_of_int !i) in
+ let jsgo = if string_is_whitespace jsgo then None else Some jsgo in
+ contents := (sectionname, divname, jsgo, content) :: !contents;
incr i
done;
let contents = List.rev !contents in
{ id = id;
+ pt = pt;
description = description;
redirect = redirect;
- contents = contents; }
+ contents_ = contents; }
in
(* Check for errors in the model. *)
let add_error msg = errors := msg :: !errors in
let get_errors () = List.rev !errors in
- if model.redirect = "" then (
- (* Empty page? *)
- if model.contents = [] then
- add_error ("This page is empty. Use 'Insert new section here' " ^
- "to write something!");
-
- (* Description field? *)
- if model.description = "" then
- add_error ("The description field is very important! This field is " ^
- "used by search engines and directories to describe " ^
- "what's on this page.");
- )
- else (* it's a redirect *) (
- (* Redirect points to a real page? *)
- let sth = dbh#prepare_cached "select 1 from pages
- where hostid = ?
- and url is not null
- and url = ?
- and id <> ?
- and redirect is null" in
- sth#execute [`Int hostid; `String model.redirect; `Int model.id];
-
- let ok = try sth#fetch1 () = [`Int 1] with Not_found -> false in
- if not ok then
- add_error ("Redirect must point to an ordinary page " ^
- "(ie. not to a page which is itself a redirect).")
+ (match model.redirect with
+ | None ->
+ (* Empty page? *)
+ if model.contents_ = [] then
+ add_error ("This page is empty. Use 'Insert new section here' " ^
+ "to write something!");
+
+ (* Description field? *)
+ if model.description = "" then
+ add_error ("The description field is very important! " ^
+ "This field is " ^
+ "used by search engines and directories to describe " ^
+ "what's on this page.");
+
+ | Some redirect ->
+ (* Redirect points to a real page? *)
+ let rows =
+ let model_id = model.id in
+ PGSQL(dbh)
+ "select 1 from pages
+ where hostid = $hostid and url is not null
+ and url = $redirect and id <> $model_id
+ and redirect is null" in
+
+ let ok = rows = [Some 1l] in
+ if not ok then
+ add_error ("Redirect must point to an ordinary page " ^
+ "(ie. not to a page which is itself a redirect).")
);
- (* All sections have sectionnames? *)
- List.iter (function (sectionnames, _, _)
- when string_is_whitespace sectionnames ->
- add_error ("Every section must have a title.");
+ (* All sections after the first one have sectionnames? The first
+ * section ONLY is allowed to have an empty title.
+ *)
+ if model.contents_ <> [] then
+ List.iter (function
+ | (None, _, _, _) ->
+ add_error
+ "Every section except the first must have a title.";
| _ -> ())
- model.contents;
+ (List.tl model.contents_);
+
+ (* There are two constraints on any non-null jsgo's:
+ * (1) Must only be present if divname is non-null.
+ * (2) Must point to a valid URL on the current host.
+ *)
+ List.iter (
+ function
+ | (_, None, Some _, _) ->
+ add_error
+ "Javascript onclick can only be used with a CSS id."
+ | (_, _, Some jsgo, _) ->
+ let rows =
+ PGSQL(dbh) "select 1 from pages
+ where hostid = $hostid
+ and url is not null
+ and url = $jsgo
+ and redirect is null" in
+ let ok = rows = [Some 1l] in
+ if not ok then
+ add_error ("Javascript onclick must point to an ordinary page " ^
+ "on the current site (ie. not to a redirect). " ^
+ "Do not put '/' at the beginning of the URL.")
+ | _ -> ()
+ ) model.contents_;
get_errors ()
in
| _, [] -> [ item ]
| n, x :: xs -> x :: (loop (n-1, xs))
in
- let contents = loop (posn, model.contents) in
- { model with contents = contents }
+ let contents = loop (posn, model.contents_) in
+ { model with contents_ = contents }
in
let action_moveup model posn =
(* posn = 1 means move up the first element, ie. do nothing
| 2, x :: y :: xs -> y :: x :: xs
| n, x :: xs -> x :: (loop (n-1, xs))
in
- let contents = loop (posn, model.contents) in
- { model with contents = contents }
+ let contents = loop (posn, model.contents_) in
+ { model with contents_ = contents }
in
let action_movedn model posn =
(* posn = 1 means move down the first element to the second position
| 1, x :: y :: xs -> y :: x :: xs
| n, x :: xs -> x :: (loop (n-1, xs))
in
- let contents = loop (posn, model.contents) in
- { model with contents = contents }
+ let contents = loop (posn, model.contents_) in
+ { model with contents_ = contents }
in
let action_delete model posn =
(* posn = 1 means delete the first element *)
| 1, x :: xs -> xs
| n, x :: xs -> x :: (loop (n-1, xs))
in
- let contents = loop (posn, model.contents) in
- { model with contents = contents }
+ let contents = loop (posn, model.contents_) in
+ { model with contents_ = contents }
in
(* Convert model to template. *)
let model_to_template model template =
- template#set "id" (string_of_int model.id);
+ template#set "id" (Int32.to_string model.id);
template#set "description" model.description;
+ (match model.pt with
+ Page page ->
+ template#set "pt_type" "page";
+ template#set "pt_value" page
+ | Title title ->
+ template#set "pt_type" "title";
+ template#set "pt_value" title);
+
(* Redirects table. *)
- let sth = dbh#prepare_cached "select url, title from pages
- where url is not null
- and redirect is null
- and hostid = ? and id <> ?
- order by 2" in
- sth#execute [`Int hostid; `Int model.id];
- let table = sth#map (function [`String url; `String title] ->
- let selected = model.redirect = url in
- [ "url", Template.VarString url;
- "title", Template.VarString title;
- "selected", Template.VarConditional selected ]
- | _ -> assert false) in
+ let rows =
+ let model_id = model.id in
+ PGSQL(dbh)
+ "select url, title from pages
+ where url is not null
+ and redirect is null
+ and hostid = $hostid and id <> $model_id
+ order by 2" in
+ let table = List.map (
+ fun (url, title) ->
+ let url = Option.get url in
+ let selected = model.redirect = Some url in
+ [ "url", Template.VarString url;
+ "title", Template.VarString title;
+ "selected", Template.VarConditional selected ]
+ ) rows in
template#table "redirects" table;
- (* Need to go to the database to get the title of the page ... *)
- let sth = dbh#prepare_cached "select title from pages
- where hostid = ? and id = ?" in
- sth#execute [`Int hostid; `Int model.id];
- let title = sth#fetch1string () in
- template#set "title" title;
+ if model.id <> 0l then (
+ (* Need to go to the database to get the title of the page ... *)
+ let rows =
+ let model_id = model.id in
+ PGSQL(dbh)
+ "select title from pages
+ where hostid = $hostid and id = $model_id" in
+ let title = List.hd rows in
+ template#set "title" title;
+ ) else (
+ match model.pt with
+ | Page page -> template#set "title" page
+ | Title title -> template#set "title" title
+ );
let ordering = ref 0 in
let table =
List.map
- (fun (sectionname, divname, content) ->
- incr ordering; let ordering = !ordering in
- [ "ordering", Template.VarString (string_of_int ordering);
+ (fun (sectionname, divname, jsgo, content) ->
+ incr ordering; let ordering = Int32.of_int !ordering in
+ let sectionname = match sectionname with None -> "" | Some s -> s in
+ let divname = match divname with None -> "" | Some s -> s in
+ let jsgo = match jsgo with None -> "" | Some s -> s in
+ [ "ordering", Template.VarString (Int32.to_string ordering);
"sectionname", Template.VarString sectionname;
"divname", Template.VarString divname;
- "content", Template.VarString content ]) model.contents in
+ "jsgo", Template.VarString jsgo;
+ "content", Template.VarString content ]) model.contents_ in
template#table "contents" table;
(* Check for errors and put those into the template. *)
template#conditional "has_errors" (errors <> [])
in
+ (* Check if a URL exists in the database. *)
+ let page_exists page =
+ let rows = PGSQL(dbh)
+ "select 1 from pages where hostid = $hostid and url = $page" in
+ rows = [ Some 1l ]
+ in
+
(* Begin editing a page, pulling the page out of the database and building
* a model from it.
*)
let begin_editing page =
- (* Pull out the page itself from the database. *)
- let sth = dbh#prepare_cached "select id, title, description,
- coalesce (redirect, '')
- from pages
- where hostid = ? and url = ?" in
- sth#execute [`Int hostid; `String page];
-
- let pageid, title, description, redirect =
- match sth#fetch1 () with
- [`Int pageid; `String title; `String description; `String redirect]->
- pageid, title, description, redirect
- | _ -> assert false in
-
- (* Get the sections. *)
- let sth = dbh#prepare_cached "select sectionname, content,
- coalesce (divname, '')
- from contents
- where pageid = ?
- order by ordering" in
- sth#execute [`Int pageid];
-
- let contents =
- sth#map (function
- | [`String sectionname; `String content; `String divname] ->
- sectionname, divname, content
- | _ -> assert false) in
-
- let model = { id = pageid;
- description = description;
- redirect = redirect;
- contents = contents; } in
+ let model = load_page dbh hostid ~url:page () in
+ model_to_template model template
+ in
+
+ (* Begin editing with a blank page, typically a template. *)
+ let begin_editing_new pt =
+ (* Just check the title. *)
+ (match pt with
+ | Page url -> ()
+ | Title title ->
+ match Wikilib.generate_url_of_title r dbh hostid title with
+ | Wikilib.GenURL_OK url -> ()
+ | Wikilib.GenURL_Duplicate url ->
+ q#redirect ("http://" ^ hostname ^ "/" ^ url)
+ | Wikilib.GenURL_TooShort | Wikilib.GenURL_BadURL ->
+ error ~back_button:true ~title:"Bad page name"
+ dbh hostid q
+ "The page name supplied is too short or invalid.";
+ return ()
+ );
+
+ let model = match pt with
+ | Page url -> new_page pt
+ | Title title -> new_page_with_title title in
model_to_template model template
in
if is_action "insert" then (
let posn = get_action "insert" in
- let item = "New section - change this", "", "Write some content here." in
+ let item =
+ Some "The title of this section",
+ None, None,
+ "Write something here." in
model := action_insert !model posn item
) else if is_action "moveup" then (
let posn = get_action "moveup" in
model_to_template !model template
in
- (* Try to save the page. Returns a boolean indicating if the
- * page was saved successfully.
+ (* Try to save the page. Only returns if there were errors in
+ * the model.
*)
let try_save () =
let model = build_internal_model () in
let no_errors = [] = check_for_errors model in
if no_errors then (
(* No errors, so we can save the page ... *)
-
- (* Pull out fields from the database. *)
- let sth = dbh#prepare_cached "select creation_date,
- coalesce (url, url_deleted),
- title, css
- from pages
- where hostid = ? and id = ?" in
- sth#execute [`Int hostid; `Int model.id];
-
- let creation_date, url, title, css =
- match sth#fetch1 () with
- [ creation_date; `String url; `String title; css ] ->
- creation_date, url, title, css
- | _ -> assert false in
-
- (* Has someone else edited this page in the meantime? *)
- let sth = dbh#prepare_cached "select max(id) from pages
- where hostid = ? and url = ?" in
- sth#execute [`Int hostid; `String url];
-
- let max_id = sth#fetch1int () in
- let edited = max_id <> model.id in
-
- if edited then (
- (* Edited by someone else ... Get the other's changes. *)
- let other_diff, _ =
- get_diff dbh hostid url ~old_version:model.id ~version:max_id () in
-
- (* Synthesize our own changes. *)
- let old_page = get_version_for_diff dbh model.id in
- let new_page =
- let css = match css with
- `Null -> "" | `String css -> css
- | _ -> assert false in
- page_for_diff css (List.map (fun (sectionname, _, content) ->
- sectionname, content) model.contents) in
- let our_diff = diff_cmd old_page new_page in
-
- (* Fill out the conflict template. *)
- template_conflict#set "other_diff" other_diff;
- template_conflict#set "our_diff" our_diff;
- template_conflict#set "old_version" (string_of_int model.id);
- template_conflict#set "new_version" (string_of_int max_id);
- template_conflict#set "url" url;
-
- q#template template_conflict;
- raise CgiExit
+ let url, pageid =
+ try
+ save_page r dbh hostid ~user model
+ with
+ SaveURLError ->
+ error ~back_button:true ~title:"Page exists"
+ dbh hostid q ("While you were editing that page, it looks " ^
+ "like another user created the same page.");
+ return ()
+
+ | SaveConflict (new_version, old_version, url, css) ->
+ (* Edited by someone else ... Get the other's changes. *)
+ let other_diff, _ =
+ get_diff dbh hostid url ~old_version ~version:new_version () in
+
+ (* Synthesize our own changes. *)
+ let old_page = get_version_for_diff dbh old_version in
+ let new_page =
+ page_for_diff css (List.map (
+ fun (sectionname, _, _, content) ->
+ let sectionname = match sectionname with
+ | None -> ""
+ | Some s -> s in
+ sectionname, content
+ ) model.contents_) in
+ let our_diff = diff_cmd old_page new_page in
+
+ (* Fill out the conflict template. *)
+ template_conflict#set "other_diff" other_diff;
+ template_conflict#set "our_diff" our_diff;
+ template_conflict#set "old_version"
+ (Int32.to_string old_version);
+ template_conflict#set "new_version"
+ (Int32.to_string new_version);
+ template_conflict#set "url" url;
+
+ q#template template_conflict;
+ return () in
+
+ (* General email notification of page edits. Send an email to
+ * anyone in the page_emails table who has a confirmed address
+ * and who hasn't received an email already today.
+ *)
+ let rows = PGSQL(dbh)
+ "select email, opt_out from page_emails
+ where hostid = $hostid and url = $url
+ and pending is null
+ and last_sent < current_date" in
+ let addrs = List.map (
+ fun (email, opt_out) ->
+ email, opt_out
+ ) rows in
+
+ if addrs <> [] then (
+ (* Construct the email. *)
+ template_email#set "hostname" hostname;
+ template_email#set "page" url;
+
+ let subject =
+ "Site notice: " ^ hostname ^ "/" ^ url ^ " has been updated" in
+
+ let content_type =
+ "text/plain", ["charset", Mimestring.mk_param "UTF-8"] in
+
+ (* Send each email individually (they all have different opt out
+ * links).
+ *)
+ List.iter (fun (to_addr, opt_out) ->
+ template_email#set "opt_out" opt_out;
+ let body = template_email#to_string in
+
+ let msg = Netsendmail.compose ~to_addrs:["", to_addr]
+ ~subject ~content_type body in
+ Netsendmail.sendmail msg)
+ addrs
);
- (* Defer the pages_redirect_cn constraint because that would
- * temporarily fail on the next UPDATE.
- *)
- let sth =
- dbh#prepare_cached "set constraints pages_redirect_cn deferred" in
- sth#execute [];
-
- (* Mark the old page as deleted. NB. There is a small race
- * condition here because PostgreSQL doesn't do isolation
- * properly. If a user tries to visit this page between the
- * delete and the creation of the new page, then they'll get
- * a page not found error. (XXX)
- *)
- let sth = dbh#prepare_cached "update pages set url_deleted = url,
- url = null
- where hostid = ? and id = ?" in
- sth#execute [`Int hostid; `Int model.id];
-
- (* Get the IP address of the user, if available. *)
- let logged_ip =
- try `String (Connection.remote_ip (Request.connection r))
- with Not_found -> `Null in
-
- (* Get redirect. *)
- let redirect = if model.redirect = "" then `Null
- else `String model.redirect in
-
- (* Create the new page. *)
- let sth = dbh#prepare_cached "insert into pages (hostid, url, title,
- description, creation_date, logged_ip,
- redirect, css)
- values (?, ?, ?, ?, ?, ?, ?, ?)" in
- sth#execute [`Int hostid; `String url; `String title;
- `String model.description; creation_date; logged_ip;
- redirect; css];
-
- (* New page ID <> old page ID model.id. *)
- let pageid = sth#serial "pages_id_seq" in
-
- (* Create the page contents. *)
- let sth = dbh#prepare_cached "insert into contents (pageid,
- ordering, sectionname, divname, content)
- values (?, ?, ?, ?, ?)" in
- let ordering = ref 0 in (* Creating new ordering. *)
- List.iter (fun (sectionname, divname, content) ->
- let divname =
- if string_is_whitespace divname then `Null
- else `String divname in
- incr ordering; let ordering = !ordering in
- sth#execute [`Int pageid; `Int ordering;
- `String sectionname; divname;
- `String content])
- model.contents;
+ (* Update the database to record when these emails were sent. *)
+ PGSQL(dbh)
+ "update page_emails
+ set last_sent = current_date
+ where hostid = $hostid and url = $url
+ and pending is null";
(* Commit changes to the database. *)
- dbh#commit ();
+ PGOCaml.commit dbh;
(* Email notification, if anyone is listed for this host. *)
let subject = "Page " ^ url ^ " has been edited" in
"Page: http://" ^ hostname ^ "/" ^ url ^ "\n\n" ^
diff in
- email_notify ~body ~subject dbh hostid;
+ email_notify ~body ~subject ~user dbh hostid;
- let buttons = [ ok_button ("/" ^ url) ] in
- ok ~title:"Saved" ~buttons
- q "The page was saved."
+ (* Redirect back to the URL. *)
+ q#redirect ("http://" ^ hostname ^ "/" ^ url)
);
-
- no_errors
in
let cancel id =
- let sth = dbh#prepare_cached "select url from pages
- where hostid = ? and id = ?" in
- sth#execute [`Int hostid; `Int id];
- let url = sth#fetch1string () in
+ let url =
+ if id <> 0l then (
+ let rows = PGSQL(dbh)
+ "select coalesce (url, url_deleted)
+ from pages
+ where hostid = $hostid and id = $id" in
+ Option.get (List.hd rows)
+ ) else if q#param "pt_type" = "page" then
+ q#param "pt_value"
+ else
+ (* Create a new page, but the user hits the cancel button. Because
+ * we didn't save where they came from, we now have nowhere to
+ * go. So we redirect to the home page. XXX
+ *)
+ "" in
q#redirect ("http://" ^ hostname ^ "/" ^ url)
in
- (* This codes decides where we are in the current editing cycle.
+ (* This code decides where we are in the current editing cycle.
*
* Inputs:
* id - if set, then we are in the midst of editing a page.
* save - if set, then we want to save the page.
* cancel - if set, abandon changes and go back to viewing the page.
* action_* - one of the action buttons was set, eg. move up/down.
- * page - the page URL opened newly for editing.
+ * page - the page URL opened newly for editing, or a template which
+ * doesn't yet exist.
+ * title - page doesn't yet exist; create it.
*)
- (try
- let id = int_of_string (q#param "id") in
- if q#param_true "cancel" then (
- cancel id;
- raise CgiExit
- );
- if q#param_true "save" then (
- let ok = try_save () in
- if ok then raise CgiExit (* ... else fall through *)
- );
- continue_editing () (* Processes the action, if any. *)
- with
- Not_found ->
- let page = q#param "page" in
- let page = if page = "" then "index" else page in
- begin_editing page);
+ let id =
+ try Some (Int32.of_string (q#param "id")) with Not_found -> None in
+ (match id with
+ | None -> (* Begin editing the page. *)
+ if q#param_exists "page" then (
+ let page = q#param "page" in
+ let page = if page = "" then "index" else page in
+ if page_exists page then
+ begin_editing page
+ else
+ begin_editing_new (Page page)
+ ) else (
+ let title = q#param "title" in
+ begin_editing_new (Title title)
+ )
+
+ | Some id ->
+ if q#param_true "cancel" then
+ cancel id;
+ if q#param_true "save" then
+ try_save (); (* might fail and fall through ... *)
+ continue_editing ()
+ );
q#template template